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 + 分页范式)(可复制) 下篇

相关推荐
光影6276 小时前
Unittest单元测试基础速记
测试工具·单元测试·json·测试覆盖率
传奇开心果编程8 小时前
【Flutter踩坑排雷】第7课:巨型 build 函数坑|全部 UI 写在同一个 build,维护难、隐性异常
android·flutter·ios·移动开发·跨平台·dart·声明式ui
ZLongTeng8 小时前
EIDE的launch.json文件
json
程序员老刘10 小时前
从短剧App的bug看响应式编程在AI时代的优势
flutter·ai编程·客户端
梦想不只是梦与想13 小时前
Python 中的 json 模块
python·json·对象转字符串
步行cgn16 小时前
JSON.stringify 详解:把 JavaScript 对象变成 JSON 字符串
开发语言·javascript·json
阿正的梦工坊17 小时前
深入理解 JSON-RPC 2.0:轻量级远程调用协议详解
rpc·json
腾讯数据架构师17 小时前
任务跑完怎么直接出图表?CubeStudio 任务可视化 ECharts 数据格式实操(CSV/JSON + metric.json,11 种图表一键渲染)
前端·json·echarts·数据可视化
救救孩子把18 小时前
结构化输出为何“格式正确、答案错误”?JSON Schema 的能力边界
android·java·json·agent·agent服务
霸道流氓气质19 小时前
智能表单生成器:结构化输出契约、JSON Schema 驱动渲染与 EAV/JSONB 混合存储的 Vue3 + Spring AI 生产级实战
人工智能·spring·json