引言
“我大概知道 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 点:
- 获取聚合(调用 Repository)
- 委托给领域对象(让 Entity / Domain Service 执行业务规则)
- 管理事务边界
- 指示持久化(通过 Repository 保存)
- 发布领域事件
相对地,下面这些代码 不应该 写在 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 实现新功能时的 推荐工作顺序。
- 先用一句话写出用例:“用户下单购买商品”
- 提取出现的术语:用户 / 商品 / 订单 / 库存
- 决定聚合及其边界:
Order聚合里要包含什么 - 从 Domain 层开始写:Entity、Value Object、Domain Service
- 编写 Domain 层测试(理论上不需要数据库)
- 编写 Application Service:Repository 先保持为 interface
- 实现 Infrastructure 层:真实的 Repository 实现、ORM 配置
- 实现 Presentation 层:Controller、请求类型、响应类型
- 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 个优点:
- 上下文边界在物理上可见,很自然就会形成“设计只在
ordering/内部闭合”的思维 - 更容易拆成微服务,可以把
contexts/ordering/原样切到单独仓库里 - 代码评审范围更聚焦,一眼就能看出这次改动是否只发生在
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 package。apps/api 只是 import 它们来使用,这样就能 在包级别强制上下文之间的依赖关系。
命名规范的一些提示
文件名和类名也最好保持一致性。根据经验,下面这些规则通常比较顺手:
| 对象 | 命名示例 |
|---|---|
| Entity / Aggregate Root | Order(名词、单数) |
| Value Object | Email、Money、OrderId |
| Repository(interface) | OrderRepository(以 Repository 结尾) |
| Repository(实现) | PostgresOrderRepository(技术名 + Repository) |
| Domain Service | PricingService、TransferService |
| Application Service | PlaceOrderUseCase、CancelOrderUseCase |
| Domain Event | OrderPlaced、OrderCancelled(过去式) |
| 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 outputBFF 是 只为页面服务、自己不持有领域的薄层。它可以跨多个聚合一次取回数据,也可以把多个上下文的结果整理成一个更适合页面使用的 JSON。把它作为 CQRS 的 Query 侧来使用也很自然。
DDD 能用在手机应用上吗
“DDD 不是更偏服务端吗?”
很多人会这么想,但 DDD 同样完全可以应用在手机应用里。尤其在涉及离线支持和同步处理时,领域模型带来的收益往往更大。
手机应用的特殊现实
| 现实情况 | 影响 |
|---|---|
| 离线支持 | 需要本地数据库 -> Repository 很有价值 |
| 推送通知 / 后台处理 | 和领域事件很契合 |
| 设备传感器 / 相机 | 作为 Infrastructure 对待 |
| 应用生命周期 | 状态持久化会更复杂 |
| 版本兼容 | 需要兼容旧设备 |
如果把这些逻辑全都直接写进 Activity、Fragment、ViewController 里,就很容易出现 操作系统一更新,整个应用就碎掉 的问题。
原生实现中的分层示例(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 时的原则
最后,把混用时的原则整理一下。
- 上下文边界一定要画清楚,即使不是 DDD 区域,也要有边界意识
- 核心领域不要省,这里偷懒,后面几乎一定会后悔
- CRUD 区域就老老实实写 CRUD,不要强行 DDD 化
- 读取允许走另一条路,善用 Read Model 或 CQRS Lite
- 复杂度上来再升级,一开始是 CRUD 的东西,后来变复杂了,再迁到 DDD
DDD 不是宗教,而是工具。投入要和领域复杂度相匹配。
常见陷阱
1. 贫血领域模型
也就是 Entity 只剩下一堆 getter / setter,逻辑都渗到了 Service 里。请记住这句话:没有行为的对象,不算真正的对象。
2. 什么都套 DDD
设置页、管理页、简单 CRUD。如果这些地方全都套上 DDD,开发速度一定会下降。只有 确实存在复杂领域的地方 才值得引入。前文关于 CRUD、管理后台与 DDD 共存的部分,也可以一起参考。
3. 只捡战术模式来用
“我做了 Entity 和 Repository,所以这是 DDD”并不成立。没有统一语言与限界上下文的讨论,单独使用这些战术模式,很难真正发挥效果。
如何开始 DDD
如果你想在实际工作中引入 DDD,建议不要一上来就全套铺开,而是分阶段推进。
- 先收集业务语言,和领域专家一起做术语表
- 建立边界意识,把“哪些属于同一语境”划清楚
- 从 Value Object 开始,把原始类型替换成有意义的类型
- 识别聚合,有意识地去思考事务边界
- 用事件实现松耦合,在需要时引入领域事件
结语
我个人认为,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"
