资讯详情

资讯详情

从ABP到Clean DDD:重构可长期演进的.NET业务系统

接手过几个用 ABP 框架搭建的中大型后台系统之后我越来越倾向于这样一种判断ABP 是一套极好的快速启动框架但它解决的是“如何快速把系统搭出来”而不是“如何让系统在五年之后依然能清晰地演进”。当业务从“增删改查”进化到“领域规则复杂、多方协作、状态流转频繁”的阶段之后纯粹的 ABP 风格开发会遇到明显的天花板。于是就有了这篇文章从 ABP 的舒适区出发逐步调整到 Clean Architecture 与 DDDDomain-Driven Design的组合打法聊聊我对“可长期演进的软件系统架构”的理解和实际操作心得。这篇文章适合正在用 ABP 或其他全栈式框架做业务系统、却又隐隐觉得架构越来越别扭的开发者也适合准备从零搭建一个复杂业务系统、不希望三五年后就陷入重构泥潭的技术负责人。我会尽量用实际场景说话把“为什么这么做”讲透而不只是罗列概念。1. ABP 带来的高效与它埋下的隐患1.1 ABP 的核心价值把八十步直接走到八十步ABP 对 .NET 开发者来说是少有的“全家桶”式框架。它帮你把模块化架构、依赖注入、自动映射、仓储模式、工作单元、后台任务、多租户、审计日志、身份认证与授权等一堆基础设施全部打包。我最早接触它的时候最大感受是我只需要写实体类然后生成应用服务、DTO、AutoMapper 映射CRUD 接口就齐了。系统第一版上线速度极快这在早期帮助团队快速验证业务是完全谈不上“过度设计”的。这种高效来自框架的强约定。他的模块之间边界清晰每个模块有独立的实体、服务、数据库迁移脚本甚至自带菜单定义。对大多数团队来说这种“开箱即用”的竞争力是刚性需求。很多项目就是靠着 ABP 的这套底座在两三个月的周期内完成了从零到一。1.2 为什么越往后越觉得“僵”但问题也恰恰出在“强约定”上。我接手过一个运行了三年的业务系统最初基于 ABP 开发实体层、应用服务层、仓储层、DTO 层分得清清楚楚。可是实际维护的时候你会发现业务逻辑开始不听话地到处泛滥应用服务里的方法越来越大。一个 PlaceOrder 方法里面先是校验库存、再检查价格、计算折扣、扣减库存、生成发货单全部堆在应用程序服务里。领域实体退化成只带属性的数据容器也就是典型的贫血模型。DTO 与实体之间的边界模糊。为了省事很多时候直接拿实体当返回结果或者把实体属性一层层向外透传。外部调用方能看到系统的内部结构任何实体结构的调整都牵一发动全身。仓储模式用得太透。所有查询都通过仓储直接执行包括各种复杂的跨聚合的统计查询。聚合根边界被彻底打破一旦数据结构变化查询逻辑四散在十几个应用服务里。新来的同事很难从代码判断业务规则究竟在哪儿。同一个业务规则你能在应用服务里找到一个版本在某个事件处理器里找到另一个版本还可能在某段定时任务的代码里发现第三个版本。ABP 本身并没有强制你一定要贫血模型但它的默认工程结构和开发范式会自然而然把人往这个方向带。因为框架帮你做好了仓储、工作单元和应用服务你很容易形成这样一个思维惯性所有业务操作 在应用服务里调用仓储方法、修改实体字段、然后让框架自动保存。短平快但是领域逻辑的归属感被弄丢了。1.3 框架升级的成本也是被低估的隐性约束有一类麻烦不直接体现在代码逻辑上而是体现在升级本身。一旦把系统深度绑定在 ABP 的基础设施之上比如所有模块都直接依赖框架的基础包和约定那当框架大版本升级时你会被迫同步调整全局的配置、模块生命周期和依赖注入方式。这种调整不只是改个版本号它会波及到所有继承自框架基类的服务类、实体基类和模块定义。如果有大量自定义扩展升级一次就是一次规模不小的重构。我不是说用框架不对而是说当一个系统被框架深度塑形之后框架的升级版本会反过来塑造你的技术路线。为了长期演进系统核心业务最好不要和具体框架实现耦合在一起。2. Clean DDD 的核心心法让依赖方向变干净2.1 依赖规则才是 Clean Architecture 的灵魂从 ABP 转向 Clean DDD首先不是引入一堆新工具、新类库而是调整依赖方向。Clean Architecture 里最根本的一条规则是源代码的依赖只能从外层指向内层内层不依赖外层。用人话说领域层不应该知道外面有数据库、有 Web API、有消息队列它只定义业务规则需要的行为接口而这些接口的实现在基础设施层。这个规则听着简单但深挖下去非常有力。如果能做到那么你的领域层可以直接脱离 ABP、脱离数据库、脱离任何外部框架放到一个几乎零依赖的类库里。领域层的代码只依赖 .NET 基础库和它自己定义的接口单元测试的时候根本不需要启动框架、不需要 mock 数据库直接把领域对象 new 出来就能验证业务规则。举个最简单的例子。在 ABP 的风格下你可能会在应用服务里写库存扣减逻辑public async Task PlaceOrderAsync(CreateOrderDto input) { var product await _productRepository.GetAsync(input.ProductId); if (product.Stock input.Quantity) { throw new BusinessException(库存不足); } product.Stock - input.Quantity; var order new Order(input.ProductId, input.Quantity, product.Price * input.Quantity); await _orderRepository.InsertAsync(order); }这段代码看起来没问题但它把“库存充足”和“扣减库存”这两条关键业务规则放在了应用层而领域对象 Product 完全是被动的。Clean DDD 更倾向的做法是让产品自己表达库存充足的行为public class Product { public int Stock { get; private set; } public void DecreaseStock(int quantity) { if (Stock quantity) throw new DomainException(库存不足); Stock - quantity; } }应用层则退化成“编排者”把 DTO 转化为领域参数调起领域行为保存结果。业务判断的原则从应用层下沉到领域层。依赖方向因此被反转领域层不再被应用层牵着走而是应用层围绕领域行为运作。2.2 聚合边界划分系统演进的最小合理单元Clean DDD 另一个关键点是“聚合”Aggregate。这个概念的落地决定了系统能不能长期稳步演进。聚合 一组领域对象的集合这些对象在业务上属于同一个整体由唯一的聚合根对外提供入口。聚合的边界往往就是事务边界也是不变量成立的边界。判断聚合划分得好不好一个最简单的检查方法是问自己如果我删除这个实体它的哪些数据必须跟着一起删除才算干净如果 A 和 B 的生死绑定在一起它们多半属于同一个聚合。实际项目里最常见的聚合错误有两种。一种是把聚合做得过大恨不得一张订单就牵扯到商品、库存、物流、促销、支付全部塞进一个大聚合根里。另一个是把聚合拆得过碎一个订单头一个订单明细各是一个聚合根然后为了保持数据一致性不得不满世界调用仓储做各种半手工同步。我个人在实际项目的体会是聚合的边界不追求一次到位而是留给演进空间。DDD 圈里传统观点认为聚合划分是为了锁死边界但我的经验是对于复杂业务系统聚合划分更像是“当前阶段的业务认知快照”。系统演进过程中原本一个大聚合很可能会裂成两个小聚合通过领域事件来做最终一致性这需要架构上留出余地比如底层能支持消息机制运维上能接受异步处理带来的短暂不一致。在这点上ABP 提供的工作单元和仓储模型并非完全禁止聚合实践但它并没有从框架层引导你去思考聚合的边界。框架默认你只需要把一个实体的增删改查做好因此开发者天然会陷入“面向数据表建模”而不是“面向业务不变量建模”的思路。2.3 应用服务与用例显式表达“用户意图”我见过太多 ABP 项目里的应用服务名字是动词方法是“大杂烩”。比如一个 OrderAppService里面有 GetById、GetList、Create、Update、Delete、Approve、Cancel、Ship、Return……每个方法动辄几十上百行。这种带血的应用服务最难维护的原因在于它并没有对应一个明确的业务交互场景而只是把实体操作按照 HTTP 动词翻译了一遍。Clean DDD 思路下我强烈建议把应用服务进一步细化成“用例”Use Case。每个用例对应一个具体的用户意图类名和命名空间就是业务场景的名字。比如PlaceOrderHandler—— 接收一个 PlaceOrderCommand执行下单流程。CancelOrderHandler—— 接收一个 CancelOrderCommand执行取消订单流程。ApproveOrderHandler—— 接收一个 ApproveOrderCommand执行审批流程。这么做的好处是直接可测试的每个用例是一个类依赖被显式声明在构造函数里测试时只需要喂入一组输入断言业务结果。业务场景演进到新流程时你甚至不需要修改旧的用例类只需要新增一个用例类。应用服务层的职责被压制到了最低它再也不能悄悄藏匿业务规则了。这个转变本质上是从“框架的约定”回到“业务的表达”。ABP 的应用服务是一种便捷工具而用例层是一种表达手段。把它放在 DDD 的架构里应用层只做协调、权限校验、DTO 转换、调用领域服务所有真正有业务含义的分支判断尽可能下沉。3. 从 ABP 渐进改造成 Clean DDD 的实操路径3.1 阶段一盘点现状识别真正的核心域改造不是推翻重来。改动已经运行着的老系统的第一步永远是把现状摸清楚。我建议团队先给项目里的模块按“核心域、支撑域、通用域”分个类。这是 DDD 里的一个基础动作但实际做起来非常有效。核心域这个系统最能带来业务竞争力的部分规则最复杂、变动最频繁。比如电商系统的订单/库存/支付ERP 系统的生产计划与排程。这些模块必须优先投入资源引入严格的 DDD 设计。支撑域虽然不是核心但为业务提供关键支撑。比如物流、配送、对账。它们可能不需要特别复杂的领域建模但需要良好的接口隔离避免被某个具体第三方实现绑定。通用域比如日志、通知、文件存储、权限管理这类不需要做复杂领域建模可能直接沿用框架提供的基础能力即可。这样分层之后你通常会发现真正值得改造的地方并不是整个系统而是核心域中的几条关键业务链路。其余大部分模块甚至可以在未来很长一段时间内继续沿用 ABP 风格的快速开发方式只要保证模块间的依赖边界清晰即可。3.2 阶段二把业务逻辑从应用服务里捞出来确定好改造范围后下一步是“业务逻辑上浮”。我在实操中既不是把逻辑搬到某个 Service 类里也不是演变成过度抽象而是遵循一个很笨但非常可靠的原则每个应用服务方法先尝试把其中可以表达为领域行为的操作提炼成实体或领域服务的方法。以订单为例如果应用服务里这段代码总是出现“库存不足则抛异常否则扣减库存”那就把它沉入 Product.DecreaseStock()。如果一段折扣计算同时依赖订单明细、商品分类和用户会员等级这些对象分属不同聚合没法放进某个实体的方法里那就新建一个领域服务 DiscountCalculator。领域服务是协调多个聚合完成一个领域行为的无状态服务它是最好的“防止应用服务背黑锅”的拦截层。这一步不需要改接口、不需要动数据库只是把代码从一个文件挪到另一个文件。但它会带来两个立竿见影的变化一是应用服务体量明显变小二是业务规则的单元测试变得可以直接写。关于改造顺序我建议先挑最长、最痛苦的一个方法做试点把这个方法的逻辑全部抽出来之后再逐条推进。不要试图一次性把整个应用服务层重写完毕那样风险太大。3.3 阶段三引入用例层替代隐式的应用服务方法当业务逻辑逐渐下沉之后下一步是把应用服务层改造成“薄薄的用例分发器”。这其实是一个比较机械的过程把应用服务里的每个动作方法重命名成对应的用例类把参数从 DTO 换成 Command把返回值换成一个简单的 Result。这是很多团队最容易犹豫的地方每个用例类都单独建一个类是不是过度设计了。这里有一个实用的判断标准如果这个应用服务方法内部只做了三五行增删改查、没有业务规则那它完全没必要拆成独立的用例类。但如果是那种几十行、包含多个领域对象的状态流转、还要做权限校验和事件发布的方法那它绝对值得拆开。拆成用例类之后应用服务就变得和 ABP 的框架生命周期解耦了不再是必须继承 ApplicationService 基类的特殊类型。你可以用一个普通的注入类来实现用例。当然如果你还在 ABP 体系内运行为了利用框架的自带功能也可以让用例类实现标准的业务接口同时仍以 ABP 应用的形态注册到容器中。关键在于用例类是“纯业务代码”不依赖 ABP 的基类、不在方法里操作 IUnitOfWork、不直接访问当前会话的审计属性这样它才能脱离框架进行测试。3.4 阶段四反转基础设施依赖用接口守住边界在从 ABP 改造到 Clean DDD 的过程中最难突破的一点是如何让领域层不依赖基础设施同时又能流畅地使用 ABP 已完成的能力比如简单查询、多租户过滤、审计字段。我的建议是建立“防腐层Anti-Corruption Layer”。对于确实需要长生命周期切换的存储介质、外部服务领域层定义接口基础设施层实现接口。比如仓储接口直接在领域层定义ABP 自己的约定式仓储被封装到接口实现后面。对于完全属于框架开放能力的部分比如文件上传、定时任务根本不需要进入领域层直接在用例层或者更外层调用就可以了。这样反转依赖之后你会得到以前在 ABP 纯方案里根本享受不到的测试便利。比如订单用例的单元测试不需要连接数据库不需要 mock 工作单元只需要 mock 一个 IOrderRepository。而且由于领域层代码是纯净的对核心业务逻辑的测试甚至可以完全不用启用框架容器。下面我给出一个改造前后模块依赖的直观对比维度ABP 默认风格Clean DDD 改造后业务规则位置应用服务方法里具体表现为 if/else、for、计算实体的领域方法、领域服务、领域事件处理器中实体状态公开 setter 或按框架惯例允许任意修改私有 setter通过行为方法变更为主应用服务继承框架基类直接调用仓储执行操作薄用例类负责协调领域对象、发布事件仓储实体直接依赖框架仓储查询方式直接绑存储领域层定义接口基础设施实现具体存储DTO经常实体替换专有参数对象用例输入既语义化又防过多暴露数据访问方式统一走框架约定的仓储与工作单元聚合托管 查询专用通道查询不污染领域3.5 阶段五从老系统到新架构的过渡期策略任何改造都不是一蹴而就的。我这里给个比较“柔和”的推进路线先在新模块中使用 Clean DDD。新业务模块直接采用“用例 领域层 基础设施实现”的结构把 ABP 框架当成基础设施的一个供应商而不是整个系统的主心骨。老模块按需渐进改造。核心域中维护成本最高的链路优先做逻辑下沉非核心模块一律不动避免技术债没有“还完”的那一天。在边界上使用适配器模式。老系统对外接口保持稳定内部的实现调转到新的用例层与领域层。这样即便线路切换后外部的调用方不会感到任何变化。这套路线的最大好处是风险可控。你不用先写到大规模重构而是每次改动都是局部的、可回归的、可对业务说明白的一次优化。若干轮迭代后老系统自然就“生长”成了新的架构。4. 长期演进中的几个关键架构习惯4.1 数据的一致性边界一个聚合一个事务长期维护的复杂系统最容易翻车的地方往往不是单个功能的开发而是跨模块的数据一致性。ABP 风格下因为工作单元默认包住一个请求的所有操作开发者很容易养出一个习惯“先改订单再改商品库存再扣优惠券全部套在同一个事务里报错了就回滚。”听起来很稳妥但现实是当业务规模变大这些跨聚合的同步更新会把所有领域对象塞进同一个事务边界里造成大量的数据库锁竞争同时每个模块都被事务拖着无法独立演化。Clean DDD 给出的思路是一个聚合对应一个事务边界跨聚合的一致性用领域事件加补偿机制来实现。订单创建后发布 OrderCreated 事件库存服务消费事件去扣减库存如果库存不足通过补偿操作回滚订单状态。这个思路要求团队在架构生涯里接受“最终一致性”。在电商下单场景中用户下订单后看到“订单已提交待确认”然后系统异步确认库存和支付是完全可以被接受的。但如果某个场景业务上硬性要求立即强一致比如不允许超卖那就要考虑在特定入口设计预占库存或分布式锁而不是把整个订单全链路都塞进一个大事务里。4.2 测试金字塔领域层要被单测充分保护架构能不能长期演进最关键的不是代码写得多么优雅而是改动之后有没有可靠的保护网。我在实际项目里对比过 ABP 风格应用和 Clean DDD 应用最大差异之一就是单元测试的数量与质量。在 ABP 里测试一个订单应用服务方法往往需要 mock 一连串框架服务导致单元测试成本极高。很多人到最后就不写了变成只依赖集成测试而集成测试又慢又贵根本不可能覆盖所有规则分支。Clean DDD 方案则不同领域规则一旦下沉单元测试变得非常轻盈不需要数据库、不需要 HTTP 上下文、不需要启动框架直接 new 一个聚合根调用业务行为方法断言异常或者状态变化。它的测速快、运行稳持续反馈效率很高。我个人的建议是至少对核心域中的以下三类逻辑写单元测试状态机流转订单状态允许哪些合法迁移什么情况下抛异常。业务不变量库存不可为负、金额精度规则、优惠券是否同时可用。领域计算折扣计算、税费计算、积分计算等。这些测试一旦写全未来你重构应用服务、替换数据库层、升级框架版本都有底气。改完跑一遍测试核心规则没被破坏剩下的基础设施变化也就不那么让人害怕了。4.3 通用语言与事件命名让架构演进看得见轨迹Clean DDD 强调“通用语言”Ubiquitous Language。这个词很容易被当成理论概念但它实际是一个非常实用的工程工具。你可以在代码里直接使用业务术语作为类名和变量名OrderApprovalPolicy、StockReserved、PaymentFailed。当业务规则需要用新概念表达时直接新增一个类或一个领域事件让业务沟通人员和开发人员用同一套名词来讨论。这套语言体系的建设和 ABP 的模块化思想并不冲突。模块依然是模块但模块内部的职责边界变得更贴近业务认知。系统的演进路径因此变得可以讨论老板娘说“我们新增一个‘预售’模式”开发同学就知道要在领域层增加一个领域服务、在用例层增加一个预售下单的用例而不是在一个叫 OrderService 的大类里再塞一个带一堆判断的新方法。4.4 注意不要让 Clean DDD 变成另一种形式的过度设计任何方法论都有副作用。Clean DDD 最大的风险是被过度使用在简单的 CRUD 功能上。一个只有增删改查、没有任何复杂业务规则的模块强行按“聚合 领域事件 用例”搭一套完整规范会让团队疲惫不堪也会让大家觉得这套方法论并不好用。我的原则是“复杂度驱动设计”聚合、领域事件、防腐层这些工具只有在核心域的复杂业务场景中才值得大力使用。对于支撑域和通用域完全可以保留轻量风格甚至继续沿用 ABP 的快速开发习惯。这也是我给准备引入这套架构的团队最重要的忠告它不是要取代 ABP而是要在 ABP 之上给复杂业务留出一块可以长期演进的空间。5. 从 ABP 走向 Clean DDD 的常见问题与排查实践5.1 改造后用例层中的事务应该怎么处理这是实操中最高频的问题。ABP 自带了工作单元很多开发习惯是直接在应用服务方法上加[UnitOfWork]特性。改造之后事务控制通常会落在用例类周围。我的建议是不要让每个用例类都自己管理事务而是通过一个统一的管道或拦截器来控制。在 ABP 体系里你可以在某个应用服务层的外围继续使用框架的 UnitOfWork 特性然后让用例类工作在这个事务边界之内。但领域层内部不要出现和事务相关的逻辑因为那属于基础设施关注点不属于业务流程关注点。判断标准很简单如果换一种持久化方式比如从数据库换成事件溯源领域层代码不能改变。5.2 拿掉 ABP 的仓储之后查询如何做Clean DDD 的一个常见误解是“不能直接在领域层之外使用查询”。领域层确实只应该面向聚合操作但系统的查询场景比如复杂的列表统计、报表分析完全不应该通过聚合根加载一堆实体再来做内存过滤。我的做法是为查询单独建一条“读模型”路径。这条路径可以绕过领域层直接使用 ORM 的复杂查询能力、映射为专门的查询 DTO甚至使用独立的表或视图。这就回到 ABP 改造的另一个小经验不要试图让所有读写都走仓储。聚合边界是写操作的事务边界而读操作完全可以用更自由的工厂方式来做。由于 DDD 强调领域层纯净查询路径天然可以和领域层隔离两者各自演化互不干扰。5.3 领域事件发布失败怎么办领域事件是跨聚合通信的利器但发布失败、消费失败在分布式场景中不可避免。实际运营中的处理方案一般是在事件发布前先记录事件对象到一张本地事件表和业务数据事务性地写入同一个数据库。随后一个后台发送器把事件分发到消息总线发送成功的打标记失败的按策略重试。这样既保证业务数据和事件不丢失也方便排查延迟或丢失的情况。这个方法虽然在 ABP 里需要自己扩展一层但它给长期演进带来的稳定性极强。事件这个异步通道一旦打稳后续新业务对接的时候只需要订阅新事件不需要再改下游服务的代码。5.4 新同学抗拒改造怎么办这个我倒是有几次带团队的经验。一整套 DDD 概念对刚接触的人来说确实有门槛。我的方法是不是先讲理论而是先给一个具体的真实业务场景让新同学看改造前和改造后的代码再说明为什么我们更愿意用改造后的写法。与其反复强调贫血模型的危害不如让他亲手跑一次测试、改一次业务逻辑用直观的体感建立认知。同时也要注意新同学不是要给所有业务都套 DDD。先约定好哪些模块要走完整设计哪些模块可以直接写简单代码这个规则写进团队的开发规范里大家花不了太多时间就能适应。6. 关于工具链还有一些建议6.1 使用一个干净的解决方案结构从 ABP 改造到 Clean DDD 时工程结构建议按“职责”区分。以一个典型的订单域为例我常用这样的分层方式Order.Domain聚合根、实体、值对象、领域服务、领域事件、仓储接口。这是一个纯粹的类库不依赖任何框架。Order.Application用例Command 与 Handler、DTO、应用接口。不依赖数据库不依赖 Web 层。Order.Infrastructure仓储实现、数据库上下文配置、消息发送实现、缓存实现。Order.Web控制器、过滤器、中间件、模型绑定与验证。它是整个系统的最外层。这个结构本质上就是 Clean Architecture 的典型表达。和 ABP 结构相比最大的差别是领域层不再只放实体类而是放“带行为的业务内核”基础设施层则是一个可以被替换的供应商。6.2 从框架里借用但不被框架绑架我一度很赞成“把 ABP 当成工具”的定位而不是“把 ABP 当成系统架构的核心”。它在模块化、身份认证、后台任务、多租户等场景里能提供很成熟的方案因此我们依然可以使用它。但核心业务领域尤其是那些变化最频繁的部分应该尽可能做到不依赖框架。这种“借用框架能力但隔离核心业务”的做法会让你的系统在框架升级、迁移或替换时付出的代价小得多。这也是“可长期演进”三个字背后最实际的含义——你选择的架构不能以某个框架的存亡为赌注。7. 架构演进的节奏感这一路走下来个人体会最深的还不是某个具体模式怎么用而是架构演进的节奏感。ABP 给的便利和 Clean DDD 给的结构它们不是非此即彼的关系。成熟的团队往往能在同一个系统里既保留 ABP 的高效生产能力又让最复杂的那部分业务遵循严格的领域设计。我很建议所有准备走这条路的团队先从一条最复杂的业务链路做起把逻辑下沉到领域层拆出显式用例让领域层零框架依赖把单测补起来。这个过程做完一次之后你会立刻感受到改业务规则时不用再提心吊胆地满项目找代码了。这个体验比任何架构理论都有说服力。另外再分享一个小技巧改造过程中每次重构做完之后尽量让一次完整的旧业务链路在新旧两套实现下跑一遍做结果对比测试。这能快速发现领域逻辑下沉时是不是遗漏了什么分支判断。踩过几次坑之后你就会把这个动作变成进入改造流程的默认习惯。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →