跳到内容

元数据卡

  • 前置知识:第12章(六边形与整洁架构);第13章(单体与微服务迁移)
  • 预计时间:50 分钟
  • 完成标志:能为一组微服务划分 Bounded Context 并运用战术模式建模

你的进度

工匠之都的工坊系统越做越大。锻造坊管一个铁件叫 "精密锻件",组装坊管它叫 "传动组件骨架",质检坊管它叫 "待检品第 3 类"。

三个工坊,三种叫法。但它们在讨论的明明是同一件东西。开会时互相听不懂,数据同步时字段对不上,代码里充满了"如果是从组装坊传来的,name 字段对应的是 component.skeleton_id"这样的翻译逻辑。

更可怕的是:你想加一个新的"淬火处理"功能,发现代码分散在六个服务的 12 个类里——没人能说清楚"淬火到底归谁管"。 你的任务

DDD(Domain-Driven Design,领域驱动设计)是 Eric Evans 提出的应对复杂业务建模的方法论。它不教你写代码时用哪些技术——它教你怎么理解你的业务领域,怎么在代码里表达出这个理解,怎么划定清晰的范围防止概念混乱。DDD 分为战略设计和战术设计,这一章两个都讲。

本章分层

  • 必读:Bounded Context、Ubiquitous Language、Entity vs Value Object
  • 选读:Aggregate 设计、Domain Events
  • 进阶:Anti-Corruption Layer 的实现

本章不会要求你掌握

  • DDD 与 CQRS 事件溯源的结合(第15章已覆盖)
  • Event Storming 工作坊的具体操作

破局 · 溯源

先看一个反例。你有一个 Workshop 类,里面有 getName()getAddress()getEquipment()listParts()addWorker()calculateProductivity()。这是"一个工坊"的模型——看起来合理,但出了什么问题?

问题:Workshop 承担了太多职责。calculateProductivity() 关心的是产能管理,addWorker() 关心的是人力资源,listParts() 关心的是库存——三个不同的概念塞进一个类。领域模型膨胀成了"上帝对象"。这不是 DDD 想看到的。

为什么 DDD 存在

传统的面向对象建模通常"根据现实世界的名词"建类。一个 Workshop 有一个名字、一个地址、一批设备和工人——于是你建一个 Workshop 类,把所有属性堆进去。

但当系统变大,同一个名词在不同的上下文中含义不同:

  • 在质检上下文中,"一个工坊"指的是"需要定期巡检的工作区域"
  • 在人力调度上下文中,"一个工坊"指的是"一组具有特定技能的工人"
  • 在财务上下文中,"一个工坊"指的是"一个成本中心"

这叫概念爆炸——一个词对应多个概念。DDD 的核心回答是: 把这些上下文明确划定边界,每个边界内只用一套统一的概念和术语。

第一层:战略设计——Bounded Context 与 Ubiquitous Language

Bounded Context(限界上下文) 是 DDD 最重要的概念。它是"一个概念在哪组含义下有效"的边界。

在锻造坊系统里:

+---------------------------+       +---------------------------+
|   锻造上下文               |       |   组装上下文               |
|   ForgeContext             |       |   AssemblyContext          |
|                           |       |                           |
|   Part = "原始零件"        |       |   Part = "待装配部件"       |
|   Order = "锻造委托单"     |       |   Order = "组装任务单"      |
|   Worker = "炉前工"        |       |   Worker = "装配工"        |
+---------------------------+       +---------------------------+
                  \                       /
                   v                     v
            +------------------------------------+
            |         全厂上下文                   |
            |         FactoryContext              |
            |                                    |
            |   Part = "备件档案"                  |
            |   Order = "生产批次"                 |
            |   Worker = "雇员"                    |
            +------------------------------------+

每个 Bounded Context 内有自己的Ubiquitous Language(通用语言)——这个上下文内的团队(业务人员 + 开发人员)使用的统一术语。

java
// 锻造上下文中的 Part
// 这里的 Part 只讨论"毛坯件"概念
package com.forge.boundedcontext;

public class Part {
    private PartId id;
    private Material material;       // 原料类型
    private ForgingSpec specification; // 锻造规格
    private HeatTreatStatus heatStatus;

    public void forge(ForgePlan plan) {
        // 毛坯件锻造逻辑
        if (material.getHardness() > plan.getMaxHardness()) {
            throw new ForgingException("材料硬度超出锻造能力");
        }
        this.heatStatus = HeatTreatStatus.PREHEATED;
        this.specification = plan.getResultSpec();
    }
}
java
// 组装上下文中的 Part
// 这里的 Part 讨论的是"备好的零件,可以拼装"
package com.assembly.boundedcontext;

public class Part {
    private PartId id;
    private AssemblySchema schema;   // 组装图纸位置
    private FitTolerance tolerance;  // 配合公差
    private Alignment alignment;

    public void installInto(Assembly assembly, SlotPosition position) {
        // 零件安装逻辑
        if (!alignment.canFit(position, tolerance)) {
            throw new AssemblyException("零件无法安装到指定位置");
        }
        assembly.addPart(this, position);
    }
}

同一个 PartId 在两个上下文中的含义、属性和行为完全不同。它们不该共享一个 Part 类。

Context Map(上下文地图) 记录了 Bounded Context 之间的关系:

关系类型含义
Partnership两个团队协调变更
Shared Kernel共享部分核心模型
Customer-Supplier上游决定,下游适配
Conformist下游完全跟随上游模型
Anti-Corruption Layer下游翻译上游的模型
Separate Ways两个上下文完全独立

第二层:战术模式——Entity、Value Object、Aggregate

战略设计划清了界限,战术设计给出了界限内的建模工具。

Entity(实体)——有唯一标识的对象。它的 identity 重要,属性可以变。

java
// Entity: 有 ID 的对象
public class Forge {
    private final ForgeId id;      // 身份标识——不可变
    private String name;
    private Temperature currentTemp;
    private ForgeStatus status;

    public Forge(ForgeId id, String name) {
        this.id = id;
        this.name = name;
        this.status = ForgeStatus.COLD;
        this.currentTemp = Temperature.roomTemp();
    }

    public void heatUp(Temperature target) {
        if (target.isAbove(maxOperatingTemp())) {
            throw new IllegalStateException("目标温度超过安全上限");
        }
        this.currentTemp = target;
        this.status = ForgeStatus.HEATING;
    }

    // 没有 setId()——ID 永不改变
    public ForgeId getId() { return id; }
}

Value Object(值对象)——没有唯一标识,只靠属性值区分。两个值相等即认为相同。不可变。

java
// Value Object: 没有 ID,不可变
public final class Temperature {
    private final double celsius;

    private Temperature(double celsius) {
        this.celsius = celsius;
    }

    public static Temperature ofCelsius(double value) {
        return new Temperature(value);
    }

    public static Temperature ofFahrenheit(double value) {
        return new Temperature((value - 32) * 5.0 / 9.0);
    }

    public static Temperature roomTemp() {
        return new Temperature(25.0);
    }

    public boolean isAbove(Temperature other) {
        return this.celsius > other.celsius;
    }

    public double getCelsius() { return celsius; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Temperature that)) return false;
        return Double.compare(celsius, that.celsius) == 0;
    }

    @Override
    public int hashCode() {
        return Double.hashCode(celsius);
    }
}

判断一个概念应该是 Entity 还是 Value Object: "如果两个对象属性相同,它们应该被视为同一个对象吗?" 是 -> Value Object。否 -> Entity。

  • 两个 Temperature(1000°C) 在系统中就是同一个温度值。Value Object。
  • 两个 Forge("1号炉") — 它们是不同的炉子,即使名字相同。Entity。

Aggregate(聚合)——一组对象的组合,作为一个数据变更的原子单位。聚合由一个根实体(Aggregate Root)作为唯一入口。

java
// Aggregate Root: 一个工单聚合
public class WorkOrder {
    private final WorkOrderId id;
    private List<ForgingStep> steps;
    private WorkOrderStatus status;

    // 聚合根是唯一外部可访问的入口
    // steps 不能从外部直接修改
    public void addStep(ForgingStep step) {
        if (status != WorkOrderStatus.DRAFT) {
            throw new IllegalStateException("只能修改草稿状态的工单");
        }
        this.steps.add(step);
    }

    public void submit() {
        if (steps.isEmpty()) {
            throw new IllegalStateException("工单必须包含至少一个锻造步骤");
        }
        this.status = WorkOrderStatus.SUBMITTED;
    }

    public WorkOrderId getId() { return id; }

    // 不暴露 List<ForgingStep> ——聚合内部封装
    public List<ForgingStep> getSteps() {
        return Collections.unmodifiableList(steps);
    }
}

聚合的设计原则:

  1. 一致性边界 — 聚合内所有操作满足事务一致性。需要 "要么都成功,要么都不改" 的数据放在同一个聚合内。
  2. 小聚合 — 聚合越大,并发冲突越多,性能越差。一个聚合通常不超过 10-15 个实体。
  3. 通过根访问 — 外部只能通过聚合根操作内部对象,不能绕过根直接修改内部。

Repository(仓储)——聚合的持久化接口。每个聚合对应一个 Repository。

java
public interface WorkOrderRepository {
    WorkOrder findById(WorkOrderId id);
    void save(WorkOrder workOrder);
    void delete(WorkOrderId id);
}

Repository 只操作 Aggregate Root,不暴露底层存储细节。

Domain Event(领域事件)——领域内发生的重要事件,其他 Bounded Context 可以订阅。

java
// 领域事件
public record WorkOrderSubmittedEvent(
    WorkOrderId workOrderId,
    List<ForgingStep> steps,
    Instant occurredAt
) {}

// 在聚合中发布事件
public class WorkOrder {
    private final List<DomainEvent> events = new ArrayList<>();

    public void submit() {
        if (steps.isEmpty()) throw new IllegalStateException(...);
        this.status = WorkOrderStatus.SUBMITTED;
        this.events.add(new WorkOrderSubmittedEvent(
            id, steps, Instant.now()));
    }

    public List<DomainEvent> popEvents() {
        var result = List.copyOf(events);
        events.clear();
        return result;
    }
}

第三层:DDD 与六边形架构的关系

第 12 章讲的六边形架构和 DDD 是天然的互补关系:

六边形架构关注DDD 关注
技术边界: 核心 vs 外部业务边界: 每个 Bounded Context
Ports 适配外部世界Repository 是持久化端口
端口作为抽象接口Aggregate 是端口操作的对象
Adapter 层实现外部 I/OInfrastructure 层实现持久化

在代码结构上,DDD 的战术模式完美映射到六边形的核心层:

core/                          ← 六边形核心 (无框架依赖)
  domain/                      ← DDD 领域层
    model/
      WorkOrder.java          ← Aggregate Root
      ForgingStep.java        ← Value Object
      WorkOrderId.java        ← Value Object
    event/
      WorkOrderSubmittedEvent.java  ← Domain Event
    service/
      SchedulingService.java   ← Domain Service
  port/
    WorkOrderRepository.java  ← Repository (Port)

adapter/                       ← 六边形适配器层
  db/
    JpaWorkOrderRepository.java  ← Repository 的 JPA 实现
  event/
    KafkaEventPublisher.java     ← 事件的 Kafka 发布

bootstrap/                     ← 依赖注入组装
  BeanConfig.java

六边形架构给了你"核心对外部一无所知"的干净边界。DDD 给了你"核心内怎么建模"的战术工具箱。两个一起用,你的核心是: 纯净的业务模型 + 适配所有外部世界的端口。

第四层:Anti-Corruption Layer(防腐层)

两个系统之间的术语冲突不可避免,尤其是当你的系统需要对接一个"老系统"(legacy system)时。老系统说 "CUST" 和 "ORD",你的系统说 "Customer" 和 "WorkOrder"。直接对接,你被老系统的混乱模型污染。

Anti-Corruption Layer (ACL) 是一个翻译层——位于你的 Bounded Context 和外部系统之间。它把外部系统的模型翻译成你的 Ubiquitous Language。

你的系统 (Bounded Context)        外部老系统
+-------------------+             +----------------+
| WorkOrder         |             | ORD             |
| Customer          |  ← ACL →   | CUST            |
| ForgingPlan       |             | PLAN_TABLE      |
+-------------------+             +----------------+
java
// ACL: 把老系统的 ORD 翻译成 WorkOrder
@Component
public class LegacyOrderTranslator {
    private final LegacyApiClient legacyClient;

    public WorkOrder toWorkOrder(String legacyOrderId) {
        LegacyOrder legacyOrder = legacyClient.fetchOrder(legacyOrderId);
        // 翻译: 老系统的数据 → 你的领域模型
        return new WorkOrder(
            new WorkOrderId(legacyOrder.getOrderNumber()),
            translateItems(legacyOrder.getLineItems()),
            translateCustomer(legacyOrder.getCustomerCode())
        );
    }

    private Customer translateCustomer(String custCode) {
        LegacyCustomer legacy = legacyClient.fetchCustomer(custCode);
        return new Customer(
            new CustomerId(legacy.getCustId()),
            legacy.getCustName(),
            legacy.getCustAddress()
        );
    }

    private List<ForgingStep> translateItems(List<LegacyLineItem> items) {
        return items.stream()
            .map(item -> new ForgingStep(
                new PartId(item.getPartCode()),
                Temperature.ofCelsius(item.getRequiredTemp())
            ))
            .toList();
    }
}

防腐层的典型用途:

  1. 对接第三方 API — 供应商 A 的订单模型翻译成你的统一模型
  2. 对接老系统 — COBOL 系统的 "CUSTOMER_MASTER" 表翻译成你的 Customer 模型
  3. Bounded Context 之间 — 锻造上下文的 Part 翻译成组装上下文的 Part

ACL 放在六边形架构的适配器层,它是一个特殊的出站适配器。


常见陷阱

陷阱一:把数据库表当做聚合。 "用户表就是 User 聚合"——错了。聚合是业务概念,不是数据概念。一个聚合可能包含多张表的数据,一张表的数据也可能跨越多个聚合。

陷阱二:Bounded Context 切得太碎。 一个上下文只有一个 Entity。导致上下文之间相互调用极其频繁。上下文应该有足够的业务自治性,通常一个微服务对应 1-3 个 Bounded Context 就够了。

陷阱三:Ubiquitous Language 只写在代码里,不在团队沟通中使用。 你和业务开会时说 "订单",代码里写 "Order"——但团队内部说 "那个单子"、"这个单"。口头用语和代码术语不一致,最终代码里的概念会慢慢跑偏。

陷阱四:DDD 是从技术出发的。 "我们要用微服务了,必须上 DDD"——这是本末倒置。DDD 从业务建模出发,它的输出是清晰的概念边界。微服务只是实现这种边界的部署形式之一。模块化单体同样可以用 DDD。


通关挑战

  • 热身:在你的项目里找出一个"一个词多个含义"的场景(如 status、type、state),画出它属于几个不同的 Bounded Context。
  • 挑战:为一个中型子系统做 DDD 建模。画出 Context Map(至少 3 个 Bounded Context),每个上下文内列出 Ubiquitous Language 的核心术语。识别 Entity 和 Value Object,画出一个聚合的组成。
  • 挑战:为你的项目里对接的一个老系统或第三方 API 实现 Anti-Corruption Layer。写一个 Translator 类,把外部模型的全部数据转换成你的领域模型。

验收标准

  • 你能区分 Bounded Context 和 Subdomain 的区别
  • 你能为一个业务场景划分至少 3 个 Bounded Context
  • 你能识别一个聚合根和它的内部实体/值对象
  • 你能解释 DDD 中的 Repository 和六边形架构中的 Port 的关系
  • 你能写一个最简单的 Anti-Corruption Layer

常见卡点

  • 不知道 Entity 和 Value Object 怎么分。 试这个判断: "如果两个对象所有属性值相同,但你认为它们是不同的——那是 Entity。如果完全相同就是同一个——那是 Value Object。"
  • 不知道聚合切多大。 一个简单的判断: 如果两件事不必在同一个事务中完成,它们就不该在同一个聚合里。

现在不需要理解

  • Event Storming 工作坊的完整流程
  • DDD 与 CQRS / Event Sourcing 的组合模式(第15章已覆盖)
  • Domain Service 与 Application Service 的区别辨析

旅人笔记

DDD 不是代码技巧,是思维工具。战略设计让你看清业务的全貌地图——哪里有一致语言,哪里需要翻译层。战术设计给你在边界内建模的手艺——Entity 负责身份,Value Object 负责数据,Aggregate 负责一致性,Repository 负责持久化,Domain Event 负责上下文间通信。第 12 章的六边形架构提供了技术上的内外边界,DDD 提供了业务上的概念边界——两章合在一起,你的系统从技术到概念的每一层都有清晰的分界线。


下一站预告

第六卷从软件构造的静态检查出发,走过设计模式、架构演进、微服务、事件驱动、AI 系统模式,再到 API 治理和领域驱动设计——你从写代码走到了设计系统。工匠之都的训练到此完成。前方的路通往 Vol 7: 分布式系统——林将军的远征军需要你。带上你的全套工具,去解决更大的问题。

Built with VitePress | Software Systems Atlas