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 这一层干三件事:
-
清洗脏数据(null、空字符串、乱格式)
-
语义转换(0/1 → bool、String → DateTime)
-
兜底(默认值、异常容错)
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 + 分页范式)(可复制) 下篇