1. 先从“搞不懂”说起:DDD到底卡在哪
1.1 战术与战略,不是两套知识,而是同一个认知的上下游
很多人搜“DDD搞不懂”,其实不是智商问题,也不是代码写得太少,而是大多数人把DDD当成了一堆名词的集合:实体、值对象、聚合、仓储、领域事件、CQRS、事件溯源……每个词单独看都能查到定义,拼在一起却不知道先干哪件事。我当年也是这样,拿着Eric Evans那本书翻了三遍,脑子里只有一句话:“嗯,很有道理。”然后回到项目里,还是Controller-Service-DAO那老三层。
后来我才想明白一件事:DDD本质上分两个层面,战术设计和战略设计。战术设计解决的是“一个限界上下文内部怎么写代码”,战略设计解决的是“多个限界上下文之间怎么划边界、怎么通信”。这两个层面不是割裂的,而是同一个认知的上下游。战术设计做得好不好,直接决定你有没有能力把战略层的边界落到真实代码里;而战略层想不清楚,战术层写得再漂亮,也只是在一个烂地基上盖精装房。
我建议初学者换个学习路径:先从单服务战术落地开始,把一个业务模块真正用DDD的方式写干净,再去接触分布式中台和战略设计。因为战略层如果不落到代码上,很容易变成PPT架构——画了一堆限界上下文,代码里却是意大利面。反过来,如果战术层练扎实了,你自然能体会到为什么需要战略层来管理边界和协作,就像练拳先练站桩,站桩不稳的人去打散打,只会被打得更惨。
1.2 一个最容易被忽略的前提:模型必须能被代码表达
DDD的全称是Domain-Driven Design,核心词在Domain,但真正让DDD区别于一堆分析文档的,是Design——领域模型必须能被代码直接表达。这句话听起来像废话,但我见过太多团队把DDD做成了“领域名词收集大会”。
举一个我实际辅导过的例子。团队要做订单系统,业务专家说“订单有状态,待支付、已支付、已发货、已完成”,开发同学就在数据库里建了一张order表,带一个status字段,然后在service层写一堆if else判断状态迁移。这算不算DDD?严格来说不算,因为状态被压扁成了一个字段,所有业务规则都藏在service里,领域模型根本不存在。真正的做法是把状态机的规则放到聚合根里,由订单自己决定能不能从“待支付”变成“已发货”,而不是让任何service层随便改status。
这里涉及到一个老生常谈的词:贫血模型与充血模型。传统三层的service层,本质上是把业务逻辑全部放在“上帝服务”里,领域对象只剩一堆getter和setter,这就是贫血模型。而DDD要求领域模型中不仅要有数据,还要有行为,让业务规则内聚在它该归属的地方。你可以把这个区别理解为自助餐和私房菜:自助餐把所有菜摆出来,你自己夹;私房菜是主厨根据你的需求配菜,吃什么、什么顺序,由主厨决定。贫血模型就是自助餐,点什么都往Controller和Service里加;充血模型是私房菜,聚合根就是那个主厨。
所以,你在单服务里写DDD时,第一个要问自己的问题是:我的业务规则到底写在哪儿?如果答案还是“写在Service里”,那你写再多的聚合、值对象、仓储,也只是换了一层皮。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单服务战术落地:先让一个限界上下文内部变干净
2.1 六边形架构:把领域模型放到太阳底下
单服务的战术落地,我推荐从六边形架构入手。六边形架构也叫端口与适配器架构,它的核心思想是让领域模型处于系统的正中心,业务对它外部的一切都不知情:不知道数据库,不知道HTTP,不知道消息队列,甚至不知道Spring或者.NET这类框架的存在。
可能有读者会问:这不就是分层架构吗?Controller算适配器,Service算应用服务,Repository算端口,Infrastructure算适配器实现,好像没差多少。但是六边形架构和经典分层有一个本质区别:依赖的方向。经典分层是从上往下依赖,Controller依赖Service,Service依赖Mapper;六边形架构是依赖倒置,领域层定义端口(比如仓储接口、消息发布端口),外部适配器来实现这些端口,依赖方向始终指向最里层的领域模型。
用一个生活类比:插座标准。你的手机充电器是领域层,它只认USB-C这个端口。墙上那个220V插座是什么电网公司、什么发电方式,充电器完全不用关心。你只需要一个适配器把交流电转成USB-C——六边形架构就是这个适配器层。把适配器拔下来换个新的,领域层一行代码都不用改。
实际动手时,我建议把一个旧工程重构成六边形架构,按照以下步骤走,效果很好:
- 先确认限界上下文,把这个上下文涉及的业务功能圈进来,不相关的功能一律不碰。
- 梳理领域模型:找出聚合根、实体、值对象、领域服务、领域事件,这一步是重中之重。
- 定义端口:把所有对外依赖抽象成接口,包括仓储、外部API调用、消息发送等。
- 实现适配器:把现有的Controller、Repository、FeignClient、KafkaProducer等填到端口外。
- 最后调整目录结构,让依赖方向从外向内,禁止反向依赖。
我可以给你一个参考目录,这只是最小结构,具体到项目可以再调整:
text复制order-context/
├── interfaces/ # 适配器层:Controller、Consumer、Job
│ ├── rest/
│ └── mq/
├── application/ # 应用服务层:事务、命令分发、查询处理
│ ├── command/
│ └── query/
├── domain/ # 领域层:聚合、实体、值对象、端口
│ ├── model/
│ ├── service/
│ └── repository/ # 仓储接口(端口)
└── infrastructure/ # 基础设施层:Repository实现、消息适配器
├── mapper/
└── producer/
当你把项目改成这个结构后,会有一个非常直观的体验:领域层变得“干净”了,纯业务逻辑、零框架依赖,单元测试可以秒级跑完。这个干净,是后续做分布式和中台整合的基础。因为你的领域模型不依赖具体数据库和框架,才可以交付给任何一个服务、任何一个团队去承载。
2.2 聚合与仓储:边界、生命周期与不可变约束
战术设计的核心,其实是聚合(Aggregate)和仓储(Repository)。
聚合是保证业务一致性的一组对象集合。比如一笔订单,包含订单头、订单行、收货地址、支付信息、物流信息,它们做成一个聚合,订单头就是聚合根。外界不能直接操作订单行,必须通过订单聚合根来访问。这样做的最大好处是:事务边界变得清晰,一个聚合一个事务,所有的不变量(比如订单总额必须等于所有订单行的金额之和)都能在同一个事务边界内被保证。
我给团队做代码评审时,经常用一句话检验聚合设计是否合理:“如果这个聚合内部的状态无法同时改变,那它就不该放在同一个聚合里。”换句话说,聚合边界不是按照表关系来定的,而是按照业务不变量的范围来定的。比如订单和商品,它们之间有外键关系,但商品库存和订单状态的变化不需要强一致,只是最终一致,那它们就不该在同一个聚合里,而应该拆成两个聚合,跨聚合的协作通过领域事件来做。
值对象也是很多人搞不明白的地方。我提供一个快速判定表:
| 特征 | 实体(Entity) | 值对象(Value Object) |
|---|---|---|
| 有无唯一标识 | 有,如订单ID | 没有或标识不参与业务,如金额、地址 |
| 是否可变 | 通常可变,有生命周期 | 最好不可变,替换式更新 |
| 比较方式 | 按标识比较 | 按属性值比较 |
| 例子 | 订单、客户、账号 | Money、Address、PhoneNumber |
值对象的一个黄金法则:不变性。你不需要修改一个金额对象,而是直接new一个替换它。这个习惯会让并发环境下的代码好写很多,因为不需要加锁,没有共享可变状态。
仓储则承担聚合的生命周期管理:创建后持久化、按ID加取、按条件查询。仓储接口定义在领域层,实现放在基础设施层。这里有一个非常常见的坑:很多团队把仓储接口和MyBatis的Mapper一一对应,要么是接口里的方法一堆,要么是直接在业务逻辑里事务包裹多个仓储操作。
经验之谈:仓储接口按聚合根设计,不是每张表一个仓储。查询可以走CQRS,查询模型和命令模型分离,能显著减少所谓“查询复杂”带来的丑陋代码。事务边界放在应用服务层,一个用例一个事务,而不是每个仓储方法各开一个事务。
3. 领域事件:从单服务迈向分布式的必经桥梁
3.1 内部事件与跨上下文事件,别混为一谈
当你把单服务的战术结构理清楚了,下一步就是领域事件。很多初学者对领域事件的第一反应是:这不就是消息队列里的消息吗?不完全是。领域事件首先是在领域层定义的一个事实,它用过去时命名,比如OrderPlaced、PaymentConfirmed、InventoryAdjusted。它代表“某件业务已经发生”,而不是“我要去做某件事”。
我举一个小例子。用户下单成功后,系统要发短信、通知仓库、更新会员积分。传统写法是:在OrderService里依次调用短信服务、仓库服务、积分服务。问题在于:下次再加一个數據分析需求,就要改OrderService;某一个下游服务出问题,整个下单流程都跟着失败。领域事件的做法是:OrderService只需要做一件事——把OrderPlaced事件发布出去,然后收工。短信、仓库、积分各自订阅事件,自己决定如何处理。这就是发布者和订阅者之间的解耦。
这里需要注意:单服务内部也可以使用领域事件,比如在一个限界上下文内部,订单聚合和客户聚合之间通过领域事件解耦,这是战术层面的用法。而跨上下文、跨服务的事件,属于战略层面的集成方式。把这个“内部”和“外部”分清楚,是整个认知升级的分水岭。
我建议团队做一次EventStorming(事件风暴),用一个workshop把领域事件都挖出来。具体流程大致是:
- 准备一个超长的墙贴和便签条,召集业务方、产品、开发,围站一圈。
- 让所有人先写“发生过的事”,每张便签一个事件,比如“客户已下单”“仓库已出库”“客服已退款”。
- 做时间线,把这些事件按业务发生顺序排开。
- 在每个事件下面标识触发它的命令、参与的角色、依赖的数据。
- 把事件按业务边界聚成几簇,每一簇就是一个候选限界上下文。
EventStorming最大的价值不是画出一张图,而是让术语统一、边界清晰。你会发现业务方口中的“订单”和开发理解的“订单”并不完全一样,这正是统一语言的价值。统一语言是DDD的基石,没有它,后续所有设计都是空中楼阁。
3.2 事件驱动的三个实践陷阱
领域事件听起来很美好,实际落地时却有不少坑,我把最常见的三个陷阱总结如下。
第一个陷阱是事件里放了数据快照还是最小信息。很多人发布一个OrderPlaced事件,一股脑把整个订单表都塞进去。这会让下游订阅者与你的内部数据结构强耦合,下游一改需求,你的事件格式就要跟着变。正确的做法是:事件里只放“发生了什么”以及必要的最小数据,比如订单号、客户ID、商品编码、数量。下游需要更多信息时,可以通过API查询来补齐。这里的权衡很有意思,事件越小耦合越松,但下游如果查询过于频繁,性能又受影响。我的折中方案是:核心且稳定的信息(如订单号、金额)放事件里,易变的业务详情走API查。
第二个陷阱是事务与发消息的一致性问题。如果你在一个数据库事务里更新了订单状态,然后调用消息中间件发送消息,中间件一旦失败,就会出现“订单状态已改但事件没发出去”的不一致。对应方案是经典的Outbox模式:先在同一数据库事务里,把领域事件写入一张outbox表,由定时任务或CDC(变更数据捕获)再把事件发布到消息中间件。看起来多存一张表有点麻烦,但这是目前分布式事务降级到“最终一致”最可靠的手段之一。我用过的方案里有定时扫表加断点续传,也有Debezium监听binlog的方案,各有取舍,小型项目用定时扫表就够了。
第三个陷阱是事件的幂等性。消息中间件不是只投递一次,而是“至少一次”,所以消费方必须支持重复消费。比如库存服务收到InventoryAdjustRequested事件,如果没有幂等处理,重复执行会扣两次库存。很多团队在消费端加一个去重表,以事件ID为唯一键做幂等判断,这也是标准做法。我的建议是:领域事件本身要携带一个全局唯一的事件ID,消费者把处理后的事件ID存到自己的表里,每次先查重再处理,这一步绝对不能省。
4. 分布式中台战略:多个限界上下文如何协同作战
4.1 上下文映射:从“边界图”到“集成战术”
单服务练熟了,眼光放到整个企业级系统,你会发现最难的已经不是“某一个订单模块怎么写”,而是“订单模块和库存模块、支付模块、客户模块之间怎么协作”。这时,DDD战略设计的核心工具——上下文映射(Context Mapping)就派上了用场。
上下文映射描述的是两个限界上下文之间的合作模式,常见的关系有:伙伴关系(Partnership)、共享内核(Shared Kernel)、防腐层(Anti-Corruption Layer)、开放主机服务(Open Host Service)、驯服客户(Conformist)、分离方式(Separate Ways)等。拿防腐层举例:你的订单上下文要对接一个老旧的库存系统,那系统的接口乱、模型烂、字段含义模糊,你不能让这种“脏东西”直接污染你的领域模型。标准做法是引入一个防腐层,把老库存系统的调用封装起来,在上游的混乱模型和你的干净模型之间做翻译。你的订单领域层只依赖防腐层接口,完全不感知上游的丑陋。
防腐层可以是一个独立的模块,也可以是一个独立的微服务,取决于调用频率和团队边界。实现时,它做的事本质上是一个翻译器:把上游返回的DTO翻译成你的领域对象,把你发出去的命令翻译成上游API的参数。这里最忌讳的是“图省事,直接用上游的DTO当自己的领域对象”——一旦这么干,防腐层就名存实亡了。
从战略视角看中台,我特别想强调一个观点:分布式中台的核心不是“把数据库表合并到一起”,而是“把多个限界上下文的协作契约定明白”。中台之所以叫中台,是因为它在多个前台业务和多个后台系统之间建立了稳定的“中间层”。这个中间层提供的是能力,不是数据。比如订单中台,不应该只是一个放着订单表的数据库,而应该是一组“订单能力”的服务化抽象:创建订单、取消订单、查询订单、跟踪物流状态。前台业务通过这些能力接口使用中台,而不是直接访问中台的库表。
所以,做分布式中台战略,你真正要做的事情有三件:第一,划清限界上下文边界,明确每个中台提供什么能力;第二,定义上下文之间的集成关系,是API、事件还是共享数据库(共享数据库基本是反模式);第三,沉淀统一语言,让中台与前台、后台之间的每一个术语都有精确含义。这三件事做完,分布式中台不再是一个空泛的概念,而是一张可落地的架构地图。
4.2 中台共享内核与事件总线的取舍
中台建设里有一个绕不开的矛盾:共享与自治。中台要共享能力给多个前台业务,但每个前台业务又有自己独特的规则,一共享就很容易互相耦合。
共享内核(Shared Kernel)是DDD的一种合作模式,表示两个上下文共享一小部分模型,比如共享一个客户模型、一套基础字典。但“共享”很容易演变成“共同修改”,范围一旦失控,就会变成耦合黑洞。我见过一个项目,原本只是共享一个用户对象,后来为了“复用”,把支付状态机、订单快照都放进了共享包里,结果任何一方改动都影响另一方,发布节奏都被拖垮了。共享内核的适用范围应该是“极小且稳定”的部分,宁可多写几行重复代码,也不要轻易共享。
真正能支撑中台松耦合协作的,是事件总线。从单服务内部事件,到跨服务的事件总线,再到基于流平台的全异步体系,是一条自然的演进路径。中台发布领域事件,前台订阅自己关心的事件,双方通过事件契约协作,彼此不需要知道对方的内部结构,这就是事件驱动架构的魅力。比如订单中台发布OrderPlaced事件,库存中台订阅后扣减库存,营销中台订阅后发优惠券,三个服务各自独立部署、独立扩展、独立演进。
技术选型上,小规模用RabbitMQ或NATS,大规模用Kafka或Pulsar都行,但比选型更重要的是事件契约的管理。事件Schema的版本兼容要早早提上日程:上线前定好兼容规则,只用新增字段不允许删除或修改已有字段,事件消费者和发布者使用独立的Schema registry做校验,这能省掉大量线上事故。
从实施路线上看,我比较推崇“单体优先,逐步演进”。不要一上来就把所有服务拆成微服务。先让所有领域逻辑在一个单体应用里用DDD战术落地,等出现了明确的独立部署、独立扩展、独立所有权需求,再按限界上下文边界拆分。这个做法能让你避开“分布式单体”的坑——很多时候我们为了微服务而微服务,把同一个业务的表拆散到多个服务里,最终只是把问题从进程内搬到了进程间,复杂度更高。
5. 实操全过程:从单服务到中台的一个最小可行样例
5.1 场景定义与技术选型
讲再多理论,不如完整走一遍。我设计了一个最小可行样例:一个电商系统中,订单和库存两个限界上下文。初始状态是单体应用里两个模块,目标状态是拆分为两个服务,通过领域事件协作。
技术栈按比较常见的组合来选:Java 17 + Spring Boot 3.x + MySQL 8 + Kafka,ORM用Spring Data JPA或MyBatis都可以,我下面示例用Spring Data JPA多一些。为什么选这一套?因为DDD本身不绑定语言,但Java生态对六边形架构、端口适配器的表达比较友好,资料也多;Kafka则适合演示事件总线的语义(发布/订阅、消费者组、分区顺序),中小团队可以直接照抄这个思路。
领域分析先用EventStorming快速梳理。订单上下文的核心事件有:订单已创建、订单已支付、订单已取消;库存上下文的核心事件有:库存已扣减、库存已回补。它们之间的协作关系是:订单创建成功后,发布事件,库存服务监听并扣减预占库存;订单超时取消后,再发一个事件,库存回补。
这里要注意:预占库存(ReservedInventory)是库存中台提供给前台的一个关键能力,它的作用是防止在支付前被别人把库存买光。这个知识点在传统“下单即扣库存”的模型里不明显,但理解预占机制之后,你会发现订单与库存之间的事件协作设计会非常自然。
5.2 核心实现与代码走读
先说工程结构。我习惯在每个限界上下文内部再按interfaces/application/domain/infrastructure四层建包,两个上下文拆成两个Maven模块,将来拆分服务的时候,模块边界直接变成服务边界。以下面订单上下文的领域层为例:
java复制// 订单聚合根
@Entity
@Table(name = "orders")
public class Order {
@EmbeddedId
private OrderId id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private Money totalAmount;
private Long customerId;
private LocalDateTime createdAt;
// 业务行为:订单确认支付
public void confirmPayment() {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("订单状态不允许支付确认");
}
this.status = OrderStatus.PAID;
this.totalAmount = calculateActualAmount(); // 可以包含折扣、运费等规则
registerEvent(new OrderPaid(id, totalAmount, createdAt));
}
}
这段代码的关键点在于:状态迁移的合法性由领域对象自己保证,谁想绕过订单来改状态都不行,把门外汉挡在门外,这就是充血模型的直接体现。registerEvent方法把领域事件暂存在实体内,事务提交前再从outbox表取出统一发布。
然后是订单创建的应用服务。它的职责非常单一:加载仓储、调用聚合行为、保存聚合、依赖领域事件实现后续动作:
java复制@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final OrderRepository orderRepository;
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command.getCustomerId(),
command.getLineItems(),
command.getAddress());
orderRepository.save(order);
// save内部会持久化outbox事件表
// 事务提交后由后台任务发布OrderCreated事件
}
}
这里的事务边界我有意放在应用服务层。为什么?因为一个用例通常会对一个聚合做一系列操作,这些操作必须在一个事务里保证原子性。如果事务放在仓储层,那么操作多个聚合的复杂用例就很难保证一致性。
订单创建成功后,领域事件先写入outbox表。我用一个简化表来说明:
sql复制CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME NOT NULL,
published_at DATETIME NULL,
INDEX idx_published (published_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
后台定时任务每2秒扫描一次未发布的事件,发布到Kafka的order-events主题,再更新published_at。发送时以aggregate_id作为Kafka key,保证同一订单的事件按顺序到达。这里有一个细节:Kafka只能保证同一个分区内的顺序,但同一订单的所有事件都发到同一个分区,顺序就对了。
下游库存服务订阅order-events主题,处理OrderCreated事件:
java复制@Component
public class InventoryConsumer {
@EventListener // 这里的伪代码示意,真实Kafka需用@KafkaListener
public void onOrderCreated(OrderCreatedEvent event) {
// 幂等判断,以eventId去重
if (idempotencyService.exists(event.getEventId())) {
return;
}
// 预占库存
inventoryAppService.reserveStock(event.getCustomerId(),
event.getLineItems());
// 记录事件ID
idempotencyService.markProcessed(event.getEventId());
}
}
这个示例里最值得玩味的是幂等判断。消息系统的at-least-once语义决定了我们必须接受重复消息,然后通过业务侧去重来达到exactly-once的效果。类似地,如果库存不足,可以发布InventoryReservationFailed事件,由订单服务来决定是挂起还是取消,两个上下文通过事件形成一个松耦合的协作闭环。
整个样例跑下来,你会看到战略和战术在这里汇合了:战术层提供聚合适配、存储建模、事务管理;战略层负责上下文边界、事件集成、幂等协议,而六边形架构则像一块坚实的基座,把这两层粘在一起又不让它们互相污染。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
我在落地DDD的过程中,每隔一段时间就会遇到相同或相似的问题,整理成下面这张表,方便你排查自己的项目:
| 症状 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| Service变得很瘦但领域服务爆炸 | 领域服务承担了过多业务编排 | 看领域服务的方法中是否大量操作多个聚合 | 把业务编排放到聚合根的方法里,只有跨聚合的协调才用领域服务 |
| 实体里塞满getter/setter,行为很少 | 贫血模型被包装成了DDD | 看核心业务规则是否写在Service里 | 把状态迁移、金额计算、规则判断迁移到实体方法中 |
| 仓储接口里方法几十个,全是“查询” | 仓储和Mapper混为一谈,被查询逼疯 | 看查询是用于命令模型还是报表模型 | CQRS分离,命令侧走聚合仓储,查询侧走独立查询模型 |
| 领域事件发了但消费者不执行 | 事务提交前就发了事件,回滚时事件已投递 | 看事件发布和事务的先后时序 | 改用Outbox模式,事务内写表、提交后发布 |
| 消费者处理事件重复执行 | 中间件at-least-once语义被忽略 | 看消费端有没有做事件幂等 | 事件ID去重表,先查重再处理 |
| 拆分微服务后事务不一致 | 把同一个事务边界拆到了多个服务 | 看拆分是否按限界上下文划分,还是按表拆分 | 重回聚合边界,跨上下文用最终一致性 |
| 六边形架构中领域层引用了Spring注解 | 依赖方向被破坏 | 搜索domain包里的@Autowired、@Service | 删掉框架注解,换成纯Java |
这张表的价值在于“对症下药”。新手容易遇到症状和原因不对应的情况,比如“领域事件不生效”未必是事件发布问题,有可能是幂等表误判把事件全过滤掉了。排查时两条线同时看:生产日志中的事件发送记录,和消费端日志中的消息接收记录,比对两者的差距在哪一步断掉。
6.2 避坑心得与方法论建议
踩过不少坑之后,我形成了一套自己的方法论,分享几个核心心得。
第一,从事件风暴开始,别从表结构开始。很多团队做DDD是反的:数据库ER图先设计好,然后照着ER图拆聚合。这是拿数据库思维做DDD,大概率会做出“伪DDD”。正确顺序是先用事件风暴梳理业务流程和事件流,事件流清楚了,聚合边界自然就浮出来了。聚合不是从表推出来的,是从事件和命令推出来的。
第二,不要在经济承受范围外引入太多模式。DDD有很多高级武器,事件溯源、CQRS、Saga,这些都不是必须的。对中小团队来说,刚开始只需要四样东西:充血模型、聚合、仓储、领域事件。等你在事务一致性和查询性能上真正遇到了痛点,再逐步引入CQRS和事件溯源,否则只是增加代码复杂度。
第三,统一语言是最大隐性收益。很多团队觉得DDD收益不明显,是因为他们只学了结构,却没把团队和业务的沟通方式改掉。DDD强调的统一语言不是文档,而是会议中、PRD中、代码注释中、写代码的时候都用同一套词,让“订单取消”和“订单已取消”不再是两个概念。当开发和业务不再因为术语产生理解偏差,你会发现DDD带来的效率提升远超所有人的预期。
第四,六边形架构要配合依赖检查工具。人都会有手滑的时候,团队协作时,领域层偷偷引入一个工具类,或者某个Controller直接调了Mapper,这种依赖泄漏很难靠代码评审全部堵住。我建议加上ArchUnit之类依赖约束检查,把“domain不能依赖infrastructure”变成一条自动化校验,跑测试时就能拦住,不用靠领导点评。
最后再分享一个我个人的判断标准:如果有一个新同学来看你的代码,他能不看任何外部文档,仅凭代码本身和领域语言,就明白“这个系统是做什么业务、每个核心字段意味着什么”,那你的DDD落地就算真正成功了。否则,即使你用了再高级的架构,也只是买了一堆名词,摆在一个没有灵魂的工程里。
回望从单服务战术到分布式中台战略的这条进阶路线,我的体会是:DDD的核心始终是“边界”。界限上下文是边界,聚合是边界,六边形架构是技术边界,防腐层是集成边界。边界清晰了,模型就稳定了;模型稳定了,系统才能从容地从一个单体演进成中台生态。这个认知升级的过程,没有捷径,但走一次比读十本书管用。
