Skip to content

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 / sagaUseCase(用例)业务流程编排
services/ 目录(业务逻辑层)Domain 层(UseCase)前端其实也有这层,只是常写得很薄
axios 封装DataSource / ApiService数据获取细节
React Query 的 useQueryRepository + ViewModel数据获取 + 缓存 + 状态
后端返回的 JSON 类型DTO(Data Transfer Object)传输结构
前端自己定义的业务 modelEntity(实体)领域概念
只有字段、没有方法的 interface贫血模型(Anemic Model)Clean 要避免的反模式
前端"按 feature 分目录"DDD 的限界上下文(简化理解)按业务能力切分
Monorepo 里的 packages/xxxDDD 的模块/聚合边界边界与依赖方向

最关键的认知差(前端转 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 ArchitectureDDD
是什么分层架构(代码怎么摆)建模方法论(业务怎么理解和切分)
关注点依赖方向、层次领域概念、边界、语言
关系DDD 的战术设计通常落在 Clean 的 Domain 层二者常配合使用,但可单独用

一句话:Clean 管"代码放哪",DDD 管"业务怎么切"。

2.1 DDD 的两个部分 ​

战略设计战术设计
关注如何划分边界(大图)如何写领域对象(细节)
产出限界上下文、上下文映射Entity、Value Object、Aggregate、Domain Service
谁参与需要与业务/产品沟通开发内部
对本项目的意义理解 ui/<feature>/ 的划分是否合理决定要不要引入 Entity / UseCase

2.2 战术设计的四个核心概念(记住这四个就够) ​

概念一句话判断方法例子(本项目语境)
Entity(实体)有唯一标识、会变化的对象"两个对象字段全一样,但是不是同一个?"→ 不是同一个就是 EntityDevice(有 deviceId)
Value Object(值对象)无标识、靠值相等、不可变"字段全一样就是同一个" → 是 VOMoney(100, 'CNY')、电量区间
Aggregate(聚合)一组必须一起保持一致的对象,有根"改这个必须同时改那个,否则数据不一致"电站 + 其下设备(改电站要校验设备)
Domain Service不属于任何单个 Entity 的领域逻辑"这个逻辑放哪个 Entity 都别扭"跨设备的功率汇总计算

2.3 统一语言(Ubiquitous Language)——最容易被忽略但最有用 ​

代码里的类名、方法名,必须和业务人员说的话一致。

❌ 坏例子✅ 好例子
handleData()activateDevice()
DeviceManager / DeviceHelperDeviceActivationService
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 → 拦截器 → HTTP

3.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 层纯 Dartdomain/models/ 基本是纯 Dart🟢 已满足

3.4 三个真实痛点(这些是引入 Clean 的正当理由) ​

结合目录规模推断,这类项目常见的问题:

痛点表现Clean 怎么解决
ViewModel 膨胀复杂 feature(如 plant_detail/ 42 个文件)的 ViewModel 可能有几百行,混合了校验、计算、接口调用、UI 状态抽出 UseCase,ViewModel 只做状态管理
业务规则散落"设备能否激活"的判断可能在多个 ViewModel 里各写一遍收敛到 Domain 层,单一来源
业务逻辑难测测业务逻辑要起 Widget 环境、mock 一堆 providerDomain 层纯 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 依赖):

dart
// 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 变薄:

dart
// 之前: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 个文件,较复杂),检查:

  1. view_models/ 里最大的文件有多少行?
  2. 里面混合了哪几类职责?(接口调用 / 数据转换 / 业务校验 / UI 状态)
  3. 有没有业务规则在其他 feature 里被重复实现?
  4. 这段逻辑现在好不好测?需要起 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 更直接,解决"大泥球"
P3Repository 接口迁到 domain/成本不高,但收益也不如前两项
不做全量重构为 Clean1118 文件,风险 >> 收益

判断标准不变:架构是为了解决问题。说不清解决什么问题的架构改进,就不要做。


相关文档 ​