31 · 架构模式:Clean Architecture / DDD 与本项目演进
本篇的立场:不为架构而架构。
HaierEnergy 有
lib/1118 个文件(data/353、ui/434), 在这种规模下"整体重构成 Clean Architecture"是高风险、低性价比的。 本篇的目标是让你看得懂架构讨论、能诊断现状、知道什么时候该引入什么—— 而不是鼓动你重写项目。📌 前置:07 篇(项目分层)、08 篇(Riverpod)、 09 篇(数据层)、27 篇(选型方法论,本篇同款思路)。
开篇:前端概念对照
| 前端 | Clean Architecture / DDD | 说明 |
|---|---|---|
| Container / Presentational 组件 | View / ViewModel 分离 | UI 与逻辑解耦 |
| Redux 的 thunk / saga | UseCase(用例) | 业务流程编排 |
services/ 目录(业务逻辑层) | Domain 层(UseCase) | 前端其实也有这层,只是常写得很薄 |
| axios 封装 | DataSource / ApiService | 数据获取细节 |
React Query 的 useQuery | Repository + ViewModel | 数据获取 + 缓存 + 状态 |
| 后端返回的 JSON 类型 | DTO(Data Transfer Object) | 传输结构 |
| 前端自己定义的业务 model | Entity(实体) | 领域概念 |
| 只有字段、没有方法的 interface | 贫血模型(Anemic Model) | Clean 要避免的反模式 |
| 前端"按 feature 分目录" | DDD 的限界上下文(简化理解) | 按业务能力切分 |
Monorepo 里的 packages/xxx | DDD 的模块/聚合边界 | 边界与依赖方向 |
最关键的认知差(前端转 Flutter 最容易踩):
前端项目普遍不做严格分层——components/ + hooks/ + services/ 就够了, 因为前端业务复杂度通常低于企业级后端,且前端的"状态管理库"已经承担了分层职责 (Redux 的 action/reducer 天然分离了"意图"与"计算")。
但 Flutter 的 ViewModel(Riverpod Notifier)很容易变成"什么都干"的垃圾桶: 调接口、转数据、算业务、管 UI 状态——全塞在一起。 Clean Architecture 要解决的核心问题就是这个。
一、Clean Architecture 是什么(够用版)
1.1 一张图记住
┌─────────────────────────────────────────────┐
│ Presentation(表现层) │
│ View + ViewModel + 状态管理 │
│ ← 最外层,最易变(UI 天天改) │
├─────────────────────────────────────────────┤
│ Domain(领域层) ⭐ 核心 │
│ Entity + UseCase + Repository 接口 │
│ ← 最内层,最稳定(业务规则很少变) │
│ ← 不依赖任何外层、不依赖 Flutter、纯 Dart │
├─────────────────────────────────────────────┤
│ Data(数据层) │
│ Repository 实现 + DataSource(远程/本地) │
│ DTO + 数据映射 │
└─────────────────────────────────────────────┘
依赖方向:外 → 内(单向)1.2 唯一必须遵守的规则:依赖规则
依赖只能指向内部。内层不知道外层的存在。
具体到代码:
| 层 | 能知道谁 | 不能知道谁 |
|---|---|---|
| Domain | 只能知道自己(Entity / UseCase / Repository 接口) | ❌ 不知道 Flutter、不知道 HTTP、不知道 JSON |
| Data | 知道 Domain(实现其接口) | ❌ 不知道 View |
| Presentation | 知道 Domain(调用 UseCase) | ❌ 不知道 DTO、不知道 HTTP 细节 |
怎么做到"内层不知道外层" → 依赖倒置(DIP):
Domain 层定义:abstract class DeviceRepository { ... } ← 接口在内部
Data 层实现:class DeviceRepositoryImpl implements DeviceRepository { ... }
运行时注入: Riverpod provider 把 Impl 绑到接口上💡 好消息:本项目已经在用这个模式的一半了——
data/repositories/<feature>/里就是abstract + Remote + Mock三件套(见 07 篇)。 差别在于:这个abstract定义在data/层,而 Clean 要求它定义在domain/层。
1.3 为什么值得这么做(真实收益,不是玄学)
| 收益 | 说明 |
|---|---|
| 业务逻辑可测 | Domain 层是纯 Dart,单测不需要 Flutter 环境,跑得飞快 |
| 换数据源不影响业务 | 远程改本地、换 HTTP 库、加缓存——只改 Data 层 |
| UI 改了不影响业务 | 重写页面不动业务逻辑 |
| 业务规则有"家" | 不用在 10 个 ViewModel 里找"某个校验逻辑在哪" |
1.4 代价(必须诚实地说)
| 代价 | 说明 |
|---|---|
| 样板代码 | 每个功能多出 Entity/DTO/Mapper/UseCase/Repository 接口+实现,可能多 3~5 个文件 |
| 间接层 | "改一个小功能要跳 5 个文件",新人上手慢 |
| 过度设计风险 | 简单的 CRUD 页面也套全套,纯属自虐 |
⚠️ 结论:Clean Architecture 的收益随业务复杂度增长。 简单 CRUD 用它是负收益,复杂业务用它是正收益。
二、DDD 是什么(与 Clean 的关系)
先说关系,避免混淆:
| Clean Architecture | DDD | |
|---|---|---|
| 是什么 | 分层架构(代码怎么摆) | 建模方法论(业务怎么理解和切分) |
| 关注点 | 依赖方向、层次 | 领域概念、边界、语言 |
| 关系 | DDD 的战术设计通常落在 Clean 的 Domain 层 | 二者常配合使用,但可单独用 |
一句话:Clean 管"代码放哪",DDD 管"业务怎么切"。
2.1 DDD 的两个部分
| 战略设计 | 战术设计 | |
|---|---|---|
| 关注 | 如何划分边界(大图) | 如何写领域对象(细节) |
| 产出 | 限界上下文、上下文映射 | Entity、Value Object、Aggregate、Domain Service |
| 谁参与 | 需要与业务/产品沟通 | 开发内部 |
| 对本项目的意义 | 理解 ui/<feature>/ 的划分是否合理 | 决定要不要引入 Entity / UseCase |
2.2 战术设计的四个核心概念(记住这四个就够)
| 概念 | 一句话 | 判断方法 | 例子(本项目语境) |
|---|---|---|---|
| Entity(实体) | 有唯一标识、会变化的对象 | "两个对象字段全一样,但是不是同一个?"→ 不是同一个就是 Entity | Device(有 deviceId) |
| Value Object(值对象) | 无标识、靠值相等、不可变 | "字段全一样就是同一个" → 是 VO | Money(100, 'CNY')、电量区间 |
| Aggregate(聚合) | 一组必须一起保持一致的对象,有根 | "改这个必须同时改那个,否则数据不一致" | 电站 + 其下设备(改电站要校验设备) |
| Domain Service | 不属于任何单个 Entity 的领域逻辑 | "这个逻辑放哪个 Entity 都别扭" | 跨设备的功率汇总计算 |
2.3 统一语言(Ubiquitous Language)——最容易被忽略但最有用
代码里的类名、方法名,必须和业务人员说的话一致。
| ❌ 坏例子 | ✅ 好例子 |
|---|---|
handleData() | activateDevice() |
DeviceManager / DeviceHelper | DeviceActivationService |
processStatus(int s) | markDeviceAsOffline() |
| 代码叫"电站",产品叫"场站" | 统一叫一个名字,写进术语表 |
为什么重要:当代码和业务说两种语言,每次需求沟通都要"翻译", 翻译就会出错,且新人永远搞不清 plant 和 station 是不是一个东西。
💡 这是 DDD 里性价比最高、成本最低的实践—— 你今天就可以开始做,不需要改任何架构。
三、诊断:本项目现在是什么架构?
3.1 现状(基于 07 篇 的真实目录)
lib/
├── ui/ 434 个 ⭐ 按 feature 组织(home/device/plant_detail/...)
│ └── <feature>/
│ ├── views/ 页面(ConsumerWidget)
│ ├── view_models/ @riverpod class XxxViewModel ← 业务逻辑在这
│ └── models/ UI 用的数据模型
│
├── data/ 353 个 ⭐ 最大目录
│ ├── repositories/ 60 个 abstract + Remote + Mock 三件套
│ └── services/api/ 285 个 Retrofit 接口、DTO、拦截器、SSE
│
├── domain/ 108 个 models/ + advance_function 等(Result<T>、Role、PageDTO)
│
├── he_components/ 108 个 自研 UI 组件库
├── core/ 平台能力(i18n、权限、WebView、PDF)
├── config/ DI 注册(providers)
├── routing/ GoRouter
└── utils/ 工具类调用链:
View → ViewModel(业务逻辑在这)→ Repository(abstract) → ApiService → 拦截器 → HTTP3.2 结论:它是 MVVM + Repository 模式
| 特征 | 本项目 | 是否符合 Clean |
|---|---|---|
| View / ViewModel 分离 | ✅ 有 | ✅ 符合 |
| Repository 抽象(依赖倒置) | ✅ 有(abstract + Remote + Mock) | ⚠️ 接口位置不对 |
| UseCase 层 | ❌ 无(业务逻辑直接在 ViewModel) | ❌ 不符合 |
| Entity(领域实体) | ⚠️ 有 domain/models/ 但只是共享数据结构 | ⚠️ 不完整 |
| Domain 层不依赖 Flutter | ⚠️ domain/ 只有 models,谈不上依赖方向 | ⚠️ 部分 |
| DTO → Entity 映射 | ⚠️ 在 ViewModel 里做(07 篇提到"DTO→UI Model") | ❌ 应在 Data 层 |
3.3 差距对照表(重点)
| Clean 要求 | 本项目现状 | 差距 |
|---|---|---|
Repository 接口在 domain 层 | 接口在 data/repositories/ | 🔴 位置错了(但抽象本身有了,改起来不难) |
| 有 UseCase 编排业务逻辑 | 无,逻辑在 ViewModel | 🟡 主要差距 |
| 有 Entity(带业务行为) | domain/models/ 是纯数据结构(贫血模型) | 🟡 缺业务行为 |
| DTO → Entity 映射在 Data 层 | 映射在 ViewModel 里做 | 🟡 位置不合适 |
| Domain 层纯 Dart | domain/models/ 基本是纯 Dart | 🟢 已满足 |
3.4 三个真实痛点(这些是引入 Clean 的正当理由)
结合目录规模推断,这类项目常见的问题:
| 痛点 | 表现 | Clean 怎么解决 |
|---|---|---|
| ViewModel 膨胀 | 复杂 feature(如 plant_detail/ 42 个文件)的 ViewModel 可能有几百行,混合了校验、计算、接口调用、UI 状态 | 抽出 UseCase,ViewModel 只做状态管理 |
| 业务规则散落 | "设备能否激活"的判断可能在多个 ViewModel 里各写一遍 | 收敛到 Domain 层,单一来源 |
| 业务逻辑难测 | 测业务逻辑要起 Widget 环境、mock 一堆 provider | Domain 层纯 Dart,单测秒级 |
⚠️ 以上是基于架构特征的推断,具体痛点到什么程度, 需要你打开几个复杂 feature 的
view_models/实际看看。
四、要不要演进?(决策框架)
4.1 决策树
先问:现在有痛点吗?
│
├─ 没有(页面简单、改得快、没人抱怨)
│ └─→ ✅ 不要动。现在这套 MVVM + Repository 很好。
│
├─ 有,但只是个别页面复杂
│ └─→ ✅ 局部引入 UseCase(见 4.2)。最小改动,收益最直接。
│
├─ 有,且整体逻辑混乱、测试几乎没法写
│ └─→ 评估渐进式演进(见 4.3),按 feature 逐步抽离
│
└─ 有人说"我们应该上 Clean Architecture"
└─→ 先问:解决什么具体问题?说不出来就不做。4.2 推荐路径:局部引入 UseCase(性价比最高)
不要整体重构,只在复杂业务流程上加一层:
之前:ViewModel → Repository
之后:ViewModel → UseCase → Repository什么时候该加 UseCase:
| 信号 | 例子 |
|---|---|
| 一个操作要调多个 Repository | 创建电站 = 校验名称 + 创建 + 绑定设备 + 刷新列表 |
| 逻辑要被多处复用 | "设备能否激活"在列表页和详情页都要判断 |
| 有复杂业务规则/校验 | 参数下发的边界校验、权限组合判断 |
| ViewModel 超过 200 行 且混了多种职责 | — |
什么时候不该加:
| 反信号 | 理由 |
|---|---|
| 简单的 CRUD(查列表、看详情) | 只是转发给 Repository,多一层没意义 |
| 只被一个地方调用,且很短 | 直接放 ViewModel 更好找 |
写法示例(纯 Dart,无 Flutter 依赖):
// domain/usecases/activate_device.dart
class ActivateDevice {
ActivateDevice(this._deviceRepo, this._plantRepo);
final DeviceRepository _deviceRepo; // ← 依赖的是抽象接口
final PlantRepository _plantRepo;
Future<Result<Device>> call(String deviceId, String plantId) async {
// 1. 校验:电站是否存在
final plant = await _plantRepo.getPlant(plantId);
if (plant == null) return const Result.failure('电站不存在');
// 2. 校验:设备是否已激活(业务规则)
final device = await _deviceRepo.getDevice(deviceId);
if (device.isActivated) return const Result.failure('设备已激活');
// 3. 执行
return _deviceRepo.activate(deviceId, plantId);
}
}
// Riverpod 里注册(在 config/providers/)
@riverpod
ActivateDevice activateDevice(ActivateDeviceRef ref) => ActivateDevice(
ref.read(deviceRepositoryProvider),
ref.read(plantRepositoryProvider),
);ViewModel 变薄:
// 之前:ViewModel 里塞满校验逻辑
// 之后:
Future<void> activate(String deviceId, String plantId) async {
state = state.copyWith(loading: true);
final result = await ref.read(activateDeviceProvider)(deviceId, plantId);
state = state.copyWith(loading: false, result: result); // 只管状态
}收益:这段业务逻辑现在可以纯 Dart 单测,不需要 Widget、不需要 mock HTTP 之外的东西。
4.3 如果要进一步:按 feature 渐进演进
原则:
| 原则 | 说明 |
|---|---|
| 增量,不 Big Bang | 一次只改一个 feature,改完跑测试、上线观察 |
| 先改最痛的 | 从最复杂、改得最频繁的 feature 开始(如 plant_detail/) |
| 新老可以共存 | 旧 feature 维持 MVVM,新 feature 用新结构,边界清晰即可 |
| 先把 Repository 接口挪到 domain | 这一步成本低、收益明确(见 3.3 差距表),建议第一步做 |
不建议做的事:
- ❌ 一次性把 1118 个文件重构成 Clean
- ❌ 为了"统一"把简单页面也套上全套
- ❌ 在没有测试覆盖的情况下大改(先补测试,见 19 篇)
五、目录结构参考(演进后的样子)
如果要做,这是推荐的目标结构(按 feature 组织,而非按层组织):
lib/
├── features/ ⭐ 按业务能力切分
│ └── device/
│ ├── presentation/ View + ViewModel
│ ├── domain/ ⭐ 纯 Dart
│ │ ├── entities/ Device(带业务行为)
│ │ ├── repositories/ DeviceRepository 接口
│ │ └── usecases/ ActivateDevice / GetDeviceList
│ └── data/
│ ├── datasources/ DeviceApiService(Retrofit)
│ ├── models/ DeviceDTO(含 fromJson)
│ └── repositories/ DeviceRepositoryImpl
│
├── core/ 跨 feature 共享(网络、错误、Result<T>)
├── he_components/ UI 组件库(现有,保持不变)
└── routing/ GoRouter注意:现有结构是按层分的(ui/ data/ domain/ 平铺), 上面是按 feature 分的。两种都可以,各有取舍:
| 组织方式 | 优点 | 缺点 |
|---|---|---|
| 按层(项目现状) | 简单直观,找某层的文件很快 | 一个需求要跨 3 个顶层目录跳 |
| 按 feature | 一个业务的所有代码在一起,内聚性强 | 跨 feature 共享的东西要放 core/ |
💡 项目现状
ui/<feature>/已经是按 feature 组织了, 但data/是 353 个文件平铺的大泥球。 如果只改一件事,建议是:把data/按 feature 拆开——这个收益比上 Clean 更直接。
六、常见误区
| 误区 | 正解 |
|---|---|
| "Clean Architecture 是最好的架构" | ❌ 没有最好的架构,只有最合适的。简单项目用它是过度设计 |
| "上 Clean 项目就更规范" | ❌ 架构不能救烂代码。命名混乱、职责不清,套什么架构都乱 |
| "DDD 就是要写很多 Entity" | ❌ DDD 的核心是统一语言和边界划分,不是类的数量 |
| "贫血模型一定不好" | ⚠️ 对简单 CRUD 来说贫血模型完全够用,强行加行为是过度设计 |
| "Repository 就是 DAO" | ❌ Repository 是领域概念的集合根,DAO 是数据访问。Repository 返回 Entity,DAO 返回 DTO/行 |
| "UseCase 每个接口一个" | ❌ 那样只是转发,没价值。UseCase 应该是"用例"(一个用户动作),常跨多个 Repository |
| "先写好架构,业务再往里填" | ❌ 架构应该随业务复杂度生长。先有重复,再抽 |
| "重构 = 重写" | ❌ 重构是小步、可回退的。1118 文件的项目绝不能一次性重写 |
"把 data/ 拆开就是 DDD" | ❌ 目录划分是表象,DDD 的核心是业务建模和统一语言 |
七、动手练习
练习 A:诊断一个真实 feature(推荐先做这个)
打开 lib/ui/plant_detail/(42 个文件,较复杂),检查:
view_models/里最大的文件有多少行?- 里面混合了哪几类职责?(接口调用 / 数据转换 / 业务校验 / UI 状态)
- 有没有业务规则在其他 feature 里被重复实现?
- 这段逻辑现在好不好测?需要起 Widget 环境吗?
输出:一份 3~5 行的诊断结论,说明"这个 feature 是否需要 UseCase 层,为什么"。
练习 B:抽一个 UseCase
选练习 A 里最复杂的一个操作(如"创建电站"或"激活设备"), 按 4.2 的写法抽出一个 XxxUseCase(纯 Dart,不 import Flutter)。
验证:
- 写单测:mock 掉 Repository,测试各种分支(成功 / 校验失败 / 异常)
- 对比:这个测试有没有用到
testWidgets?如果没有,说明 Domain 层抽干净了 - 感受:单测运行速度是否明显快于 Widget 测试
练习 C:统一语言检查
挑 3 个业务概念(如"电站/场站"、"设备激活/绑定"、"安装商/运维商"),检查:
- 代码里的命名和产品/业务说的话是否一致?
- 有没有同一个概念用了两个英文单词(如
plant和station混用)? - 列一张术语表,标出不一致的地方
八、自检清单
学完本文,你应该能够:
- [ ] 画出 Clean Architecture 的三层图,并说出依赖规则(只能指向内部)
- [ ] 解释什么是依赖倒置,以及本项目哪里已经用上了(Repository 三件套)
- [ ] 说出 Clean 的三个真实收益与三个代价
- [ ] 区分 Clean(代码怎么摆) 与 DDD(业务怎么切)
- [ ] 说出 DDD 战术设计的四个概念:Entity / Value Object / Aggregate / Domain Service,并会判断
- [ ] 解释"统一语言"是什么,为什么它是性价比最高的 DDD 实践
- [ ] 诊断出本项目是 MVVM + Repository,并说出三个与 Clean 的差距
- [ ] 说明本项目的
domain/为什么"不是完整领域层"(只有 models,无 UseCase/Entity 行为) - [ ] 判断什么时候该加 UseCase、什么时候不该(各说 3 条)
- [ ] 针对本项目给出务实的演进建议(不是重写)
- [ ] 说出"按层组织"与"按 feature 组织"的取舍,并判断项目当前更适合改哪个
- [ ] 至少能反驳 3 条常见误区(如"Clean 是最好的架构")
九、本篇结论(给你的一句话建议)
不要整体重构成 Clean Architecture。
优先级建议:
| 优先级 | 动作 | 理由 |
|---|---|---|
| P0 | 统一语言(命名对齐业务) | 零成本、立刻见效 |
| P1 | 复杂 feature 局部引入 UseCase | 直接解决"ViewModel 膨胀 + 难测" |
| P2 | 把 data/(353 个文件)按 feature 拆分 | 收益比上 Clean 更直接,解决"大泥球" |
| P3 | Repository 接口迁到 domain/ | 成本不高,但收益也不如前两项 |
| 不做 | 全量重构为 Clean | 1118 文件,风险 >> 收益 |
判断标准不变:架构是为了解决问题。说不清解决什么问题的架构改进,就不要做。
相关文档
- 07 · 项目架构总览 —— 项目真实目录与分层(本篇的诊断基础)
- 09 · 数据层链路 —— Repository、DTO、拦截器的具体实现
- 08 · 状态管理 Riverpod —— ViewModel 的写法、依赖注入
- 27 · 状态管理选型与横向对比 —— 同款的"选型/演进"方法论
- 19 · 测试实战 —— 重构前如何补测试、UseCase 如何单测
- 30 · 包与插件开发 —— 抽离复用代码的形态选择(含"何时不该抽")
- 11 · 组件与主题 —— UI 组件层(
he_components/)