1. 为什么很多团队搞不清DDD到底解决什么问题
我见过太多团队在技术选型时把领域驱动设计挂在嘴边,结果一讨论就变成“聚合根怎么建”“Repository接口怎么抽象”这类纯代码层面的争论。代码是落地方式,不是DDD的核心。DDD的全部价值在于:它教你怎么用代码去表达业务,而不是用业务去迁就代码。
一个典型的反例是:某支付系统上线两年后,所有逻辑都堆在Service层,一个转账方法写了800行,里面藏着手续费计算、风控校验、账务流水、通知发送,谁都不敢动。这时候团队想起DDD,开始重构,结果发现根本不知道边界画在哪里——因为业务规则早已散落得到处都是。这正是需要DDD的场景:业务复杂度已经压倒了技术复杂度,你需要一套方法论把混乱的领域知识重新组织起来。
DDD不是一个框架、不是一种代码分层模板,它是Eric Evans提出的一套软件建模思想,核心就两件事:第一,把业务拆成清晰的领域和边界;第二,在每个边界内部用代码构建一个能够准确表达业务规则的模型。它解决的关键问题是“业务知识的丢失”——传统开发模式下,业务专家脑子里的规则经过需求文档、口头转述、开发理解、代码实现,层层衰减,最后代码表达的和真实业务已经偏离了很远。
这篇文章适合正在做业务系统开发、被复杂逻辑困扰的技术负责人和高级开发者,也适合刚接触DDD但被各种概念绕晕的初学者。我会把战略设计、战术设计、代码落地、常见坑全部串起来讲,尽量用实际场景说话,不讲空理论。
2. 从战略设计开始:先划边界,再谈建模
2.1 限界上下文就是系统的“国境线”
很多初学者学习DDD时直接扑向实体、值对象、聚合这些战术概念,这是本末倒置。DDD的起点是战略设计,而战略设计的起点是限界上下文。
限界上下文(Bounded Context)的概念其实非常简单——它就是一个业务边界。在一个限界上下文内部,业务语言是统一且自洽的;跨了边界,同一个词可能是完全不同的含义。
我用一个最经典的电商例子来说明。在“商品目录”上下文里,“商品”指的是SPU(Standard Product Unit,标准产品单元),包含名称、类目、属性组、图片这些信息;在“库存”上下文里,“商品”指的是SKU(Stock Keeping Unit,库存量单位),关注的是库存数量、仓库位置、预警阈值;在“订单”上下文里,“商品”又变成了订单行项目,关心的是下单时的价格快照、数量、优惠分摊。同一个词“商品”,在三个上下文里形态完全不同。
如果强行用一个全局的“商品”模型去服务所有场景,结果就是:一个类几百个字段,每种场景只用其中一小部分,并且任何修改都要评估对全链路的影响。这就是典型的大泥球(Big Ball of Mud)的诞生过程。
所以画限界上下文的本质,是画业务语言的边界。每个上下文内部使用通用语言(Ubiquitous Language),也就是业务专家和开发人员共同认可的那套名词和规则。某供应链项目里,业务方口中的“在途库存”和“可用库存”经常混用,开发团队也跟着含糊,后来在建模阶段把每个上下文里的术语表拉出来一一对照,才理清楚这是两个不同上下文里的不同概念。
2.2 上下文映射:定义边界之间的协作关系
划完边界之后,边界之间怎么协作,这是战略设计的第二步。DDD定义了多种上下文映射关系:
- 合作关系(Partnership):两个上下文互相依赖,需要同步协调变更。
- 共享内核(Shared Kernel):两个上下文共享一部分模型代码,比如共享值对象。
- 客户方-供应方(Customer-Supplier):上游供应方决定模型,下游客户方按其接口开发,典型如订单上下文调用库存上下文。
- 防腐层(Anti-Corruption Layer):下游上下文为抵御上游模型腐蚀而建立的翻译层,后端系统对接第三方系统时常用。
- 开放主机服务(Open Host Service):通过API将上下文能力开放出去,配合发布语言(Published Language),如REST API或消息协议。
防腐层是我在实际项目中遇到最多的落地形态。我做过一个对接某外部物流平台的系统,对方接口字段命名混乱、返回结构不稳定,而且他们更新接口完全不考虑下游调用方。如果直接把对方的DTO透传到整个业务层,我们的核心模型就会被对方的坏味道腐蚀掉。最终方案是在我们的上下文边界内建了一层ACL,负责把外部物流平台的模型翻译成内部的运单模型和轨迹模型,内部代码永远不直接引用外部类型。后期对方接口升级,我们只改ACL内部逻辑,核心业务模型毫发无损。
上下文映射关系最好通过上下文地图(Context Map)画出来。不需要用什么高端工具,一张白纸、几个矩形框、几条带箭头标注关系的线就够了。关键是让团队所有成员对边界和协作关系有一致的认知。
2.3 战略设计的落地顺序与判断标准
实际操作中,我不建议一上来就做什么轰轰烈烈的“领域分析工作坊”。最稳妥的方式是:
第一步,找业务专家做事件风暴(Event Storming),用黄色便签贴出业务过程中的所有领域事件,比如“订单已创建”“库存已扣减”“支付已完成”。这个过程能快速暴露出业务全貌。
第二步,根据事件的发生逻辑聚簇,找出主要的业务角色、聚合边界、业务规则,然后命名限界上下文。
第三步,画出上下文地图,明确每个上下文之间的依赖方向和数据流动方式。
第四步,为每个上下文明确通用语言术语表,统一关键名词的定义。
判断一个上下文划分得好不好,有三个标准:一是每个上下文内部的模型是否高内聚,即改动原因是否唯一;二是上下文边界是否符合业务本身的组织架构和语言习惯;三是上下文之间是否有明确的依赖方向,尽量避免循环依赖。
很多团队战略设计做不好的根源在于,划分上下文不是基于业务逻辑,而是基于“为了服务拆分”或者“为了数据库解耦”。我见过一个团队把“用户”和“账户”放在两个上下文里,理由是这俩表数据量都大,以后要分库。等建模的时候才发现,账户的核心业务规则(余额变更、冻结/解冻)离不开用户信息,两个上下文之间反复互相查询,最后不得不合并。正确的做法是先按业务逻辑分,技术优化往后放。
3. 战术设计核心:聚合的正确构建方式
3.1 实体与值对象的判断方法
进入战术设计阶段,第一个基础问题是:什么是实体,什么是值对象。
最直白的判断标准:如果一个对象你有且只有这一个,它有生命周期,你关心“它是谁”的变化——实体;如果一个对象的价值在于“它是什么”而不是“它是谁”,可以被替换、被比较、被无副作用地创建和销毁——值对象。
订单是实体,订单号是它的身份标识;金额是值对象,一百块和另一张一百块没有任何区分度;地址是值对象,“北京市海淀区某路某号”这个值本身就是全部意义,你不会关心“这个地址是哪一次创建的”;用户是实体,用户ID是身份标识,用户名、手机号、头像都可以变,但用户这个身份不会变。
我在实践中发现,新手最容易犯的错误是把值对象活活做成实体。比如“收货地址”这个对象,有人给它加了一个自增主键ID,然后开始纠结“这个地址ID怎么传给订单”。地址完全不需要ID,它就是客户下单那一刻的一个快照值。给它加ID,意味着你能通过ID查询地址、修改地址、更新地址,这实际上是把一个不该有生命周期的对象赋予了生命周期,徒增复杂度。
反过来,该做实体的也绝不能做成值对象。“用户”如果被定义成值对象,那修改用户昵称就成了“创建了一个新用户”,整个系统就乱套了。
3.2 聚合边界与聚合根的设计原则
聚合(Aggregate)是DDD中最容易理解错也最容易用错的概念。聚合是一组对象的集合,外部只能通过聚合根(Aggregate Root)访问聚合内部的任何对象。聚合的边界界定原则可以归结为一句话——一致性边界。
什么意思?一个聚合内部的对象之间必须满足强一致性,事务的边界应该限定在聚合内部;聚合之间则是最终一致性。所以设计聚合的第一件事就是问:“哪些对象必须同时被修改,并且如果被并发修改会产生错误?”
拿订单来举例。“订单”和“订单项”必须在一个聚合内。一个订单可能包含多个订单项,删除、修改订单项都会影响订单总金额,订单项离开订单没有任何存在意义,因此订单是聚合根,订单项是内部实体。但“订单”和“支付记录”是否需要放在同一个聚合内?不需要。订单创建、支付成功、退款,这些操作的发生是有时序性的,中间可以隔很久,而且查询维度完全不同,它们不要求强一致,最终一致就足够了。强行放在一个聚合里,会导致每次下单都要锁定支付记录,性能急剧下降,而且并发场景下逻辑极其复杂。
聚合设计的几个硬性规则,我踩过坑之后总结如下:
- 聚合根必须通过ID被外部引用,不允许外部直接持有聚合内部对象的引用。这是为了保边界。
- 聚合内所有对象的事务性由聚合根负责,修改任何内部对象都必须经过聚合根的方法。
- 聚合根设计要尽量小,一个事务只修改一个聚合。如果一个业务操作要涉及多个聚合,应该通过领域事件或Saga来协调。
- 聚合内部的一致性由不变条件(Invariant)维护,比如“订单总金额必须等于所有订单项金额之和”“库存扣减不能低于0”,这些规则必须在提交前的聚合根方法内强制校验。
3.3 如何利用事件风暴辅助聚合划分
事件风暴(Event Storming)是一个用于领域建模的实战工具,极其适合在聚合设计阶段使用。操作过程大致如下:
准备一面足够大的墙,按时间线横向排列。让业务专家和开发人员坐在一起,先用橙色便签贴出所有领域事件(过去发生的业务事实,如“订单已提交”),再用蓝色便签在事件前后贴出触发该事件的命令(如“提交订单”),用黄色便签贴出读取数据的视图和决策,用绿色便签贴出外部系统或依赖方。
事件和命令都贴完之后,观察哪些命令和事件在时间和规则上高度关联——它们往往属于同一个聚合。比如“添加订单项”和“修改订单数量”都涉及订单金额的重新计算,与订单相关的事件紧挨着,你自然就能判断出订单聚合的边界在哪里。如果某个事件几乎不依赖其他事件,比如“物流轨迹已更新”,它大概率属于另一个独立的聚合。
事件风暴最大的价值是让业务人员直接参与到建模过程中,避免开发自己躲在工位上猜想业务规则。成本也不高,半天到一天时间即可完成一次中等业务的建模,收益远大于投入。
4. 领域服务与领域事件:让模型跑起来
4.1 领域服务的使用时机与判断标准
领域服务(Domain Service)是DDD战术设计中最容易被滥用的概念。很多人把领域服务当成了Service层的代名词,实际上这是完全错误的。
真正的领域服务适合承载那些“不属于任何实体或值对象,但又必须属于领域层”的业务逻辑。典型的判断标准是:这个操作涉及多个聚合,或者涉及领域模型中不存在的计算规则,但规则本身属于业务知识,不该被丢到应用层。
举个例子。转账操作涉及转出账户和转入账户两个聚合,扣减余额和增加余额不归属于任何单一账户聚合,但它们都是账户领域内的规则。这时候可以提供一个“转账服务”,它协调两个账户聚合的操作,确保两边的余额变动满足总账平衡的约束。再比如计息场景:计算一笔贷款的应还利息,涉及合同、还款计划、利率规则等多个聚合,这个过程本身可以建模为领域服务。
判断一个逻辑该不该做成领域服务的有效方法:问自己“这个逻辑如果没有任何类和对象归属,放在哪里最合适”。如果答案是“放在某个聚合根方法里合理”,就不要建领域服务;如果答案是“哪都不合适,但它确实是业务规则”,那就建领域服务。我的经验是,领域服务的数量应该远少于应用服务,如果一个项目里领域服务比聚合还多,大概率是建模出了问题。
4.2 领域事件的设计与发布订阅
领域事件(Domain Event)表示领域中发生的、对业务有意义的事情,例如“订单已支付”“库存已扣减”“用户已注册”。它的价值在于解耦聚合之间的协作,让最终一致性成为默认选项。
注意,领域事件不是技术意义上的消息事件,它是建模概念,代表业务事实。实现层面可以通过事件总线、消息队列或事务性消息来承载。
设计领域事件的时机判断标准:一个聚合状态发生变更后,是否需要其他聚合或系统做出响应?如果需要,并且响应方与变更方不属于同一个聚合,就用领域事件。
一个完整领域事件的基本要素包括唯一事件ID、事件发生时间戳、事件类型和相关实体的ID。事件内容不要携带完整数据快照,只携带足够接收方处理的最小字段集。尽量减少“订阅方需要回查数据库”的依赖,但也不能把大而全的数据全部塞进事件里,这会给后续消费者增加负担。
落地时常见的一个坑:事件发布与聚合状态变更不一致。假设你在应用服务中先保存订单数据,再发布“订单已创建”事件,如果保存成功但发布失败(比如网络抖动、消息服务不可用),就会出现“状态变了但通知没发出”的问题。解决办法是使用事务性发件箱(Transactional Outbox)模式:在同一个本地事务中,先写入事件表,后台任务再把事件表里的事件异步发布到消息中间件。这个方案看起来多一步,但能够在极端场景下保住一致性,值得为可靠而付出。
4.3 应用服务、基础设施与领域层的职责划分
很多项目分层混乱的原因在于,应用服务(Application Service)做了领域层的活,或者领域层里写了基础设施的代码。
三者的职责划分应该是这样:基础设施层(Infrastructure)提供技术能力,比如数据库访问、消息发送、外部系统对接;领域层(Domain)承载业务规则和业务状态;应用服务(Application Service)作为薄层,负责协调领域对象完成一个用例,但不承载业务规则。
用“用户下单”这个用例来示例:
基础设施层:UserRepository接口的实现,OrderRepository接口的实现,数据库事务管理器,消息发布组件。
领域层:Order聚合根,OrderItem实体,Money值对象,OrderService领域服务,OrderCreated领域事件,以及OrderRepository接口的定义。
应用服务:CreateOrderApplicationService,负责启动事务、调用Order聚合根创建订单、调用OrderRepository保存、发布OrderCreated事件、提交事务。
判断是否干净的标尺:应用服务的代码里不能出现业务规则的“if/else”。比如“订单金额不能低于0”“下单数量不能超过库存”,这些必须存在于领域层,而不是应用服务中。如果应用服务里出现了这类语句,说明业务规则漏到了外层,领域模型被架空,后续迟早出大问题。
5. 代码落地:一个完整的订单聚合实现示例
5.1 代码结构与传统三层架构的对比
理论讲再多,没有代码落地都是空谈。我以一个简化的订单上下文为例,展示DDD落地后的代码长什么样。先看标准的目录结构,这里按DDD的分层方式组织:
com.example.order ├── interfaces // 用户接口层(Controller、DTO) │ ├── web │ └── api ├── application // 应用服务层 │ ├── CreateOrderApplicationService.java │ └── assembler // DTO与领域模型的转换器 ├── domain // 领域层 │ ├── order │ │ ├── Order.java // 聚合根 │ │ ├── OrderItem.java // 聚合内部实体 │ │ ├── OrderId.java // 值对象 │ │ ├── Money.java // 值对象 │ │ ├── OrderStatus.java // 枚举/值对象 │ │ ├── OrderRepository.java // 聚合根对应的仓储接口 │ │ ├── OrderService.java // 领域服务(如果需要) │ │ └── event │ │ ├── OrderCreatedEvent.java │ └── shared │ └── domainevent │ ├── DomainEvent.java │ └── DomainEventPublisher.java └── infrastructure // 基础设施层 ├── repository │ └── OrderRepositoryImpl.java ├── eventpublisher │ └── OutboxEventPublisher.java └── persistence └── OrderJpaEntity.java传统的三层架构(Controller-Service-Mapper)里,你通常在Service里看到大量的业务判断和数据处理逻辑,而Mapper层只有傻瓜式的增删改查。DDD代码落地后最大的变化是:业务逻辑回归到领域模型中,Service层变得非常薄,业务语义更接近真实的业务语言。
这个结构里最需要理解的不是某个具体文件,而是“依赖方向”——依赖是由外向内的:Interfaces层依赖Application层,Application层依赖Domain层,Domain层不依赖任何外部层。这是经典的依赖倒置原则在DDD中的体现:领域层定义了Repository接口,基础设施层去实现它,方向是反的。
5.2 聚合根的完整实现与事务边界示例
下面看Order聚合根的简化代码,重点观察它是如何封装不变条件的:
public class Order { private final OrderId id; private final List<OrderItem> items; private OrderStatus status; private final Money totalAmount; private Long customerId; public Order(OrderId id, Long customerId) { this.id = id; this.customerId = customerId; this.items = new ArrayList<>(); this.totalAmount = Money.zero(); this.status = OrderStatus.CREATED; } public OrderId getId() { return id; } public List<OrderItem> getItems() { return Collections.unmodifiableList(items); } public Money getTotalAmount() { return totalAmount; } public void addItem(OrderItem item) { // 不变条件:订单项不能重复添加 if (items.stream().anyMatch(i -> i.getProductId().equals(item.getProductId()))) { throw new IllegalArgumentException("该商品已经在订单中,请使用数量变更操作"); } // 不变条件:金额非负 if (item.getPrice().isGreaterThan(Money.zero())) { items.add(item); recalculateTotal(); } else { throw new IllegalArgumentException("商品价格必须大于零"); } } public void markShipped() { if (this.status != OrderStatus.PAID) { throw new IllegalStateException("只有已支付订单才能发货"); } this.status = OrderStatus.SHIPPED; } private void recalculateTotal() { // 不变条件:总金额 = 所有订单行金额之和 Money sum = items.stream() .map(OrderItem::getLineAmount) .reduce(Money.zero(), Money::add); this.totalAmount.update(sum); } }这份代码有几个必须关注的细节。
getItems()返回的是Collections.unmodifiableList(items)—— 这是防外部修改,确保聚合内部的任何变化只能通过聚合根的方法进行。我见过很多团队在这一点上忽视,直接把内部List返回出去,外部随便add/remove,聚合的规则约束形同虚设。
markShipped()里的状态校验,是典型的业务规则封装。如果这个逻辑写在应用服务里,那么应用服务必须知道“只有已支付订单才能发货”这条规则,这条规则就会在代码的各个角落被复制。写在聚合根里,所有调用方都被强制经过这条校验。
事务边界要精确理解:事务发生在应用服务层,业务规则发生在领域层。也就是说,应用服务内部调用订单聚合的方法时,如果抛异常,事务就被标记回滚;如果正常返回,事务提交。这个协作关系才是DDD常见代码实现里最精妙的部分——业务规则捕获在领域层,事务控制保持在应用层。
5.3 仓储接口的抽象与实现选择
Repository的设计在DDD代码落地中同样关键。仓储(Repository)的作用是:为聚合根提供持久化存取能力,让领域层不必关心数据库细节。
OrderRepository接口定义在Domain层:
public interface OrderRepository { OrderId nextId(); Optional<Order> findById(OrderId id); void save(Order order); }为什么接口定义在Domain层而实现放在Infrastructure层?这是依赖倒置的核心体现——Domain层不依赖Infrastructure层,Infrastructure层依赖Domain层。这样带来的好处是:领域层的代码完全不知道自己在用什么数据库、什么ORM,整个领域模型不受到技术栈的强制绑定。
保存一个订单聚合时,需要把内部的OrderItem一起保存。如果使用JPA或MyBatis等ORM框架,基础设施层的实现就负责聚合根与持久化实体之间的转换:
- 方案一:直接让Order继承或使用JPA注解,ORM实体和领域模型合二为一。适合小项目和原型,但耦合度高,领域模型会被JPA的字段映射方式绑架。
- 方案二:领域模型与持久化模型分离,Order和OrderJpaEntity是两个类,RepositoryImpl负责转换。适合中大型项目,领域模型保持纯净,但需要写转换代码。
我在落地时选方案二的居多,因为长期来看,领域模型应该保持对技术栈的独立性。如果哪天换了数据库中间件,领域模型完全不受影响。
有一个转折点要注意:不要在Repository里提供“通用查询”接口,比如findAll、findByCustomerIdAndStatus。因为聚合的加载应该是面向用例的——你只需要加载这个用例需要操作的聚合根,而不是把所有可能的查询条件列全。查询逻辑可以另建查询仓储或读模型,不必都塞进Repository。
6. 落地DDD时常见的错误与排查方法
6.1 常见坑位排行榜:从充血模型到过量设计
这几年帮不少团队看DDD改造项目,总结下来,最常见的坑位几乎一样。
第一坑:完全不理解“聚合”边界的直接结果——把一个实体类里的代码搞得极其膨胀,一个类几千行,各种业务方法全部塞进去,美其名曰“充血模型”。这其实是把贫血模型的坏味道换了个地方集中。聚合根不是把系统里所有方法都收编,它只负责与自己内部对象相关的强一致业务规则。跨聚合的协作交给领域服务或领域事件,不要硬塞到聚合根里。
第二坑:过量设计。一上来就想把所有战术模式都用一遍,实体、值对象、聚合、领域事件、领域服务、仓储、工厂、规格模式,恨不得十个模式全上。这是DDD落地最严重的问题。实际上一个聚合可能只需要极少的模式,比如订单聚合可能只需要实体、值对象和聚合根,事件和领域服务是按需引入的。模式永远是服务于业务的工具,不适合业务模式直接用不用,没有任何问题。
第三坑:把领域事件当成消息聊天的工具。很多团队使用领域事件仅仅是为了让“订单创建后发个短信”这种需求,这完全是误解。领域事件是业务事实,不是技术分发手段。如果只是通知,异步消息即可;领域事件的核心价值在于它代表“已经发生、不可撤销、过去时态”的业务事件,有时序和因果的约束。
第四坑:应用服务和领域服务的职责彻底混淆。很多代码里,应用服务里写满业务规则,领域服务却是空的;或者领域服务承担了太多协调工作,应用服务变成了纯粹的CRUD转发。我的纠偏建议:应用服务讲用例、处理输入输出、管事务,领域服务讲规则、协调聚合,不要把两者搞混。
6.2 排查方法论:从现象反推模型问题
当你的DDD代码已经写得比较“像样”了,但系统仍然出现了问题,该怎么排查?
我习惯按以下顺序排查:
第一步,确认是否出现了“模型与事务不匹配”的现象。比如某个方法内改动了两个聚合,并且用了一个事务事务性。如果确实发生了,再回看这个业务操作是否真的需要强一致,如果不是,就该拆成两个聚合的事务,配合领域事件做最终一致。
第二步,检查是否存在隐式关联导致的循环依赖。例如,Order聚合内部保存了CustomerId,而Customer聚合内部的某个值对象保存了OrderId。如果领域层出现了循环引用,通常说明建模有误,或者不该把对方ID存放在自己的聚合内部。
第三步,看看领域事件是否“丢失”。系统里有些操作没生效,但数据库数据已提交。这种情况优先排查事务性发件箱有没有正常工作,或者事件发布是不是放在了事务外面。
第四步,彻底审查你的限界上下文地图。业务增长后,原本合理的上下文边界可能变得不合理。比如两个上下文之间的依赖关系从单向变双向了,或者上下文越来越多依赖到同一个共享内核,这些变化都在提示你需要重新审视上下文划分。
我处理过的最难排查的一个真实问题:某营销系统里,订单金额字段在统计报表里的数据和业务系统数据对不上,定位了三天,最后发现是两个上下文里“订单金额”的定义不同——一个是含税价、一个是税前价,而且双方都觉得自己是对的。这类问题需要通过统一术语表和模型校验来解决,代码层查找永远找不到根源。
6.3 引入DDD的合理路径:非技术先行
一定有人在想:我们项目目前很混乱,想引入DDD大改造,怎么开始最稳?
我的建议是:不要从重构代码开始。从一个新需求、一个新模块开始,做小范围的DDD试点。选一个业务边界清晰、规则复杂度高、最容易出价值的小模块,从事件风暴开始,画出上下文地图,确定聚合边界,写代码落地。等拿到成果和团队经验,再考虑逐步推广。
另外,不要想着一次性把整个系统全部重构成DDD风格。重构的每一步都应该独立可交付:战术层先跑通,战略层逐步调整,每一次重构都保留业务行为不变。这个过程必须配合足够的自动化测试,否则改到一半没人敢提“回归没问题”。
我会特别提醒团队的一件事是:DDD实施的成功与否,核心不在开发,在于业务专家的参与程度。如果业务专家不参与事件风暴,开发团队闭门造车,最后模型一定和真实业务两张皮。DDD不是什么技术框架,它是“用技术重现业务世界”的建模过程,必须让懂业务的人深度参与进来。
7. 写在最后的一些个人体会
关于DDD,我最后想说的不是方法论本身,而是方法论的适用性问题。
DDD不适用于所有系统。如果你做的系统是CRUD为主的、业务规则极简单、数据关系纯粹的管理系统,引入DDD大概率是负重前行,造出一堆不必要的抽象。DDD发挥作用的前提是:业务复杂度足够高,高到你觉得技术手段已经无法兜住业务混乱的程度。这个时候DDD的价值才真正体现出来——它不是让代码变优雅的银弹,而是帮助你重新建立业务和代码之间精准映射的方法。
我实操下来最深的感受是:DDD落地最难的不是写代码,而是让团队建立“先建模、后写码”的思维惯性。绝大多数开发拿到需求的第一反应是“哪些表、哪些接口”,而DDD要求你先问“这个业务的领域是什么、边界在哪、规则是什么”。这种思维方式的变化,远比掌握几个战术模式困难得多。
如果你所在团队正被复杂业务折磨得焦头烂额,可以先从一场两个小时的限界上下文梳理会议开始,把业务方拉进来,一起在白板上画出上下文地图和通用语言术语表。不用一上来就谈Aggregate、Repository这些词汇,先把业务边界画清楚,你就已经迈出了DDD最重要的一步。
剩下的路,慢慢走,代码层面的东西可以迭代,方向对了就不会白费。