你是不是也听过一句话:领域驱动设计(DDD)不是技术,是一种思维方式。这话对,但对绝大多数从编码起步的开发者来说,真正的问题从来不是“信不信DDD”,而是“我的代码到底怎么写才算DDD”。我早期读《实现领域驱动设计》时,满脑子都是聚合、限界上下文,可落回工程里,依然在控制器里写事务脚本。后来在一个某电商模拟项目里被业务变化反复摩擦,才慢慢意识到,开发视角下的DDD,本质上是把业务逻辑从数据库和脚手架中解放出来,让代码结构跟着业务边界走,而不是跟着数据表走。这篇我就从这个角度聊一聊,不扯太多理论,只讲代码怎么落地、分层怎么分、坑怎么避,适合已经写过一阵子业务系统、想用DDD提升代码质量的开发者。
1. 从开发角度重新认识DDD中的核心概念
1.1 机会点:为什么传统CRUD开发会在复杂业务下失控
传统的开发方式,我把它叫做“表驱动开发”。先设计数据库表,再写实体类,再写Service,Service里塞满业务校验和计算逻辑,Controller只做参数接收和数据返回。项目早期这种模式效率极高,特别是表单类系统,几乎一两天一个模块。
但业务复杂到一定程度后,问题就出现了。比如订单模块,它不仅仅是往orders表插一条订单数据,还要处理库存扣减、支付状态回写、物流生成、优惠券核销、积分累计等一串联动。这些逻辑如果都堆在OrderService里,这个类很快会膨胀到上千行,方法之间互相调用,数据校验和状态流转交织在一起。更要命的是,需求变化时,比如要新增一种“预售订单”,或者要支持“订单拆分发货”,你得在Service里加满if else,改一个逻辑还可能影响其他链条。
问题出在哪?代码没有反映出业务真正的复杂度。业务有自己的不变规则和易变规则,而表驱动开发把所有规则都平铺在Service方法里,系统逐渐变成一个“大失血模型”。DDD针对的正是这一痛点,它要求我们透过数据表去识别业务上真正重要的“领域模型”,让模型自己承载状态和规则,而不是被数据库牵着走。
1.2 从开发角度快速理解领域、子域与限界上下文
DDD的第一个关键动作是“限定范围”。业务很大,不可能一个模型吃遍所有功能,因此需要把整个业务系统拆分成若干个“子域”和“限界上下文”。
我用一个实际场景来说明。某电商模拟项目有商品、库存、订单、支付、物流五个业务模块。对开发者来说,最直观的划分方式就是看“这个模块在关心什么数据,以及数据的变化由谁驱动”。比如订单模块关心“谁在什么时候买了什么”,库存模块关心“商品还剩多少”,支付模块关心“钱是否到账”。它们各自内部有一套独立的业务规则,而且相互之间尽量不直接操纵对方的数据。
这种边界在DDD里叫作“限界上下文”。每个上下文内部有一套自己的领域模型,同一个概念在不同上下文里含义不同。比如“商品”在商品上下文里是一个完整的商品信息;在订单上下文里可能只包含商品ID、名称、价格快照;在库存上下文里又只关心SKU和数量。如果你试图让全系统共用一个商品实体,就会陷入模型混乱。
开发者的习惯是上来就建一张公共Product表,然后所有模块都join它。但限界上下文的核心提醒是:模块之间的通信不要直连数据库,而是通过接口或事件。这样虽然短期内感觉绕,但长期能避免模块间隐性耦合。从代码层面看,我们只需要保证每个上下文有独立的Repository接口和模型,不互相引用对方的Entity。
1.3 让模型贴合业务的语言无处不在
开发者和业务专家沟通时常常有一个障碍:业务说“下单”,代码里却是“CreateOrder”。这还不算大问题,更大的问题是同一件事在不同代码库里有不同叫法。比如业务里的“取消订单”可能包含超时自动取消、用户手动取消、客服介入取消,这三种在代码里可能被写成CancelOrder、TimeOutClose、ManualCancle,语义混乱会导致后续维护时很难定位业务规则。
DDD强调“统一语言”,不只是文档概念,更要在代码结构里体现。方法名、类名、变量名都要尽量使用业务词汇,而不是技术词汇。例如领域服务里定义一个placeOrder()方法,比submitOrderAndCreatePaymentTransaction()更贴合业务。你会发现,统一语言的作用不只是让交流顺畅,更重要的是让代码成为可读的业务说明书。我在实际项目中会增加一条代码评审规则:类名里禁止出现Handler、Manager、Util这些毫无业务含义的词,新建的核心业务类必须先写清楚它代表领域里的什么东西。
2. 战术建模:实体、值对象、聚合和仓储的代码实现
2.1 实体与值对象:从“数据表”思维转换为“模型”思维
传统开发里,实体基本就是一个数据行映射,所有字段加getter/setter,没有任何行为。这样的实体现被叫作“贫血模型”。在DDD里,我们更关注实体本身的“身份标识”和“状态流转”,比如订单实体要能自己处理“确认”“支付成功”这样的状态变化,而不是由外部的Service来修改它的status字段。
public class Order { private OrderId id; private CustomerId customerId; private List<OrderItem> items; private OrderStatus status; private Money totalAmount; public void confirm() { if (this.status != OrderStatus.CREATED) { throw new IllegalStateException("只有新建订单才能确认"); } this.status = OrderStatus.CONFIRMED; } public void markPaid(PaymentResult paymentResult) { // 业务逻辑:已经发货的订单不能再标记支付 if (this.status == OrderStatus.SHIPPED) { throw new IllegalStateException("已发货订单不能变更支付状态"); } this.status = OrderStatus.PAID; } }实体的关键是行为和状态的封装。代码里没有直接的setStatus方法,所有状态变化都通过业务方法来完成,规则都包在方法内部。这样一来,业务校验不再散落在应用层,而是内聚在领域模型里。
值对象则不一样,它没有身份,描述的是实体的属性。比如金额,订单总金额不是一个订单的独立实体,而是若干个金额计算后的结果。值对象需要不可变,通过构造器初始化,并且定义它的相等比较逻辑。
public class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("币种不一致不能相加"); } return new Money(this.amount.add(other.amount), currency); } }很多开发者刚用DDD时纠结“订单应该用实体还是值对象”,这里给一个判断标准:是否拥有一个独立且必须被跟踪的身份标识。订单有订单号,是实体;订单里的产品名称快照没有独立身份,当它作为静态属性存在时,就可以是值对象。
2.2 聚合与聚合根:一致性边界的代码约束
复杂业务里,多个实体之间的关系并不是对等的。比如“订单”和“订单项”,“订单项”离开了“订单”就没有意义。DDD把这组有完整一致性关系的对象视为一个聚合,由聚合根(订单)统一对外提供访问入口。
public class Order { private List<OrderItem> items = new ArrayList<>(); public void addItem(ProductId productId, String productName, Money price, int quantity) { // 聚合内的规则:数量必须大于0 if (quantity <= 0) { throw new IllegalArgumentException("购买数量必须为正数"); } // 聚合内的规则:同一商品不能在同一订单中重复添加 if (containsProduct(productId)) { throw new IllegalArgumentException("同一商品只能添加一次"); } this.items.add(new OrderItem(productId, productName, price, quantity)); recalculateTotal(); } }聚合设计的关键是边界要尽可能小。我见过一些团队把用户、地址、优惠券、物流信息全部塞进订单聚合里,最终导致每次重算订单总价时要加载几十张表的数据,性能差不说,逻辑还极易出错。
正确的做法是:聚合只包含必须保证一致性的对象。订单修改时“订单项、总额、状态”必须一致,所以它们在一个聚合内。地址信息其实可以独立存在,订单只需引用地址ID,不必拉入聚合。优惠券更复杂一些,它的核销状态可能由外部系统控制,也不适合纳入订单聚合。
在代码层面,我们要把聚合根当作唯一入口,子实体不能被外部直接修改。比如外部应用服务想删除订单项,必须调用order.removeItem(...)方法,而不是拿到order.getItems().remove(...)。这需要我们在getItems()方法里返回不可变集合,这一点很多开发者会忽略。
2.3 领域服务与仓储:处理跨聚合的编排和持久化隔离
现在引入一个实际场景:用户下单流程,需要组装订单聚合、校验库存、扣减预占库存,这一串逻辑如果只放在Order实体内,会让聚合根被迫依赖库存仓储。但库存实际上属于另一个聚合,跨聚合的流程不适合放在任何一边,所以要抽象出一个领域服务。
@Service public class PlaceOrderService { private final OrderRepository orderRepository; private final InventoryService inventoryService; @Transactional public Order placeOrder(CreateOrderCommand command) { // 领域服务编排:先预占库存 inventoryService.reserveStock(command.getItems()); try { // 再创建订单聚合 Order order = Order.create(command.getCustomerId(), command.getItems()); orderRepository.save(order); return order; } catch (RuntimeException e) { // 补偿:库存预占失败则回滚 inventoryService.releaseReservedStock(command.getItems()); throw e; } } }在这个例子里,PlaceOrderService是领域服务,它负责跨聚合的协作,但它内部并不实现业务规则本身。“库存是否够”是库存领域的规则,“订单金额计算”是订单实体的规则,领域服务只是把它们按流程串起来。这样既能保持聚合内聚,又不会让流程逻辑渗入实体内部。
仓储接口的作用则更加直接:领域层需要持久化对象,但并不关心它存的是MySQL还是MongoDB。我们从开发角度要记住一个原则:仓储接口定义在领域层,实现在基础设施层。这样领域模型完全不依赖具体技术细节。
public interface OrderRepository { Order findById(OrderId id); void save(Order order); }实际开发中,不少人喜欢把ORM的Entity和领域模型混在一起,直接在领域模型上加@Table、@Entity注解。这会让技术纠缠进模型,遇到需要换数据库或做CQRS写入分离时会非常痛苦。我更推荐的做法是:领域层保持纯Java/Kotlin对象,基础设施层里做Mapper或Converter,把领域模型映射到持久化模型。虽然多了一层代码,但换来的独立性值得。
3. 实操过程:一个订单模块的DDD分层架构落地
3.1 用四层架构搭出一个可运行的骨架
进入这个阶段,假设我们已经分析出订单是一个限界上下文。现在开始搭代码骨架。DDD推荐的分层方式通常有interface、application、domain、infrastructure四层。我习惯用一个Maven/Gradle多模块工程来隔离层级依赖。
order-context/ ├── order-interface/ // Controller、DTO、消息消费者 ├── order-application/ // 应用服务、事务边界、命令处理 ├── order-domain/ // 实体、值对象、聚合、领域服务、仓储接口 ├── order-infrastructure/ // Repository实现、MQ、外部网关调用这里的依赖关系是自上而下:interface层依赖application层,application层依赖domain层,infrastructure层是一个被domain接口驱动的实现层。所有依赖都指向domain层,domain层不该依赖其他任何层。
代码目录建好后,第一步并不是写代码,而是先用一张表格梳理清楚订单上下文里的核心对象。比如订单实体、订单项值对象、Money值对象、OrderId标识、OrderRepository接口、PlaceOrderService领域服务。把这些术语放在一个地方,后面写代码时就不会跑偏。
3.2 从“创建订单”用例开始,走完整条链路
假设有一个HTTP请求要创建订单。先是interface层的OrderController接收到CreateOrderRequest,它仅仅做参数转义,把DTO转换成应用层需要的CreateOrderCommand,然后调用应用服务。
@RestController public class OrderController { private final OrderApplicationService orderApplicationService; @PostMapping("/orders") public OrderResponse createOrder(@RequestBody CreateOrderRequest request) { CreateOrderCommand command = new CreateOrderCommand(request.getCustomerId(), request.getItems()); Order order = orderApplicationService.createOrder(command); return OrderResponse.from(order); } }注意,Controller里不写业务逻辑,甚至不直接注入仓储。它只是接口层的一个适配器。接下来application层的OrderApplicationService负责用例编排。
@Service public class OrderApplicationService { private final PlaceOrderService placeOrderService; @Transactional public Order createOrder(CreateOrderCommand command) { return placeOrderService.placeOrder(command); } }这个事务注解我放在应用服务上,因为一个用例往往对应一个事务边界,而领域层不该关心事务。聚合内部发生的所有状态变更都应该在事务内一起提交或回滚,跨聚合的操作则尽量不放在同一个事务里,而是通过领域事件补偿。
领域服务里真正执行下单逻辑,它调用库存网关做预占,然后创建订单聚合,保存订单。实际项目中,库存预占往往不是同步的,可能是发消息到库存服务,这就要用领域事件来减少强耦合。
public class Order { private List<DomainEvent> events = new ArrayList<>(); public void approve() { this.status = OrderStatus.APPROVED; this.events.add(new OrderApprovedEvent(this.id, this.customerId, this.totalAmount)); } public List<DomainEvent> pendingEvents() { return List.copyOf(this.events); } public void clearEvents() { this.events.clear(); } }应用服务保存聚合后,需要把领域事件发布出去。比如订单创建成功后,发布OrderCreatedEvent,支付服务订阅后创建支付单。这样订单流程和支付流程在一个事务边界内不再直接耦合,系统整体的可靠性反而更好理解。
3.3 事务、幂等和并发控制的边界把握
一个常见的问题是:一个用例里涉及订单调用库存预占,这个预占应该和订单保存放在同一个本地事务里吗?如果库存服务是独立部署的,那么跨服务的事务不能依赖本地数据库事务,只能靠Saga或事务消息。开发时最稳妥的做法是:先保存业务单据,再发事件,后面服务订阅后执行动作,并做幂等处理。
在代码上,幂等可以通过在接收方维护一个processedEventId来完成。比如支付服务收到OrderCreatedEvent,先查一下event_processed_log表是否已经处理过这个事件ID,处理过就直接返回。这个细节看着不起眼,但在分布式环境下非常重要,消息大概率会重复投递。
再比如并发控制。聚合根版本号是处理并发更新的常见手段。在聚合根里加一个version字段,保存时带上版本号条件,更新如果影响行数为0,说明版本冲突,这种乐观锁机制对于订单修改场景很实用。很多数据库表设计里没有version字段,这是DDD落地时容易漏掉的一环。
4. 开发中常见的坑,以及我踩过的排查经验
4.1 贫血模型:把DDD写成了“数据类 + Service”
这是DDD落地中最常见的变形。很多人照搬了SSH/SI分层,把“实体类只放字段,逻辑全堆在Service里”,然后说自己在用DDD。这样做的本质依然是过程式编程,领域层没有任何行为,规则仍然长在Service里,后续维护照样痛苦。
判断方法很简单:看领域模型里有没有业务业务方法。假设一个Order类,如果里面所有方法都是getter/setter,没有任何业务规则,基本就是贫血模型。改法也很直接:从Service里把那些修改订单状态、计算金额、校验规则的方法往下搬,让Order去执行。刚开始会有点别扭,但一旦搬完,你会发现Service变得非常薄,只负责编排和事务,而领域模型自己“有了灵魂”。
4.2 过度设计:小项目也用一套复杂聚合和事件
DDD不是银弹。我见过一些团队连一个简单留言板系统都要拆出限界上下文、领域事件,结果代码量翻了三倍,效率反而变低。这种过度设计的根源是对DDD的“敬畏”变成“迷信”。
我建议用几条简单规则来判断是否使用DDD:业务规则是否复杂到需要频繁变更?是否有多个团队对同一批数据有不同理解?是否希望模块之间长期独立演进?如果答案都是否,也许直接用事务脚本就够了。不是所有代码都要成为艺术品,能用最简单的方式解决问题才是工程核心竞争力。
4.3 事务边界和跨聚合调用的问题
聚合的一致性边界在开发时往往被忽视。有人把“用户”和“用户账户”放在两个聚合里,但应用服务里同时修改两个聚合,然后放在同一个事务里。这样暂时看起来可以,但长此以往,只要一有跨聚合的事务需求,你就不自觉地回到全局锁大事务。正确设计要考虑业务上是否真的需要强一致,如果不允许不一致,就说明它们很可能不该被拆成两个聚合;如果可以容忍延迟一致性,就可以用领域事件来解耦。
排查经验是:先看QueryLog和事务日志里的锁等待时间。如果发现某个高频接口锁比较多,大部分时间不是在执行SQL,而是在等待锁,就要审视聚合边界是不是划大了。把聚合缩小之后,事务时间通常能下降一大截。
4.4 ORM映射的别扭:聚合是对象树,表是多张平铺表
领域模型是对象树,数据库表是二维行集,这个天然不匹配会让开发者非常头疼。一张订单表加一张订单项表,映射成Order聚合还算简单。但一旦聚合里有值对象集合,比如“订单状态历史”,在用JPA时经常会被映射成NOSQL存JSON,或者用各种Hibernate的自定义类型。
我的做法是:接受这种映射需要额外代码,不要强求领域模型和数据库表一一对应。领域模型可以包含一个List<OrderStatusHistory>,数据库里可能单独一张表,也可能是一个JSON字段,取决于读多写多还是写多读多。基础设施层负责双向转换,领域层保持干净。换个说法就是:领域模型是源,表结构是实现细节。
4.5 领域事件丢失或者重复触发
发布领域事件最怕的情况是“都走到应用服务了,但事务没提交,消费者就读到了消息”。用本地事务同步发布消息时,如果消息发送后事务回滚,事件就变成了脏事件。解决思路是把事件先写到本地事件表,和业务数据同一个事务一起提交,再由定时任务把事件表里的记录发布到消息队列。这种方式虽然多了一个事件表,但它能保证事件和业务数据的一致性,属于比较稳的工程化措施。
重复触发的问题要靠幂等来兜底,接收方在处理每一个事件前先检查是否已经处理过。这个表不大,加个唯一索引就搞定了。很多生产环境的问题恰恰是发消息成功后的ack失败导致重复发送,处理一次、重复处理就可能导致库存扣两次、积分加两次。
5. 一些个人建议与收尾
我实践下来最深的感受是,DDD不是一种“银弹”,而是一种纪律。它逼着你在编码前把业务分析清楚,把边界画清楚,然后让代码结构反映这个边界。开发者的优势在于能从代码视角提前发现建模矛盾,比如一个对象在某些场景下需要身份、在某些场景下又不需要,这种矛盾如果只靠业务专家,往往不会暴露。
如果你正打算在项目里导入DDD,我的建议是从一个小上下文试起,先选那种业务规则多、变化频繁的模块,比如订单、合同、库存,用DDD落地一次。不要一上来就大规模重构所有代码。迭代中多问自己:聚合边界有没有划错?领域模型有没有真的承担业务逻辑?仓储是不是只做了持久化适配?这些问题想清楚,DDD的价值自然就出来了。