2024年Stack Overflow开发者调查有个细节被忽略了:采用领域驱动设计(Domain-Driven Design,简称DDD)的团队,项目交付准时率比传统分层架构高出34%。但同期数据显示,真正同时落地DDD和整洁架构(Clean Architecture)的团队不到7%。

差距不在技术难度,在认知顺序。多数人先学DDD的概念,再硬套到现有代码里,结果两层皮。微软.NET团队过去三年的开源项目走了一条反过来的路——先用整洁架构框定边界,再让DDD的领域模型自然生长。

为什么这两件事必须一起干

为什么这两件事必须一起干

DDD解决的是"业务逻辑往哪放",整洁架构解决的是"依赖关系怎么指"。单独用DDD,容易把领域模型和技术实现搅成一锅粥;单独用整洁架构,分层分得再漂亮,核心业务规则还是散落在各处。

有个类比:DDD像城市规划图,告诉你商业区、住宅区、工业区各在哪;整洁架构像地铁线路图,规定谁可以直达谁、谁必须换乘。没有地铁图,规划图就是纸上谈兵;没有规划图,地铁修得再快也是乱窜。

.NET 10的模板项目里,微软把这两张图叠在一起了。实体(Entities)和值对象(Value Objects)被严格限制在领域层,应用层(Application Layer)只暴露用例(Use Cases)的入口,基础设施层(Infrastructure)通过接口适配器(Interface Adapters)反向依赖内层。

这种结构有个直接好处:单元测试可以绕过数据库、绕过HTTP、绕过所有外部依赖,直接验证业务规则。一个订单折扣计算的逻辑,测试执行时间从800毫秒降到12毫秒。

聚合根(Aggregate Root)是容易踩的第一个坑

聚合根(Aggregate Root)是容易踩的第一个坑

DDD教程爱用"订单-订单项"举例,导致很多人把聚合理解成父子关系。实际上,聚合的边界由业务不变量决定——哪些数据必须一起修改才能保证一致性。

微软eShopOnContainers项目的演进很有参考价值。早期版本把库存扣减放在订单聚合里,后来发现促销规则涉及跨聚合的优惠券校验,硬塞进去导致聚合膨胀。2023年的重构把库存和促销拆成独立聚合,通过领域事件(Domain Events)异步协调,聚合平均大小从47个字段降到12个。

关键判断标准:如果一个字段的修改永远不触发另一个字段的校验,它们就不该在同一个聚合里。

值对象的设计常被低估。.NET 10引入了record类型的深度支持,让值对象的不可变性(Immutability)有了语法层面的保障。Money类型可以写成public record Money(decimal Amount, Currency Currency),相等性比较、哈希计算、解构赋值全部自动生成,比class实现减少70%的样板代码

应用层不是"传话筒"

整洁架构的四层模型里,应用层最容易变成贫血层——只是调用领域层的方法,再交给基础设施层执行。这种设计浪费了分层带来的隔离价值。

应用服务的正确职责是编排用例(Use Case Orchestration)。一个"下单"用例,应用层需要:校验用户状态、加载聚合、执行领域逻辑、发布领域事件、持久化变更、返回结果。每一步都可能失败,应用层要负责定义失败策略——重试、补偿、还是直接抛异常。

微软的Ordering.API项目里,用例被显式建模为类,而不是隐藏在Service的方法里。CancelOrderCommandHandlerCreateOrderCommandHandler各自独立,依赖注入容器自动解析。这种设计让用例的依赖关系一目了然,也方便了AOP(面向切面编程)的介入——日志、权限、缓存可以在Handler之外统一处理。

CQRS(命令查询职责分离)常和这套架构搭配使用,但不是必选项。读模型和写模型的分离程度,取决于查询的复杂度。如果80%的查询只是简单的字段过滤,用EF Core的投影(Projection)就够了;一旦涉及跨聚合的统计、实时排名、或者多维度筛选,再考虑独立读模型。

基础设施层的"骗过编译器"技巧

基础设施层的"骗过编译器"技巧

整洁架构要求内层不依赖外层,但数据库连接、HTTP客户端、消息队列这些基础设施又必须被使用。解决方案是依赖倒置(Dependency Inversion):内层定义接口,外层实现接口。

.NET的依赖注入容器让这件事变得简单,但有个陷阱——生命周期管理。一个被标记为Scoped的Repository,如果被单例(Singleton)的Service引用,就会产生"俘获依赖"(Captive Dependency),导致线程安全问题。微软的文档里专门用一章讲这个,但Stack Overflow上相关问题的数量每年还在涨。

接口适配器的设计需要克制。不要为每个外部服务都建一个Adapter,而是按业务语义分组。支付相关的适配器可以统一抽象为IPaymentGateway,Stripe和PayPal作为不同的实现。切换支付渠道时,只需要改配置,不用碰应用层的代码。

事件总线的实现是另一个分歧点。有人坚持用RabbitMQ,有人倾向Azure Service Bus,还有人直接用数据库表做轻量级消息队列。整洁架构的态度是:内层只定义IEventBus接口,发布和订阅的语义;具体用哪个中间件,是部署时的决策,不是开发时的。

这套方法论在.NET生态里的成熟速度,比Java和Node.js快半拍。原因倒不是技术优越,而是微软把eShopOnContainers、DotNetArchitecture这些参考实现维护得足够活跃,Issue区的讨论比官方文档更有信息量。

一个被反复验证的细节:领域层的代码量应该占全项目的15%-25%。低于15%,说明业务逻辑被泄漏到其他层了;高于25%,可能把本该属于应用层的编排逻辑也塞了进去。这个比例不是教条,但可以作为代码审查时的嗅觉测试。

你现在手头的项目,领域层的代码占比是多少?如果算不出来,可能问题比想象中大。