从最初接手那套“千层饼”一样的订单系统,到后来带团队按照领域驱动设计(DDD)重新梳理核心链路,这中间踩过的坑比看过的理论书多得多。老实说,市面上讲 DDD 的书和文章不少,但大多停在“什么是聚合、什么是限界上下文”的概念层,真正到了写代码的时候,很多人还是不知道怎么把“原则”变成一个个具体的“对象”,也不知道该让哪个类负责哪件事。这篇文章我不打算复读教科书,而是围绕“DDD 架构与对象”这条主线,结合真实项目经历,拆解从原则到实践的完整路径。
适合看这篇文章的人,一方面是被网上各种 DDD 海报和架构图搞得一头雾水、想在代码层面真正理解它的开发者,另一方面是已经在用 DDD 但总觉得代码别扭、想回头审视自己建模和对象设计逻辑的团队。我会把核心对象(实体、值对象、聚合)、分层架构、仓储落地、领域服务与应用服务的边界这些内容揉开讲,并且尽量说“为什么这样设计”,而不是只丢结论。
1. DDD 不是银弹:先想清楚它到底解决什么问题
刚开始接触 DDD 的人,最容易把它当成一套“用了就能解决所有架构问题”的终极方案。我见过不少团队,一上来就把项目从三层架构强行改成四层架构,包名全部换成domain、infrastructure,然后宣称“我们在搞 DDD”。折腾几个月之后,代码比原来更绕,交付效率反而更低。问题的根源不在于 DDD 本身,而在于没搞清楚它到底为了解决什么问题、适合什么场景。
1.1 业务复杂度的临界点:什么时候该上 DDD
很多团队是在业务逻辑逐渐失控的时候才想起 DDD。典型症状是:一个 Service 类里堆了几千行代码,一个订单状态流转的方法里塞了十几个 if 分支,任何一次需求变更都要小心翼翼,因为你根本不知道改这一处会影响哪些地方。这种代码的普遍特征是“事务脚本(Transaction Script)”——每个方法就是一条流程脚本,从参数校验、状态判断、数据更新到调用外部接口,全部串在一个方法里。
事务脚本本身并不是坏东西,在业务逻辑简单、变化不频繁的场景下它非常高效,甚至比强行引入 DDD 更快。但一旦业务规则复杂起来,比如保险定价策略、财务对账、供应链调度这类领域逻辑密集的系统,事务脚本的缺陷就暴露了:业务规则散落在各个 Service 方法中,无法被复用,也无法被清晰表达,改一个规则可能要翻十几个文件。
DDD 的适用场景恰恰是这里的“复杂领域逻辑”:当你发现核心业务规则足够复杂,复杂到需要一种方式把知识显性化、把规则集中沉淀下来时,才值得引入 DDD。这也是 DDD 中“领域”二字的含义——它关心的是业务专家脑子里的那套规则,而不是技术框架怎么分层。如果你的系统只是简单的增删改查,数据表几十张、接口几百个、逻辑大多是 CRUD 拼接,老实说,DDD 带来的复杂度会超过它解决的问题。个人经验是:核心业务模块(比如订单、库存、结算)上 DDD,边缘模块用传统方式,比“一刀切全员 DDD”要务实得多。
1.2 DDD 的核心思想:让对象承载规则,而不是让流程操作数据
DDD 有几个关键概念,很多文章要么堆概念,要么只讲单词。我习惯用一句话概括:DDD 的核心思想,是让业务规则住进对象内部,而不是漂浮在 Service 方法里。
举个例子,一个“订单取消”的业务规则。在传统事务脚本风格下,你可能这样写:
public class OrderService { public void Cancel(long orderId, string cancelReason) { var order = _orderRepository.GetById(orderId); if (order.Status != OrderStatus.PendingPayment && order.Status != OrderStatus.Paid) throw new BizException("当前订单状态不允许取消"); order.Status = OrderStatus.Cancelled; order.CancelReason = cancelReason; order.CancelTime = DateTime.Now; _orderRepository.Update(order); } }这段代码看起来没问题,但你有没有发现:关于“什么状态的订单能取消”这个规则,根本不存在于 Order 对象里,而是存在于 OrderService 方法里。如果另一个地方也有类似的取消动作(比如管理员强制取消、超时自动取消),它就必须把这段 if 逻辑再复制一遍。这还没完:假如业务规则变成“已发货订单取消需经过审核”,你就要去所有写取消逻辑的地方逐一修改,漏改一处线上就出问题。
DDD 的做法,是把状态判断内聚到领域对象里:
public class Order : AggregateRoot { public OrderStatus Status { get; private set; } public string CancelReason { get; private set; } public void Cancel(string reason) { if (Status != OrderStatus.PendingPayment && Status != OrderStatus.Paid) throw new BizException("当前订单状态不允许取消"); Status = OrderStatus.Cancelled; CancelReason = reason; CancelTime = DateTime.Now; AddDomainEvent(new OrderCancelledEvent(Id, reason)); } }同样一个动作,规则从 Service 移到了领域对象内部。Service 只需要调用order.Cancel("用户主动取消"),然后交给仓储保存。这样做最大的好处是:业务规则被封装在它本该存在的地方,任何需要取消订单的场景,都必须经过 Order 对象,规则不会被绕开。
我经常拿“助理和专家”的类比来解释这种区别。事务脚本是助理,什么流程都会帮你跑一遍,但真正的“专业知识”在助理脑袋里是碎片化的;DDD 里的聚合根则像是领域专家,你说“取消订单”,专家自己就知道什么情况能取消、取消后要做什么,而不是你教他每一步怎么走。这个思维转变,对写代码的人来说往往是第一道坎。
2. 领域对象的前世今生:从失血模型到充血模型
理解了“让对象承载规则”这个方向,接下来要解决的就是具体问题:一个对象里到底该放什么?不该放什么?状态和行为应该怎么组织?这一节我会从对象设计的历史演进讲起,因为很多人对贫血模型、充血模型只停留在一个“听说过”的层面,不知道怎么判断、怎么落地。
2.1 贫血模型为什么会让 DDD 名存实亡
所谓贫血模型(Anemic Domain Model),指的是领域对象只有属性、只有 getter/setter,没有任何业务行为。比如:
public class Order { private Long id; private Integer status; private BigDecimal totalAmount; private String cancelReason; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status = status; } public BigDecimal getTotalAmount() { return totalAmount; } public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; } // ... }这种对象在 Java 生态里太多了。你为了“分层清晰”建了一个domain包,里面却全是这种只有数据的 POJO(Plain Old Java Object)。所有规则都在 Service 层写,放在哪个 Service 就由调用方的习惯决定,结果就是服务层越来越臃肿,领域层形同虚设。名字叫 DDD,玩出来的却是典型的“事务脚本 + 数据模型”。
很多人会问:为什么贫血模型会这么普遍?一个重要原因是思维惯性。从三层架构时代开始,我们的习惯就是把“模型”当作数据传输的工具,对象就是对应数据库表结构,Service 才是处理逻辑的地方。另一个原因是框架推力,比如 Spring Data JPA 和 MyBatis 都鼓励你从表结构反推对象,久而久之就把“领域对象”等同于“数据实体”。
但贫血模型带来的问题是深远的。试想一个“订单总金额计算”的规则,如果涉及折扣、税费、运费多个因素,基于贫血模型的写法往往是在 Service 里一步步调一堆 getter 去算,规则分散又难以测试。而充血模型(Rich Domain Model)则把计算行为放进对象里,外部只需要调用order.calculateTotal(),规则变了只改这一处。
2.2 充血模型的设计要点:状态与行为的一致性
充血模型的核心,是保持对象状态与行为的一致性——也就是说,对象的所有状态变更必须经过对象自己的方法来完成,不允许外部随意 set。回到上面的例子,setStatus(1)这种代码在充血模型里是不允许出现的,状态变更必须通过Cancel()、Confirm()、Ship()这类业务方法来触发。这样对象内部的规则就能一直生效。
设计充血模型时,有几个实操要点值得单独说:
- 私有 setter:对外部隐藏状态字段的写操作,所有状态变更入口收敛到业务方法。
- 不变量检查:在状态变更方法内先检查前置条件,不满足就抛领域异常。
- 事件记录:状态变更时记录领域事件(Domain Event),供其他模块异步响应,而不是直接在对象里调远程服务。
- 持久化隔离:对象自己不关心怎么存,它只负责业务规则,持久化交给仓储。
有人担心“充血模型会导致对象里塞太多东西”,这确实是新手容易走偏的另一个方向。一个订单对象里既写状态流转、又写折扣计算、还写消息通知、再写日志记录,那也是一个上帝对象,比贫血模型更糟糕。所以下一节必须重点讲:怎么划分对象,怎么界定每个对象该装什么。
3. 核心对象的划分与边界:实体、值对象、聚合的实战判别
DDD 里面最容易被误解、也最影响代码质量的,就是对实体(Entity)、值对象(Value Object)和聚合(Aggregate)这三个基本对象类型的判断。很多团队之所以最终代码混乱,根源就在这一步没想清楚就开始写。我在实际项目中总结了一套相对实用的判断方法,不一定跟书本完全一致,但落地效果好。
3.1 实体和值对象:看它有没有“身份”
实体和值对象是两种基本对象类型,判断标准其实非常朴素:一个东西是否需要被单独追踪身份?需要,它大概率是实体;不需要,它更可能是值对象。
举一个典型的例子:订单里的“收货地址”。在多数业务系统里,收货地址通常被建模成实体,有一张 address 表、有主键 id、有各种字段。但认真想想,附件地址有“身份”吗?系统里关心“这个地址是谁吗”?只要地址内容相同,它跟另一个地址就没有本质区别。它是典型的臣服于订单的“值对象”。如果你的业务中需要反复修改同一个地址、追踪地址的历史,再把它建模成实体也不迟。但绝大多数电商场景,把地址建模成值对象会简单很多:
public class Address { private final String province; private final String city; private final String detail; // 构造器、equals、hashCode... }值对象最重要的特性是:不可变、靠值相等来判断相等。正因为不可变,它可以被多个对象安全共享,不用担心被意外修改。我经常跟团队说,如果你发现自己给一个值对象写了 update 方法,先停下来想一想:为什么不能直接替换成新对象?地址变了,就传一个新的 Address 对象给订单,而不是去改旧对象内部的状态。这个思维对保证数据一致性很有帮助。
实体的特征则相反:它有唯一标识(id),它在整个生命周期里即使属性全变了,“它是它”的事实依然不变。比如一个人的手机号变了,他还是同一个人,因为身份没变。实体的身份是持久的,而属性是变化的。建模时,实体的重点在于稳定标识和生命周期管理,比如订单就是一个典型实体,订单号就是它永恒的身份。
3.2 聚合:一致性边界才是真正的划分依据
很多教程对聚合的解释停留在“一组相关对象的集合”这种层面,但“相关”这个词说得太模糊了。实践里我判断聚合只有一个核心标准:事务一致性边界。也就是说,什么样的一组对象,在业务规则上必须作为一个整体来保存、更新,它们就应该属于同一个聚合。
拿经典的订单-订单项关系来说。订单项的总金额必须从属于订单,订单的状态变化会直接影响订单项;如果允许订单项脱离订单单独存取,很容易出现“订单改成了取消,订单项还在库存里占着”这种不一致。把订单当成聚合根,订单项作为聚合内部的子实体,所有对订单项的访问都必须通过订单这个“入口”,才能保证事务边界内的数据强一致。
在代码层面,聚合有几个强制设计要求:
- 聚合根是外部访问聚合的唯一入口,外部只能拿到聚合根的引用,不能直接拿内部实体去修改。
- 聚合内部的对象引用只能从聚合根出发,不允许外部把内部实体的引用再塞回其他地方。
- 聚合内部事务一致,聚合之间最终一致——这是很多刚入门的人最难接受的一点。以订单为例,下单后要扣库存,严格来说订单和商品库存是两个聚合,不能把库存写进订单聚合里用一个数据库事务强一致地处理,而是发布领域事件,由库存限界上下文消费事件做最终一致。
我把聚合的集合逻辑做个简化对比,方便大家对照:
| 判断维度 | 实体 | 值对象 | 聚合 |
|---|---|---|---|
| 有没有唯一身份 | 有 | 无 | 聚合根有,内部对象可无 |
| 可变性 | 可变 | 不可变 | 聚合根可变,内部受控 |
| 是否可共享 | 不建议随意共享 | 可安全共享 | 不允许跨聚合直接引用内部 |
| 典型例子 | 订单、用户、发票 | 金额、地址、时间段 | 订单(含订单项) |
3.3 事务边界和业务能力:从业务场景反推聚合设计
关于聚合的设计,还有一个很容易踩坑的点:聚合不能设计得太大,也不能设计得太小。聚合太大,比如把订单、商品、库存、物流全部塞进一个聚合,表面上是“强一致”了,实际上系统性能会急剧下降,所有写入都要在一个事物里锁一大片表,并发一高系统根本扛不住。聚合太小,把可信任一个共享值对象的订单项和订单拆成两个聚合,又会导致一致性没法保障。
我的经验是从一个具体业务场景反推:先明确这个业务场景中,为了完成一次操作,哪些数据必须被同时修改、同时保持一致?这些必须强一致的数据就该在一个聚合内。其他只做查询、只做引用的数据,一律放到聚合外。比如“用户修改个人地址”这个场景,必须同时修改的是用户的基本信息和收货地址列表,那它们就在同一个聚合里;“下单”这个场景,必须强一致的是订单头、订单项、订单金额,商品库存则不必在同一个事物里强一致,所以库存是另一个聚合。用这种方式反复推演,聚合边界会越来越清晰,而不是靠拍脑袋决定“谁跟谁绑定”。
4. 从原则到落地:领域层、应用层和基础设施层的职责与协作
对象层面想清楚了,接下来就是架构层面的事情。DDD 分层架构中的四层——用户接口层(User Interface / API)、应用层(Application)、领域层(Domain)、基础设施层(Infrastructure)——看起来简单,实际项目中最容易出问题的是领域层和应用层的边界。很多人把应用服务当成了康庄大路,什么逻辑都往里塞,结果领域层又变成了摆设。这里我展开说每一层到底该干什么。
4.1 领域层:只放业务规则,不放流程编排
领域层是 DDD 的核心,也是业务规则的家。它包含实体、值对象、聚合根、领域服务(Domain Service)和领域事件。这里有一条容易被忽略的铁律:领域层不依赖任何技术框架或基础设施。它不应该知道“我是用 MyBatis 从 MySQL 读的数据”,也不该关心“我这个数据最终怎么变成 JSON 返回给前端”。所有的业务规则应该可以脱离 Spring/Java EE 等框架独立测试,这也是领域层代码可测性高的根源。
领域服务(Domain Service)解决的是“这个逻辑放哪个对象里都不合适”的情况。比如“跨订单的冲突检测”或者“分润计算”,它涉及多个聚合、多个实体,但仍然是业务规则,不是流程编排。这种情况下,可以写一个领域服务:
public class CommissionCalculator { public Commission calculate(Order order, Distributor distributor) { // 分润计算的核心公式在这里 } }注意,领域服务里的输入是领域对象,输出的也是领域对象或值对象,它不碰数据库,不碰远程调用,也不管事务怎么开。这些都是应用层或基础设施层的职责。
4.2 应用层:做协调者,做事不决策
应用层是领域层与外部世界的桥梁。它接收用户请求,协调一个或多个领域对象完成业务用例,但应用服务本身不应该包含任何业务规则和业务决策。换句话说:应用层知道“做什么、按什么顺序做”,但“怎么做、什么能做”,要问领域层。
以一个“创建订单”的用例为例,应用服务的职责是:
- 构造 Order 聚合所需的基础数据(比如从请求参数组装 Address 值对象)。
- 调用 Order 工厂或构造函数生成订单对象。
- 调用仓储保存订单。
- 发布领域事件(通常由事件总线异步分发)。
- 处理事务边界(通常加在应用服务的方法上)。
这些步骤里没有一步需要应用服务自己判断“订单能不能创建”,判断逻辑都在 Order 聚合根的构造器或工厂方法里。这样设计出来的应用服务代码非常薄,可读性高、容易测试,这也是很多人说的“用例编排”。
4.3 基础设施层:把技术细节关在门外
基础设施层包括数据库访问、缓存、消息队列、文件存储、外部服务调用等。在 DDD 中,它的一个重要职责是定义和实现仓储接口。领域层定义仓储接口(如OrderRepository),基础设施层负责实现(比如使用 Spring Data JPA 实现JpaOrderRepository),实现细节完全隔离在领域层之外。这正是依赖倒置原则的体现:高层模块不依赖低层模块,两者都依赖抽象。
实际项目里一些团队会在领域层引入 MyBatis 或 Hibernate 的注解,导致领域对象被技术框架污染。这不是说绝对不行,但代价是领域层的可移植性和可测试性下降,换数据源的时候要改领域代码。我更推荐用纯 POJO 定义领域对象,用独立的持久化对象(PO)和 Mapping 机制把领域对象转成数据库结构。虽然多写一层转换代码,但换数据库或换框架时,领域层完全不用动。对于团队规模小、迭代速度快的项目,直接从领域对象映射数据库表也未尝不可,只要理解代价、接受即可。
4.4 一个完整用例的分层跑通:创建订单
光说理论容易飘,我用一个最简单的“创建订单”用例,把四层串一遍。假设前端请求参数是:
{ "userId": 10001, "items": [ { "skuId": "A001", "quantity": 2 }, { "skuId": "A002", "quantity": 1 } ], "address": { "province": "浙江省", "city": "杭州市", "detail": "某某大厦 18 层" } }用户接口层:接收 HTTP 请求,把 JSON 转成应用层需要的参数对象(DTO),调applicationService.createOrder(...)。这一层不参与任何业务判断,只做参数格式适配和响应结果转换。
应用层:
public class OrderApplicationService { private final OrderRepository orderRepository; private final ProductRepository productRepository; private final DomainEventPublisher eventPublisher; @Transactional public CreateOrderResult createOrder(CreateOrderCommand command) { // 1. 查询商品快照(组装聚合所需数据) List<ProductBrief> products = productRepository.findByIds(command.getItemSkuIds()); // 2. 构建值对象 Address address = new Address(command.getProvince(), command.getCity(), command.getDetail()); // 3. 创建聚合根 Order order = Order.create(command.getUserId(), products, address); // 4. 保存 orderRepository.save(order); // 5. 发布事件 eventPublisher.publish(order.popEvents()); return CreateOrderResult.from(order); } }领域层,Order.create这个聚合根工厂方法内部会校验商品是否可售、计算金额、生成订单号:
public class Order extends AggregateRoot { public static Order create(Long userId, List<ProductBrief> products, Address address) { if (products == null || products.isEmpty()) { throw new BizException("订单项不能为空"); } // 校验商品上下架状态、库存数量等 // 计算总金额、优惠金额、应付金额 Order order = new Order(); order.userId = userId; order.address = address; order.items = products.stream() .map(p -> new OrderItem(p.getSkuId(), p.getPrice(), p.getQuantity())) .toList(); order.calculateTotal(); order.addDomainEvent(new OrderCreatedEvent(order.id)); return order; } }基础设施层,OrderRepository的实现负责把 Order 聚合持久化到数据库,可能需要把聚合一拆为多表(order、order_item)保存。
这个例子虽然简单,但已经能完整看到各层的边界:业务规则(校验、计算)在领域层,流程编排(查商品、存订单、发事件)在应用层,技术访问(数据库、事件分发)在基础设施层。如果你写的应用服务比这个复杂得多,比如里面有大量 if/else 判断某状态能不能执行某个操作,那大概率规则放错层了。
5. 架构落地中的坑:领域模型的序列化、持久化与事务处理
即使理解了三层职责,真正把 DDD 落地到实际项目时,还会有不少容易被忽视的坑。这些坑我全都踩过,分布在序列化、持久化、事务三个最日常的环节。下面结合具体场景展开。
5.1 领域对象的序列化:别让 JSON 污染领域层
一个很常见的问题:领域对象能不能用 Jackson 注解?能不能把它直接序列化后发到消息队列?
我的建议是:领域层尽量不依赖任何 JSON 注解。原因有两点:一,领域层引入 Jackson 注解会让它依赖外部框架,破坏独立性;二,直接序列化领域对象往往会把内部结构暴露给外部。更合理的做法是定义独立的 DTO 或事件对象,专门用于 API 层传输和消息队列发布。比如OrderCreatedEvent我可以设计成包含订单号、用户 ID 等必要字段的事件模型,而不是让消息队列直接发 Order 聚合根。这样以后订单聚合内部结构变化,不会直接破坏下游消费者。
这里涉及一个“表达模型”和“操作模型”分离的问题。领域模型是操作模型,它负责业务规则和一致性;而 API 层面对前端的是表达模型(DTO),它是按接口需要裁剪过的。两者之间的转换在应用层或者用户接口层做。看上去多写了一些转换代码,但长期维护收益非常大。
5.2 聚合根持久化的选择:JPA 与 MyBatis 的两难
关于聚合根的持久化,我观察到一个很实际的现象:用 JPA/Hibernate 的团队更容易落地 DDD,因为 JPA 允许你从领域模型出发建立对象关系映射,而且有脏检查、级联保存,一个聚合根 save 就能级联把整个聚合内部的数据都写进多张表。MyBatis 则更倾向于 SQL 直接对应表结构,写聚合内多个对象时经常需要手工控制插入/更新顺序,稍不留神就会漏字段。
如果你的技术栈是 MyBatis 且不想更换,几个实践建议可以参考:
- 聚合根的仓储实现里,用 Unit OfWork(工作单元)模式管理内存中聚合的变更,最后统一 flush。
- 订单聚合内部的订单项,不要单独提供 update 接口,而是在保存时先删除旧的子项再插入新集合,或者做增量对比。
- 领域对象转持久化对象(PO)的 Mapping 逻辑集中放在仓储层内部,不要让 Service 层感知 PO。
但这里想说明一个本质问题:DDD 并不规定你必须在 JPA 还是 MyBatis 之间二选一。它的核心要求是“持久化细节不进领域层”。你用什么框架实现仓储,是实现自由。但为了省事,很多团队在基础设施层直接用 JPA 注解标注领域对象,这在领域复杂度和团队规模有限的情况下是可以接受的;如果领域模型非常复杂,我更推荐独立的持久化模型。没有绝对标准,关键是知道自己为了什么取舍。
5.3 事务边界:应用服务还是聚合根
事务边界这个问题,团队里经常吵。有人主张事务应放在应用服务方法上(@Transactional),因为它是用例边界;也有人认为事务应该由仓储控制。我个人的经验是:只能把事务放在应用服务或用例边界,绝对不能放在领域对象内部。
原因很简单:领域对象不应该感知事务。如果 Order 内部自己开事务,意味着领域层依赖了事务框架,而且事务粒度也被锁死在单个聚合根上,一旦一个用例需要同时更新两个聚合,你这个事务就根本管不到另一处。应用服务作为一个用例的协调者,天然是事务边界的正确位置。
但有一点必须提醒:如果一个用例里跨了多个聚合根,这个事务只应该保证它从“开始”到“持久化”之间的一致性,而不应该试图把所有关联聚合的数据强一致地放进同一个数据库事务里。跨聚合的强一致,在大规模分布式系统里几乎不可能做到,也不应该强求。正确的姿势:核心写操作落在各自聚合的仓储上,跨聚合间的协调采用最终一致性,比如领域事件 + 重试 + 对账。
5.4 领域事件的发布时机:事务发件箱模式
如果聚合内有领域事件,发布时机很讲究。直接在聚合方法里调用消息中间件发布事件是最危险的做法——如果事件已发布,但之后事务回滚了,消费者会基于一个“从未发生”的变化去执行逻辑,造成严重的数据不一致。
我常用的方案是“事务发件箱模式”:聚合根产生的事件先记录到本地 outbox 表,和业务数据放在同一个数据库事务里提交,然后后台任务去轮询 outbox 表,把事件发布到消息队列或者直接推送下游。这个方法看似多了一点轮询逻辑,但它保证了业务数据和事件的一致性。团队小、量不大时,你也可以简化:聚合根先把事件放在内存列表中,应用层在事务成功提交后统一发。但如果你们对一致性要求比较高,最终一致性缺口不能接受,outbox 模式是最稳妥的。
6. 实践中的注意点:怎么避免 DDD 变成新的“过度设计”
写了这么多 DDD 的细节,最后必须说点泼冷水的话:DDD 并不是规模越小、逻辑越简单的项目的解药。我见过太多团队,为了“领域驱动”引入一堆概念和模式,最后代码量翻倍、解释成本飙升,出 bug 概率反而更高。DDD 是解决“业务复杂度”的工具,不是解决“技术复杂性”的工具。如果业务本身五张表就能说清楚,老老实实写 CRUD 不比任何架构差。
从团队实操角度,有几个建议值得考虑:
- 从核心域开始探索。不是所有业务模块都用 DDD,聚焦在“核心域”——也就是公司最核心、规则最复杂、变化最频繁的那部分,比如电商里的交易、金融里的风控。周边支撑模块(公用基础能力、报表查询等)完全可以走传统方式。
- 建模的起点是业务事件和业务规则,不是数据库表。很多团队一画 ER 图就停不下来,但 DDD 的建模起点应该是事件风暴(Event Storming)或者用户故事,把业务规则和场景先找出来,再推导出对象。
- 领域对象重量把控好。没有一层不变的标准,订单这种聚合根确实会有较多信息和方法,但内部子实体(订单项)应该保持简单,避免每个对象都做成“万能对象”。
- 先跑通端到端的最小闭环,再扩展边界。不要一上来就把限界上下文、防腐层全部铺满,先在一条核心链路上走通 DDD 的对象设计、仓储和事务,团队理解一致后再推广到其他模块。
考虑到团队协作,我还想多说一点:DDD 的有效实施,不只是技术活,还需要业务专家参与。你设计聚合边界时,如果业务专家对“哪些操作必须强一致”有不同说法,大概率是需求没聊透,而不是技术方案有问题。让业务规则驱动对象设计,而不是让数据库表结构驱动对象设计,这才是“领域驱动”四个字的原始含义。做到这一层,DDD 才能真正帮你控制复杂度,而不是变成一套听着高级、用着重重的行业黑话。