Freezed + json_serializable:DTO / Domain 分层与不可变模型(入门到落地)-----上篇

1. 先把结论说清楚:为什么一定要 DTO / Domain 分层?

一句话:网络/存储是"脏世界",业务模型是"干净世界"。

  • DTO(Data Transfer Object):专门服务于 API / DB / 第三方协议

    • 字段命名随接口(snake_case、奇怪字段、可空一堆)

    • 版本变化频繁、兼容逻辑多

  • Domain(领域模型):专门服务于业务逻辑/状态管理/UI

    • 字段语义清晰、可控、尽量非空

    • 不被接口牵着走,能稳定演进

工程上你会立刻得到:

  • API 改字段名、加字段、null 乱飞 → 只改 DTO

  • 业务逻辑、UI 状态需要稳定 → 只依赖 Domain

  • 单测更好写,状态更可控,后期重构成本低

2. 推荐目录结构(Flutter 工程落地)

以"组件化 + domain 分层"为例(你之前的思路完全一致):

复制代码
lib/
  core/
    network/        // dio/client/interceptor
    errors/         // AppError/Failure
    utils/
  features/
    user/
      data/
        dto/        // freezed + json_serializable
        mappers/    // dto -> domain
        datasource/ // remote/local
        repo_impl/
      domain/
        entity/     // freezed immutable domain model
        repo/       // abstract repository
        usecase/
      presentation/
        pages/
        widgets/
        state/       // riverpod/bloc

核心规则:presentation 只能 import domain;不允许直接拿 dto 去渲染 UI。

3. Freezed 放哪里?DTO 和 Domain 都可以用,但"目的不同"

DTO 用 Freezed 的目的

  • 快速生成 fromJson/toJson

  • 容忍字段可空、默认值、兼容旧字段

  • 适配接口命名(@JsonKey(name: 'xx')

Domain 用 Freezed 的目的

  • 不可变(immutable)+ copyWith

  • 值对象(value equality)

  • 更适合状态管理(Riverpod/Bloc)"只改一点点字段"

4. DTO(接口模型)写法:Freezed + json_serializable(含兼容)

user_dto.dart

Dart 复制代码
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_dto.freezed.dart';
part 'user_dto.g.dart';

@freezed
class UserDto with _$UserDto {
  const factory UserDto({
    @JsonKey(name: 'id') required String id,

    // 接口叫 user_name,我们不改接口,DTO 适配它
    @JsonKey(name: 'user_name') String? userName,

    // 接口可能不给 avatar,给个默认值避免 null 传染
    @JsonKey(name: 'avatar_url') @Default('') String avatarUrl,

    // 接口是 0/1,DTO 先按 int 收
    @JsonKey(name: 'vip') @Default(0) int vipFlag,

    // 时间字段可能为空或乱格式:DTO 先按 String? 收
    @JsonKey(name: 'created_at') String? createdAt,
  }) = _UserDto;

  factory UserDto.fromJson(Map<String, dynamic> json) =>
      _$UserDtoFromJson(json);
}

DTO 的可空不是"坏味道",这是现实:接口就是会乱给。

5. Domain(业务实体)写法:不可变、语义清晰、尽量非空

user.dart

Dart 复制代码
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    required String avatarUrl,
    required bool isVip,
    required DateTime createdAt,
  }) = _User;
}

这里你会发现:Domain 不需要 Json (大多数业务不应该依赖 Json)。

需要持久化时,你可以用 separate 的 storage model,或者在 data 层处理。

6. Mapper:DTO -> Domain(工程里最关键的一层)

user_mapper.dart

Dart 复制代码
import '../../domain/entity/user.dart';
import '../dto/user_dto.dart';

extension UserDtoMapper on UserDto {
  User toDomain() {
    final safeName = (userName?.trim().isNotEmpty ?? false)
        ? userName!.trim()
        : 'Unknown';

    final safeCreatedAt = DateTime.tryParse(createdAt ?? '') ??
        DateTime.fromMillisecondsSinceEpoch(0);

    return User(
      id: id,
      name: safeName,
      avatarUrl: avatarUrl,
      isVip: vipFlag == 1,
      createdAt: safeCreatedAt,
    );
  }
}

Mapper 这一层干三件事:

  1. 清洗脏数据(null、空字符串、乱格式)

  2. 语义转换(0/1 → bool、String → DateTime)

  3. 兜底(默认值、异常容错)

7. Repository:对上提供 Domain,对下处理 DTO

user_repository.dart(domain)

Dart 复制代码
import '../entity/user.dart';

abstract class UserRepository {
  Future<User> getMe();
}

user_repository_impl.dart(data)

Dart 复制代码
import '../../domain/entity/user.dart';
import '../../domain/repo/user_repository.dart';
import '../datasource/user_remote_ds.dart';
import '../mappers/user_mapper.dart';

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remote;

  UserRepositoryImpl(this.remote);

  @override
  Future<User> getMe() async {
    final dto = await remote.getMe();     // UserDto
    return dto.toDomain();                // User
  }
}

8. 状态管理:只让 UI 接触 Domain(freezed 不可变的优势)

以 Riverpod 举例(Bloc 同理):

Dart 复制代码
@freezed
class UserState with _$UserState {
  const factory UserState({
    @Default(false) bool loading,
    User? user,
    String? error,
  }) = _UserState;
}

更新状态时你就会爽到:

Dart 复制代码
state = state.copyWith(loading: true);

final user = await repo.getMe();
state = state.copyWith(loading: false, user: user);

因为 Domain 也是 immutable,所以 UI 状态链路天然干净。

9. 常见坑(踩过才叫工程)

坑 1:DTO 直接给 UI 用

短期快,长期灾难。接口一变你全工程改 UI。

坑 2:Domain 也写 fromJson/toJson

除非你明确 Domain 需要脱离 data 层独立序列化(少见)。

否则会导致 domain 反向依赖 json 结构,分层就废了。

坑 3:DTO 全字段 required

接口一旦缺字段就炸,线上崩。DTO 层要容错优先

坑 4:Mapper 写到处都是

推荐做法:

  • 每个 feature 的 data/mappers/ 集中放 mapper

  • 一个 DTO 对应一个 mapper(extension 或 standalone class 都行)

  • mapper 必须可单测(输入 json → domain 输出)

10. 你可以直接照抄的"工程规范"

DTO 命名规则XxxDto、字段尽量贴近接口(加 @JsonKey)

Domain 命名规则Xxx、字段贴近业务语义(非空优先)

分层依赖presentation -> domain -> data(impl),禁止反向

不可变模型 :UI/State/Domain 全用 Freezed,稳定 + 易维护

兼容逻辑:只写在 DTO + Mapper,别污染业务层

下一篇:

Freezed + json_serializable:DTO / Domain 分层(完整工程版:Dio 错误治理 + Failure + 分页范式)(可复制) 下篇

相关推荐
程序员老刘·14 小时前
谷歌有没有画饼?Flutter 2025 路线图完成度核验
flutter·跨平台开发·客户端开发
菩提祖师_14 小时前
量子计算在网络安全中的应用
开发语言·javascript·爬虫·flutter
菩提祖师_14 小时前
基于Docker的微服务自动化部署系统
开发语言·javascript·flutter·docker
牛马11114 小时前
Flutter Web性能优化标签解析(二)
前端·javascript·flutter
走在路上的菜鸟15 小时前
Android学Flutter学习笔记 第三节 Android视角认知Flutter(触摸事件,List,Text,Input)
android·学习·flutter
kirk_wang15 小时前
Flutter `audio_service` 在鸿蒙端的后台音频服务适配实践
flutter·移动开发·跨平台·arkts·鸿蒙
张风捷特烈15 小时前
如何用 Dart 写个自己的MCP服务
flutter·dart·mcp
java_t_t1 天前
Java属性解析映射到Json
java·json
dev1 天前
【flutter】0. 搭建一个多端 flutter 开发环境
flutter·架构·前端框架