domain-driven-design

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Domain-Driven Design (DDD)

领域驱动设计(DDD)

This skill applies only to projects that have opted in to DDD. Do not apply these patterns to projects that use a different approach.
For hexagonal architecture (ports and adapters), load the
hexagonal-architecture
skill. DDD and hexagonal architecture are complementary but independent — a project may use one without the other.
Deep-dive resources are in the
resources/
directory. Load them on demand:
ResourceLoad when...
aggregate-design.md
Designing or splitting aggregates, sizing questions, optimistic locking
domain-services.md
Unsure if logic is a domain service vs use case, naming conventions
domain-events.md
Cross-aggregate coordination, Decider pattern, event dispatch (outbox), process managers
bounded-contexts.md
Drawing context boundaries, integrating with external systems (ACL), context mapping
error-modeling.md
Deciding between result types and exceptions, error propagation
testing-by-layer.md
Writing tests for DDD code, property-based testing for invariants
For authoritative sources, see
../REFERENCES.md
.

本技能仅适用于选择采用DDD的项目。请勿将这些模式应用于采用其他方法的项目。
如需六边形架构(端口与适配器),请加载
hexagonal-architecture
技能。DDD与六边形架构互为补充但相互独立——项目可单独采用其中一种。
深度资源位于
resources/
目录中,可按需加载:
资源加载时机...
aggregate-design.md
设计或拆分聚合、规模问题、乐观锁
domain-services.md
不确定逻辑属于领域服务还是用例、命名规范
domain-events.md
跨聚合协调、决策器模式、事件分发(发件箱)、流程管理器
bounded-contexts.md
划分上下文边界、与外部系统集成(ACL)、上下文映射
error-modeling.md
选择返回结果类型还是异常、错误传播
testing-by-layer.md
为DDD代码编写测试、不变量的属性测试
权威来源请查看
../REFERENCES.md

When to Use DDD

何时使用DDD

DDD adds value for complex domains with rich business rules. Not every project needs it.
Use DDD when:
  • Domain has complex business rules and invariants
  • Multiple stakeholders with domain expertise
  • Business logic is the core differentiator
  • Terms have specific, important meanings
Don't use DDD when:
  • Simple CRUD with no business rules
  • Technical/infrastructure-focused projects
  • No domain expert to consult
Start simple and evolve: Begin with ubiquitous language (glossary) and value objects. Add aggregates, domain events, and bounded contexts only when the domain demands it. Your first model will be wrong — that's fine. The goal is to learn quickly and refactor toward deeper insight.

DDD能为拥有复杂业务规则的复杂领域带来价值。并非所有项目都需要它。
使用DDD的场景:
  • 领域包含复杂业务规则与不变量
  • 存在多位具备领域专业知识的利益相关者
  • 业务逻辑是核心差异化竞争力
  • 术语具有特定且重要的含义
不使用DDD的场景:
  • 无业务规则的简单CRUD
  • 聚焦技术/基础设施的项目
  • 无领域专家可咨询
从简入手,逐步演进: 从通用语言(术语表)和值对象开始。仅当领域需求时,再添加聚合、领域事件和限界上下文。你的第一个模型必然存在不足——这很正常。目标是快速学习并通过重构深化理解。

Core Principle

核心原则

The code must speak the language of the domain. Every type, function, variable, and test name must use terms from the project's ubiquitous language (glossary). If a concept doesn't have a domain term, that's a modeling gap to discuss with stakeholders — not something to paper over with technical jargon.
Domain models evolve. The first model is never the final model. As understanding deepens through conversations with domain experts and building working software, the model should change — types get renamed, aggregates get split or merged, new concepts emerge. This is expected and ideal. A model that never changes is either perfect (unlikely) or stagnant (the team stopped learning). TDD and behavioral tests make this evolution safe — rename a concept, update the glossary, and the tests tell you what needs to change.

代码必须使用领域语言。 所有类型、函数、变量和测试名称都必须使用项目通用语言(术语表)中的术语。如果某个概念没有对应的领域术语,这意味着存在建模缺口,需与利益相关者讨论——而非用技术术语掩盖。
领域模型会演进。 第一个模型永远不会是最终模型。随着与领域专家的交流及软件构建带来的理解加深,模型应随之变化——类型重命名、聚合拆分或合并、新概念出现。这是预期且理想的情况。从未变更的模型要么是完美的(可能性极低),要么是停滞的(团队停止学习)。TDD和行为测试让这种演进变得安全——重命名概念、更新术语表,测试会告诉你需要修改哪些内容。

Where Does This Code Belong?

代码归属何处?

This is the most common decision in DDD. When unsure, use this framework:
QuestionIf yes →If no ↓
Does it enforce a business rule or compute a business value?
domain/
(entity function, value object, or domain service)
Does it orchestrate multiple domain operations without owning logic?Use case / application service
Does it format, transform, or prepare data for display?
lib/
or inline in the view
Does it talk to an external system (DB, API, file system)?Adapter (implements a port defined in domain)
Is it framework-specific glue (route handler, middleware)?Delivery layer (
app/
)
The purity test is necessary but not sufficient. A pure function that formats a date for display does not belong in
domain/
just because it's pure. The question is always: "Is this a business rule?"
typescript
// ❌ Pure but NOT domain — formats for human display
export const formatEventDate = (date: string | null) =>
  date ? format(parseISO(date), "MMMM d, yyyy") : undefined;
// → Belongs in lib/format.ts

// ✅ Pure AND domain — business rule that affects behavior
export const isPastEvent = (eventDate: string | null, now: Date) =>
  eventDate ? parseISO(eventDate) < now : false;
// → Belongs in domain/event/

// ✅ 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/budget/
Why placement matters:
domain/
files typically have strict coverage requirements and zero infrastructure imports. Putting code in the wrong layer creates unnecessary testing obligations and architectural violations.

这是DDD中最常见的决策。若不确定,请遵循以下框架:
问题是 →否 ↓
它是否执行业务规则或计算业务价值?
domain/
(实体函数、值对象或领域服务)
它是否编排多个领域操作但不包含业务逻辑?用例/应用服务
它是否格式化、转换或准备用于展示的数据?
lib/
或内联在视图中
它是否与外部系统(数据库、API、文件系统)交互?适配器(实现领域中定义的端口)
它是否是框架特定的粘合代码(路由处理器、中间件)?交付层(
app/
纯函数测试是必要但不充分的。 一个用于格式化日期供展示的纯函数,不能仅因为它是纯函数就放在
domain/
中。核心问题始终是:“这是业务规则吗?”
typescript
// ❌ 纯函数但不属于领域层——用于人类可读的格式转换
export const formatEventDate = (date: string | null) =>
  date ? format(parseISO(date), "MMMM d, yyyy") : undefined;
// → 应放在 lib/format.ts

// ✅ 纯函数且属于领域层——影响行为的业务规则
export const isPastEvent = (eventDate: string | null, now: Date) =>
  eventDate ? parseISO(eventDate) < now : false;
// → 应放在 domain/event/

// ✅ 纯函数且属于领域层——业务计算
export const calculateCommittedTotal = (items: readonly GiftItem[]) =>
  items.filter(i => i.status !== "idea").reduce((sum, i) => sum + i.pricePence, 0);
// → 应放在 domain/budget/
代码位置的重要性:
domain/
文件通常有严格的覆盖率要求,且不能导入基础设施相关代码。将代码放在错误的层会产生不必要的测试负担并违反架构规范。

Ubiquitous Language & Glossary

通用语言与术语表

DDD projects must maintain a glossary file that defines all domain terms. This is the single source of truth for naming. The glossary evolves as the model evolves — when the team discovers a better name or splits a concept, update the glossary first and let the code follow.
DDD项目必须维护一个定义所有领域术语的术语表文件。这是命名的单一来源。术语表会随模型演进——当团队发现更合适的名称或拆分某个概念时,先更新术语表,再调整代码。

The Glossary File

术语表文件

For projects with multiple bounded contexts, organize by context. The same term may have different definitions in different contexts — this is correct, not a bug.
markdown
undefined
对于包含多个限界上下文的项目,按上下文组织。同一术语在不同语境下可能有不同定义——这是正确的,并非问题。
markdown
undefined

Gifting Context

赠礼上下文

TermDefinitionExamples
OccasionA gift-giving event (birthday, holiday)"Mum's Birthday", "Christmas 2026"
Gift IdeaA potential gift for an occasion"Cookbook", "Scarf"
ContributionMoney pledged toward a gift"£25 from Dad"
术语定义示例
Occasion赠礼活动(生日、节日)"妈妈的生日", "2026年圣诞节"
Gift Idea某个活动的潜在礼物"食谱", "围巾"
Contribution为礼物承诺的资金"爸爸出资25英镑"

Notifications Context

通知上下文

TermDefinitionExamples
OccasionAn upcoming event that may trigger reminders(same events, different concern)
RecipientThe person being gifted — target of reminder scheduling"Mum"
undefined
术语定义示例
Occasion可能触发提醒的即将到来的活动(与赠礼上下文的活动相同,但关注点不同)
Recipient收礼人——提醒调度的目标"妈妈"
undefined

Enforcement Rules

执行规则

  • All
    type
    and
    interface
    names must use glossary terms
  • All function names must use glossary verbs and nouns
  • All test descriptions must use domain language
  • If you need a new term, add it to the glossary first
typescript
// ✅ Uses domain language
type GiftIdea = {
  readonly id: GiftIdeaId;
  readonly description: string;
  readonly occasion: OccasionId;
  readonly estimatedCost: Money;
};

// ❌ Technical jargon
type Item = { readonly id: string; readonly text: string; readonly parentId: string; };

  • 所有
    type
    interface
    名称必须使用术语表中的术语
  • 所有函数名称必须使用术语表中的动词和名词
  • 所有测试描述必须使用领域语言
  • 若需要新术语,先添加到术语表中
typescript
// ✅ 使用领域语言
type GiftIdea = {
  readonly id: GiftIdeaId;
  readonly description: string;
  readonly occasion: OccasionId;
  readonly estimatedCost: Money;
};

// ❌ 使用技术术语
type Item = { readonly id: string; readonly text: string; readonly parentId: string; };

Building Blocks

构建块

Value Objects

值对象

Immutable, identity-less, compared by their attributes (not by reference). Represent domain concepts defined by their attributes. Two
Money
values with the same amount and currency are equal — value objects have no identity.
typescript
type Currency = 'GBP' | 'USD' | 'EUR';
type Money = { readonly amount: number; readonly currency: Currency };

const createMoney = (amount: number, currency: Currency): Money => {
  if (amount < 0) throw new Error('Money cannot be negative');
  return { amount, 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.
For value objects crossing trust boundaries (API input, form data), use Zod schemas. For domain-internal value objects, plain types + factory functions suffice. See the
typescript-strict
skill for schema-first patterns.
Zod-to-branded-type bridging — parse raw input into branded domain types at trust boundaries:
typescript
// 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({ amount: z.number().positive(), currency: CurrencySchema }),
});

// Reconstitution from persistence — same pattern, used in driven adapters
const toOccasion = (row: OccasionRow): Occasion => ({
  id: createOccasionId(row.id),
  name: row.name,
  budget: createMoney(row.budgetAmount, parseCurrency(row.budgetCurrency)),
  totalPledged: createMoney(row.pledgedAmount, parseCurrency(row.budgetCurrency)),
  isFundingClosed: row.isFundingClosed,
});
Reconstitution (rebuilding domain objects from DB rows) uses the same factory functions as creation. The factory validates, so invalid persisted data is caught on read rather than silently corrupting the domain.
不可变、无标识、通过属性而非引用比较。代表由属性定义的领域概念。两个具有相同金额和货币的
Money
值是相等的——值对象没有标识。
typescript
type Currency = 'GBP' | 'USD' | 'EUR';
type Money = { readonly amount: number; readonly currency: Currency };

const createMoney = (amount: number, currency: Currency): Money => {
  if (amount < 0) throw new Error('Money cannot be negative');
  return { amount, currency };
};
// 工厂函数抛出异常 = 违反不变量(调用代码存在bug)。
// 模式会在信任边界处捕获无效用户输入,然后再调用工厂函数。
// 如果工厂函数抛出异常,说明有内容绕过了模式校验。
对于跨越信任边界(API输入、表单数据)的值对象,使用Zod模式。对于领域内部的值对象,纯类型+工厂函数即可满足需求。有关优先使用模式的规范,请查看
typescript-strict
技能。
Zod与品牌类型的桥接——在信任边界处将原始输入解析为品牌化领域类型:
typescript
// 信任边界处的模式——将原始字符串转换为品牌化类型
const PledgeInputSchema = z.object({
  occasionId: z.string().min(1).transform(createOccasionId),
  contributorId: z.string().min(1).transform(createContributorId),
  amount: z.object({ amount: z.number().positive(), currency: CurrencySchema }),
});

// 从持久化数据重构——相同模式,用于驱动适配器
const toOccasion = (row: OccasionRow): Occasion => ({
  id: createOccasionId(row.id),
  name: row.name,
  budget: createMoney(row.budgetAmount, parseCurrency(row.budgetCurrency)),
  totalPledged: createMoney(row.pledgedAmount, parseCurrency(row.budgetCurrency)),
  isFundingClosed: row.isFundingClosed,
});
重构(从数据库行重建领域对象)使用与创建时相同的工厂函数。工厂函数会进行校验,因此读取时会捕获无效的持久化数据,而非让其静默破坏领域模型。

Branded Types

品牌类型

Prevent accidental swapping of primitives at compile time. Use for entity IDs and single-value value objects. Always provide a factory function — raw strings become branded types only through validation:
typescript
type OccasionId = string & { readonly __brand: 'OccasionId' };
type GiftIdeaId = string & { readonly __brand: 'GiftIdeaId' };
type EmailAddress = string & { readonly __brand: 'EmailAddress' };

// Factory functions — the only way to create branded values
const createOccasionId = (raw: string): OccasionId => {
  if (!raw.trim()) throw new Error('OccasionId cannot be empty');
  return raw as OccasionId; // justified: factory validates, then brands
};

const createEmailAddress = (raw: string): EmailAddress => {
  if (!raw.includes('@')) throw new Error('Invalid email');
  return raw as EmailAddress; // justified: factory validates, then brands
};
The
as
assertion is the one justified exception in branded type factories — the factory validates first, then brands. This is the standard TypeScript pattern for nominal typing. Everywhere else, the compiler prevents mixing up
OccasionId
and
GiftIdeaId
.
在编译时防止原始类型被意外混淆。用于实体ID和单值值对象。始终提供工厂函数——原始字符串仅能通过校验转换为品牌化类型:
typescript
type OccasionId = string & { readonly __brand: 'OccasionId' };
type GiftIdeaId = string & { readonly __brand: 'GiftIdeaId' };
type EmailAddress = string & { readonly __brand: 'EmailAddress' };

// 工厂函数——创建品牌化值的唯一方式
const createOccasionId = (raw: string): OccasionId => {
  if (!raw.trim()) throw new Error('OccasionId cannot be empty');
  return raw as OccasionId; // 合理:工厂已校验,再进行品牌化
};

const createEmailAddress = (raw: string): EmailAddress => {
  if (!raw.includes('@')) throw new Error('Invalid email');
  return raw as EmailAddress; // 合理:工厂已校验,再进行品牌化
};
as
断言是品牌类型工厂中唯一合理的例外——工厂先校验,再进行品牌化。这是TypeScript实现标称类型的标准模式。在其他任何地方,编译器都会阻止混淆
OccasionId
GiftIdeaId

Entities

实体

Have identity and a lifecycle. Always valid after construction or state transition.
typescript
type Occasion = {
  readonly id: OccasionId;
  readonly name: string;
  readonly date: Date;
  readonly giftIdeas: ReadonlyArray<GiftIdea>;
  readonly budget: Money;
};

// Immutable update — returns new valid state
const renameOccasion = (occasion: Occasion, newName: string): Occasion => ({
  ...occasion,
  name: newName,
});
Always-valid principle: An entity must satisfy its invariants at all times. Validate on construction (factory functions or schema parsing) and on every state transition. Never allow an entity to exist in an invalid state, even temporarily.
具有标识和生命周期。构造或状态转换后始终有效。
typescript
type Occasion = {
  readonly id: OccasionId;
  readonly name: string;
  readonly date: Date;
  readonly giftIdeas: ReadonlyArray<GiftIdea>;
  readonly budget: Money;
};

// 不可变更新——返回新的有效状态
const renameOccasion = (occasion: Occasion, newName: string): Occasion => ({
  ...occasion,
  name: newName,
});
始终有效原则: 实体必须始终满足其不变量。在构造(工厂函数或模式解析)和每次状态转换时进行校验。绝不允许实体处于无效状态,哪怕是临时的。

Make Illegal States Unrepresentable

让非法状态无法表示

Use the type system to make invalid states impossible. Replace boolean flags and optional fields with discriminated unions where each variant carries only the data valid for that state:
typescript
// WRONG — boolean + optional allows { isVerified: true, verifiedAt: undefined }
type User = { readonly isVerified: boolean; readonly verifiedAt?: Date };

// RIGHT — impossible to be verified without a date
type User =
  | { readonly status: 'unverified' }
  | { readonly status: 'verified'; readonly verifiedAt: Date };
Apply the same principle to entity lifecycles:
typescript
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 };
Always handle all variants exhaustively. The
never
type ensures the compiler catches unhandled states when you add a new variant:
typescript
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; }
  }
};
使用类型系统使无效状态成为不可能。用区分联合类型替换布尔标志和可选字段,每个变体仅携带该状态下有效的数据:
typescript
// 错误——布尔值+可选字段允许{ isVerified: true, verifiedAt: undefined }
type User = { readonly isVerified: boolean; readonly verifiedAt?: Date };

// 正确——已验证状态必须包含验证日期
type User =
  | { readonly status: 'unverified' }
  | { readonly status: 'verified'; readonly verifiedAt: Date };
将同一原则应用于实体生命周期:
typescript
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
类型确保编译器能捕获未处理的状态:
typescript
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; }
  }
};

Aggregates

聚合

Clusters of entities and value objects with a single root. All modifications go through the root.
  1. One aggregate root per transaction
  2. Reference other aggregates by ID — never embed
  3. All invariants enforced by the root
  4. Keep aggregates small — only what's needed for consistency
For detailed aggregate design guidance, see
resources/aggregate-design.md
.
由实体和值对象组成的集群,拥有单一根。所有修改都必须通过根进行。
  1. 每个事务仅操作一个聚合根
  2. 通过ID引用其他聚合——绝不嵌入
  3. 所有不变量由根强制执行
  4. 保持聚合规模较小——仅保留一致性所需的内容
有关聚合设计的详细指南,请查看
resources/aggregate-design.md

Specifications (Predicate Functions)

规约(谓词函数)

Complex business rules for filtering, eligibility, or validation are expressed as predicate functions in the domain layer. Evans calls these "specifications."
typescript
// Specification: "can this contributor pledge to this occasion?"
const canPledge = (occasion: Occasion, contributor: Contributor, amount: Money): boolean =>
  !occasion.isFundingClosed &&
  amount.amount <= contributor.walletBalance.amount &&
  amount.currency === occasion.budget.currency;

// Compose specifications for complex eligibility
const isGiftReady = (occasion: Occasion): boolean =>
  occasion.totalPledged.amount >= occasion.budget.amount &&
  occasion.giftIdeas.some(idea => idea.status === 'selected');
Specifications are pure predicate functions — they return
boolean
and have no side effects. Use them in domain services, use cases, and query filters. Name them with
is
,
can
, or
has
prefixes.
用于过滤、资格验证或校验的复杂业务规则,在领域层中表示为谓词函数。Evans将其称为“规约”。
typescript
// 规约:“该贡献者是否能为该活动出资?”
const canPledge = (occasion: Occasion, contributor: Contributor, amount: Money): boolean =>
  !occasion.isFundingClosed &&
  amount.amount <= contributor.walletBalance.amount &&
  amount.currency === occasion.budget.currency;

// 组合规约实现复杂资格验证
const isGiftReady = (occasion: Occasion): boolean =>
  occasion.totalPledged.amount >= occasion.budget.amount &&
  occasion.giftIdeas.some(idea => idea.status === 'selected');
规约是纯谓词函数——返回
boolean
且无副作用。可在领域服务、用例和查询过滤器中使用。命名时使用
is
can
has
前缀。

Domain Events

领域事件

Domain events represent something meaningful that happened in the domain ("OrderPlaced", "ContributionPledged"). They coordinate side effects across aggregates and bounded contexts.
Domain events earn their complexity when:
  • Side effects cross aggregate boundaries
  • Other bounded contexts need to react to changes
  • You need an audit trail or event-driven workflows
Don't add domain events when:
  • All logic is within a single aggregate
  • Side effects are within the same transaction
  • Explicit return values from domain functions suffice
For most projects, start without domain events and add them when the domain demands coordination. See
resources/domain-events.md
for the Decider pattern and detailed guidance.

领域事件表示领域中发生的有意义的事情(如“OrderPlaced”、“ContributionPledged”)。它们用于协调跨聚合和限界上下文的副作用。
领域事件的适用场景:
  • 副作用跨越聚合边界
  • 其他限界上下文需要对变更做出反应
  • 需要审计追踪或事件驱动工作流
不添加领域事件的场景:
  • 所有逻辑都在单个聚合内
  • 副作用在同一事务内
  • 领域函数的显式返回值已足够
对于大多数项目,先不使用领域事件,仅当领域需要协调时再添加。有关决策器模式和详细指南,请查看
resources/domain-events.md

Domain Services

领域服务

When business logic doesn't belong to a single entity, it belongs in a domain service — a stateless function in the domain layer that operates across multiple entities or aggregates.
typescript
// ❌ WRONG — cramming cross-entity logic into one entity
const addContribution = (occasion: Occasion, contribution: Contribution): Occasion => {
  // This needs to check the contributor's wallet balance — wrong aggregate!
};

// ✅ CORRECT — domain service operates across aggregates
const pledgeContribution = (
  occasion: Occasion,
  contributor: Contributor,
  amount: Money,
): PledgeResult => {
  if (amount.amount > contributor.walletBalance.amount) {
    return { success: false, reason: 'insufficient-balance' };
  }
  return {
    success: true,
    occasion: addContribution(occasion, { contributorId: contributor.id, amount }),
    contributor: deductBalance(contributor, amount),
  };
};
Domain service vs use case (application service):
Domain ServiceUse Case
Contains business logic?YesNo — orchestration only
Lives in
domain/
domain/
— identifiable by taking ports as params
Depends onDomain types onlyRepositories, ports, domain services
Example
pledgeContribution(occasion, contributor, amount)
handlePledge(repo, dto)
— loads, calls domain service, saves
For detailed guidance, see
resources/domain-services.md
.

当业务逻辑不属于单个实体时,应放在领域服务中——领域层中的无状态函数,可跨多个实体或聚合操作。
typescript
// ❌ 错误——将跨实体逻辑硬塞进单个实体
const addContribution = (occasion: Occasion, contribution: Contribution): Occasion => {
  // 这需要检查贡献者的钱包余额——属于错误的聚合!
};

// ✅ 正确——领域服务跨聚合操作
const pledgeContribution = (
  occasion: Occasion,
  contributor: Contributor,
  amount: Money,
): PledgeResult => {
  if (amount.amount > contributor.walletBalance.amount) {
    return { success: false, reason: 'insufficient-balance' };
  }
  return {
    success: true,
    occasion: addContribution(occasion, { contributorId: contributor.id, amount }),
    contributor: deductBalance(contributor, amount),
  };
};
领域服务 vs 用例(应用服务):
领域服务用例
是否包含业务逻辑?否——仅负责编排
所在位置
domain/
domain/
——通过接收端口作为参数识别
依赖仅依赖领域类型依赖仓库、端口、领域服务
示例
pledgeContribution(occasion, contributor, amount)
handlePledge(repo, dto)
——加载数据、调用领域服务、保存结果
有关详细指南,请查看
resources/domain-services.md

Error Modeling

错误建模

Use discriminated union result types for expected business outcomes. Reserve exceptions for programmer mistakes and infrastructure failures.
typescript
type PledgeResult =
  | { readonly success: true; readonly occasion: Occasion; readonly contributor: Contributor }
  | { readonly success: false; readonly reason: 'insufficient-balance' | 'funding-closed' | 'not-found' };
The test: Could a user's action legitimately cause this outcome? If yes → result type. If no (it would mean a bug) → exception.
For detailed error modeling patterns and how errors propagate through layers, see
resources/error-modeling.md
.

使用区分联合结果类型处理预期的业务结果。将异常留给程序员错误和基础设施故障。
typescript
type PledgeResult =
  | { readonly success: true; readonly occasion: Occasion; readonly contributor: Contributor }
  | { readonly success: false; readonly reason: 'insufficient-balance' | 'funding-closed' | 'not-found' };
测试标准: 用户的操作是否可能合法导致该结果?如果是→使用结果类型。如果否(意味着存在bug)→使用异常。
有关错误建模模式及错误在各层的传播方式,请查看
resources/error-modeling.md

Repository Pattern

仓库模式

Repositories provide collection-like access to aggregates. Interfaces in the domain layer, implementations in the adapter layer. Repositories use
interface
(not
type
) because they define behavior contracts — this aligns with the TypeScript strict rule "reserve
interface
for behavior contracts." Name methods using domain language.
typescript
// Port (domain layer) — interface because it's a behavior contract
interface OccasionRepository {
  readonly findById: (id: OccasionId) => Promise<Occasion | undefined>;
  readonly save: (occasion: Occasion) => Promise<void>;
}

// Adapter (infrastructure layer) — see hexagonal-architecture skill
Repositories handle writes and single-aggregate reads. For reads that need to JOIN across aggregates (dashboard views, detail pages combining data from multiple entities), repositories are the wrong tool — they enforce aggregate boundaries that reads need to cross. Use query functions that JOIN freely instead. This is the CQRS-lite pattern: writes go through repositories (consistency), reads go through query functions (flexibility). See the
hexagonal-architecture
skill's CQRS-lite section and
resources/cqrs-lite.md
for details.
For simple domains where reads map cleanly to a single aggregate, repository reads are fine. Don't separate prematurely.

仓库提供类似集合的方式访问聚合。接口定义在领域层,实现位于适配器层。仓库使用
interface
而非
type
,因为它们定义的是行为契约——这符合TypeScript严格规则“保留
interface
用于行为契约”。使用领域语言命名方法。
typescript
// 端口(领域层)——使用interface因为它是行为契约
interface OccasionRepository {
  readonly findById: (id: OccasionId) => Promise<Occasion | undefined>;
  readonly save: (occasion: Occasion) => Promise<void>;
}

// 适配器(基础设施层)——查看hexagonal-architecture技能
仓库处理写入和单聚合读取。 对于需要跨聚合JOIN的读取(仪表板视图、组合多个实体数据的详情页),仓库不是合适的工具——它们会强制读取需要跨越的聚合边界。应改用可自由JOIN的查询函数。这是CQRS精简模式:写入通过仓库(保证一致性),读取通过查询函数(保证灵活性)。有关详细信息,请查看
hexagonal-architecture
技能的CQRS精简部分和
resources/cqrs-lite.md
对于读取可清晰映射到单个聚合的简单领域,使用仓库读取即可。无需过早分离。

DDD + TDD Integration

DDD + TDD 集成

Test by Domain Concept, Not Implementation File

按领域概念而非实现文件组织测试

tests/
  occasions/
    create-occasion.test.ts       # Behavior: creating occasions
    add-gift-idea.test.ts         # Behavior: managing gift ideas
    occasion-budget.test.ts       # Behavior: budget constraints
tests/
  occasions/
    create-occasion.test.ts       # 行为:创建活动
    add-gift-idea.test.ts         # 行为:管理礼物想法
    occasion-budget.test.ts       # 行为:预算约束

Primary Test Boundary: The Use Case

主要测试边界:用例

Test by calling use cases with driven ports replaced by in-memory fakes (not mocks). This exercises domain entities, domain services, and orchestration together — proving the feature works as a whole.
Domain unit tests complement use case tests for complex pure business rules. They don't replace them.
PriorityBoundaryWhat it proves
PrimaryUse case (faked driven ports)Feature works end-to-end within the hexagon
ComplementDomain pure functions directlyComplex business rules in isolation
SecondaryDriven adapters (real DB/MSW)Adapter translates correctly
VerificationE2E (full stack)User experience works
For detailed testing guidance, see
resources/testing-by-layer.md
. For a complete worked example showing one feature through every layer with tests, see the hexagonal-architecture skill's
resources/worked-example.md
.
通过调用用例并将驱动端口替换为内存伪实现(而非模拟)进行测试。这会一起测试领域实体、领域服务和编排逻辑——证明功能整体可用。
领域单元测试补充用例测试,用于复杂的纯业务规则。它们不能替代用例测试。
优先级边界验证内容
首要用例(伪实现驱动端口)六边形架构内的功能端到端可用
补充直接测试领域纯函数孤立验证复杂业务规则
次要驱动适配器(真实数据库/MSW)验证适配器转换正确
验证端到端(全栈)验证用户体验可用
有关详细测试指南,请查看
resources/testing-by-layer.md
。有关展示一个功能跨所有层级及对应测试的完整示例,请查看hexagonal-architecture技能的
resources/worked-example.md

Test Factories Use Domain Language

测试工厂使用领域语言

typescript
const getTestOccasion = (overrides?: Partial<Occasion>): Occasion =>
  OccasionSchema.parse({
    id: createOccasionId('occasion-1'),
    name: "Mum's Birthday",
    giftIdeas: [],
    budget: createMoney(100, 'GBP'),
    ...overrides,
  });

typescript
const getTestOccasion = (overrides?: Partial<Occasion>): Occasion =>
  OccasionSchema.parse({
    id: createOccasionId('occasion-1'),
    name: "Mum's Birthday",
    giftIdeas: [],
    budget: createMoney(100, 'GBP'),
    ...overrides,
  });

Bounded Contexts

限界上下文

A bounded context is a linguistic boundary within which a particular domain model and glossary apply. The same word (e.g., "User") can mean different things in different contexts — and that's correct.
  1. Each context owns its own model and glossary
    User
    in billing differs from
    User
    in shipping
  2. Communicate between contexts via events or explicit contracts — never share mutable state
  3. Anti-Corruption Layer (ACL) — when integrating with external systems or other contexts whose model doesn't fit yours, translate at the boundary rather than letting their types leak in
  4. Shared kernel should be minimal — only truly universal value objects (Money, Email). If the shared kernel grows, boundaries are unclear
  5. Each context has its own glossary section
For context mapping patterns, monorepo structure, and ACL examples, see
resources/bounded-contexts.md
.

限界上下文是一个语言边界,特定领域模型和术语表在该边界内适用。同一个词(如“User”)在不同上下文中可能有不同含义——这是正确的。
  1. 每个上下文拥有自己的模型和术语表——计费上下文的
    User
    与配送上下文的
    User
    不同
  2. 通过事件或显式契约在上下文间通信——绝不共享可变状态
  3. 防腐层(ACL)——与外部系统或模型不匹配的其他上下文集成时,在边界处进行转换,而非让它们的类型渗透进来
  4. 共享内核应尽可能小——仅包含真正通用的值对象(如Money、Email)。如果共享内核变大,说明边界不清晰
  5. 每个上下文有自己的术语表章节
有关上下文映射模式、单体仓库结构和ACL示例,请查看
resources/bounded-contexts.md

Anti-Patterns

反模式

Anemic Domain Model

贫血领域模型

Entities are data bags with no behavior. All logic in "services." Fix: put behavior as pure functions next to the types they operate on.
实体是无行为的数据容器。所有逻辑都放在“服务”中。修复方法:将行为作为纯函数放在其操作的类型旁边。

Generic Technical Names

通用技术名称

Using
Item
,
Entity
,
Record
,
Data
,
Info
instead of domain terms. Always use the glossary.
使用
Item
Entity
Record
Data
Info
等替代领域术语。始终使用术语表中的名称。

Presentation Logic in Domain

领域层中的展示逻辑

Display formatting does not belong in
domain/
. The test: "make this look right for a human" = presentation (
lib/
). "Enforce a business rule" = domain. Purity is not sufficient — a pure formatting function is still presentation.
展示格式化不属于
domain/
。判断标准:“让内容对人类更美观”=展示层(
lib/
)。“执行业务规则”=领域层。纯函数不足以成为判断标准——纯格式化函数仍然属于展示层。

Leaking Domain Logic

领域逻辑泄露

Business logic in route handlers, database queries, or adapters. Keep it in
domain/
.
业务逻辑存在于路由处理器、数据库查询或适配器中。应将其保留在
domain/
中。

Over-Engineering

过度设计

Not every project needs aggregates, domain events, or bounded contexts. Start with:
  1. Ubiquitous language (glossary)
  2. Value objects and entities
  3. Add complexity only when the domain demands it
并非所有项目都需要聚合、领域事件或限界上下文。从以下几点开始:
  1. 通用语言(术语表)
  2. 值对象和实体
  3. 仅当领域需求时再增加复杂度

Resisting Model Evolution

抗拒模型演进

Treating the initial model as sacred — refusing to rename types, split aggregates, or restructure bounded contexts as understanding deepens. The model should evolve continuously. If a refactoring reveals that "Occasion" should really be "GiftEvent" and "SavingsGoal", do it. The glossary changes, the types change, the tests guide the migration. Evans calls these "breakthroughs" — moments where the model fundamentally improves because the team learned something new about the domain.

将初始模型视为神圣不可侵犯——拒绝随着理解加深而重命名类型、拆分聚合或重构限界上下文。模型应持续演进。如果重构发现“Occasion”实际上应该是“GiftEvent”和“SavingsGoal”,那就去做。术语表更新,类型更新,测试指导迁移。Evans将这些称为“突破”——团队对领域有了新的认识,模型得到根本性改进的时刻。

Checklist

检查清单

  • Glossary file exists and is up to date
  • All types use glossary terms
  • All functions use glossary verbs and nouns
  • All test descriptions use domain language
  • Value objects are immutable and identity-less
  • Entities are always valid (invariants enforced on construction and transitions)
  • Entities have branded IDs; primitive value objects use branded types
  • Aggregate roots enforce all invariants
  • Other aggregates referenced by ID, not embedded
  • Cross-aggregate logic in domain services, not crammed into one entity
  • Repository interfaces defined in domain layer
  • Discriminated unions have exhaustive switch handling
  • Expected business outcomes use result types, not exceptions
  • Domain logic has zero infrastructure dependencies
  • Presentation logic is NOT in domain/ (even if pure)
  • Tests organized by domain concept, not implementation file
  • Each layer has behavioral tests at the appropriate level
  • 存在术语表文件且内容最新
  • 所有类型使用术语表中的术语
  • 所有函数使用术语表中的动词和名词
  • 所有测试描述使用领域语言
  • 值对象是不可变且无标识的
  • 实体始终有效(构造和转换时强制执行不变量)
  • 实体使用品牌化ID;单值值对象使用品牌类型
  • 聚合根强制执行所有不变量
  • 通过ID引用其他聚合,而非嵌入
  • 跨聚合逻辑放在领域服务中,而非硬塞进单个实体
  • 仓库接口定义在领域层
  • 区分联合类型有穷举的switch处理
  • 预期业务结果使用结果类型,而非异常
  • 领域逻辑无基础设施依赖
  • 展示逻辑不在domain/中(即使是纯函数)
  • 测试按领域概念组织,而非实现文件
  • 各层在适当级别有行为测试