Others

DDD领域驱动设计:从战略到战术的全面指南

前言

如果你写过几年业务代码,一定经历过这种痛苦:需求越来越复杂,代码越来越臃肿,Service 层动辄几千行,一个改动能牵连半个项目。你开始怀疑——是不是从一开始,我们的代码组织方式就有问题?

DDD(Domain-Driven Design,领域驱动设计)就是在这种”代码越来越像屎山”的背景下,给出的一套解法。

这篇文章不是要给你灌输教条,而是务实地聊聊:DDD 到底是什么、它解决了什么问题、哪些东西值得你学、以及怎么落地。

DDD 是什么

2003 年,Eric Evans 出版了《Domain-Driven Design: Tackling Complexity in the Heart of Software》这本书,正式提出了 DDD 的概念。

一句话总结 DDD 的核心思想:以业务领域为中心驱动软件设计,而不是以技术框架或数据库为中心。

这听起来像是一句正确的废话,但你想想我们平时是怎么写代码的:拿到需求,先想建什么表、用什么框架、分几层——很少会先坐下来,认真梳理业务领域的核心概念和它们之间的关系。

DDD 认为,软件复杂性的根源在于业务本身的复杂性,而不是技术。你试图用技术的”简洁”去掩盖业务的”复杂”,结果就是代码和业务越来越脱节,最终变成一座谁也不敢动的屎山。

DDD 提供了一套从战略战术的方法论,帮助你把业务领域的复杂性”管理”起来,而不是”逃避”。

为什么要用 DDD

先说痛点。如果你中了两条以上,DDD 值得认真了解一下:

  • 业务逻辑散落各处:同一个业务规则,在 Controller 里写一份,在 Service 里写一份,在 SQL 里又写一份,到底以哪个为准?
  • 模型贫血:实体类只有一堆 getter/setter,所有业务逻辑都在 Service 里,实体变成了”数据搬运工”。
  • 沟通鸿沟:产品说的”订单”和开发写的 Order 类,含义完全不同,各说各话。
  • 牵一发动全身:改一个功能,影响十个模块,没人敢碰。
  • 新人上手难:代码结构不能反映业务结构,新人靠读代码理解业务,效率极低。

DDD 的核心价值在于:

  1. 让代码结构反映业务结构。核心业务逻辑封装在领域层,不依赖任何基础设施,稳定且可测试。
  2. 统一语言(Ubiquitous Language)。产品、开发、测试说同一种”语言”,消除沟通歧义。
  3. 控制复杂性的传播。通过限界上下文划定边界,一个上下文内的复杂性不会泄漏到外部。

战略设计:先想清楚全局

战略设计是 DDD 的”宏观架构”部分,解决的是”系统怎么拆、边界在哪”的问题。

子域划分

DDD 认为,一个复杂的业务系统可以拆分为多个子域(Subdomain),按重要性分为三类:

核心子域(Core Domain):这是你的业务核心竞争力,是区别于竞品的关键。比如电商系统的”订单履约”、物流系统的”路径规划”。核心子域值得投入最优秀的团队、最精细的设计。

支撑子域(Supporting Subdomain):不是核心竞争力,但支撑核心业务运转。比如电商的”商品管理”、“库存管理”。它们有业务特殊性,但不需要像核心子域那样极致优化。

通用子域(Generic Subdomain):通用能力,没有业务特殊性。比如”用户认证”、“消息通知”、“日志记录”。这些能买就买,能用开源就用开源,不要自己造轮子。

我的建议:把 80% 的精力放在核心子域上。通用子域自己写,是许多团队最容易犯的错。

限界上下文(Bounded Context)

这是 DDD 中最重要的概念,没有之一

限界上下文是一个语义边界。在同一个限界上下文内,每个术语都有明确且唯一的含义。不同的上下文中,同一个术语可以有不同的含义。

举个例子:

  • 商品上下文中,“商品”有 SKU、价格、库存、详情页等属性。
  • 订单上下文中,“商品”只需要知道商品 ID、名称、单价、数量。
  • 物流上下文中,“商品”关心的是重量、体积、是否需要冷链。

这三个上下文中的”商品”不是同一个东西!如果不划定边界,你就会搞出一个”万能商品类”,包含几十个字段,谁看了都头疼。

限界上下文的本质是:承认一个概念在不同业务视角下有不同的含义,并且用明确的边界把它们隔离开。

上下文映射(Context Map)

上下文有了,它们之间怎么交互?这就是上下文映射要解决的问题。

常见的映射模式:

模式含义适用场景
共享内核(Shared Kernel)两个上下文共享一小部分模型两个团队紧密协作时
客户-供应商(Customer-Supplier)上游提供,下游消费,有协商团队有依赖关系时
遵从者(Conformist)下游完全遵循上游的模型,没有协商权对接外部系统、遗留系统时
防腐层(ACL)在下游走加一层翻译,隔离上游模型对接不可控的外部系统时
开放主机服务(OHS)上游提供标准化的 API 协议多个下游需要对接时
发布语言(Published Language)用标准格式(JSON Schema、Protobuf)定义 API配合 OHS 使用

其中防腐层(Anti-Corruption Layer) 是实战中用得最多的。当你不得不依赖一个设计糟糕的外部系统时,不要直接让它的模型侵入你的代码,而是在中间加一层翻译。

战术设计:落地到代码

战略设计想清楚了”怎么拆”,战术设计解决的是”每个上下文内部怎么写”。

实体(Entity)

实体是有唯一标识的领域对象,标识它的身份,而不是它的属性。

public class Order {
    private OrderId id;          // 唯一标识
    private CustomerId customerId;
    private Money totalAmount;
    private OrderStatus status;
    
    // 业务行为封装在实体内部
    public void confirm() {
        if (this.status != OrderStatus.PENDING) {
            throw new OrderStateException("只有待确认的订单才能确认");
        }
        this.status = OrderStatus.CONFIRMED;
        // 注册领域事件
        this.registerEvent(new OrderConfirmedEvent(this.id));
    }
}

注意,业务逻辑(confirm() 方法)封装在实体内部,而不是扔到一个 OrderService 里。这是 DDD 和传统三层架构最大的区别。

值对象(Value Object)

值对象没有唯一标识,通过属性值来相等性判断。它描述的是”什么”,而不是”哪个”。

public class Money {
    private final BigDecimal amount;
    private final Currency currency;
    
    // 不可变对象
    public Money add(Money other) {
        assertSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

值对象的核心特征:

  • 不可变:创建后不能修改,只能替换。
  • 通过值相等Money(100, CNY) 等于另一个 Money(100, CNY)
  • 可以描述业务概念:把”金额”从原始类型 BigDecimal 提升为 Money,赋予它业务含义和行为。

很多团队只写实体不写值对象,结果就是大量业务逻辑散落在 Service 里。值对象是 DDD 中最被低估的建模工具。

聚合(Aggregate)与聚合根

聚合是一组紧密相关的实体和值对象的集合,对外通过一个聚合根(Aggregate Root) 来访问。

聚合的核心规则:

  1. 外部只能通过聚合根引用聚合。不能绕过聚合根直接操作内部对象。
  2. 聚合内部保持一致性。聚合根负责在每次操作中保证业务不变量(Invariant)。
  3. 聚合之间通过 ID 引用,不持有对方的对象引用。
  4. 一个事务只修改一个聚合。跨聚合的一致性通过领域事件实现最终一致。

聚合的设计原则是——边界越小,并发冲突越少,性能越好。判断一个对象是否应该在同一个聚合内,就看它们之间是否有强一致性的业务约束。

领域服务与应用服务

领域服务(Domain Service):当某个业务操作不属于任何一个实体或值对象时,把它放在领域服务中。领域服务属于领域层,包含真正的业务逻辑。

// 转账操作涉及两个账户,不属于单个 Account 实体
public class TransferService {
    public void transfer(Account from, Account to, Money amount) {
        from.debit(amount);
        to.credit(amount);
    }
}

应用服务(Application Service):薄薄的协调层,负责获取输入、调用领域逻辑、发送事件、持久化。不包含业务逻辑。

public class TransferAppService {
    @Transactional
    public void transfer(TransferCommand cmd) {
        Account from = accountRepository.findById(cmd.getFromId());
        Account to = accountRepository.findById(cmd.getToId());
        transferService.transfer(from, to, new Money(cmd.getAmount()));
        accountRepository.save(from);
        accountRepository.save(to);
    }
}

仓储(Repository)

仓储为聚合提供持久化抽象,屏蔽底层存储细节。

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

几个关键点:

  • 每个聚合一个 Repository,不为内部实体单独建 Repository。
  • Repository 的接口定义在领域层,实现在基础设施层。这就是依赖倒置。
  • Repository 返回的是聚合的完整对象图,而不是让调用方自己去拼装。

领域事件(Domain Event)

领域事件表示”领域中发生了一件重要的事”。它是聚合之间通信的主要方式。

// 领域事件定义在领域层
public class OrderCreatedEvent {
    private final OrderId orderId;
    private final CustomerId customerId;
    private final Money totalAmount;
    private final LocalDateTime occurredAt;
}

// 在实体中注册事件
public class Order {
    private List<DomainEvent> events = new ArrayList<>();
    
    public void create(CustomerId customerId, List<OrderItem> items) {
        // ... 业务逻辑
        this.events.add(new OrderCreatedEvent(this.id, customerId, totalAmount, now()));
    }
}

领域事件的价值:

  • 解耦聚合:聚合之间不直接调用,而是通过事件异步通信。
  • 表达业务语义OrderCreatedEventorderMapper.insert(order) 有意义得多。
  • 支持扩展:新增业务逻辑(如”下单后发积分”),只需新增一个事件监听器,不改原有代码。

DDD 的适用场景

DDD 不是银弹,它有明确的适用边界。

适合用 DDD 的场景:

  • 业务逻辑复杂,规则多变。比如电商、金融、医疗、物流。
  • 业务模型需要长期演进,不是一次性项目。
  • 团队规模较大,需要清晰的边界划分来支持并行开发。
  • 产品和开发之间经常因为”这个字段什么意思”扯皮。

不适合用 DDD 的场景:

  • 简单的 CRUD 应用。如果你的系统就是增删改查,用 DDD 是过度设计。
  • 以数据为中心的系统。比如报表系统、数据分析平台,业务逻辑很少。
  • 小项目、短生命周期项目。DDD 的前期投入不小,项目太小不划算。
  • 团队对 DDD 完全没有了解且没有学习意愿。强推 DDD 只会制造混乱。

一个务实的判断标准:如果你的系统用三层架构 + Service 就能搞定,别折腾 DDD。等到复杂度真的失控了再引入也不迟。

DDD 与微服务

DDD 和微服务是天作之合。准确地说,DDD 的限界上下文是微服务拆分的最佳指导原则

很多团队拆微服务的方式是”按技术层拆”:一个前端服务、一个 UserService、一个 OrderService、一个 ProductService……拆完之后发现,一个业务操作要在五六个服务之间调用,分布式事务搞到崩溃。

正确的做法是按限界上下文拆:

  1. 先用 DDD 战略设计识别出所有的限界上下文。
  2. 每个限界上下文对应一个微服务(或者几个紧密相关的上下文合并为一个服务)。
  3. 上下文之间通过 API 或事件通信,内部实现完全自治。

这样做的好处:

  • 每个服务有自己的数据模型。订单服务不需要关心商品有多少个字段,它只知道自己需要的信息。
  • 团队可以独立开发和部署。每个限界上下文由一个团队负责,边界清晰。
  • 数据一致性边界明确。同一个聚合内强一致,不同聚合(不同服务)之间最终一致。

没有 DDD 指导的微服务拆分,就像没有地图的旅行——你走得很快,但方向可能是错的。

从 0 落地 DDD 的实践建议

说了这么多理论,怎么落地?以下是我总结的渐进式实践路径:

第一步:事件风暴(Event Storming)

别一上来就写代码。把产品、开发、测试拉到一起,用事件风暴的方式梳理业务流程:

  1. 用橙色便利贴写出业务中发生的领域事件(过去时态,如”订单已创建”)。
  2. 用蓝色便利贴写出触发事件的命令
  3. 用黄色便利贴写出涉及的聚合
  4. 用绿色便利贴写出需要的读模型

这个过程会帮你快速识别出限界上下文、聚合、领域事件这些核心概念。

第二步:从值对象开始

不要一上来就搞完整的 DDD 分层架构,太重了。先从引入值对象开始:

  • String name 变成 UserName
  • BigDecimal amount 变成 Money
  • String phone 变成 PhoneNumber

这一步几乎零成本,但立刻让代码的业务表达力提升一个档次。

第三步:让实体充血

把 Service 里的业务逻辑逐步迁移回实体和值对象中。实体不应该只是数据容器,它应该有自己的行为。

这个过程需要耐心,因为你要对抗”所有逻辑都在 Service 里”的肌肉记忆。

第四步:引入仓储模式

把数据访问从 Service 中抽离出来,定义 Repository 接口。接口在领域层,实现在基础设施层。

这一步做完,你的领域层就不再依赖数据库了。

第五步:引入领域事件

当聚合之间需要通信时,用领域事件替代直接调用。这是实现聚合解耦的关键。

第六步:划定限界上下文

当系统足够复杂时,开始用限界上下文来划分模块边界。不同的上下文可以有各自的数据模型,通过上下文映射模式来通信。

不需要一步到位。DDD 是一个渐进的过程,每一步都有独立的价值。

结语

DDD 不是一套必须严格遵守的规范,而是一种思考方式——它教你从业务领域的角度去组织代码,而不是从技术的角度。

最重要的不是你会不会画 UML 图,而是你在写代码之前,有没有认真想过:这段业务的核心概念是什么?它们之间的关系是什么?边界在哪?

如果你正在被复杂业务代码折磨,不妨试试 DDD 的思路。不需要全盘照搬,哪怕只是引入值对象、让实体充血、用领域事件解耦,都能让你的代码质量提升一大截。

最后推荐几本入门读物:

  • Eric Evans《Domain-Driven Design》(DDD 圣经,偏理论)
  • Vaughn Vernon《Implementing Domain-Driven Design》(实战导向,更推荐)
  • Vaughn Vernon《Domain-Driven Design Distilled》(DDD 精华浓缩版,适合快速入门)

最近的文章

SSE流式响应:前端取消请求后,服务端还在烧Token吗?

更早的文章

VibeGuard:专为AI生成代码打造的安全Lint工具

欢迎在评论区留下您的见解~