关于 domain-driven-design
面向已采用 DDD 的项目,指导团队围绕业务规则、领域语言和模型组织代码。内容涵盖领域对象设计、代码职责划分及术语表维护,并强调按需加载深入资料。
主要功能
- 01判断 DDD 的适用场景
- 02指导领域与应用职责划分
- 03维护通用语言和术语表
- 04设计值对象与实体
- 05提供按需深入资料
适用场景
- 01为复杂业务建模
- 02确定业务逻辑应放置的位置
- 03为领域概念统一命名
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill domain-driven-design安装后在 Claude Code 中输入/domain-driven-design确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/aggregate-design.mdbounded-contexts.mddomain-events.mddomain-services.mderror-modeling.mdreferences.mdtesting-by-layer.md详细介绍
领域驱动设计(DDD)
此技能仅适用于已选择采用 DDD 的项目。请勿将这些模式应用于采用其他方法的项目。
对于六边形架构(端口与适配器),请加载 hexagonal-architecture 技能。DDD 和六边形架构相辅相成,但彼此独立——项目可以只采用其中一种。
设计或调整物理布局时,请使用 structure-codebase 技能。它会将有界上下文、功能/用例内聚性、包约束,以及任何已选择采用的六边形架构内外边界,分别作为独立的结构维度处理。如果没有要求进行物理重构,请保留仓库现有布局,同时确保领域逻辑彼此隔离。
深入学习资料位于 resources/ 目录中。请按需加载:
| 资源 | 加载时机…… |
|---|---|
aggregate-design.md | 设计或拆分聚合、评估大小问题、处理乐观锁时 |
domain-services.md | 不确定某段逻辑属于领域服务还是用例、需要了解命名约定时 |
domain-events.md | 处理跨聚合协调、Decider 模式、事件分发(outbox)、流程管理器时 |
bounded-contexts.md | 划分上下文边界、与外部系统集成(ACL)、进行上下文映射时 |
error-modeling.md | 决定使用结果类型还是异常、处理错误传播时 |
testing-by-layer.md | 为 DDD 代码编写测试、为不变量编写基于属性的测试时 |
references.md | 查阅有关 DDD、层级职责和测试的针对性资料来源及其依据时 |
权威资料请参阅 resources/references.md。
何时使用 DDD
DDD 能为复杂领域带来价值,因为这类领域包含丰富的业务规则。并非每个项目都需要它。
适用 DDD 的情况:
- 领域包含复杂的业务规则和不变量
- 有多位具备领域专业知识的利益相关者
- 业务逻辑是核心差异化优势
- 某些术语具有特定且重要的含义
不适用 DDD 的情况:
- 没有业务规则的简单 CRUD
- 以技术或基础设施为核心的项目
- 没有可咨询的领域专家
**从简单开始,逐步演进:**先建立通用语言(术语表)和值对象。只有在领域需要时,才添加聚合、领域事件和有界上下文。你的第一个模型会有错误——这没关系。目标是快速学习,并通过重构加深理解。
核心原则
**代码必须使用领域的语言。**仓库中声明的术语表是每个有界上下文的命名依据。为已声明的概念命名时,类型、函数、变量和测试都应使用其规范拼写和大小写。术语表中未包含的概念,不代表自动获准或禁止使用;这表示该概念尚未达成共识。计划、研究、注释、讨论和现有代码都可能揭示候选词汇,但不能在未经确认的情况下将其确立为规范术语。
**领域模型会不断演进。**第一个模型永远不会是最终模型。随着通过与领域专家交流和构建可运行软件加深理解,模型也应随之改变——类型会重命名,聚合会拆分或合并,新概念会出现。这不仅正常,而且是理想状态。一个从不改变的模型,要么已经完美(不太可能),要么停滞不前(团队停止了学习)。TDD 和行为测试能确保这种演进安全进行——重命名概念、更新术语表,测试会告诉你还需要修改哪些内容。
这段代码应该放在哪里?
这是 DDD 中最常见的决策。不确定时,请使用以下框架:
以下角色用于指导逻辑上的代码归属。使用 structure-codebase 来选择实际的文件夹和包。
| 问题 | 如果是 → | 如果否 ↓ |
|---|---|---|
| 它是否强制执行业务规则或计算业务值? | 领域策略(实体函数、值对象或领域服务) | ↓ |
| 它是否在不拥有规则的情况下编排领域操作? | 应用策略/用例 | ↓ |
| 它是否会格式化、转换或准备要展示的数据? | 表现层代码;在六边形架构中,通常位于驱动端 | ↓ |
| 它是否与外部系统(数据库、API、文件系统)交互? | 基础设施/集成代码;在六边形架构中,由受驱动适配器实现内部拥有的端口 | ↓ |
| 它是否是特定于框架的胶合代码(路由处理程序、中间件)? | 框架入口点;在六边形架构中,是驱动适配器 | — |
纯度测试是必要条件,但并非充分条件。 仅仅因为某个纯函数会格式化要展示的日期,并不意味着它属于领域策略。问题始终是:“这是一条业务规则吗?”
// ❌ Pure but NOT domain — formats for human display
export const formatEventDate = (date: string | null) =>
date ? format(parseISO(date), "MMMM d, yyyy") : undefined;
// → Belongs in presentation code; a hexagonal app may place it at the driving edge
// ✅ Pure AND domain — business rule that affects behavior
// The application supplies current time as data; domain code does not read a clock.
export const isPastEvent = (eventDate: Date | null, now: Date) =>
eventDate ? eventDate.getTime() < now.getTime() : false;
// → Belongs in domain policy for events
// ✅ Pure AND domain — business calculation
export const calculateCommittedTotal = (items: readonly GiftItem[]) =>
items.filter(i => i.status !== "idea").reduce((sum, i) => sum + i.pricePence, 0);
// → Belongs in domain policy for budgets
为什么归属位置很重要: 领域策略代码通常有严格的覆盖率要求,且不能导入任何基础设施。将代码放在错误的角色中,会带来不必要的测试义务和架构违规。
应用 structure-codebase 后,使用其包/导入规则,以机械方式强制执行领域边界及任何可见的六边形架构边界。
当架构使用端口时,端口的归属由需要该抽象的最内层使用方决定。因此,存储库端口和网关端口通常归应用层所有,因为用例会使用它们。只有当领域模型本身确实会使用该抽象时,端口才属于领域层。“内部拥有”不等于“领域拥有”。
领域策略应避免依赖框架、传输和持久化类型、时钟、UUID 生成器、供应商 SDK 以及 UI。应在边界处解析外部输入;将当前时间、标识符和其他外部事实作为类型化值传入领域函数。应用用例负责协调这些函数以及所需的任何端口。
通用语言与术语表
DDD 项目必须维护由代码库声明的术语表,其中定义约定好的领域术语。它是该限界上下文的命名依据。术语表会随着模型演进而更新——当团队发现更好的名称或拆分某个概念时,应先商定并记录变更,再更新代码。
术语表文件
对于包含多个限界上下文的项目,应按上下文组织术语表。同一个术语在不同上下文中可以有不同定义——这是正确的,不是错误。
## Gifting Context
| Term | Definition | Examples |
|------|-----------|----------|
| Occasion | A gift-giving event (birthday, holiday) | "Mum's Birthday", "Christmas 2026" |
| Gift Idea | A potential gift for an occasion | "Cookbook", "Scarf" |
| Contribution | Money pledged toward a gift | "£25 from Dad" |
## Notifications Context
| Term | Definition | Examples |
|------|-----------|----------|
| Occasion | An upcoming event that may trigger reminders | (same events, different concern) |
| Recipient | The person being gifted — target of reminder scheduling | "Mum" |
强制规则
- 为已声明的概念命名时,使用规范的拼写和大小写——即使请求、工单或用户使用了已弃用的别名也一样。请求中的措辞是待翻译的输入,而不是命名依据:如果请求在术语表声明应使用
Booking并拒绝Reservation的情况下使用了“reservation”,那么代码中应使用Booking,Reservation绝不能出现在类型、字段或函数名称中,并且回复应说明哪些词语经过了翻译。 - 将缺失的术语视为需要协商的候选项,而不是自动批准或禁止使用的术语。
- 被拒绝或弃用的别名必须指向其所属的限界上下文和规范替代名称。
- 在检查声明的范围内,对
type和interface名称、函数名称以及测试描述应用术语表检查。 - 将语言检查命令通过视为仅能证明其针对的渐进式检查有效,绝不能据此认定所有文本段落和标识符都正确使用了通用语言。
// ✅ Uses domain language
type GiftIdea = {
readonly id: GiftIdeaId;
readonly description: string;
readonly occasion: OccasionId;
readonly estimatedCost: Money;
readonly status: 'proposed' | 'selected' | 'purchased';
};
// ❌ Technical jargon
type Item = { readonly id: string; readonly text: string; readonly parentId: string; };
构建块
值对象
不可变、无标识,并按属性(而非引用)进行比较。表示由属性定义的领域概念。两个具有相同最小货币单位和币种的 Money 值相等——值对象没有标识。
type Currency = 'GBP' | 'USD' | 'EUR';
type Money = { readonly minorUnits: number; readonly currency: Currency };
const createMoney = (minorUnits: number, currency: Currency): Money => {
if (!Number.isSafeInteger(minorUnits)) throw new Error('Money minor units must be a safe integer');
if (minorUnits < 0) throw new Error('Money cannot be negative');
return { minorUnits, currency };
};
// Factory throws = invariant violation (a bug in calling code).
// Schemas catch invalid user input at trust boundaries BEFORE
// the factory is called. If the factory throws, something
// bypassed the schema.
以整数最小货币单位存储和计算金额,绝不使用二进制浮点数表示主要货币单位。接受十进制文本的边界必须使用币种的小数位数,并明确处理精度超出时的策略(例如拒绝或采用银行家舍入);绝不能使用 Math.round(rawNumber * 100) 进行转换。
对于跨越信任边界的值对象(API 输入、表单数据),使用 Zod 模式。对于领域内部的值对象,普通类型 + 工厂函数即可。模式优先的写法请参阅 typescript-strict skill。
Zod 到品牌类型的桥接 — 在信任边界将原始输入解析为品牌领域类型:
// Schema at trust boundary — parses raw strings into branded types
const PledgeInputSchema = z.object({
occasionId: z.string().min(1).transform(createOccasionId),
contributorId: z.string().min(1).transform(createContributorId),
amount: z.object({ minorUnits: z.number().int().safe().positive(), currency: CurrencySchema }),
});
// Reconstitution from persistence — same pattern, used at the integration boundary
// (a driven adapter in hexagonal architecture loads the gift ideas alongside the row)
const toOccasion = (row: OccasionRow, giftIdeas: ReadonlyArray<GiftIdea>): Occasion => ({
id: createOccasionId(row.id),
name: row.name,
giftIdeas,
budget: createMoney(row.budgetMinorUnits, parseCurrency(row.budgetCurrency)),
totalPledged: createMoney(row.pledgedMinorUnits, parseCurrency(row.budgetCurrency)),
isFundingClosed: row.isFundingClosed,
});
重建(从数据库行还原领域对象)时使用与创建相同的工厂函数。工厂会进行验证,因此无效的持久化数据会在读取时被捕获,而不会悄然破坏领域模型。
品牌类型
品牌类型模式本身由 typescript-strict skill 介绍——加载该 skill 以了解通用规则。DDD 特定的应用方式:
- 为每个实体提供独立的品牌 ID(
OccasionId、GiftIdeaId),让编译器拒绝混用不同实体的 ID——在需要OccasionId的地方传入GiftIdeaId会导致编译错误,而不是运行时错误。 - 每种品牌类型只通过一个验证工厂进行品牌化——原始字符串只有经过验证后才能成为品牌值。该工厂内部唯一的
as断言就是品牌类型唯一获得的断言:先验证,再品牌化。预算:每种品牌类型恰好一个as,其他地方为零。由品牌值派生的值——例如从另一个日期计算出的日期、从行中读回的 ID——都要再次调用工厂进行品牌化,绝不能再写一个as。 - 对实体 ID 和单值值对象(
EmailAddress)使用品牌类型。
type OccasionId = string & { readonly __brand: 'OccasionId' };
type GiftIdeaId = string & { readonly __brand: 'GiftIdeaId' };
const createOccasionId = (raw: string): OccasionId => {
if (!raw.trim()) throw new Error('OccasionId cannot be empty');
return raw as OccasionId; // justified: factory validates, then brands
};
实体
具有标识和生命周期。构造完成或状态转换后始终有效。
type Occasion = {
readonly id: OccasionId;
readonly name: string;
readonly giftIdeas: ReadonlyArray<GiftIdea>;
readonly budget: Money;
readonly totalPledged: Money;
readonly isFundingClosed: boolean;
};
// Immutable update — returns new valid state
const renameOccasion = (occasion: Occasion, newName: string): Occasion => ({
...occasion,
name: newName,
});
始终有效原则: 实体必须始终满足其不变量。在构造时(通过工厂函数或模式解析)以及每次状态转换时进行验证。绝不允许实体以无效状态存在,哪怕只是暂时如此。
让非法状态无法表示
通用类型安全模式(仅在无法避免的互操作边界使用受限的 any、判别联合、模式优先验证)属于 typescript-strict skill。DDD 特定的应用方式:使用判别联合建模实体的生命周期,每个变体只携带该状态下有效的数据——绝不使用布尔标志加可选字段,因为这会允许出现 { isVerified: true, verifiedAt: undefined } 这样的矛盾组合:
type Order =
| { readonly status: 'draft'; readonly items: ReadonlyArray<OrderItem> }
| { readonly status: 'placed'; readonly items: ReadonlyArray<OrderItem>; readonly placedAt: Date }
| { readonly status: 'shipped'; readonly items: ReadonlyArray<OrderItem>; readonly placedAt: Date; readonly shippedAt: Date; readonly trackingNumber: string };
始终穷举处理所有生命周期变体。 添加新变体时,never 类型可确保编译器捕获未处理的状态:
const describeOrder = (order: Order): string => {
switch (order.status) {
case 'draft': return `Draft with ${order.items.length} items`;
case 'placed': return `Placed at ${order.placedAt.toISOString()}`;
case 'shipped': return `Shipped: ${order.trackingNumber}`;
default: { const _exhaustive: never = order; return _exhaustive; }
}
};
聚合
由实体和值对象组成、具有单一根的集群。所有修改都必须通过根进行。
- 每个事务只有一个聚合根
- 通过 ID 引用其他聚合——绝不嵌入
- 所有不变量都由根强制执行
- 保持聚合精简——只包含保持一致性所需的内容
有关聚合设计的详细指导,请参阅 resources/aggregate-design.md。
规约(谓词函数)
用于筛选、资格判定或验证的复杂业务规则,应在领域层中表示为谓词函数。Evans 将这些称为“规约”。
// Specification: "can this eligible contributor pledge to this occasion?"
const canPledge = (occasion: Occasion, eligibility: ContributorEligibility, amount: Money): boolean => {
const values = [amount.minorUnits, occasion.totalPledged.minorUnits, occasion.budget.minorUnits];
if (!values.every(Number.isSafeInteger)) return false;
if (occasion.totalPledged.minorUnits > occasion.budget.minorUnits) return false;
return eligibility.mayPledge &&
!occasion.isFundingClosed &&
amount.minorUnits > 0 &&
amount.currency === occasion.totalPledged.currency &&
occasion.totalPledged.currency === occasion.budget.currency &&
amount.minorUnits <= occasion.budget.minorUnits - occasion.totalPledged.minorUnits;
};
// Compose specifications for complex eligibility
const isGiftReady = (occasion: Occasion): boolean =>
occasion.totalPledged.minorUnits >= occasion.budget.minorUnits &&
occasion.giftIdeas.some(idea => idea.status === 'selected');
规约是纯谓词函数——返回 boolean 且没有副作用。在领域服务、用例和查询筛选条件中使用它们。使用 is、can 或 has 前缀命名。
领域事件
领域事件表示领域中发生的有意义的事情(“OrderPlaced”“ContributionPledged”)。它们协调跨聚合和限界上下文的副作用。
在以下情况下,领域事件值得其带来的复杂性:
- 副作用跨越聚合边界
- 其他限界上下文需要响应变更
- 你需要审计跟踪或事件驱动的工作流
在以下情况下,不要添加领域事件:
- 所有逻辑都在单个聚合内
- 副作用都在同一事务中
- 显式返回值足以满足领域函数的需求
对于大多数项目,应先不使用领域事件,等领域需要协调时再添加。有关 Decider 模式和详细指南,请参阅 resources/domain-events.md。当事件成为事实来源——持久化到仅追加日志并通过重放来重建状态——这就是事件溯源;加载 event-sourcing 技能(它直接基于此 Decider 构建)。如果领域事件已经存在,可将可观测性作为另一个订阅者免费接入——在现有发布者端口上添加可观测性适配器,而不是另设一个公告渠道(参见 hexagonal-architecture 技能的四层可观测性模型和 observability 技能)。
领域服务
当业务逻辑不属于单个实体时,它就属于领域服务——领域层中的无状态函数,用于组合领域值或只读策略事实。领域服务不会让跨多个聚合的写入具备原子性:当某个不变量要求同步变更时,应重新划分聚合;或者在应用层协调单个聚合写入和持久化事件。
// ❌ WRONG — cramming an external eligibility policy into one entity
const addContribution = (occasion: Occasion, contribution: Contribution): Occasion => {
// This cannot know whether the contributor is currently eligible.
};
// ✅ CORRECT — pure domain service consumes a read-only policy fact
const pledgeContribution = (
occasion: Occasion,
eligibility: ContributorEligibility,
pledge: { readonly id: PledgeId; readonly amount: Money },
): PledgeDecision => {
if (!eligibility.mayPledge) {
return { success: false, reason: 'contributor-ineligible' };
}
if (occasion.isFundingClosed) {
return { success: false, reason: 'funding-closed' };
}
if (
pledge.amount.currency !== occasion.totalPledged.currency ||
occasion.totalPledged.currency !== occasion.budget.currency
) {
return { success: false, reason: 'currency-mismatch' };
}
const values = [
occasion.totalPledged.minorUnits,
occasion.budget.minorUnits,
pledge.amount.minorUnits,
];
if (!values.every(Number.isSafeInteger)) throw new Error('Invalid Money invariant');
if (pledge.amount.minorUnits <= 0) {
return { success: false, reason: 'non-positive-amount' };
}
if (occasion.totalPledged.minorUnits > occasion.budget.minorUnits) {
throw new Error('Invalid Occasion funding invariant');
}
if (pledge.amount.minorUnits > occasion.budget.minorUnits - occasion.totalPledged.minorUnits) {
return { success: false, reason: 'exceeds-budget' };
}
const totalPledged = occasion.totalPledged.minorUnits + pledge.amount.minorUnits;
if (!Number.isSafeInteger(totalPledged)) throw new Error('Money addition overflowed');
return {
success: true,
occasion: { ...occasion, totalPledged: createMoney(totalPledged, pledge.amount.currency) },
events: [{
type: 'PledgeRecorded',
id: pledge.id,
occasionId: occasion.id,
contributorId: eligibility.contributorId,
amount: pledge.amount,
}],
};
};
领域服务与用例(应用服务)的区别:
| 领域服务 | 用例 | |
|---|---|---|
| 包含业务逻辑? | 是 | 否——仅负责编排 |
| 逻辑角色 | 领域策略 | 应用策略——协调领域操作与协作 |
| 依赖于 | 默认仅依赖领域类型 | 领域服务,通常还依赖应用层拥有的存储库/集成端口 |
| 示例 | pledgeContribution(occasion, eligibility, pledge) | handlePledge(persistence, eligibilityGateway, dto)——加载、调用领域服务、保存一个聚合及其 outbox 事件 |
默认情况下,应保持领域业务决策纯粹。领域拥有的驱动端口是一种罕见且明确的例外,仅当模型本身(而非应用编排)拥有该交互时才使用。此时,使用该端口的领域服务会产生副作用,但仍不依赖具体提供方:将其纯决策逻辑隔离,并使用伪造对象测试端口交互。不要将此例外用于仅供应用编排使用的存储库或网关。
物理位置取决于所选架构。优先按限界上下文或业务能力组织,而非按技术角色组织。在显式的六边形后端中,这两种角色都位于内部;当依赖边界的成本合理时,通常放在独立的领域/应用包中,也可以放在同一能力包内内聚的功能/用例模块中。不采用六边形架构的 DDD 项目也可以采用其他以上下文为先的组织方式。请遵循 structure-codebase,不要想当然地认为 domain/ 包含所有内部代码。
详细指南请参阅 resources/domain-services.md。
错误建模
对预期的业务结果使用带判别标签的联合结果类型。异常仅用于程序员错误和基础设施故障。
type PledgeDecision =
| { readonly success: true; readonly occasion: Occasion; readonly events: readonly PledgeRecorded[] }
| { readonly success: false; readonly reason: 'contributor-ineligible' | 'non-positive-amount' | 'currency-mismatch' | 'exceeds-budget' | 'funding-closed' };
type PledgeResult =
| PledgeDecision
| { readonly success: false; readonly reason: 'not-found' | 'concurrent-change' }; // application outcomes
**判断标准:**用户的操作是否可能合理地导致这种结果?如果是 → 使用结果类型。如果不是(意味着存在程序错误)→ 使用异常。
有关错误建模模式以及错误如何在各层之间传播的详细信息,请参阅 resources/error-modeling.md。
Repository 模式
Repository 提供类似集合的方式来访问聚合。将接口放在使用它的最内层策略中,将具体实现放在基础设施或集成代码中。在明确采用六边形架构的系统中,Repository 端口通常由应用层负责,因为用例会加载和保存聚合;将它们放在应用层策略旁边,并由驱动适配器实现。只有当领域模型本身确实拥有并使用该抽象时,才将其放在领域层。没有端口与适配器的 DDD 并不会因此自动获得适配器层;请遵循 structure-codebase 选定的架构。Repository 使用 interface(而非 type),因为它们定义行为契约,其方法使用领域语言。
// Application-owned repository contract — an inside-owned port when hexagonal architecture is used
interface OccasionRepository {
readonly findById: (id: OccasionId) => Promise<Occasion | undefined>;
readonly save: (occasion: Occasion) => Promise<void>;
}
// Concrete implementation belongs with infrastructure/integration;
// in hexagonal architecture it is a driven adapter.
Repository 负责写入和单个聚合的读取。 对于需要跨聚合执行 JOIN 的读取(仪表板视图、组合多个实体数据的详情页),Repository 并不适用——它们强制执行读取需要跨越的聚合边界。请改用可自由执行 JOIN 的查询函数。这就是 CQRS-lite 模式:写入通过 Repository 进行(一致性),读取通过查询函数进行(灵活性)。详情请参阅 hexagonal-architecture 技能的 CQRS-lite 部分及其 resources/cqrs-lite.md。
对于读取可直接映射到单个聚合的简单领域,使用 Repository 读取即可。不要过早拆分。
DDD + TDD 集成
按领域概念测试,而非按实现文件测试
遵循为项目选定的物理结构,并将聚焦测试放在其描述的行为旁边;DDD 并不要求在根目录下创建 tests/ 目录。
使用词汇表中的业务行为来命名 describe 和 it 代码块,绝不要使用被测函数的名称:
// ❌ Titled after helpers — proves nothing a domain expert would recognise
describe('addDays', () => { it('rolls over a month boundary', ...) });
describe('createMoney', () => { it('rejects a negative amount', ...) });
// ✅ Titled after the rule
describe('Pledging to an Occasion', () => { it('is refused once funding is closed', ...) });
describe('Committed total for an Occasion', () => { it('excludes gift ideas that are only proposed', ...) });
日期或算术辅助函数不需要自己的 describe;应通过使用它的规则来验证。每个实现文件对应一个测试文件是会导致测试以辅助函数命名的反模式——应按所测试的概念对测试进行分组。
<selected-context-or-feature>/
occasions/
create-occasion.test.ts # Behavior: creating occasions
add-gift-idea.test.ts # Behavior: managing gift ideas
occasion-budget.test.ts # Behavior: budget constraints
选择主要行为边界
通过所选架构实际具备的最广泛且稳定的公共行为进行测试:
- 对于仅包含领域模型或库的项目,直接调用聚合操作、领域服务或 Decider;这些测试是主要测试,而非备用方案。
- 当存在应用层策略时,使用协作契约的简单伪对象或桩对象来调用用例。这样既能测试领域行为,也能测试编排,无需凭空创建端口。
- 在明确采用六边形架构的系统中,将驱动端口作为测试对象,并以外部测试交互器替换被驱动参与方;有关该测试策略,请加载
hexagonal-architecture。 - 根据实际基础设施和用户风险添加集成测试与交付测试,而不是因为 DDD 要求分层金字塔。
如果两者都存在,针对复杂纯业务规则的聚焦测试可补充应用层测试。对于不依赖提供方的领域包,它们也可以构成完整的主要测试套件。
有关与架构无关的决策,请参阅 resources/testing-by-layer.md。对于同时采用六边形架构的 DDD 系统,六边形架构技能的 resources/worked-example.md 展示了一个功能如何贯穿端口、适配器和测试。
测试工厂使用领域语言
const getTestOccasion = (overrides?: Partial<Occasion>): Occasion =>
OccasionSchema.parse({
id: createOccasionId('occasion-1'),
name: "Mum's Birthday",
giftIdeas: [],
budget: createMoney(10_000, 'GBP'),
totalPledged: createMoney(0, 'GBP'),
isFundingClosed: false,
...overrides,
});
限界上下文
限界上下文是一个语言边界,在此范围内适用特定的领域模型和术语表。同一个词(例如,“User”)在不同上下文中可以有不同的含义——这是合理的。
- 每个上下文都有自己的模型和术语表——计费中的
User与配送中的User不同 - 通过事件或明确的契约在上下文之间通信——绝不共享可变状态
- 防腐层(ACL)——与外部系统或模型不适合自身的其他上下文集成时,应在边界处进行转换,而不是让对方的类型渗入
- 共享内核应尽可能精简——只包含真正通用的值对象(Money、Email)。如果共享内核不断膨胀,说明边界不清晰
- 每个上下文都有自己的术语表章节
有关上下文映射模式、单体仓库结构和 ACL 示例,请参阅 resources/bounded-contexts.md。
反模式
贫血领域模型
实体只是没有行为的数据包。所有逻辑都在“服务”中。解决方法:将行为作为纯函数,放在它们所操作类型的旁边。
泛化的技术名称
使用 Item、Entity、Record、Data、Info,而不是领域术语。始终使用术语表中的词汇。
领域中的表示逻辑
显示格式化不属于领域策略。判断标准:“让这个内容对人来说看起来合适”=表示逻辑。“执行业务规则”=领域逻辑。纯度并不足够——纯格式化函数仍然属于表示逻辑。
领域逻辑泄漏
业务逻辑写在路由处理程序、数据库查询或适配器中。应将其保留在项目所选物理结构下的领域策略模块中。
由关系驱动的聚合
通过映射实体层次结构来设计聚合(“Order 包含 OrderLines,OrderLine 包含 Product”),而不是找出不变量。明显迹象:聚合方法只负责添加、移除或关联子项,却不执行任何业务规则。如果聚合唯一的作用是管理父子关联,这些关系很可能应该作为数据库约束或简单数据模型处理,而不是建立聚合层次结构。
**解决方法:**问“状态变化时,哪些条件必须始终成立?”如果答案仅涉及结构关系(一对多、一对一),就不需要聚合——只需要外键。只有在命令执行期间能够强制实施行为不变量时,聚合的复杂性才有意义。
过度设计
并非每个项目都需要聚合、领域事件或限界上下文。可以从以下内容开始:
- 通用语言(术语表)
- 值对象和实体
- 仅在领域需要时增加复杂性
抵制模型演进
把初始模型奉为不可更改的准则——随着理解加深,仍拒绝重命名类型、拆分聚合或重构限界上下文。模型应持续演进。如果重构发现“Occasion”实际上应该拆分为“GiftEvent”和“SavingsGoal”,就这样做。术语表随之调整,类型也随之变化,测试则为迁移提供指引。Evans 将这些称为“突破”——团队对领域有了新认识,模型因此得到根本性改进的时刻。
检查清单
- 术语表文件存在且为最新版本
- 声明的概念使用术语表中的规范拼写和大小写
- 缺失术语作为待协商的候选项处理,而非未经商议就采用
- 被拒绝的别名注明所属的有界上下文和规范替代词
- 报告任何通过的语言检查结果时,注明实际检查的目标范围
- 值对象不可变且没有身份标识
- 实体始终有效(在构造和状态转换时强制执行不变量)
- 实体具有品牌化 ID;基本类型值对象使用品牌化类型
- 聚合根强制执行所有不变量
- 聚合边界由不变量决定,而非实体之间的关系
- 聚合中不包含仅为查询便利而存在的只读属性
- 通过 ID 引用其他聚合,而非将其嵌入其中
- 跨聚合逻辑放在领域服务中,而非塞进单个实体
- 用例和编排放在应用策略中,而非领域策略中
- 仓储和网关端口与最内层的使用方放在一起——通常是应用策略;只有领域真正使用的端口才由领域拥有
- 具体的驱动适配器实现内部拥有的端口,并与基础设施/集成代码放在一起
- 区分联合使用穷尽式 switch 处理
- 预期的业务结果使用结果类型,而非异常
- 领域逻辑不依赖任何基础设施
- 如果已应用
structure-codebase,则其领域/上下文及任何可见的内部/外部规则均已存在并通过检查 - 表现逻辑不属于领域策略(即使它是纯逻辑)
- 按领域概念组织测试,而非按实现文件组织
- 每种逻辑角色都在适当的级别进行行为测试