DDDDomain-Driven DesignArchitectureSoftware Design

重新梳理领域驱动设计(DDD)

Sloth255
Sloth255
·13 min read·2,874 words

引言

“我大概知道 DDD 是什么,但到底做到什么程度才算是 DDD?”
“Entity 和 Value Object 的区别到底是什么?”
“只要做了 Repository,就算是 DDD 吗?”

很多人应该都带着类似的疑问。本文会基于 Eric Evans 的《Domain-Driven Design》和 Vaughn Vernon 的《Implementing Domain-Driven Design》,把 DDD 的核心内容重新整理成一种 能在实际工作中用起来的形态

文中主要术语的说明,会尽量贴近 Eric Evans 的 DDD Reference 与 Vaughn Vernon 官方概述中的表述。

什么是 DDD

领域驱动设计(Domain-Driven Design,DDD) 是一种设计方法。它认为,在开发拥有复杂业务逻辑的软件时,应该

把“领域(业务领域)”放在软件的中心,并用领域中的语言来编写代码

重点在于,DDD 既不是框架,也不是某种固定的架构,它是 一组思想与技术方法的集合。所以,“装了一个 DDD 库就算 DDD”这种理解是不成立的。

DDD 大体分为两个层面

DDD 大体可以分成下面两部分。

分类 别名 做什么
战略设计 Strategic Design 正确划分领域,并画清边界
战术设计 Tactical Design 在代码层面表达领域

很多人容易把“DDD = 创建 Entity 和 Value Object”当成全部,但那只是战术设计。真正更重要的,其实是 战略设计

战略设计

统一语言(Ubiquitous Language)

这是 DDD 的起点,也是最重要的概念。

领域专家(熟悉业务的人)、开发者、利益相关者 使用同一套语言

比如在电商网站里提到“订单(Order)”时:

  • 市场部门:“加入购物车的那一刻就是订单”
  • 财务:“支付完成的那一刻才是订单”
  • 物流:“发出出库指令的那一刻才是订单”

同一个词在不同人嘴里含义不同,这种情况非常常见。如果直接把这种模糊性原样带进代码里,最后谁也说不清 Order 类到底表示什么。

让模型成为语言的骨架,并在对话和代码中持续使用这套语言。这就是 DDD 的基础。

限界上下文(Bounded Context)

统一语言并不需要在整个组织范围内完全统一,而且通常也做不到。

因此,我们会划出一个边界,规定“在这个范围内,这个词就是这个意思”。这就是 限界上下文(Bounded Context)。这个边界不仅是沟通上的约定,也会体现在团队组织、应用中的适用范围、代码库划分以及数据存储分离上。

flowchart LR
  subgraph Sales["销售上下文"]
    S["Order =<br/>结账后的购买意图"]
  end
  subgraph Logistics["物流上下文"]
    L["Order =<br/>带发货指令的可发货包裹"]
  end
  N["同样的 Order 可以有不同含义"]

  S -.-> N
  L -.-> N

  classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
  class S,L context
  class N note

这些边界组成的地图,就是 上下文映射(Context Map)。在决定微服务边界时,它也是非常有力的指导依据。

如何从需求中提炼领域

“所以,到底要怎么找到领域?”这是每个人都会遇到的问题。这里介绍几个在实际现场确实很有帮助的方法。

1. 访谈时要有意识地抓“动词”和“名词”

和客户或业务负责人沟通时,最好有意识地把出现的 名词 = 候选模型,以及 动词 = 候选行为 分开记录。

销售人员 制作 报价单,把它 发送客户,在被 批准 之后转成 订单。”

光是这一句话,就可以提取出:

  • 名词:销售人员、报价单、客户、订单 -> Entity / Value Object 候选
  • 动词:制作、发送、批准、转成订单 -> 行为 / 领域事件候选

业务人员无意识使用的那些词,正是统一语言最原始的矿石。

2. 使用 EventStorming

对于复杂领域,EventStorming 非常有效。做法是在白板上,或者在 Miro / FigJam 这样的工具里,用便签把事件排开

flowchart LR
  E1["🟧 已请求报价"] --> E2["🟧 已创建报价"] --> E3["🟧 已发送报价"]
  E3 --> C2["🟦 命令<br/>批准报价"] --> E4["🟧 已批准报价"] --> E5["🟧 已接受订单"]
  E3 --> C1["🟦 命令<br/>取消报价"] --> E6["🟧 已取消报价"]
  A1["🟪 聚合<br/>报价单"] -.触发事件的主体.-> E2
  R1["🟨 规则<br/>只有已批准的报价才能转换为订单"] -.约束.-> E5

  classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
  classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
  classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
  classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
  class E1,E2,E3,E4,E5,E6 event
  class C1,C2 command
  class A1 aggregate
  class R1 rule
  • 🟧 橙色便签:领域事件,用过去式书写
  • 🟦 蓝色便签:命令,也就是某个人的动作
  • 🟪 紫色便签:聚合,即触发这些事件的主体。例子:报价单
  • 🟨 黄色便签:业务规则 / 约束。例子:“只有已批准的报价才能转换为订单”

例如,“批准报价” 是命令,因为它是某个人执行的指示;而 “已批准报价” 是领域事件,因为它表示一件已经发生的事实。

和业务负责人一起做这件事,可以让 隐性知识一下子可视化。它最大的价值在于,很多关键对话会自然发生:“啊,原来这里还会有一个判断?”“其实这种情况下是例外……”

3. 一定要问“异常场景”

在需求访谈里,信息量最大的往往是 异常流程

问题 能挖出的信息
“什么情况下这件事做不了?” 不变条件、业务规则
“过去有没有什么容易引发争议的案例?” 隐藏约束
“现在有哪些是靠人工绕过去处理的?” 尚未实现的业务规则
“这项业务和 5 年前相比,发生了哪些变化?” 容易变化的部分 / 稳定的部分

如果只听 happy path,就很难真正看清领域的轮廓。真正的领域本质,往往藏在那些痛点里。

4. 分成核心、支撑、通用三类

提炼出的领域,并不需要全部用同样的力度来实现。

分类 说明 策略
核心领域 企业竞争优势的来源 用 DDD 认真自研
支撑子域 支撑核心的业务能力 轻量自研或外包
通用子域 各家公司都差不多的部分,如认证、支付 用 SaaS / OSS

以电商网站为例:

  • 核心:商品推荐、定价策略、库存分配
  • 支撑:商品目录管理、配送协调
  • 通用:认证、支付、邮件发送

如果什么都想全力投入,项目几乎一定会失控。先明确到底该把力气花在哪里,是边界设计的第一步。

5. 画上下文边界时的判断标准

边界画在哪里确实需要经验,但可以先把下面这些信号当成判断依据。

  • 🚩 同一个词的含义变了(开头的 Order 例子)
  • 🚩 负责的组织或部门不同(康威定律)
  • 🚩 生命周期不同(订单和客户的生命周期不同)
  • 🚩 变更频率不同(定价策略经常变,地址管理则没那么频繁)
  • 🚩 所需一致性级别不同(库存要求强一致,推荐可以接受最终一致)

一旦你感受到这些“错位”,那里往往就是边界候选点。

战术设计

接下来进入代码层面。

Value Object(值对象)

值对象表示“值本身”,它具有以下特征:

  • 不可变(immutable)
  • 按值相等比较,不是按 ID 比较
  • 没有副作用
// ❌ 只是字符串
const email: string = "user@example.com";

// ✅ Value Object
class Email {
  constructor(private readonly value: string) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      throw new Error("无效的电子邮件地址");
    }
  }
  equals(other: Email): boolean {
    return this.value === other.value;
  }
  toString(): string {
    return this.value;
  }
}

关键点在于,要把“这是一个合法的邮箱地址”这个约束 封装进类型里。这样一来,验证逻辑就不会散落在各处。

Entity(实体)

实体是 通过标识符(ID)来决定同一性 的对象。

class User {
  constructor(
    public readonly id: UserId,
    private name: UserName,
    private email: Email,
  ) {}

  changeEmail(newEmail: Email): void {
    this.email = newEmail;
  }

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

哪怕名字变了,邮箱变了,只要 id 相同,它仍然是同一个 User。这就是 Entity 的本质。

Aggregate(聚合)

聚合是把多个 Entity / Value Object 组织成一个整体的 一致性边界

作为聚合入口的实体叫作 聚合根(Aggregate Root),外部访问必须经过聚合根。

// 聚合根
class Order {
  private items: OrderItem[] = [];

  constructor(
    public readonly id: OrderId,
    private readonly userId: UserId,
  ) {}

  // 聚合内部的一致性由聚合根来守护。
  addItem(product: Product, quantity: number): void {
    if (this.items.length >= 100) {
      throw new Error("超出了单笔订单的条目上限");
    }
    this.items.push(new OrderItem(product.id, quantity, product.price));
  }

  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }
}

一条重要原则是:聚合尽量保持小而精。做得太大,就会在并发更新冲突和加载成本上吃尽苦头。

Repository(仓储)

仓储是负责聚合持久化的抽象。它存在的目的,是 让领域层不依赖基础设施

// 领域层:只定义接口。
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// 基础设施层:实现放在外侧。
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> { /* ... */ }
  async save(order: Order): Promise<void> { /* ... */ }
}

这里最关键的是,Repository 是 为聚合根提供全局访问能力,并且让调用方像操作集合一样使用它的抽象。不要把它只理解成“往数据库里存东西”,而要把它看成“保存和取回聚合的入口”。

Domain Service(领域服务)

领域服务用来放置那些在领域中很重要,但如果硬塞进某个 Entity 或 Value Object 里会显得不自然的流程或转换逻辑。

// “电子邮件是否重复” 不是 User 单体能判断的。
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}

  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

拿不准时:该放在 Entity 还是 Service?

开发时最容易纠结的问题之一,就是“这段逻辑应该写到 Entity 里,还是写到 Service 里?”下面这个判断流程,能覆盖绝大多数场景。

flowchart TB
  Start["这段逻辑..."] --> Q1{"只是修改单个对象的状态吗?"}
  Q1 -- YES --> E["Entity 方法"]
  Q1 -- NO --> Q2{"需要跨多个聚合做判断吗?"}
  Q2 -- YES --> D["Domain Service"]
  Q2 -- NO --> Q3{"需要 repository 或外部服务吗?"}
  Q3 -- YES --> D
  Q3 -- NO --> Q4{"用动词表达比用名词更自然吗?"}
  Q4 -- YES --> D
  Q4 -- NO --> V["Entity / Value Object"]

  classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
  class Q1,Q2,Q3,Q4 question
  class E,D,V answer
✅ 应该放进 Entity 的东西

保护自身状态和不变条件的逻辑,属于 Entity 的职责。

class Order {
  private status: OrderStatus;
  private items: OrderItem[];

  // ✅ 自己保护自己的状态迁移
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error("已发货的订单不能取消");
    }
    this.status = OrderStatus.CANCELLED;
  }

  // ✅ 只根据自身数据计算的逻辑
  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }

  // ✅ 检查自己的不变量
  addItem(item: OrderItem): void {
    if (this.items.length >= 100) throw new Error("已超出上限");
    this.items.push(item);
  }
}

判断标准可以这样想:如果把它拟人化后问一句“Order,你可以取消吗?”,听起来自然,那大概率就是 Entity 的职责。

✅ 应该放进 Domain Service 的东西

单个 Entity 无法独立判断,或者必须与外部交互的逻辑,就属于 Domain Service。

// ✅ 跨两个聚合的“转账”逻辑
class TransferService {
  transfer(from: Account, to: Account, amount: Money): void {
    from.withdraw(amount);
    to.deposit(amount);
    // “转账” 这个概念不属于任何一方账户
  }
}

// ✅ 需要 repository 的“重复检查”
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}
  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

这里的判断标准是:如果你问“User,你知道这个邮箱是不是已经被别人注册了吗?”,它最自然的回答是“我怎么会知道”,那它就应该属于 Service。

❌ 反模式:什么都丢进 Service
// ❌ 不应该变成这样
class OrderService {
  cancel(order: Order): void {
    if (order.status === "SHIPPED") throw new Error(/* ... */);
    order.status = "CANCELLED";  // 通过 setter 改写
  }
  totalPrice(order: Order): Money { /* 遍历 items 计算总和 */ }
  addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
  status: string;
  items: OrderItem[];
  // 只有 getter / setter ← 贫血领域模型
}

这就是典型的 事务脚本(Transaction Script)。Entity 已经退化成纯数据结构。原则很简单:凡是能用 Order 作主语表达的行为,就应该写进 Order 本身。

总结一下

视角 放在 Entity 放在 Domain Service
主语 对象自身 动词 / 业务动作
状态 改变自己的状态 无状态(stateless)
依赖 不依赖 Repository 可以依赖 Repository / 外部系统
范围 单一聚合内部 跨多个聚合
例子 order.cancel() transferService.transfer(a, b, money)

Domain Event(领域事件)

领域事件是用来表示“领域中发生的重要事情”的对象。它和微服务、异步处理都很契合,因此近几年特别受关注。

class OrderPlaced {
  constructor(
    public readonly orderId: OrderId,
    public readonly userId: UserId,
    public readonly occurredAt: Date,
  ) {}
}

这样就能把副作用松耦合地拆开,例如“下单了 -> 发邮件”“下单了 -> 发积分”。

分层架构

DDD 通常会以类似下面的层次结构实现。

flowchart TB
  P["Presentation 层<br/>UI / API"]
  A["Application 层<br/>用例"]
  D["Domain 层<br/>模型 / 业务规则<br/>主角"]
  I["Infrastructure 层<br/>DB / 外部 API"]

  P --> A --> D
  I --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class P,A,I layer
  class D domain

依赖方向 始终向内。领域层不依赖任何其他层。为了贯彻这一点,常常会采用洋葱架构或六边形架构(Ports and Adapters)。

明确每一层的职责

实际开发里,最容易出问题的就是 “逻辑渗漏到各层”。把每一层的职责用一句话记住,会很有帮助。

职责 不该做的事
Presentation 接收请求、展示结果 编写业务规则
Application 协调用例流程 编写业务规则
Domain 承载业务规则本身 依赖框架、数据库
Infrastructure 实现持久化和外部通信 做业务判断

核心约束就是:业务规则只能写在 Domain 层。守住这一点,即使框架变化,业务逻辑也还能存活。

Presentation 层的作用

它只负责接收用户(或其他系统)的输入,把输入传给 Application 层,再把结果返回出去。原则就是:尽量薄

// ✅ 好的 Controller:只负责转交
@Controller("orders")
class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  @Post()
  async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
    // 1. 整理请求形状(转换成 DTO)
    const command = new PlaceOrderCommand(
      body.userId,
      body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
    );

    // 2. 委托给用例
    const result = await this.placeOrder.execute(command);

    // 3. 整理响应形状后返回
    return OrderResponse.from(result);
  }
}

Presentation 层应该做的是:

  • 解析请求并做形式上的校验,例如必填项、类型、长度
  • 做认证与授权入口处理
  • 转换为响应格式
  • 选择 HTTP 状态码

相反,它 不应该 做“没库存就返回 400”这种业务判断。那是 Domain 层的工作。Presentation 只负责把 Domain 的结果翻译成 HTTP 的语言。

Application 层的作用

这是 DDD 中最容易被误解的一层。Application Service 是 用例的协调者并不持有业务逻辑

// ✅ 好的 Application Service:只负责编排流程
class PlaceOrderUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly inventoryService: InventoryService,  // Domain Service
    private readonly transactionManager: TransactionManager,
    private readonly eventPublisher: EventPublisher,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderResult> {
    return this.transactionManager.run(async () => {
      // 1. 获取所需聚合
      const user = await this.userRepository.findById(command.userId);
      if (!user) throw new UserNotFoundError(command.userId);

      const products = await this.productRepository.findByIds(
        command.items.map(i => i.productId),
      );

      // 2. 把处理委托给领域(业务规则留在 Domain 内部)
      const order = Order.place(user, products, command.items);

      // 3. 调用领域服务(跨多个聚合的判断)
      await this.inventoryService.reserve(order);

      // 4. 持久化
      await this.orderRepository.save(order);

      // 5. 发布领域事件
      await this.eventPublisher.publishAll(order.pullEvents());

      return OrderResult.from(order);
    });
  }
}

Application Service 的职责可以归纳为这 5 点:

  1. 获取聚合(调用 Repository)
  2. 委托给领域对象(让 Entity / Domain Service 执行业务规则)
  3. 管理事务边界
  4. 指示持久化(通过 Repository 保存)
  5. 发布领域事件

相对地,下面这些代码 不应该 写在 Application Service 里:

// ❌ 反模式:业务规则泄漏到了 Application 层
class PlaceOrderUseCase {
  async execute(command: PlaceOrderCommand) {
    // ❌ 在这里判断库存 → 领域知识外泄
    if (product.stock < command.quantity) {
      throw new Error("库存不足");
    }
    // ❌ 在这里计算总金额 → 是 Order 的职责
    const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
    // ❌ 在这里判断状态 → 是 Order 的职责
    if (user.status === "BLOCKED") throw new Error("账户已停用");

    await this.orderRepository.save({ /* ... */ });
  }
}

判断标准可以是:“把这段代码给业务人员看,他们能从中看懂业务规则吗?”如果这里通过 if 语句在做业务判断,那就是应该迁回 Domain 层的信号。

一次请求如何流过各层

我们来追一下真实开发里,“用户点击下单按钮”时整个请求的流向。

sequenceDiagram
  participant Browser
  participant Presentation as Presentation / OrderController
  participant Application as Application / PlaceOrderUseCase
  participant Domain as Domain / Order.place(...)
  participant Infrastructure as Infrastructure / Repository 实现

  Browser->>Presentation: POST /orders
  Presentation->>Application: 传递 PlaceOrderCommand
  Note over Application: 开始事务
  Application->>Infrastructure: userRepository.findById()
  Application->>Infrastructure: productRepository.findByIds()
  Infrastructure-->>Application: User / Product 聚合
  Application->>Domain: Order.place(user, products, items)
  activate Domain
  Domain->>Domain: 验证业务规则
  Domain->>Domain: 检查不变量
  Domain->>Domain: 生成 OrderPlaced 事件
  Domain-->>Application: Order 聚合与 OrderPlaced
  deactivate Domain
  Application->>Domain: inventoryService.reserve(order)
  Domain-->>Application: 库存预留结果
  Application->>Infrastructure: orderRepository.save(order)
  Application->>Infrastructure: eventPublisher.publishAll(...)
  Infrastructure-->>Application: 持久化完成
  Note over Application: 提交事务
  Application-->>Presentation: OrderResult
  Presentation-->>Browser: 201 Created

从这个流程可以看出,真正发生业务判断的地方只有 Domain 层。其他层只是做准备和收尾,让 Domain 能把事情做好。

DTO 与领域对象的边界

跨层时,标准做法是 不要把领域对象原封不动暴露到外部

// Domain 层
class Order { /* 携带业务逻辑的聚合 */ }

// Application 层:输入 / 输出 DTO
class PlaceOrderCommand { /* 请求 DTO */ }
class OrderResult { /* 响应 DTO */ }

// Presentation 层:HTTP 类型
class PlaceOrderRequest { /* JSON schema */ }
class OrderResponse { /* 响应 JSON */ }

看起来似乎麻烦,但它带来的好处很大:

  • 即使 API 结构变化,Domain 也不受伤
  • 不会为了 JSON 序列化去污染领域模型
  • 切换到 GraphQL / gRPC 也只需要局部调整

尤其要注意,绝对不要在 Domain Object 上加 @Column 这种 ORM 装饰器,或者 @Expose 这种序列化注解。一旦加上,Domain 就开始依赖框架了。

实现时推荐的顺序

最后,给出一个用 DDD 实现新功能时的 推荐工作顺序

  1. 先用一句话写出用例:“用户下单购买商品”
  2. 提取出现的术语:用户 / 商品 / 订单 / 库存
  3. 决定聚合及其边界Order 聚合里要包含什么
  4. 从 Domain 层开始写:Entity、Value Object、Domain Service
  5. 编写 Domain 层测试(理论上不需要数据库)
  6. 编写 Application Service:Repository 先保持为 interface
  7. 实现 Infrastructure 层:真实的 Repository 实现、ORM 配置
  8. 实现 Presentation 层:Controller、请求类型、响应类型
  9. E2E 测试

关键在于 从内向外写。如果从数据库表设计开始,就已经违背了 DDD 的精神。领域模型优先,表结构在后。 这是不能让步的原则。

文件夹结构的最佳实践

在 DDD 的目录组织上,大家最先遇到的问题通常是这个:

应该按 来切,还是按 领域 / 功能 来切?

结论先说:最稳妥的做法是 先按领域切,再在内部按层切

❌ 先按层切(偏反模式)

src/
├── controllers/
│   ├── user.controller.ts
│   ├── order.controller.ts
│   └── product.controller.ts
├── services/
│   ├── user.service.ts
│   ├── order.service.ts
│   └── product.service.ts
├── repositories/
│   ├── user.repository.ts
│   ├── order.repository.ts
│   └── product.repository.ts
└── entities/
    ├── user.entity.ts
    ├── order.entity.ts
    └── product.entity.ts

刚开始看起来很直观,但一旦规模变大:

  • 改一个用例要在 4 个目录之间来回跳
  • 领域边界看不出来,所有东西都像摊平了一样
  • 以后想拆成微服务会很难拆

痛感会迅速增加。

✅ 先按领域切(推荐)

src/
├── contexts/                      ← 按 Bounded Context 组织
│   ├── ordering/                  ← 订单上下文
│   │   ├── domain/
│   │   │   ├── order.ts           (Entity / Aggregate Root)
│   │   │   ├── order-item.ts
│   │   │   ├── order-id.ts        (Value Object)
│   │   │   ├── order-status.ts
│   │   │   ├── order.repository.ts(仅 interface)
│   │   │   └── events/
│   │   │       └── order-placed.ts
│   │   ├── application/
│   │   │   ├── place-order.usecase.ts
│   │   │   └── cancel-order.usecase.ts
│   │   ├── infrastructure/
│   │   │   ├── postgres-order.repository.ts
│   │   │   └── stripe-payment.gateway.ts
│   │   └── presentation/
│   │       ├── order.controller.ts
│   │       └── dto/
│   │           ├── place-order.request.ts
│   │           └── order.response.ts
│   ├── catalog/                   ← 商品目录上下文
│   │   └── (相同结构)
│   └── shipping/                  ← 配送上下文
│       └── (相同结构)
├── shared-kernel/                 ← 所有上下文共享的 Value Object
│   ├── money.ts
│   ├── email.ts
│   └── user-id.ts
└── shared/                        ← 横切技术基础
    ├── logger.ts
    ├── transaction-manager.ts
    └── event-bus.ts

这种布局主要有 3 个优点:

  1. 上下文边界在物理上可见,很自然就会形成“设计只在 ordering/ 内部闭合”的思维
  2. 更容易拆成微服务,可以把 contexts/ordering/ 原样切到单独仓库里
  3. 代码评审范围更聚焦,一眼就能看出这次改动是否只发生在 ordering

领域层内部如何整理

如果 domain/ 目录开始膨胀,推荐做法是 按聚合再切一层目录

ordering/domain/
├── order/
│   ├── order.ts                   (聚合根)
│   ├── order-item.ts
│   ├── order-id.ts
│   ├── order-status.ts
│   ├── order.repository.ts
│   └── events/
│       ├── order-placed.ts
│       └── order-cancelled.ts
├── customer/
│   ├── customer.ts
│   └── customer.repository.ts
└── services/
    └── pricing.service.ts          (Domain Service)

当一个聚合像是在维护“自己的世界”一样组织起来后,依赖关系也会更容易追踪。

Monorepo 中的组织方式

如果一个仓库同时管理多个服务,结构通常会更像这样:

monorepo/
├── apps/
│   ├── web/                       (Next.js 前端)
│   ├── api/                       (后端 API)
│   └── admin/                     (管理后台)
├── packages/
│   ├── ordering-context/          ← 打包后的 Bounded Context
│   ├── catalog-context/
│   ├── shipping-context/
│   └── shared-kernel/
└── package.json

关键点在于,每个 Bounded Context 都是 一个独立的 npm packageapps/api 只是 import 它们来使用,这样就能 在包级别强制上下文之间的依赖关系

命名规范的一些提示

文件名和类名也最好保持一致性。根据经验,下面这些规则通常比较顺手:

对象 命名示例
Entity / Aggregate Root Order(名词、单数)
Value Object EmailMoneyOrderId
Repository(interface) OrderRepository(以 Repository 结尾)
Repository(实现) PostgresOrderRepository(技术名 + Repository)
Domain Service PricingServiceTransferService
Application Service PlaceOrderUseCaseCancelOrderUseCase
Domain Event OrderPlacedOrderCancelled(过去式)
Command(DTO) PlaceOrderCommand

尤其是 Domain Event 用过去式 这一点很重要。这样可以明确表达“这是已经发生的事”,而不是“接下来要做的事”。

有 UI 的系统里,分层该怎么想

到目前为止,讨论主要偏向后端。那么,当 UI(Web 前端) 也加入进来时,分层思路会有什么变化?

把后端的四层结构原样搬进前端,会怎样?

结论是:通常不建议强行照搬。 前端有自己的关注点,如果硬套和服务端完全相同的分层,会显得很别扭。

[前端特有的关注点]
- 路由
- 页面状态(加载、错误、选中等)
- 表单校验(面向 UX)
- 动画
- 响应式重渲染

如果把这些东西全都塞进 Domain 层,Domain 就会开始依赖 React、Vue 这类框架,反而本末倒置。

前端可用的分层模型

更实用的划分方式大致如下:

flowchart TB
  R["Pages / Routes 层<br/>Next.js 的 app/, pages/ 等"]
  C["Components 层<br/>UI 组件 / 视觉职责"]
  V["ViewModel / Hooks 层<br/>页面状态与用例调用"]
  A["Application 层<br/>用例 / API 客户端"]
  D["Domain 层<br/>前端侧最小领域模型"]

  R --> C --> V --> A --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class R,C,V,A layer
  class D domain

各层职责如下:

职责 例子
Pages / Routes URL 与页面映射 /orders/new 页面
Components 外观与交互 <OrderForm /><Button />
ViewModel / Hooks 管理画面状态,触发用例 useOrderForm()
Application 调用 API、整理数据 placeOrderUseCase()
Domain 前端所需的最小领域规则 Order.canCancel()

React + TypeScript 的实现示例

// Domain 层:保留一层精简版的后端领域规则
class Order {
  constructor(
    public readonly id: string,
    public readonly status: OrderStatus,
    public readonly items: OrderItem[],
  ) {}

  // UI 中“能不能点取消按钮?”的判断集中在这里
  canCancel(): boolean {
    return this.status === "PENDING" || this.status === "CONFIRMED";
  }

  totalPrice(): number {
    return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
  }
}

// Application 层:调用 API 的用例
class PlaceOrderUseCase {
  constructor(private readonly api: OrderApiClient) {}

  async execute(items: CartItem[]): Promise<Order> {
    const response = await this.api.placeOrder({ items });
    return Order.fromResponse(response);
  }
}

// ViewModel / Hooks 层:管理页面状态
function useOrderForm() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);

  const submit = async () => {
    setIsSubmitting(true);
    try {
      await placeOrder.execute(items);
    } catch (e) {
      setError(formatError(e));
    } finally {
      setIsSubmitting(false);
    }
  };

  return { items, setItems, isSubmitting, error, submit };
}

// Components 层:只关注表现
function OrderForm() {
  const { items, isSubmitting, error, submit } = useOrderForm();
  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {/* JSX */}
    </form>
  );
}

重点在于,Component 可以 只专注于界面。状态管理放在 Hook 里,业务规则(如 canCancel())放在 Domain 里,API 调用放在 Application 里。

前端的 Domain 层要做到什么程度?

这取决于 项目本身的性质

项目性质 前端 Domain 层
大多只是简单 CRUD 页面 几乎不需要,直接用 API 响应也可以
UI 侧需要复杂计算或判断 需要有,例如价格重算、可选项判断
离线支持 / 移动端 需要扎实一些(后文会讲)

另一个重要点是:没有必要把服务端的领域模型完整复制到前端。前端只需要保留 和用户交互直接相关的规则

BFF 也是一种选择

如果出现“前端需要的数据结构和后端对不上”或者“一个页面要组合多个 API”的情况,在中间加一层 BFF 往往很有效。

flowchart TB
  Browser["浏览器"]
  BFF["BFF<br/>Next.js API Routes / Hono 等<br/>为页面优化的端点"]
  Ordering["订单服务"]
  Catalog["目录服务"]
  Inventory["库存服务"]
  Response["面向页面整形后的响应"]

  Browser --> BFF
  BFF --> Ordering
  BFF --> Catalog
  BFF --> Inventory
  Ordering --> BFF
  Catalog --> BFF
  Inventory --> BFF
  BFF --> Response --> Browser

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
  classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
  class Browser,BFF layer
  class Ordering,Catalog,Inventory service
  class Response output

BFF 是 只为页面服务、自己不持有领域的薄层。它可以跨多个聚合一次取回数据,也可以把多个上下文的结果整理成一个更适合页面使用的 JSON。把它作为 CQRS 的 Query 侧来使用也很自然。

DDD 能用在手机应用上吗

“DDD 不是更偏服务端吗?”

很多人会这么想,但 DDD 同样完全可以应用在手机应用里。尤其在涉及离线支持和同步处理时,领域模型带来的收益往往更大

手机应用的特殊现实

现实情况 影响
离线支持 需要本地数据库 -> Repository 很有价值
推送通知 / 后台处理 和领域事件很契合
设备传感器 / 相机 作为 Infrastructure 对待
应用生命周期 状态持久化会更复杂
版本兼容 需要兼容旧设备

如果把这些逻辑全都直接写进 ActivityFragmentViewController 里,就很容易出现 操作系统一更新,整个应用就碎掉 的问题。

原生实现中的分层示例(Swift / Kotlin)

App/
├── Domain/
│   ├── Entities/
│   │   ├── Recipe.swift
│   │   └── Ingredient.swift
│   ├── ValueObjects/
│   │   └── CookingTime.swift
│   ├── Repositories/
│   │   └── RecipeRepository.swift   (protocol / interface)
├── Application/
│   └── UseCases/
│       └── SearchRecipeUseCase.swift
├── Infrastructure/
│   ├── Repositories/
│   │   ├── CoreDataRecipeRepository.swift  (本地数据库)
│   │   └── ApiRecipeRepository.swift       (远程 API)
│   ├── Storage/
│   │   └── KeyValueStore.swift
│   └── Sync/
│       └── RecipeSyncService.swift
└── Presentation/
    ├── ViewModels/
    │   └── RecipeListViewModel.swift
    └── Views/
        └── RecipeListView.swift            (SwiftUI)

和前文保持一致,UseCase 放在 Application 层,Domain 层则专注于模型及其规则。

Repository 在离线支持里发挥作用的例子

// Domain 层:抽象多个持久化目标
protocol RecipeRepository {
    func find(id: RecipeId) async throws -> Recipe?
    func save(_ recipe: Recipe) async throws
}

// Infrastructure 层:组合本地 + 远程
class HybridRecipeRepository: RecipeRepository {
    let local: CoreDataRecipeRepository
    let remote: ApiRecipeRepository

    func find(id: RecipeId) async throws -> Recipe? {
        // 即使离线也可以从本地读取
        if let cached = try await local.find(id: id) {
            return cached
        }
        let remote = try await remote.find(id: id)
        if let recipe = remote {
          try await local.save(recipe)  // 刷新缓存
        }
        return remote
    }
}

由于离线逻辑 被封装在 Repository 的实现内部,ViewModel 和领域模型只需要“去取数据”即可。这正是 DDD 的威力所在。

在 React Native / Flutter 中的应用

跨平台场景下,思路也是一样的。更大的优势在于,Web 和 Mobile 可以共享同一套 Domain 层

shared/
└── domain/                        ← Web 与 Mobile 共用
    ├── recipe.ts
    └── recipe.repository.ts
apps/
├── web/
│   └── infrastructure/
│       └── api-recipe.repository.ts
└── mobile/
    └── infrastructure/
        └── sqlite-recipe.repository.ts  (offline first)

之所以能做到这一点,正是因为 Domain 不依赖其他任何东西。这也可以说是 DDD 最能体现价值的地方之一。

但别忘了“轻量化”

把服务端完整版 DDD 原样搬到手机应用里,通常是过度的。 在手机应用中:

  • 聚合边界不需要像服务端那样严格,因为最终一致性仍由服务端保障
  • 领域事件通常只需要做到 本地事件通知 的程度
  • 大多数情况下没有必要一路上升到 CQRS

比较现实的平衡点,是 保留服务端领域模型的一个轻量客户端版本

CRUD、管理后台与 DDD 的共存

读到这里,很多人可能会想:“我们的应用大部分都是设置页、管理页,这也算 DDD 吗?” 这种违和感是对的。

DDD 是为 复杂领域 准备的。没有复杂性的地方硬上 DDD,只会让代码变长,却得不到对应收益。

先判断领域的“重量”

即使在同一个项目里,不同功能的复杂度也不同。

功能 复杂度来自哪里 实现方针
订单处理 库存分配、价格计算、复杂状态迁移 值得用 DDD 投入
库存管理 预留、释放、维持一致性 值得用 DDD 投入
商品主数据管理 基本就是 CRUD 轻量即可
用户设置 只是保存简单值 CRUD 就够了
管理后台日志查看 只是查询并展示 CRUD 就够了

这里真正重要的是,前面说过的“核心 / 支撑 / 通用”分类,会直接对应到实现方式的轻重。

领域分类 实现风格 例子
核心领域 完整 DDD 订单、库存、定价策略
支撑子域 轻量 DDD 或事务脚本 商品主数据、配送设置
通用子域 CRUD,或者 SaaS 认证、邮件发送、设置页

CRUD 的部分该怎么写

轻量的部分,直接用事务脚本来写完全没有问题。

// ✅ 更新设置页面,这样就够了
class UpdateSiteSettingsHandler {
  constructor(private readonly db: Database) {}

  async execute(input: UpdateSiteSettingsInput): Promise<void> {
    await this.db.siteSettings.update({
      where: { id: 1 },
      data: input,
    });
  }
}

如果这里硬塞 Entity、Repository、Domain Service,只会增加代码量,却不会带来任何收益

在同一个项目里混用两种风格

实际工作中,更现实的做法是 按上下文切换实现风格

src/contexts/
├── ordering/                ← 完整 DDD
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── presentation/
├── inventory/               ← 完整 DDD
│   └── (同上)
├── catalog/                 ← 轻量 DDD(保留 Entity,Service 尽量少)
│   └── (简化版)
└── admin/                   ← 基于 CRUD
  ├── handlers/            (只放精简 handler)
    └── controllers/

只要每个上下文内部保持一致,就足够了。没有必要要求整个项目都完全统一成一种风格。

管理后台的只读 API 里,CQRS 很好用

管理后台里很常见的一种需求是:“把很多聚合的数据拼起来做列表展示。”这通常意味着 跨聚合读取,而这和 DDD 对聚合边界的严格要求并不总是合拍。

这时很适合做 轻量的 CQRS 引入:写入(Command)仍然严格通过聚合来做,读取(Query)则允许直接使用原始 SQL。

// 写入通过聚合(严格)
class CancelOrderUseCase {
  async execute(orderId: OrderId): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    order.cancel();                    // 领域规则
    await this.orderRepository.save(order);
  }
}

// 只读查询可以直接用原生 SQL(针对页面优化)
class OrderListQuery {
  async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
    return this.db.raw(`
      SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
      FROM orders o
      JOIN users u ON ...
      JOIN order_items oi ON ...
      WHERE ...
    `);
  }
}

不要把聚合一个个取出来再在内存中拼接。读操作就该用读操作的工具。 这才是务实的做法。

混用 CRUD 与 DDD 时的原则

最后,把混用时的原则整理一下。

  1. 上下文边界一定要画清楚,即使不是 DDD 区域,也要有边界意识
  2. 核心领域不要省,这里偷懒,后面几乎一定会后悔
  3. CRUD 区域就老老实实写 CRUD,不要强行 DDD 化
  4. 读取允许走另一条路,善用 Read Model 或 CQRS Lite
  5. 复杂度上来再升级,一开始是 CRUD 的东西,后来变复杂了,再迁到 DDD

DDD 不是宗教,而是工具。投入要和领域复杂度相匹配。

常见陷阱

1. 贫血领域模型

也就是 Entity 只剩下一堆 getter / setter,逻辑都渗到了 Service 里。请记住这句话:没有行为的对象,不算真正的对象。

2. 什么都套 DDD

设置页、管理页、简单 CRUD。如果这些地方全都套上 DDD,开发速度一定会下降。只有 确实存在复杂领域的地方 才值得引入。前文关于 CRUD、管理后台与 DDD 共存的部分,也可以一起参考。

3. 只捡战术模式来用

“我做了 Entity 和 Repository,所以这是 DDD”并不成立。没有统一语言与限界上下文的讨论,单独使用这些战术模式,很难真正发挥效果。

如何开始 DDD

如果你想在实际工作中引入 DDD,建议不要一上来就全套铺开,而是分阶段推进。

  1. 先收集业务语言,和领域专家一起做术语表
  2. 建立边界意识,把“哪些属于同一语境”划清楚
  3. 从 Value Object 开始,把原始类型替换成有意义的类型
  4. 识别聚合,有意识地去思考事务边界
  5. 用事件实现松耦合,在需要时引入领域事件

结语

我个人认为,DDD 的本质在于 用代码正面回应领域本身的复杂性

如果把“记住各种模式”变成目的,代码往往只会变得更复杂。和团队一起培育统一语言,才是 DDD 的起点,也是某种意义上的终点。

不妨从明天的代码评审开始,先问一句:“这个类名,业务侧的人看了也能理解吗?”

参考资料

  • Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
  • Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
  • Vaughn Vernon "Implementing Domain-Driven Design"
  • Masanobu Naruse "领域驱动设计入门"
  • Toru Masuda "在现场派得上用场的系统设计原则"
  • Martin Fowler "Patterns of Enterprise Application Architecture"
  • Robert C. Martin "Clean Architecture"
  • Alberto Brandolini "Introducing EventStorming"