news 2026/10/11 1:10:37

领域驱动设计落地指南:从战术建模到六边形架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
领域驱动设计落地指南:从战术建模到六边形架构

简介:面向软件开发人员与架构师的DDD入门教程,以领域驱动设计方法论为主线,讲解如何从复杂业务中提炼精简模型、统一团队语言,并通过聚合、限界上下文、事件风暴等实践方法落地;聚合根、领域事件、服务层、仓储等核心模式也都有清晰说明。资源为单个PDF文档,来自InfoQ中文站出品的《领域驱动设计精简版》,共1个文件,压缩包约1.27MB,内容简明、便于通读,适合希望快速建立DDD整体认知的中高级开发者。已有483人学习。这份文档不仅覆盖核心概念与建模步骤,还给出了应对业务与技术持续对话、团队协作等现实挑战的思路;节选内容包含译序与体系化导读,可帮助读者避开常见误区,衔接Eric Evans原版思想,是系统学习DDD的实用起点。

1. 领域驱动设计(DDD设计模型):先找边界,再谈建模

一个反直觉的结论:多数团队学领域驱动设计(DDD)的第一步是“画领域模型图”,而真正跑通DDD的团队,第一步几乎都在“找限界上下文”。DDD设计模型不是一套画图规范,也不是把数据库表换一种叫法,而是一种让业务规则在代码里“有归属、有边界、能演化”的组织方式。它解决的是那种业务规则复杂、状态流转多、多个团队共享一个老单体时,改动互相踩脚的问题。适合做DDD的项目,是业务流程有真实复杂度、规则会随业务持续变化的核心系统;如果项目本质是CRUD录入、规则都写在存储过程里,那DDD带来的复杂度很可能比收益更大。这篇文章不讲表演式教程,只聊拿它建模时真正值得抓的东西和落地的边界。

2. DDD设计模型的核心拼图:战术建模要抓的三张牌和一个边界

DDD(领域驱动设计)走到代码层靠的是战术建模。很多人学DDD时被一堆名词吓住:实体、值对象、聚合、仓储、领域事件、应用服务……其实真正要在建模期反复拿捏的,只有三类模型元素和一个边界。把这三张牌打对,代码结构自然会清晰;打错任何一张,后面都是靠经验和加班救火。

2.1 实体与值对象:两个判断标准,一线最常搞反

实体(Entity)和值对象(Value Object)是最容易在建模初期混淆的一对。判断标准我一般只记两条:有没有独立标识、要不要跟踪生命周期。

一条订单有订单号,订单状态会从“待支付”变“已支付”,你需要根据订单号去找到它并修改它——这是实体。而订单里的“收货地址”,如果用户改了地址,你不会说“订单的地址被改了”,你会说“换了一个新地址”。没有唯一标识、不可变、靠属性整体来判等,这是典型的值对象。用两条规则快速决策:需要单独追溯和修改的,建模为实体;只是一组属性的集合、被整体替换的,建模为值对象。

常见翻车是建模初期把所有名词都做成实体、都建表存id。结果是值对象的判等逻辑依赖数据库主键,业务里“两个地址相等”永远比不出来。正确的做法是让值对象自带equals和hashCode实现,以属性组合判等,同时设计为不可变对象。

还有一个容易被忽视的收益:值对象能干掉一大类防御性代码。比如金额和币种做成Currency + Money值对象,那“人民币金额加美元金额”这种错误在编译期就没了,不需要在服务层写一堆if判断币种。把业务约束收进值对象的工厂方法里,是DDD设计模型落地时成本最低、回报最快的一步。

2.2 聚合与聚合根:一致性边界的划定,是DDD建模的全部

聚合是DDD设计模型里最核心也最容易被误解的概念。建模时不要问“哪些类放一起”,要问“哪些数据必须同时一致”。一致性边界以内,是聚合;边界以外,只能通过聚合根访问。

订单和订单项必须一起变:删除订单时必须同时删除订单项,订单总金额由订单项计算得出——这是同一个聚合。而订单和客户,是两套事务边界,订单创建时客户可能正在被其他流程修改,但它们之间通过标识引用,不直接持有对象。常见错误是为了画图方便,把订单、客户、商品全挂到一起,做一个巨大的“订单聚合”,结果是每次操作都要锁一堆数据,并发能力直接被拖垮。

定聚合根时我一般问三个问题:谁拥有这些数据?数据变更的入口是否唯一?外部要操作聚合内数据时,是否都必须经过聚合根方法?三个问题的答案指向同一个类,那个类就是聚合根。聚合根内部保证不变式,外部通过聚合根方法改变状态。

一个实际经验:聚合边界宁小勿大。小聚合容易保证一致性,也容易做并发优化;大聚合在单机上跑无所谓,一旦拆服务、上分布式事务,每一个跨聚合的强一致诉求都会变成事故风险。宁可把聚合拆小后用领域事件去做最终一致,也不要为了省事把聚合撑大。

2.3 领域事件与模块边界:把“状态同步”改成“事件通知”

很多系统里“下单后发通知”“支付成功后更新库存”这类逻辑,通常写在服务方法的后半段,用同步调用串起来。短期能跑,长期会变成改一发而动全身的链条。领域事件(Domain Event)就是用来解开这条链的:聚合根在状态变更时,把“发生了什么”作为一个事实发布出去;谁关心谁订阅,发布者不关心下游做了什么。

建模时我用一个简单标准判断要不要用领域事件:如果“A发生后必须立刻做B”,且A、B属于不同聚合或不同模块,这就说明存在隐含的耦合。把它改成聚合A发布“OrderCreatedEvent”,订阅方接收后做自己的事,A不需要知道B的存在。比如支付成功后订阅方去更新履约单状态,再发通知,各做各的。

需要说明的是,领域事件不等于消息队列。单体应用里事件总线在进程内同步触发也没问题;拆成微服务后,事件发布自然会落到消息中间件上。这里的关键是建模思维要反过来:从“我要通知谁”变成“我发布了什么事实”。同一个事件,未来可能多一个订阅方,发布方代码一行不用改。

模块边界(Bounded Context)是DDD设计模型里最容易忽略、但最影响长期演进的一层。它不指代码里的module目录,而是指业务上真正独立的语境。同一个“商品”,在销售上下文里有价格和库存,在采购上下文里有供应商和批量,两者不是同一个对象,各自的模型和表可以不同。跨上下文的通信只通过翻译后的接口或事件,不直接共享数据表。识别限界上下文的方法后面会讲,这里先立概念:DDD设计模型能不能让架构长期演化,取决于限界上下文划得准不准,不取决于类写得漂不漂亮。

3. 把DDD设计模型落到代码层:订单模块的分层拆解与落地步骤

概念讲得再顺,不落到代码都是空谈。这一章用一个最常见的下单流程,把DDD设计模型里的实体、值对象、聚合、仓储、应用服务串成一个能在工程里直接照着拆的分层结构。以下都是我平时用的组织方式,不代表唯一正确解,但至少可以让你少走一遍“不知道写在哪一层”的弯路。

3.1 项目结构:按照依赖倒置来排目录,先定接口后写实现

DDD设计模型在代码层的落地,核心是让领域层不要依赖基础设施层。最直观的体现就是目录结构:把领域模型放最内层,接口定义在领域层,数据库、消息、外部API的实现全部挡在外部。

com.example.order ├── interfaces // 入口层:REST Controller、消息监听、定时任务 │ └── web │ └── OrderController.java ├── application // 应用层:用例编排、事务边界、DTO转换 │ ├── OrderApplicationService.java │ └── dto │ └── CreateOrderCommand.java ├── domain // 领域层:聚合、实体、值对象、仓储接口 │ ├── model │ │ ├── Order.java // 聚合根 │ │ ├── OrderItem.java // 聚合内实体 │ │ ├── Money.java // 值对象 │ │ └── OrderStatus.java // 枚举(值对象的一种形态) │ ├── event │ │ └── OrderCreatedEvent.java │ └── repository │ └── OrderRepository.java // 仓储接口,注意是接口 ├── infrastructure // 基础设施层:仓储实现、消息发送、外部服务适配 │ ├── persistence │ │ └── OrderRepositoryImpl.java │ └── message │ └── OrderEventPublisher.java

这段结构最关键的一点是domain层不引入任何Spring、MyBatis等框架注解。Order、Money、OrderItem是纯Java对象,OrderRepository是个接口。依赖方向是从外向内:interfaces依赖application,application依赖domain,infrastructure实现domain定义的接口。这样做的收益是领域模型可以被单元测试直接驱动,不启动Spring容器也能跑业务单测。

参数上需要注意一个细节:DDD落地时,事务注解@Transactional不是加在仓储实现上,而是加在应用服务的方法上。因为一次用例可能跨多个仓储操作,事务必须覆盖整个用例,而不是单次读写。把事务放在应用层,领域层的聚合根方法才能保持纯净,不掺入事务语义。

3.2 用一次下单流程,把实体、值对象、仓储、应用服务串起来

建好目录后看核心流程。下单这个动作涉及:创建订单聚合、校验商品库存、计算金额、保存订单。在DDD设计模型里,命令入口在应用层,业务规则在聚合根方法里,持久化通过仓储接口完成,三块各司其职。先看聚合根:

public class Order { private OrderId id; private Money totalAmount; private OrderStatus status; private List<OrderItem> items; private CustomerId customerId; private Order() {} // 静态工厂方法:代替构造函数,承担创建约束 public static Order create(CustomerId customerId, List<OrderItem> items) { if (customerId == null) throw new IllegalArgumentException("customerId不能为空"); if (items == null || items.isEmpty()) throw new IllegalArgumentException("订单项不能为空"); Order order = new Order(); order.id = new OrderId(UUID.randomUUID().toString()); order.customerId = customerId; order.items = items; order.status = OrderStatus.CREATED; order.totalAmount = items.stream() .map(OrderItem::getSubtotal) .reduce(Money.ZERO, Money::add); return order; } // 只有聚合根才能改变聚合内状态 public void cancel(String reason) { if (this.status == OrderStatus.SHIPPED) { throw new IllegalStateException("已发货订单不能取消"); } this.status = OrderStatus.CANCELLED; // 发布领域事件,记录事实 DomainEventPublisher.publish(new OrderCancelledEvent(this.id, reason)); } }

这段代码里有两个必须注意的点。第一,构造函数是私有的,创建订单必须走Order.create静态工厂,这是把业务约束收进模型的手段——调用方根本绕不开“订单项不能为空”这条规则。第二,聚合根的方法cancel里校验了不可变状态,不满足就抛异常。这就是领域层的业务规则,不依赖任何框架,纯内存就能单测。

参数设计上,OrderId和CustomerId虽然都是String的包装,但作为值对象单独定义,是为了避免“订单id当客户id传”这种低级错误。Java在编译期帮不上忙,值对象类型能让这类错误在编译期现形。这是DDD设计模型里成本最低收益最高的类型安全手段。

接着看仓储接口和实现。接口定义在domain层,只声明方法签名:

public interface OrderRepository { Order findById(OrderId id); void save(Order order); }

接口有两个方法就够了。findById用于加载聚合根,save用于持久化。注意没有update,因为整个聚合以聚合根为单位持久化,不是逐字段update。实现类是infrastructure层的事,用MyBatis还是JPA都可以换。

@Repository public class OrderRepositoryImpl implements OrderRepository { @Autowired private OrderMapper orderMapper; @Override public Order findById(OrderId id) { OrderDO orderDO = orderMapper.selectById(id.getValue()); // 把数据对象装配回领域模型 return OrderAssembler.toDomain(orderDO); } @Override public void save(Order order) { // 先删后插,以聚合为单位做全量持久化 orderMapper.deleteItems(order.getId().getValue()); orderMapper.insertOrder(OrderAssembler.toDO(order)); for (OrderItem item : order.getItems()) { orderMapper.insertItem(OrderAssembler.toItemDO(item)); } } }

这里有个常见的争议点:保存时“先删后插”是不是很浪费?在DDD设计模型里,聚合内部的数据一致性比单条SQL的性能更重要,全量持久化能规避字段漏更问题。性能不够时,可以用变更追踪做增量更新,但那是优化阶段的事,不要在建模初期过度设计。逻辑上,仓储的关键是处理“数据库行”和“领域对象”之间的转换,业务逻辑不能出现在仓储实现里。

应用服务层做一个用例编排,把仓库、校验服务都拉进来:

@Service public class OrderApplicationService { @Autowired private OrderRepository orderRepository; @Autowired private InventoryService inventoryService; @Transactional public OrderId createOrder(CreateOrderCommand cmd) { // 注意:这里不写业务规则,只做协调和事务控制 List<OrderItem> items = cmd.getItems().stream() .map(i -> new OrderItem( new ProductId(i.getProductId()), i.getQuantity(), new Money(i.getUnitPrice(), Currency.getInstance("CNY")))) .collect(Collectors.toList()); Order order = Order.create(new CustomerId(cmd.getCustomerId()), items); orderRepository.save(order); return order.getId(); } }

应用服务要做的事务只有两件:把外部命令翻译成领域模型的方法调用,以及标记事务边界。真正“能不能下单”的规则,应该写在Order.create或专门的领域服务里。当你在应用服务里写超过五行if-else的时候,就要警惕业务规则在往上层泄露了。

3.3 事务边界与基础设施实现:三个常见的错误位置

代码结构搭完后,最常出错的是把逻辑放错层。整理三个高频错误位置,命中任何一条都说明分层已经变形。

第一,把业务规则写进Controller或应用服务。比如在Controller里判断“订单金额大于1000需要审批”,这行代码可能让规则散落到各个入口,未来连审批条件都找不到一处改。规则应该收敛到领域层,由Order模型自己保证。

第二,把持久化逻辑写进聚合根方法。比如在Order.cancel里直接调用orderMapper.updateStatus——这会让聚合根依赖基础设施,单元测试必须启动数据库才能跑。聚合根方法只改内存状态并发布事件,持久化由应用服务在事务边界内调用仓储完成。

第三,用DTO代替领域模型在层间传递。有些团队在应用服务里用OrderDTO一路传到领域层,秒变贫血模型。DTO只在interfaces层和application层之间使用,进入domain层后必须转换成领域对象,判断依据是:领域方法参数里出现“DTO”字样,属于需要处理的信号。

4. DDD落地避坑实录:建模对了代码也翻了车,五个常见问题

DDD设计模型在实际落地时,坑基本不在“概念不理解”,而在“建模与实现错位”。这一章把一线踩过的坑按“现象→原因→解决”整理成五条,每一条都是付费买来的血泪经验。

4.1 值对象被数据库主键绑架,判等逻辑失效

现象:订单项作为值对象设计,却在表里建了自增主键id。后续判断两个订单项是否相等时,因为id不同,业务上完全相同的两个商品项被判定为不同。排查半天发现是equals方法只比较了id。

原因:把值对象的身份和持久化机制绑在一起。数据库要主键是物理实现问题,值对象判等是业务问题,两者混淆后,值对象的不可变和按值判等特性全丢。

解决:值对象的equals和hashCode只基于业务属性实现,持久化时即使表里有物理主键,也仅作技术主键,不进领域模型。识别方法很简单:值对象领域模型里不要暴露物理id字段,仓储转换时把物理id留在DO层。

4.2 聚合根过大,一个聚合锁半张表

现象:订单、客户、商品、库存全放进一个Order聚合,每次下单都要校验库存、锁定客户信用、计算订单价格,操作一个订单要碰四五张表。上线后并发一下降立竿见影,运维天天收到锁等待告警。

原因:把“业务上有关联”理解成了“数据上必须强一致”。关联不等于同一事务,在一起才能保证一致的才需要放进同一聚合。

解决:按事务边界拆分聚合。库存操作单独建Inventory聚合,订单只持有ProductId引用,下单时通过领域事件或独立接口做减库存。容忍最终一致,用库存预占等方案兜底。

4.3 仓储接口里写业务逻辑,领域模型变成空壳

现象:OrderRepository里出现findEligibleForCancelOrder、findOverdueOrder这类带业务语义的方法声明,实现类里写着一大串SQL和条件判断。领域模型里Order类只剩getter/setter,业务人员提的新规则全加在仓储层。

原因:仓储职责被扩大成了“查询业务入口”。仓储的本职是囤聚合并按id读写,带业务语义的查询应该建模为领域服务或查询专用路径,不应混入仓储接口。

解决:把找“可取消的订单”这类逻辑上移到领域服务,仓储接口只保留findById、save这类通用方法。需要复杂查询时,独立建读模型或QueryService,和仓储解耦。

4.4 领域事件在进程内同步发送,订阅方异常导致主流程回滚

现象:下单后发布OrderCreatedEvent,通知服务在订阅方里调用外部API,结果外部API超时,整个下单事务回滚。客户重试又重复下单。

原因:把事件发送和事务绑在同一个线程里,订阅方的执行结果直接影响主事务的成功与否。

解决:事务性发件箱模式。在同一事务里写业务表和outbox事件表,事务提交后由后台任务把事件投递到消息中间件。订阅方的异常最多导致事件重投,不影响主事务。单体里如果坚持进程内同步发事件,记住一个原则:订阅方抛出的异常不得让发布方的事务失败,必须捕获后做补偿记录。

4.5 限界上下文没划清,两个团队共用一张表

现象:订单团队和库存团队维护同一个product表,订单团队加了字段,库存团队上线时为了兼容又改回。线上出了故障,两边都认为对方该负责。

原因:识别限界上下文时,只按组织结构划分,没按业务语言和变化频率划分。两个上下文对“商品”的业务关注点完全不同,却共享了同一套数据模型。

解决:拆分物理表。订单上下文只存商品快照(名称、单价),库存上下文独自管理实时库存。上线同步机制从“共享表”改成“订阅商品基础信息事件”,谁改谁负责发布。这属于比较重的重构,但对验证“多个团队长期共用一个老表”的场景,这是最终出路。

5. 用六边形架构给DDD设计模型兜底:从分层到依赖方向的收口

最后一章落在一个具体的延展技巧上:把DDD设计模型的依赖规则和六边形架构(端口与适配器)结合,用架构测试让边界不腐化。很多团队DDD建模没问题,代码跑着跑着分层又乱回去了,原因是没有一个机制在持续验证“依赖方向”,纯粹靠人盯人是盯不住的。

六边形架构和DDD的关系,一句话说就是:六边形架构给了DDD一个物理落位图。DDD定义了哪些元素在领域层,六边形明确了端口和适配器,内部是应用服务和领域模型,外部是数据库、消息队列、外部API,依赖方向一律从外向内。在代码层面识别依赖方向最简单的方法是看import语句:domain层不允许import任何infrastructure的开头包名。但靠code review拦截不够,这里我一般用ArchUnit做自动化约束,像单测一样跑在CI里:

// 这段测试需要引入ArchUnit依赖,不是应用代码 @AnalyzeClasses(packages = "com.example.order") public class DependencyRuleTest { @Test void domainLayer_shouldNotDependOnInfrastructure() { JavaClasses classes = new ClassFileImporter() .importPackages("com.example.order"); ArchRule rule = layeredArchitecture() .consideringAllDependencies() .layer("Interface").definedBy("..interfaces..") .layer("Application").definedBy("..application..") .layer("Domain").definedBy("..domain..") .layer("Infrastructure").definedBy("..infrastructure..") .whereLayer("Interface").mayNotBeAccessedByAnyLayer() .whereLayer("Application").mayOnlyBeAccessedByLayers("Interface") .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure") .whereLayer("Infrastructure").mayNotAccessAnyLayer(); rule.check(classes); } }

这段测试跑一次,能在包级别强制依赖规则。说明一下四个层各自的作用:Interface层是REST Controller和消息订阅入口,只做参数解析和结果返回,不含规则;Application协调用例和事务;Domain是纯业务内核;Infrastructure是仓储实现和对外端口适配器。规则表达的是“Infrastructure不可以访问任何上层业务包”,反过来,Domain不能被Infrastructure的实现细节污染。

参数上唯一需要按项目改的是包名前缀和两个访问规则。如果你们用的是模块化分包而不是分层分包,ArchUnit的包匹配规则要对应调整,但依赖方向在module之间的约束原理一样。还有一个参数细节:ArchUnit测试本身需要逐步放宽——老系统往往已经存在违例,建议先写一个当前违例清单,用freezePattern让存量违例冻结,然后增量约束新代码不再新增违例。

落地建议是,先把五条避坑检查一遍,再补上ArchUnit依赖规则测试,最后用六边形架构视角重新审视infrastructure层的进出端口。我自己做DDD建模时,在第一个迭代一定会做一件事:把所有外部依赖都用接口包起来,让domain层在脱离Spring、脱离MySQL的情况下把所有核心业务流程单测跑一遍。这一步做完,你会发现DDD设计模型的真正价值不在画图上,而在让你敢于在不启动任何外部组件的条件下验证业务规则。跑通了这关,再谈复杂业务的迭代才有一点底气。每次重构或者新需求进来,先看依赖规则测试是不是绿的,再看业务测试是不是绿的,两个都绿才敢合入——这是我个人比较推荐的DDD落地的验证习惯,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 1:09:21

编译原理实验报告写作指南:从词法分析到语法分析的完整拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 1:09:16

AUTOSAR以太网协议栈仿真套件:在PC上跑通SOME/IP与DoIP

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 1:08:31

5G基站硬件安装实战指南:从BBU到AAU的站点验收与避坑要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 1:08:15

OTC承兑平台系统源码拆解:从部署到二开全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 1:07:40

MES基础业务考核题:制造现场知识校准的压力测试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 1:07:20

人工智能老年健康护理落地:从跌倒检测到边缘部署的关键实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华