做中后台和SaaS类系统的团队,大概率都绕不开 ABP 这个名字。它把模块化、仓储模式、工作单元、动态 API、审计日志、多租户这些东西一次性打包成开箱即用的起点,用 .NET 技术栈做内部系统,几乎第一天就能跑起来。我所在的团队也是这样起步的,当时 ABP 帮我们省了大概一个多月的基建时间,说实话,第一版交付速度是相当漂亮的。
可当系统跑过一年半载,从一条产品线扩到三四条,模块之间开始交叉引用,业务规则开始细化以后,ABP 带来的便利慢慢变成了另一种负担。框架替你做的约定越多,你想打破约定时付出的成本就越高。到这个时候我开始认真琢磨 Clean DDD 那条路,不是要把 ABP 推翻,而是把框架从“架构本身”降级成“基础设施之一”。这篇文章想把这条演化路线的思考和具体做法整理出来,适合正处在“ABP 用着挺好但总觉得哪里不对劲”阶段的团队参考。
1. 为什么 ABP 前期很香,中期开始别扭
1.1 ABP 真正帮我们省下的是什么
先说清楚 ABP 到底解决过什么问题,否则容易把后面对它的批评变成“立场先行”。ABP 最大的价值不是“那个 AppService 基类”,而是它帮你建立了从 Entity、Repository、ApplicationService 到 Controller 的一条默认流水线。业务逻辑只管写实体、改造仓储、然后在 ApplicationService 里调一调,剩下的数据库上下文、事务边界、接口暴露、权限校验,框架都替你接好了。
对刚开始做业务系统的团队来说,这是一份非常标准、成熟度很高的脚手架。它帮你把团队里每个人写代码的下限拉高了,新同学只要照着模板抄,也不会写出太离谱的分层。这一点在项目初期价值极大,因为我们那时候最大的问题不是“架构不好”,而是“大家不知道公共约定是什么”。
ABP 的模块化设计也很有用。不同业务域拆成不同模块,模块之间通过引用关系和显式依赖来协作,比把所有业务塞进一个 Web 项目里要清晰得多。加上多租户、审计日志、软删除这些横切关注点都是现成的,省掉的并不只是写代码时间,而是“要不要自己造一套基础设施”的决策成本。
1.2 别扭通常在什么阶段出现,具体堵在哪
问题是在系统真正进入“演进期”之后浮出来的。大致在三条产品线共用一套平台、业务规则开始互相约束的时候,几个地方会开始让人难受。
第一是框架约束渗透到领域模型里。我们早期直接继承 ABP 的实体基类,实体自带 Id、创建时间、软删除标记,看起来很顺手,但时间一长就会发现,领域对象不应该被任何框架的基类“定义”出来。比如有些核心实体我们希望用特殊的主键策略,有些希望用值对象来表达身份,但为了跟框架兼容只能继续用基础类型,这种妥协是隐性的,等到要改的时候已经遍布全代码库。
第二是 ApplicationService 越来越不像“应用服务”,而更像“万能中转站”。ABP 允许在服务里注入仓储和领域服务,代码写快了以后,一个方法里既做了权限判断、又做了业务校验、还顺手写了两条审计日志、最后返回一个 DTO。名义上是分层了,实际业务逻辑全堆在最外层,应用层和领域层完全无法区分。第三是升级链路太承重。框架版本一升级,基类变了、泛型变了、启动过程变了,这种影响是全方位的,而且属于“不升级不行、升级又怕破坏存量功能”的典型两难。
还有一个容易被忽视的点:ABP 的动态 API 和隐式约定,在前期很省事,后期却成了理解负担。后期团队里会有一种“代码到底是通过什么路由暴露出来的”的模糊感,排查问题时绕一圈才能定位真正入口。
2. Clean DDD 并非一套层次模板,而是一套约束
2.1 依赖规则才是灵魂,不能被技术牵着走
Clean DDD 经常被误解成“把项目分成 Domain、Application、Infrastructure 三层照着写就行”,实际上分层只是次生结果,真正起约束作用的是那条依赖规则。领域层不引用任何基础设施技术,应用层只定义需要的抽象接口,具体实现全部放到最外层。这条规则保证了一层更重要的东西:你的业务核心可以在不被数据库、框架、中间件绑架的情况下独立演化和测试。
用生活化一点的说法:领域层应该是“规则手册”,应用层是“操作员”,基础设施是“工具箱”。规则手册不该知道自己会被哪些工具箱使用,操作员按手册指挥,工具箱按手册提供能力。ABP 之所以后期吃力,是因为它的基类和约定本身充当了一部分“规则手册”,导致你想独立审视规则时,必须同时理解工具。
实际上,在迁移到 Clean DDD 之后,我们并没有马上获得任何业务能力上的提升,最大的变化是“决策成本重新回到我们自己手里”。比如今天想把 EF Core 换成别的东西,因为仓储接口定义在领域层,实现只是基础设施层的一个类,替换范围被限制住了。这种“可替换性”不是说一定会替换,而是说替换之前不需要再评估框架绑定的那部分代码有哪些。
2.2 用例驱动与贫血模型回填
在 ABP 的默认写法里,实体大多数时候是贫血的,也就是只有一堆属性和 GetById、GetList 这类查询,真正的逻辑都在 ApplicationService 里。这种模式在 CRUD 功能里没问题,一旦业务规则变复杂,就会出现“服务层枝繁叶茂、领域层空空如也”的现象,最后所有校验、状态流转、金额计算都堆在服务方法里,测试难度也随之增加。
Clean DDD 的做法是把“用户在某种场景下要完成的一件事”建模为用例,应用层方法只做编排,把规则判断和状态变更放回实体或领域服务。举个例子,订单状态流转“从待支付到已取消”,原来可能在服务层写好几行 if 判断,重构后订单实体自己提供 Cancel(reason) 方法,内部保证“只有待支付才能取消”,应用层只负责找到订单、取到参数、调用 Cancel、保存结果。应用层变薄了,但每个操作背后的领域规则反而更清楚。
回填领域行为的过程并不轻松,因为需要业务人员一起把规则重新梳理一遍。但收益会在后续做单元测试时体现得很明显:测试同样是“构造订单、调用 Cancel、断言状态”,在实体上测和在服务层里测的差别就是“规则归属是否清楚”。
2.3 聚合边界是长期演进的关键决策
Clean DDD 最容易翻车的地方不是分层,而是聚合边界的设计。聚合边界一旦划错,要么把所有实体塞进一个大聚合里导致锁范围过大,要么把一个需要保持一致的实体拆到多个聚合里导致事务边界失控。这个平衡没有银弹,更多时候靠的是领域分析经验和对业务一致性的敏感度。
我们犯过的错误是把“订单”和“订单明细”直接建模成一个超大聚合,觉得这样可以保证所有明细都在同一事务里修改。结果某个高频接口每次保存订单都要加载所有明细,性能越来越差。后来改用“明细作为独立聚合、但在读模型里显示时做表连接聚合”的方案,一致性诉求比较强的状态流转仍然放在订单聚合里,但明细的新增和调整允许独立处理。这里有一个确认过的判断依据:如果两个对象在业务流程中总是同时载入、同时修改,并且离开彼此没有独立意义,那就在一个聚合里;否则尽早拆开,用聚合 ID 建立关联,而不是用对象引用满天飞。
3. ABP 向 Clean DDD 迁移的映射关系与总体路线
3.1 逐层替代的映射表:关键要素怎么对应
做迁移前,先手写一份“ABP 概念到 Clean DDD 概念”的映射表,能有效帮助团队减少争论。我们实际操作时参考了下面这张结构,重点不在于名词替换,而在于“基类驱动”转向“接口驱动”。
| ABP 中的要素 | Clean DDD 中的对应物 | 迁移时要注意什么 |
|---|---|---|
| Entity 基类、审计属性 | POCO 实体,基础设施通过拦截器来填充公共字段 | 实体不再继承框架基类,审计信息由持久化层统一处理 |
| Repository 的泛型基类接口 | 领域层自定义仓储接口,去掉通用 CRUD 约束 | 接口里放业务需要的查询,而不是“所有能搜的方法” |
| ApplicationService 基类 | 应用用例,普通类,不继承任何基类 | 用例只做编排,不直接操作数据库,不接框架上下文 |
| 模块生命周期钩子 | 组合根来注册依赖关系 | 模块化能力可以保留,但不让它侵入领域语义 |
| 动态 API 暴露 | 显式 Controller 调用用例 | 需要补一套精简的 Controller,路由可以被开发团队掌握 |
| 工作单元机制 | 用例控制事务边界,必要时通过基础设施实现 | 明确每个用例的一致性边界,避免跨越太多聚合 |
| AutoMapper 全自动映射 | 显式映射器或手写赋值 | 性能更可控,映射逻辑也更明确 |
这张表最有用的一点是:它告诉我们“换掉 ABP”不是一次性工程,而是把每一个框架动词改成显式代码。把每个隐式约定都变成显式声明,长期维护起来才不费脑力。
3.2 迁移路线:不妨先用绞杀者模式给老系统留退路
在已有系统上做架构改造最忌讳“停止业务、全量重写”。我们采用的路线其实就是经典的绞杀者模式:先让新结构和老结构并存,新功能尽量用新方式写,老功能保持原状,等某个模块的改动频繁到无法忍受时,再把它整体迁移到新结构上。
具体落地时,我们在原有 ABP 解决方案里新增了一套独立的领域核心项目,初版只包含领域模型、领域服务、仓储接口和应用用例,完全不带 ABP 依赖。老代码继续跑,新写的业务先落到这套核心项目里,Controller 层再做一层薄薄的适配,既能调用老 AppService,也能调用新用例。这个阶段不要追求一步到位,关键是验证新结构能不能支撑业务节奏,能不能跑通一条完整的用例链路。
跑了大概两个迭代之后,我们发现新结构的单元测试成本远低于老结构,因为用例不再依赖 ABP 的 TestBase,而是直接跑内存仓储。团队的新成员在写新功能时也更容易表达“业务是什么,而不是框架怎么用”。到了这个状态,才能确认继续推进是值得的。
3.3 动态 API、仓储、工作单元三个硬骨头的接管方案
先说动态 API。ABP 自动暴露 AppService 为 HTTP 接口,省了 Controller 但也模糊了入口。接管方案就是“显式 Controller + 显式用例”。看起来多写了代码,但换来的是路由可追踪、参数校验和中间件可以被部署团队理解。推荐的做法:Controller 保持薄壳,只做参数解析、权限声明和结果包装,所有实质逻辑都转到用例里。
仓储方面,ABP 的 IRepository<TEntity, TPrimaryKey> 太泛化,谁都能往里塞 IQueryable 查询条件,实际上一拉就是一长串表达式。我们把仓储接口改造成领域语义,比如 OrderRepository.FindPendingOrdersByCustomer(customerId),而不是通用地写 Repository.GetAll().Where(...) 。这背后还有一个原因:通用仓储接口会让上层代码脱离业务语言,领域层应该表达“我要找什么”,而不是“怎么找”。
工作单元是另一块容易踩坑的地方。ABP 的 UnitOfWork 会自动开启事务,某种程度上是帮你兜底了;但代价是事务边界不可见,一个请求可能莫名其妙地跨越很多数据库操作。改造时我们采用“用例级别”的事务边界思想:每个用例在执行前开启显式数据库事务,用例成功提交、失败回滚。跨聚合的用例用一个事务保护,但尽量约束范围,而不是让所有操作天然处于一个大事务里。
4. 实操复盘:一次真实迁移过程
4.1 改造前状态与痛点清单
为了不纸上谈兵,这里把我们团队实际经历的一次迁移完整复盘一遍。当时面对的是一个已经运行了一年半的中后台系统,四个子域:客户管理、订单、财务结算、消息通知。代码量大概在二十万行左右,全部基于 ABP 搭建。痛点集中在这几类:订单服务的核心方法长达三百行,价格计算、优惠分摊、库存扣减、状态流转全揉在一起;实体中几乎没有业务行为,任何状态变更都依赖服务层之外的大量 if;多租户逻辑虽然 ABP 有现成支持,但租户过滤器偶尔会对跨租户查询误伤,排查费劲;单元测试几乎约等于零,没有 ABP 内部测试基类就不知道怎么写。
我们也盘点过“继续用 ABP 修修补补”的方案。能在现有结构上继续加功能,但不解决“规则在哪”的根本问题。最后决定用两个迭代的时间完成核心域迁移试点,试点选择的是订单域,因为它是逻辑最复杂、改动最频繁、团队感知最明显的一块。
4.2 第一刀:剥掉实体基类与仓储基类
第一步动手的是领域最底层。把原来的订单实体从继承 ABP 的 Entity 改成普通 POCO 类,去除全参数构造函数以外的框架基类依赖。实体的主键保留 Guid 类型不变,这样数据库层不会出现显著变化,暂时规避了主键策略切换带来的风险。
原来实体上的 CreatedAt、UpdatedAt、IsDeleted 这些字段,我们先保留属性定义,但不从基类继承了。这意味着框架不会自动给它们赋值,所以紧接着引入了 EF Core 的 SaveChanges 拦截器:在保存时统一补上审计字段、软删除标记。这一步看起来很平淡,但实际上是把“框架隐式行为”变成了“基础设施显式行为”,领域模型从此彻底干净。
仓储基类的剥离比预想中更考验耐心。因为老代码大量应用了 GetAll().Where(...) 的写法,直接改成领域语义仓储会带来很大的改动面。我们采用逐步收口策略:新用例一律通过新定义的 OrderRepository 接口拿数据,老代码保留 ABP 仓储,但封装一层防腐适配器,将框架仓储的调用转换为领域仓储接口。这样老模块不用一天改完,新模块也不会再被框架仓储污染。
4.3 第二刀:把 ApplicationService 改造成纯用例编排
领域层干净之后,紧接着处理应用层。原来订单相关的 AppService 上的每一个方法,我都逐个拆成了独立的用例类,例如 CancelOrderHandler、CreateOrderFromCartHandler。用例类的依赖只有一个总入口和若干领域服务/仓储接口,不再直接持有数据库上下文或 ABP 的 IUnitOfWork。
拆用例的过程中,最大的改造点是“把逻辑从服务层搬回领域层”。拿价格计算举例,原来逻辑用到的条件分散在服务方法、一些静态工具类、甚至查询语句里,很难确认哪一段是真正的业务规则。我们专门约了一场线下梳理会,把价格规则重新写成了订单聚合上的显式行为,应用层用例只负责把参数传进去、拿到结果、再调一次数据库保存。改造后的方法行数从三百行降到三十行左右,可读性是肉眼可见的变好,但这只是结果。本质变化是“规则归属明确了”。
还有一点值得提醒:用例虽好,但也别拆得太碎。如果团队里每个小操作都拆一个用例,命名成本和组织成本都会快速上升。我们坚持一个原则——只有承载了独立业务场景且存在不同成功/失败分支的操作才拆成用例;简单 CRUD 不拆分,直接由 Controller 调用一个薄的应用服务即可,保持适度的务实。
4.4 第三刀:基础设施层重建与事务边界控制
领域层和应用层调整到位以后,基础设施层成为最后一个战场。数据库实体映射改为使用 Fulent API 显式配置,不再依赖 ABP 约定,例如软删除过滤器、租户过滤器、审计字段,都在 DbContext 里通过配置和拦截器显式实现。这个过程中要测试的行为很多,最常见的是“软删除是否还把关联子表的行带了出来”,以及“多租户查询是否被错误过滤掉”。
事务边界控制是我们踩坑最集中的地方。原来需求方可能觉得“下单、扣库存、发通知”应该在一个事务里,如果按照严格 DDD 路线,这三个对象可能天然就是三个聚合,跨聚合的强一致事务要么缩小范围,要么通过流程事件来最终一致。我们实际选型是:先把库存扣减和订单状态放在同一个用例里,用同一个事务保住强一致,发通知则放进领域事件的后置流程中,允许失败重试。这是妥协,但也是能落地的折中方案。改造之后事务边界不再散落四处,而是集中体现在用例或者流程顶层。
5. 常见问题与排查技巧实录
5.1 事务与工作单元边界丢失
改造中最先遇到的问题就是“方法跑完了数据没保存”。ABP 里一个 AppService 方法结束时会统一提交工作单元,改造成普通用例后,每个方法除了职责本身的代码,还必须显式地处理“保存动作”。解决方案相当直白:约定用例执行完成后统一经过一个输出管道来调用 SaveChangesAsync,或者在使用例的外层套上统一的执行器,负责开启事务、提交、回滚。这比每个用例内部手动调用 SaveChanges 更不容易遗漏。
排查这类问题时,我习惯先问自己三个问题:这个用例是不是只操作一个聚合?如果是,单次 SaveChanges 原子性就够了;如果不是,这个用例为什么需要跨聚合,能否拆成事件通知?如果必须保持强一致,事务是要明确接管的,而不是靠巧合把所有操作堆到一个方法里顺便成功。
5.2 审计字段、软删除等横切关注点回归
ABP 自动填充审计字段的便利,在改造初期会“突然消失”。有一段时间我们新写的用例里创建时间全是默认值,因为拦截器没接上或者该实体没走统一配置。这个问题的根因往往是“纪律没有跟上”。如果实体不继承基类,那就要靠基础设施统一填值,而非业务代码各自处理。把审计字段赋值统一放在 EF 拦截器或保存管道里之后,这个问题基本没再登高。
软删除的处理比审计字段复杂一点。早先 ABP 有全局过滤器,切换到 EF Core 后,我们必须自己在模型配置里给每个聚合根加上查询过滤器。最坑的坑在于,如果聚合内部有子实体是联动删除的,查询过滤器的表达式稍有写错,就会出现还堆在数据库里的旧数据突然在界面里消失或冒出来的现象。建议迁移后的第一个月,把删除行为都写成集成测试,至少覆盖“删除后无法查询、子数据同步隐藏、反向关联不受影响”三种场景。
5.3 导航属性与 N+1 问题
ABP 时代我们习惯在实体上放大量导航属性,反正可以懒加载或手动 Include。Clean DDD 后如果还沿用这套写法,很快会吃到大亏。领域聚合之间原则上只保留 ID 引用,需要展示关联数据的查询,应当单独建读模型或者使用专门的查询仓储,而不是从聚合根一路导航过去。我们有一段时间图省事,在聚合内继续写了一对多导航,结果列表接口随手触发几十条 SQL,直接被压测抓出来。
遇到 N+1,排查思路比较固定:抓取对应 SQL 日志,找到最频繁的主查询,再寻找重复的次查询。次查询一旦稳定发生,无非两个修法——调整 Include 策略把关联数据预加载出来,或者干脆把展示需要的字段做成独立的投影查询。长期来看,我更推荐尽量走投影查询,因为读模型一旦独立出来,领域聚合就可以更纯粹地专注写操作,性能问题也更好控制。
5.4 权限、校验等切面要显式补回来
ABP 自带的权限机制会在进入业务代码前做拦截。改造以后,这类横切关注点并不会自动继承。实际处理时,我们把权限校验放在 Controller 层统一做,Controller 拿到用户上下文后,先调用权限服务判断当前用户是否有该用例的执行权限,然后再进入用例。这套做法虽然看起来把权限逻辑留在外部,但其实很合理,因为权限本质上是“入口控制”,不该污染领域模型。
另一种做法是在用例外层包装一个带权限检查的代理,实现上用装饰器模式或中间件。两种方式没有绝对优劣,关键是要有一致约定。我们最后选择 Controller 层检查,是因为团队对这套方式的理解成本最低,排查某个接口没权限时,顺着入口一路往下走就行了。
6. 架构长期演进的配套治理
6.1 用架构测试守护依赖方向
Clean DDD 的结构优势不会因为“大家说好了按规则写”就自动保持。人的记忆是会衰退的,尤其是新同学加入后更是如此。我们把依赖方向检查做成了自动化的架构测试,规则很简单:领域层项目不能引用应用层、基础设施层的项目;应用层只能引用领域层和定义的接口;基础设施层可以引用所有层,但不得反向影响领域层定义。
这个检查不需要很复杂的框架,用行业里常见的架构测试库就能定义项目间的引用规则,CI 里跑一遍。实践证明,团队写代码时违反依赖方向的行为会被立刻指出来,比在代码评审中口口相传有效率得多。这类测试的成本不高,但它是架构长期演进的“交通护栏”,建议任何打算做 Clean DDD 改造的团队尽早加上。
6.2 团队协作约定与代码评审清单
在代码评审阶段,我们的清单也变了。评审不再问“这个方法怎么实现的”,而是更关心几个问题:这个业务规则是否表述在领域层?用例是否过厚或在编排之外额外做了规则判断?仓储接口是否暴露了不适当的查询能力?实体是否又出现了无意义的 Set 方法?这些检查点比“有没有写注释”有价值得多,也基本把“慢慢退回贫血模型”的趋势挡住了。
约定层面有一条我们后来一直用的口头规则:能通过代码表达清晰的,就不写注释;能用领域术语命名的,就不用通用名词。比如仓储接口方法叫 FindPendingRefunds 就比 GetAll 更能体现业务意图。这类约定并不属于 Clean DDD 本身,但它让架构风格落实到日常协作中,效果非常好。
6.3 什么时候不建议 Clean DDD 硬迁移
说到最后,也得提醒一句老实话:不是所有项目都值得做这套迁移。如果你的系统规模就在十万行以内,业务规则也不复杂,ABP 本身的框架约束并不会成为主要瓶颈,硬搬 Clean DDD 只会增加写作成本和认知负担。尤其是团队规模很小的场景,让每个人都同时理解依赖规则、聚合边界、事件一致性,反而可能拖慢交付速度。
我们当时能够推进,是因为团队已经到了“多个业务域共享一套平台、逻辑复杂度持续上升、单测基本为零”这个阶段,改造的收益可以被明显感知。如果你的问题只是“觉得 ABP 版本升级麻烦”或“某个接口写得不优雅”,那最佳策略是局部调整而不是全量重构。架构本身是为演进服务的,不适合为“架构潮流”去服务。
逐个模块替换完以后,我个人最大的体会不是“Clean DDD 比 ABP 强多少”,而是“团队现在敢改代码了”。以前改一个核心实体逻辑时要反复确认三个服务方法有没有被依赖,现在只要对着用例和领域模型改就能整体把握影响面。这套模式跑过两个版本后,我又不小心踩过一次“在领域层引用基础设施类”的坑,被架构测试当场拦下。此后我更加确信,长期演进真正依赖的,不是某一套架构的名字,而是团队能够持续识别约束、落实约束、再把约束变成自动化工具的能力。如果一个项目已经到了业务复杂度和交付节奏的临界点,花时间设计这样一条迁移路线是值得的。