写这篇的时候我刚从IDE里抬起头咖啡已经凉透了。从第一天开始摸这套老系统到现在十几天的时间里大部分精力其实不是花在“写代码”上而是花在“决定代码该长什么样”上。很多团队对DDD的认知停在“事件风暴画几张黄色便签”“限界上下文输出一张全景图”到了真正要落代码的那一步才发现领域模型到可运行、可维护的代码之间还有一条不小的鸿沟。今天正好是第十三天我打算把这几天战术建模的落地过程、关键取舍、代码边界和踩过的坑按顺序整理一遍。这篇内容适合谁如果你正在做老系统改造或者打算用DDD重构一个中后台业务模块但不确定“聚合根怎么写”“仓储接口放哪层”“老表怎么接进新模型”这类问题那这篇文章应该能给你省下不少时间。我不打算教你DDD的全部理论那些书上都写得比我清楚我只讲这一路实际动手时哪些地方必须较真哪些地方可以妥协。1. 战略建模完成之后真正难的是战术落地的“最后一公里”1.1 前十二天我们在做什么先交代一下背景。这套老系统是一个典型的大单体一个后台Web应用承载了订单、库存、客户、对账、权限等多个模块数据库表两百多张很多业务规则散落在存储过程、定时任务和Service层的几百行if-else里。我接手后的前十二天基本都在做战略层的工作和业务方一起梳理核心流程通过事件风暴划出十几个候选限界上下文用上下文映射图明确各个上下文之间的协作关系哪些是防腐层关系哪些是共享内核圈定核心域订单履约、支撑域库存分配、通用域组织权限输出了一份初步的领域模型草图给核心聚合划了边界。这些工作做完之后团队一度觉得“重构快完成了”。但设计图和生产代码之间隔着一堵很现实的墙——老系统的数据库表结构和新模型并不是一一对应的服务间的调用方式也带着历史包袱。第十三天开始我正式进入战术建模与代码落地阶段说白了就是把纸面上的模型变成能跑的、可测试的、可以渐进式替换老逻辑的代码骨架。1.2 战术建模要解决的三个核心矛盾战略建模回答“系统应该分成几个部分、各自边界在哪”的问题战术建模回答“边界内部的代码长什么样、边界之间如何通信”的问题。落到这片老代码里实际上就变成三个具体矛盾第一个矛盾是模型与存储结构不一致。老表设计是面向数据库第三范式的订单主表和订单明细表分得很开业务上却经常需要把订单作为一个整体来校验和操作。如果不通过聚合把一致性边界重新立起来代码就会继续散落在各个Manager类里。第二个矛盾是领域逻辑与基础设施耦合。原来的Service层直接注入JdbcTemplate、RedisTemplate、MQ的Producer业务方法和底层技术细节搅在一起导致任何一次存储层改造都要动业务逻辑。战术建模要求通过仓储接口做依赖倒置领域层只依赖接口。第三个矛盾是渐进式替换与原有功能稳定性的平衡。老系统不可能停摆了重写必须是新代码和老代码并存、逐步切流。这就意味着每写一个聚合根还得配套设计防腐层和适配器在模块边界处把老模型翻译成新模型或者反过来。这三个矛盾本质上是在逼我回答“这段业务逻辑应该属于哪一层”以及“它应该通过什么方式访问依赖”。战术建模并不是把类名改成Entity、ValueObject那么简单而是把原先揉在一起的职责重新切分。2. 领域模型的代码映射实体、值对象、聚合的边界怎么定2.1 先从代码结构上确立分层骨架动手之前我把包结构重新理了一遍。新模块的代码严格按依赖方向分层这个骨架看起来简单但它是后面所有战术建模动作的物理约束com.company.orders ├── interfaces # 前端适配层Controller、DTO、消息监听 ├── application # 应用层用例编排、事务边界、权限校验 ├── domain # 领域层实体、值对象、聚合根、仓储接口、领域服务 │ ├── model # 领域模型 │ ├── repository # 仓储接口 │ └── service # 领域服务 └── infrastructure # 基础设施层JPA实现、MyBatis实现、RPC适配、消息发送这个分层的核心约束只有一个domain 层不能依赖 infrastructure 层更不能依赖 Spring、不能依赖数据库驱动。所有跨层依赖都是通过接口来倒转。刚开始团队有人觉得“Spring的注解为什么不能用在实体上”我直接解释了——一旦你的实体类上出现了 JPA 的Table、MyBatis 的TableName它就不再是一个纯粹的领域对象而是一个“被持久化绑架”的对象将来换存储方案、做单元测试、维护领域行为都会很痛苦。2.2 实体和值对象不是按“有没有id”来区分而是按“生命周期”很多文章会把实体和值对象的区别归纳成“实体有id值对象没有id”这个说法太粗糙了。真正落地的时候我常用的判断标准是两个是否拥有独立的生命周期、是否关心身份的同一性。举个例子订单明细OrderLine这个对象它的增删改是跟随订单走的没有独立于订单之外的业务操作。你说它是实体吗它有id但它不会在订单之外单独被更新、单独被跟踪。在实际建模里我更愿意把订单明细建模成实体但它的生命周期完全聚合在订单之下。而订单的收货地址虽然数据库里是一张关联表但业务上我们从不把这条地址单独拿出来修改地址被替换之后原来的地址就没有存在意义了所以它是值对象。这个区分做对之后代码层面最大的好处是你可以很清楚地知道哪些对象需要仓储、哪些对象不需要。实体需要仓储哪怕是间接的值对象永远不需要。如果哪天产品经理说“要看所有历史收货地址”那是产生了新的业务概念应该新建一个“地址簿”聚合而不是把订单内的地址值对象强行拉出来持久化。2.3 聚合边界一致性边界和事务边界聚合是整个战术建模里最容易扯皮的部分。我在第十三天下午跟团队过一遍订单聚合大家讨论了半天也没法一致确定“订单和支付流水是不是一个聚合”。最后把判断标准收敛成一个问题如果这两个对象的一致性被破坏会发生什么如果不能接受其中一项成功、另一项失败那它们就应该在同一个聚合内如果可以通过后续对账、补偿来修复那它们就是不同聚合。按照这个标准我们把“订单订单明细订单状态流转记录”划为一个聚合聚合根是订单。而支付流水、优惠券核销记录都划到了各自的聚合里跨聚合的一致性通过领域事件和最终一致性来保证。聚合写出来大致是这个样子这里只保留核心骨架public class Order { private OrderId orderId; private CustomerId customerId; private OrderStatus status; private ListOrderItem items; private Money totalAmount; private Address shippingAddress; public void addItem(ProductSnapshot product, Quantity quantity) { // 业务规则校验库存快照、计算金额 this.items.add(new OrderItem(product, quantity)); this.recalculateTotalAmount(); } public void confirm() { // 业务规则只有待支付状态才能确认 if (this.status ! OrderStatus.PENDING_PAYMENT) { throw new IllegalStateException(当前状态不允许确认); } this.status OrderStatus.CONFIRMED; // 不直接调用任何外部服务只是完成自身状态流转 // 外发事件由应用层在事务提交后处理 } public OrderItem findItem(ProductId productId) { return this.items.stream() .filter(item - item.getProductId().equals(productId)) .findFirst() .orElse(null); } // 只暴露业务方法不暴露内部状态setter }这段代码看起来很简单但这里有几个设计点是花了很多时间才想通的第一聚合根是唯一的访问入口。外部要操作订单里的某个明细不是先去查明细表再改字段而是通过订单这个根去定位明细、执行操作。这样可以保证所有不变量在修改时被统一校验。第二聚合根内部没有任何基础设置注入。它不注入Repository、不注入MQ连Spring的Service都没有。它只做纯内存的领域逻辑这样在做单元测试的时候根本不需要启动Spring容器。第三聚合内的数据加载要小心“懒加载”。我在用JPA实现仓储时刚开始图省事直接在实体上用ManyToOne(fetch LAZY)结果聚合根的校验逻辑在事务外被调用时懒加载的集合直接抛出异常。后来把聚合根需要的数据全部在仓储方法内一次性加载完实体上保持纯领域模型不在领域层做任何持久化映射。2.4 领域服务放不进实体、又确实属于领域的逻辑并不是所有业务逻辑都能塞进一个实体里。比如计算一件商品在某个客户等级、某个促销活动下的成交价它同时涉及商品、客户、促销三个不同聚合的数据。它不天然属于任何一个聚合根但又是一条核心业务规则。这种情况下我会建一个领域服务注意它和应用层服务的区别领域服务仍然只处理“业务规则决策”不处理事务、不发送消息、不调外部接口。它依赖的是领域层的仓储接口而不是具体实现。public class PricingDomainService { private final PromotionRepository promotionRepository; private final CustomerRepository customerRepository; public Money calculateFinalPrice(Product product, Customer customer) { var promotion promotionRepository.findByProductId(product.getProductId()); var memberLevel customer.getLevel(); // ... 领域规则计算涉及多个聚合的协作 return finalPrice; } }那个“价格计算到底要不要单独一个服务”的问题我们吵了一阵子。实际上判别标准就一条这段规则如果写在某个聚合根里会不会导致该聚合根必须依赖其他聚合的数据会导致那就说明它超出了这个聚合的边界把它抽成领域服务最合适。3. 仓储层设计让领域层彻底摆脱对数据库的依赖3.1 仓储接口对应聚合而非数据表仓储Repository这个概念其实非常容易理解但非常容易被用歪。老系统里DAO接口是面向表的一个订单主表一个DAO一个订单明细表又一个DAOService层拿到这两个DAO左拼右凑。DDD里的仓储是面向聚合的也就是说一个聚合根配一个仓储接口仓储对外提供的操作语义是“把订单整个取出来”“把订单整个保存进去”。我们为订单聚合定义了这样的仓储接口放在domain层public interface OrderRepository { Order findById(OrderId orderId); void save(Order order); void delete(OrderId orderId); }这里有个很重要的细节findById返回的是整个聚合根它内部已经把所有明细、状态流转都加载好了。调用方不需要再另外查明细表来拼接。而save的含义是“把整个聚合的变更持久化”具体是insert还是update由基础设施层判断。3.2 重构期我用JPA实现但保留换MyBatis的可能老系统大部分查询是复杂报表、多表联查用JPA写起来很别扭。所以我们在仓储的实现上做了一个策略组合写操作新增订单、更新状态走JPA实现用JPA的事务管理和实体映射来保证聚合的一致性读操作复杂查询、列表分页单独走一个只读的Mapper接口直接以DTO形式返回不进领域模型。这个策略本质上是一种轻量的CQRS。刚开始觉得“DDD重构还要引入CQRS是不是太复杂”后来发现如果不这样分仓储接口就会被逼着写一堆查询DTO方法最终退化成一个“DAO换皮”。把读通道单独拿出去之后写模型保持纯净查询性能也更容易优化。JPA实现的大致样子如下注意一个点领域对象没有注解基础设施层用独立的“持久化对象”来承接映射。Repository public class OrderRepositoryJpaImpl implements OrderRepository { private final OrderPoRepository jpaRepository; Override public Order findById(OrderId orderId) { OrderPO po jpaRepository.findById(orderId.getValue()) .orElseThrow(() - new OrderNotFoundException(orderId)); return OrderConverter.toDomain(po); } Override public void save(Order order) { OrderPO po OrderConverter.toPO(order); jpaRepository.save(po); } }这里没有直接在实体上标Entity而是单独做了一套PO持久化对象。代价是多写一层转换代码好处是领域模型和数据库模型可以各自演进。老系统的表结构短期内不会按理想模型调整中间转换层就成了缓冲地带。等哪天表结构真的改了只需要改Converter和PO领域层一行都不用动。3.3 事务边界的正确落地位置DDD里事务的经典建议是“一个聚合一个事务”跨聚合要最终一致性。但在Spring里落地事务不能打在仓储上要打在应用层。因为一次用例比如“下单”可能需要先调一个领域服务计算价格再调订单仓储保存订单再调库存仓储预占库存。这个用例是一个完整事务跨了多个聚合。所以我在应用服务层加Transactional注意下面的细节整个用例内部如果有调用外部RPC、发送MQ这些操作不能放在事务中间要把它们挪到事务提交之后再执行。Service public class CreateOrderAppService { private final OrderRepository orderRepository; private final InventoryRepository inventoryRepository; private final OrderEventPublisher eventPublisher; Transactional public void createOrder(CreateOrderCommand command) { // 1. 校验、计算 // 2. 保存订单聚合 // 3. 预占库存这个本身也是个聚合 // 4. 事务提交后发送事件 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { eventPublisher.publish(orderCreatedEvent); } }); } }这样处理之后最直接的收益是业务数据在事务内是强一致的外部通知是最终异步的不会出现“订单没保存成功但MQ消息发出去了”这种脏状态。4. 防腐层设计新模型和老系统之间的翻译官4.1 防腐层到底在防什么老系统内部的关系错综复杂同一个“客户”在主数据系统里叫客户在积分系统里叫会员在订单系统里叫买家。如果新重构的订单模块直接去调老系统的Mapper查询客户数据那老系统模型里的坏味道就会顺着调用链渗透进新模型。防腐层Anti-Corruption Layer简称ACL就是在两个系统边界上做一层隔离和翻译。它把老系统对外暴露的接口语言转换成新领域模型的语言让领域层永远感知不到老系统的存在。具体到我们这次重构老系统提供了一个客户服务接口返回值里有一堆字段包括custLevel、grade、vipFlag等等含义模糊还互相重叠。在防腐层里我们把它翻译成领域对象Component public class CustomerLegacyAdapter { private final LegacyCustomerClient legacyCustomerClient; public Customer toCustomer(String legacyCustomerId) { LegacyCustomerDTO dto legacyCustomerClient.getCustomer(legacyCustomerId); return Customer.builder() .customerId(new CustomerId(dto.getCustId())) .level(translateLevel(dto)) .isVip(translateVip(dto)) .build(); } private MemberLevel translateLevel(LegacyCustomerDTO dto) { // 老系统grade1/2/3新模型映射为枚举 return switch (dto.getGrade()) { case 1 - MemberLevel.NORMAL; case 2 - MemberLevel.SILVER; case 3 - MemberLevel.GOLD; default - MemberLevel.NORMAL; }; } }这个类放在基础设施层领域层只定义一个CustomerRepository接口由这个Adapter实现或包装。这样一来如果哪一天客户中台系统上线我们只需要更换Adapter实现领域模型和订单用例完全不受影响。4.2 防腐层不是“多包一层结果包了个寂寞”防腐层有个很常见的反模式说是防腐层实际上就是把老接口的getter原封不动搬一遍方法名起得好听一点里面什么都没改。比如老系统返回值里有个type字段新系统里也照样叫type不做任何翻译和收敛那防腐层就真的只是“包了一层皮”。真正有效的防腐层至少要做三件事第一字段语义收敛。老接口返回的10个字段领域层可能只需要3个。那就只暴露3个不要全量透传。第二数据形态转换。老系统的枚举值、单位、编码规则统一转换成领域层定义的标准形态。比如老系统重量单位用的克新模型内部一律用千克转换就在这一层完成。第三错误语义翻译。老系统接口返回的-1、NULL、空字符串要转换成领域层能理解的业务异常不能让NullPointerException往领域层冒。4.3 开放主机服务和发布语言的实践在另一个模块库存模块的重构中我们使用了一组对外接口来承接下游系统的调用这组接口在DDD术语里叫“开放主机服务”Open Host Service它对外发布的消息结构就是“发布语言”Published Language。实际操作上我们做了一套独立的防腐层API下游系统不直接依赖库存模块的内部领域对象而是通过一个独立的API包交互这个包里的DTO结构和领域对象结构完全独立。这样下游系统和库存模块的内部模型各自演进谁也不会因为对方的改动而被迫修改。当时团队有人提过“直接暴露内部DTO不也一样么反正JSON字段差不多”但真到调用方要改字段时就知道独立DTO的好处了——你可以只对某个下游暴露它需要的字段而不是把内部模型里的一堆技术字段比如版本号、创建人全部泄露出去。5. 这些坑我花了整整三天才走出来5.1 聚合根内部的循环依赖第一天落地仓储和实体时我一度坚持“通过仓储接口来加载另一个聚合根的数据”。比如订单要获取客户等级来算优惠我就直接在订单实体里注入了CustomerRepository。结果马上发现不对劲——聚合根变成了一个依赖基础设施的对象而且Spring容器里实体是new出来的根本没法注入。后来说清楚这个问题的根源聚合根之间不能通过依赖对方仓储来协作它们之间的协作应该放到应用层或领域服务里。也就是订单不需要自己去查客户而是在用例编排时先把Customer加载好作为参数传给领域服务。5.2 懒加载带来的序列化异常JPA实现仓储时有一段代码是在事务外读取订单明细的。因为实体映射用了OneToMany(fetch FetchType.LAZY)明细集合在事务提交之后就是一堆未初始化的代理对象Fastjson序列化时直接抛异常。排查之后我把JPA实体和领域对象彻底分开仓储在事务内完成转换返回给应用层的领域对象里没有任何JPA代理类。从此“LazyInitializationException”彻底消失。这个改动也顺带解决了另一个问题领域对象在序列化传给前端时不会再带上Hibernate的内部字段。5.3 跨聚合事务的伪需求设计支付和订单的协作时第一个版本做法是在一个Transactional方法里同时保存订单和支付结果看起来很简单很合理。但后来发现一个严重的副作用——支付回调处理超时率极高每次回调都要锁订单而且支付结果保存失败会导致整个回调重试订单状态被反复回滚。后来改成支付回调只负责更新支付聚合然后发一个PaymentCompletedEvent订单侧监听事件做状态确认。订单确认失败会有重试任务补偿。整体一致性从强一致降到了最终一致但接口响应速度上去了重试逻辑也变得清晰。教训是不要先想着事务怎么包要先想清楚这两个聚合是否真的需要同生共死。5.4 进度复盘到了第十三天晚上我把这几天的代码提交记录拉出来看了一遍做了个简单的复盘事项完成情况备注包结构分层已完成领域层无Spring依赖已通过依赖检查脚本验证订单聚合落地已完成含实体、值对象、聚合根、领域服务仓储接口定义已完成写模型走JPA读模型走独立Mapper防腐层实现完成80%客户适配器已完成库存适配器还差价格同步接口单元测试覆盖50%核心业务规则已测基础设施层测试待补老System切流未开始计划先在预发环境按10%流量灰度这个进度比我预想的慢一点主要是聚合边界和事务边界的问题返工了两次但这正是DDD重构的正常节奏——前期的设计判断直接影响后期写代码的顺利程度。5.5 吃过大亏之后几点操作上的建议这些经验不是书上写的是蹲了三天坑才得出的放在这算是给同样在重构路上的人一些参考一领域层禁止出现任何Spring注解。这不是洁癖而是为了让领域对象可以在纯Java环境下测试。哪天想换Spring Boot换成Quarkus领域代码可以原封不动带走。二仓储接口不要写查询方法。凡是返回列表、分页、聚合统计的都归到读模型。仓储只负责按id存取聚合保持语义的一致性和简单。三跨模块交互统一走防腐层。哪怕老系统接口目前看起来“凑合能用”也要先包一层否则后面每改一次老接口新模块就被牵连一次。四测试优先写业务规则不要先写数据库测试。聚合根里的校验逻辑状态流转、金额计算都是纯内存逻辑应该用普通JUnit一把梭直接跑。先跑通这些再去搞SpringBootTest集成测试。五小步提交、频繁编译。重构期间分支不要拖太长每完成一个聚合就编译、测试、提交一次。这能让你在走错方向时快速回退。重构进行到第十三天最深的体会是DDD真正值钱的部分并不是那些概念而是“边界感”。知道一段逻辑该放在领域层还是应用层知道一个对象是实体还是值对象知道一个聚合该多大、事务该多长——这些判断力只能靠亲手踩坑换回来。图纸画得再漂亮落地时该撞的墙一面都躲不掉。不过撞完墙再回头看包结构清晰了测试好写了需求变更也敢接了这个第十三天过得还算值。