从ABP到Clean DDD:中后台系统架构迁移实践与反思

📅 发布时间:2026/10/12 6:02:25
从ABP到Clean DDD:中后台系统架构迁移实践与反思
做中后台和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 的 IRepositoryTEntity, 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 导航属性与 N1 问题ABP 时代我们习惯在实体上放大量导航属性反正可以懒加载或手动 Include。Clean DDD 后如果还沿用这套写法很快会吃到大亏。领域聚合之间原则上只保留 ID 引用需要展示关联数据的查询应当单独建读模型或者使用专门的查询仓储而不是从聚合根一路导航过去。我们有一段时间图省事在聚合内继续写了一对多导航结果列表接口随手触发几十条 SQL直接被压测抓出来。遇到 N1排查思路比较固定抓取对应 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 强多少”而是“团队现在敢改代码了”。以前改一个核心实体逻辑时要反复确认三个服务方法有没有被依赖现在只要对着用例和领域模型改就能整体把握影响面。这套模式跑过两个版本后我又不小心踩过一次“在领域层引用基础设施类”的坑被架构测试当场拦下。此后我更加确信长期演进真正依赖的不是某一套架构的名字而是团队能够持续识别约束、落实约束、再把约束变成自动化工具的能力。如果一个项目已经到了业务复杂度和交付节奏的临界点花时间设计这样一条迁移路线是值得的。