
Clean Ddd Hexagonal
- 4k installs
- 57 repo stars
- Updated July 7, 2026
- ccheney/robust-skills
Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture, Hexagonal, ports and adapters, entitie
About
The clean-ddd-hexagonal skill Proactively apply when designing APIs microservices or scalable backend structure Triggers on DDD Clean Architecture Hexagonal ports and adapters entities value objects domain events CQRS event sourcing repository pattern use cases onion architecture outbox pattern aggregate root anti-corruption layer Use when working with domain models aggregates repositories or bounded contexts Clean Architecture DDD Hexagonal patterns for backend services language-agnostic Go Rust Python TypeScript Java C Clean Architecture DDD Hexagonal Backend architecture combining DDD tactical patterns Clean Architecture dependency rules and Hexagonal ports adapters for maintainable testable systems This skill is an opinionated synthesis of several related architecture traditions It is not a single canonical architecture model Use the original source that matches the design question you are answering DDD for domain modeling Hexagonal Architecture for ports adapters Clean Architecture for dependency direction Onion Architecture for domain-centered layering and CQRS Event Sourcing only for specific read write or temporal requirements When to Use and When NOT to Use When Skip When.
- agnostic (Go, Rust, Python, TypeScript, Java, C#).
- # Clean Architecture + DDD + Hexagonal
- centered layering, and CQRS/Event Sourcing only for specific read/write or temporal requirements.
- ## When to Use (and When NOT to)
- | Complex business domain with many rules | Simple CRUD, few business rules |
Clean Ddd Hexagonal by the numbers
- 4,024 all-time installs (skills.sh)
- +97 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #157 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
clean-ddd-hexagonal capabilities & compatibility
- Capabilities
- proactively apply when designing apis, microserv · reference guided agent workflow · skill.md grounded routing
- Use cases
- documentation
What clean-ddd-hexagonal says it does
Clean Architecture + DDD + Hexagonal Backend architecture combining DDD tactical patterns, Clean Architecture dependency rules, and Hexagonal ports/adapters for maintainable, testa
npx skills add https://github.com/ccheney/robust-skills --skill clean-ddd-hexagonalAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4k |
|---|---|
| repo stars | ★ 57 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 7, 2026 |
| Repository | ccheney/robust-skills ↗ |
How do I apply clean-ddd-hexagonal patterns from its SKILL.md documentation?
Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture, Hexagonal, ports and adapters, entities, value objects, domain events, CQRS, e
Who is it for?
Developers using clean-ddd-hexagonal inside Claude Code or Cursor agent workflows.
Skip if: Skip when the task is unrelated to this skill's documented scope.
When should I use this skill?
Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture, Hexagonal, ports and adapters, entities, value objects, do
What you get
Actionable clean-ddd-hexagonal workflow grounded in the skill reference files.
- bounded-context structure
- ports-and-adapters layout
- domain layer interfaces
By the numbers
- SKILL.md grounded workflow
- Agent-triggered invocation
Files
Clean Architecture + DDD + Hexagonal
Backend architecture combining DDD tactical patterns, Clean Architecture dependency rules, and Hexagonal ports/adapters for maintainable, testable systems.
This skill is an opinionated synthesis of several related architecture traditions. It is not a single canonical architecture model. Use the original source that matches the design question you are answering: DDD for domain modeling, Hexagonal Architecture for ports/adapters, Clean Architecture for dependency direction, Onion Architecture for domain-centered layering, and CQRS/Event Sourcing only for specific read/write or temporal requirements.
When to Use (and When NOT to)
| Use When | Skip When |
|---|---|
| Complex business domain with many rules | Simple CRUD, few business rules |
| Long-lived system (years of maintenance) | Prototype, MVP, throwaway code |
| Team of 5+ developers | Solo developer or small team (1-2) |
| Multiple entry points (API, CLI, events) | Single entry point, simple API |
| Need to swap infrastructure (DB, broker) | Fixed infrastructure, unlikely to change |
| High test coverage required | Quick scripts, internal tools |
Start simple. Evolve complexity only when needed. Most systems don't need full CQRS or Event Sourcing.
Pattern Boundaries
| Pattern | Primary Question | Use It For | Do Not Treat As |
|---|---|---|---|
| DDD | How do we model a complex business domain? | Ubiquitous language, bounded contexts, aggregates, value objects | A folder structure by itself |
| Hexagonal Architecture | How does the application interact with the outside world? | Ports, driver adapters, driven adapters, testable application core | A mandate for six sides or one exact package layout |
| Clean Architecture | Which direction should dependencies point? | Inward dependency rule, use case boundaries, framework independence | A universal four-folder template |
| Onion Architecture | How do we keep the domain model central? | Domain-centered layers and dependency inversion | A separate requirement when Clean/Hexagonal already solve the local problem |
| CQRS | Do reads and writes need different models? | Bounded contexts with divergent read/write workloads | A default application architecture |
| Event Sourcing | Do we need state from a complete event history? | Audit, temporal queries, replayable workflows | A persistence default for CRUD systems |
CRITICAL: The Dependency Rule
Dependencies point inward only. Outer layers depend on inner layers, never the reverse.
Infrastructure → Application → Domain
(adapters) (use cases) (core)Violations to catch:
- Domain importing database/HTTP libraries
- In this architecture style, controllers calling repositories directly instead of application use cases
- Entities depending on application services
Design validation: "Create your application to work without either a UI or a database" — Alistair Cockburn. If you can run your domain logic from tests with no infrastructure, your boundaries are correct.
Quick Decision Trees
"Where does this code go?"
Where does it go?
├─ Pure business logic, no I/O → domain/
├─ Orchestrates domain + has side effects → application/
├─ Talks to external systems → infrastructure/
├─ Defines HOW to interact (interface) → port (domain or application)
└─ Implements a port → adapter (infrastructure)"Is this an Entity or Value Object?"
Entity or Value Object?
├─ Has unique identity that persists → Entity
├─ Defined only by its attributes → Value Object
├─ "Is this THE same thing?" → Entity (identity comparison)
└─ "Does this have the same value?" → Value Object (structural equality)"Should this be its own Aggregate?"
Aggregate boundaries?
├─ Must be consistent together in a transaction → Same aggregate
├─ Can be eventually consistent → Separate aggregates
├─ Referenced by ID only → Separate aggregates
└─ >10 entities in aggregate → Split itRule: One aggregate per transaction. Cross-aggregate consistency via domain events (eventual consistency).
Directory Structure
src/
├── domain/ # Core business logic (NO external dependencies)
│ ├── {aggregate}/
│ │ ├── entity # Aggregate root + child entities
│ │ ├── value_objects # Immutable value types
│ │ ├── events # Domain events
│ │ ├── repository # DDD repository interface (driven port)
│ │ └── services # Domain services (stateless logic)
│ └── shared/
│ └── errors # Domain errors
├── application/ # Use cases / Application services
│ ├── {use-case}/
│ │ ├── command # Command/Query DTOs
│ │ ├── handler # Use case implementation
│ │ └── port # Driver port interface
│ └── shared/
│ └── unit_of_work # Transaction abstraction
├── infrastructure/ # Adapters (external concerns)
│ ├── persistence/ # Database adapters
│ ├── messaging/ # Message broker adapters
│ ├── http/ # REST/GraphQL adapters (DRIVER)
│ └── config/
│ └── di # Dependency injection / composition root
└── main # Bootstrap / entry pointPort placement: This skill defaults to a DDD-centered layout where aggregate repository interfaces live beside the aggregate in domain/. A stricter Hexagonal layout may instead put driven ports under application/ports/driven/. Pick one convention per codebase and keep the dependency rule intact.
DDD Building Blocks
| Pattern | Purpose | Layer | Key Rule |
|---|---|---|---|
| Entity | Identity + behavior | Domain | Equality by ID |
| Value Object | Immutable data | Domain | Equality by value, no setters |
| Aggregate | Consistency boundary | Domain | Only root is referenced externally |
| Domain Event | Record of change | Domain | Past tense naming (OrderPlaced) |
| Repository | Persistence abstraction | Domain (port) | Per aggregate, not per table |
| Domain Service | Stateless logic | Domain | When logic doesn't fit an entity |
| Application Service | Orchestration | Application | Coordinates domain + infra |
Anti-Patterns (CRITICAL)
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Anemic Domain Model | Entities are data bags, logic in services | Move behavior INTO entities |
| Repository per Entity | Breaks aggregate boundaries | One repository per AGGREGATE |
| Leaking Infrastructure | Domain imports DB/HTTP libs | Domain has ZERO external deps |
| God Aggregate | Too many entities, slow transactions | Split into smaller aggregates |
| Skipping Use Cases | Controllers call repositories directly in a use-case architecture | Route through application use cases |
| CRUD Thinking | Modeling data, not behavior | Model business operations |
| Premature CQRS | Adding complexity before needed | Start with simple read/write, evolve |
| Cross-Aggregate TX | Multiple aggregates in one transaction | Use domain events for consistency |
Implementation Order
1. Discover the Domain — Event Storming, conversations with domain experts 2. Model the Domain — Entities, value objects, aggregates (no infra) 3. Define Ports — Repository interfaces, external service interfaces 4. Implement Use Cases — Application services coordinating domain 5. Add Adapters last — HTTP, database, messaging implementations
DDD is collaborative. Modeling sessions with domain experts are as important as the code patterns.
Reference Documentation
| File | Purpose |
|---|---|
| references/LAYERS.md | Complete layer specifications |
| references/DDD-STRATEGIC.md | Bounded contexts, context mapping |
| references/DDD-TACTICAL.md | Entities, value objects, aggregates (pseudocode) |
| references/HEXAGONAL.md | Ports, adapters, naming |
| references/CQRS-EVENTS.md | Command/query separation, events |
| references/TESTING.md | Unit, integration, architecture tests |
| references/CHEATSHEET.md | Quick decision guide |
Sources
Primary Sources
- Hexagonal Architecture — Alistair Cockburn (2005)
- Domain-Driven Design: The Blue Book — Eric Evans (2003)
- The Clean Architecture — Robert C. Martin (2012)
- Onion Architecture — Jeffrey Palermo (2008)
- Implementing Domain-Driven Design — Vaughn Vernon (2013)
Primary Pattern References
- CQRS — Martin Fowler
- Event Sourcing — Martin Fowler
- Repository Pattern — Martin Fowler (PoEAA)
- Unit of Work — Martin Fowler (PoEAA)
- Bounded Context — Martin Fowler
- Transactional Outbox — microservices.io
- Effective Aggregate Design — Vaughn Vernon
Implementation Guides
- Microsoft: DDD + CQRS Microservices
- Domain Events — Udi Dahan
Supplemental Syntheses
- Clean Architecture: Standing on the Shoulders of Giants — Herberto Graça
- Explicit Architecture — Herberto Graça (opinionated synthesis, not canonical source)
- Get Your Hands Dirty on Clean Architecture — Tom Hombergs
Quick Reference Cheatsheet
See SKILL.md for full source list.
This cheatsheet summarizes an opinionated synthesis, not a single canonical architecture. Use DDD, Hexagonal, Clean Architecture, Onion Architecture, CQRS, and Event Sourcing independently when only one pattern fits the problem.
Layer Summary
flowchart TB
subgraph Infra["INFRASTRUCTURE (Adapters)"]
I1["REST/gRPC controllers"]
I2["CLI handlers"]
I3["Framework code"]
I4["Database repositories"]
I5["Message publishers"]
I6["External service clients"]
end
subgraph App["APPLICATION (Use Cases)"]
A1["Command/Query handlers"]
A2["DTOs"]
A3["Transaction management"]
A4["Port interfaces"]
A5["Application services"]
A6["Event dispatching"]
end
subgraph Domain["DOMAIN (Business Logic)"]
D1["Entities"]
D2["Aggregates"]
D3["Repository interfaces"]
D4["Business rules"]
D5["Value Objects"]
D6["Domain Events"]
D7["Domain Services"]
D8["Specifications"]
end
Infra -->|depends on| App
App -->|depends on| Domain
style Infra fill:#6366f1,stroke:#4f46e5,color:white
style App fill:#3b82f6,stroke:#2563eb,color:white
style Domain fill:#10b981,stroke:#059669,color:whiteDependencies point inward
---
Pattern Boundaries
| Pattern | Use For | Avoid Assuming |
|---|---|---|
| DDD | Ubiquitous language, bounded contexts, aggregates | It requires a specific folder layout |
| Hexagonal | Ports/adapters around an application core | Every port must be a separate interface |
| Clean Architecture | Inward dependency rule and use-case boundaries | Every project needs four layers |
| Onion Architecture | Domain-centered dependency inversion | It is mandatory in addition to Clean/Hexagonal |
| CQRS | Divergent read/write models in a bounded context | It should be system-wide by default |
| Event Sourcing | Audit trails, temporal queries, replayable workflows | It is a normal CRUD persistence choice |
---
Quick Decision Trees
"Where does this code go?"
Is it a business rule or constraint?
├── YES → Domain layer
└── NO ↓
Is it orchestrating a use case?
├── YES → Application layer
└── NO ↓
Is it dealing with external systems (DB, API, UI)?
├── YES → Infrastructure layer
└── NO → Reconsider; probably domain"Entity or Value Object?"
Does it have a unique identity that persists?
├── YES → Entity
└── NO ↓
Is it defined entirely by its attributes?
├── YES → Value Object
└── NO → Probably an Entity"Aggregate boundary?"
Must these objects change together atomically?
├── YES → Same aggregate
└── NO ↓
Can one exist without the other?
├── YES → Different aggregates (reference by ID)
└── NO → Probably same aggregate"Domain Service or Entity method?"
Does it naturally belong to one entity?
├── YES → Entity method
└── NO ↓
Does it require multiple aggregates?
├── YES → Domain Service
└── NO ↓
Is it stateless business logic?
├── YES → Domain Service
└── NO → Reconsider placement---
Common Patterns Quick Reference
Value Object Template
export class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: string,
) {}
static create(amount: number, currency: string): Money {
if (amount < 0) throw new Error('Negative');
return new Money(amount, currency);
}
add(other: Money): Money {
return Money.create(this._amount + other._amount, this._currency);
}
get amount(): number { return this._amount; }
get currency(): string { return this._currency; }
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
}Entity Template
export class OrderItem extends Entity<OrderItemId> {
private _quantity: Quantity;
private constructor(id: OrderItemId, private readonly _productId: ProductId, quantity: Quantity) {
super(id);
this._quantity = quantity;
}
static create(productId: ProductId, quantity: Quantity): OrderItem {
return new OrderItem(OrderItemId.generate(), productId, quantity);
}
increaseQuantity(amount: number): void {
this._quantity = this._quantity.add(amount);
}
get productId(): ProductId { return this._productId; }
get quantity(): Quantity { return this._quantity; }
}Aggregate Root Template
export class Order extends AggregateRoot<OrderId> {
private _items: OrderItem[] = [];
private _status: OrderStatus;
private constructor(id: OrderId, customerId: CustomerId) {
super(id);
this._customerId = customerId;
this._status = OrderStatus.Draft;
}
static create(customerId: CustomerId): Order {
const order = new Order(OrderId.generate(), customerId);
order.addDomainEvent(new OrderCreated(order.id, customerId));
return order;
}
addItem(productId: ProductId, quantity: Quantity, price: Money): void {
this.assertCanModify();
this._items.push(OrderItem.create(productId, quantity, price));
}
confirm(): void {
this.assertCanModify();
if (this._items.length === 0) throw new EmptyOrderError();
this._status = OrderStatus.Confirmed;
this.addDomainEvent(new OrderConfirmed(this.id, this.total));
}
private assertCanModify(): void {
if (this._status === OrderStatus.Cancelled) {
throw new InvalidOrderStateError('Order is cancelled');
}
}
get total(): Money { /* ... */ }
}Repository Interface Template
export interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}Use Case Handler Template
export class PlaceOrderHandler {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly productRepo: IProductRepository,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(CustomerId.from(command.customerId));
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
order.addItem(product.id, Quantity.create(item.quantity), product.price);
}
await this.orderRepo.save(order);
await this.eventPublisher.publishAll(order.domainEvents);
return order.id;
}
}---
Port Naming Conventions
Repository port placement varies by school: DDD-centered code often keeps aggregate repositories in domain/{aggregate}/repository; stricter Hexagonal layouts often group them under application/ports/driven/. Pick one convention per codebase.
| Type | Pattern | Examples |
|---|---|---|
| Driver Port | I{Action}UseCase | IPlaceOrderUseCase, IGetOrderUseCase |
| Driven Port | I{Resource}Repository | IOrderRepository, IProductRepository |
| Driven Port | I{Action}Service | IPaymentService, INotificationService |
| Driven Port | I{Resource}Gateway | IPaymentGateway, IShippingGateway |
---
Common Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Anemic Domain | Entities are just data bags | Put behavior in entities |
| Repository per table | One repo per DB table | One repo per aggregate |
| Fat Use Cases | Business logic in handlers | Move to domain |
| Leaky Abstraction | Domain depends on ORM | Keep domain pure |
| God Aggregate | One massive aggregate | Split into smaller ones |
| Cross-Aggregate TX | Modifying multiple in one TX | Use domain events |
| Direct Layer Skip | Controller -> Repository in this architecture style | Route through application use case |
| Premature CQRS | Adding complexity early | Start simple, evolve |
| Event Proliferation | Too many fine-grained events | May signal context boundary |
---
Dependency Rules Matrix
| Domain | Application | Infrastructure | |
|---|---|---|---|
| Domain | ✅ | ❌ | ❌ |
| Application | ✅ | ✅ | ❌ |
| Infrastructure | ✅ | ✅ | ✅ |
✅ = Can depend on ❌ = Cannot depend on
---
Hexagonal Quick Reference
flowchart LR
subgraph Driver["DRIVER (Left/Primary/Inbound)"]
direction TB
D1["REST Controller"]
D2["gRPC Service"]
D3["CLI Command"]
D4["Message Consumer"]
DP["Port (Interface)"]
D1 & D2 & D3 & D4 -->|calls| DP
end
subgraph App["Application"]
Core[" "]
end
subgraph Driven["DRIVEN (Right/Secondary/Outbound)"]
direction TB
DRP["Port (Interface)"]
DR1["Database Repository"]
DR2["Message Publisher"]
DR3["External API Client"]
DR4["Cache Adapter"]
DR1 & DR2 & DR3 & DR4 -->|implements| DRP
end
Driver -->|"How world\nuses app"| App
App -->|"How app\nuses world"| Driven
style Driver fill:#3b82f6,stroke:#2563eb,color:white
style App fill:#10b981,stroke:#059669,color:white
style Driven fill:#f59e0b,stroke:#d97706,color:white---
When to Use / Skip
Use Clean + DDD + Hexagonal When:
- ✅ Complex business domain with many rules
- ✅ Long-lived system (years of maintenance)
- ✅ Large team (5+ developers)
- ✅ Need to swap infrastructure (DB, broker, etc.)
- ✅ High test coverage required
- ✅ Multiple entry points (API, CLI, events, scheduled jobs)
Skip When:
- ❌ Simple CRUD application (most applications)
- ❌ Prototype / MVP / throwaway code
- ❌ Small team (1-2 devs)
- ❌ Short-lived project
- ❌ Trivial business logic
Complexity Ladder (Start Simple)
Level 1: Simple layered (Controller → Service → Repository)
↓ When business rules grow complex
Level 2: Domain model (Entities with behavior)
↓ When need multiple entry points
Level 3: Hexagonal (Ports & Adapters)
↓ When read/write patterns diverge significantly
Level 4: CQRS (Separate read/write models)
↓ When need complete audit trail / temporal queries
Level 5: Event Sourcing (Store events, derive state)Don't skip levels. Each level adds complexity. Move up only when you've proven the current level insufficient.
---
File Naming Conventions
domain/
├── order/
│ ├── order.ts # Aggregate root
│ ├── order_item.ts # Entity
│ ├── value_objects.ts # OrderId, Money, etc.
│ ├── events.ts # OrderCreated, etc.
│ ├── repository.ts # IOrderRepository
│ ├── services.ts # Domain services
│ └── errors.ts # OrderError, etc.
application/
├── place_order/
│ ├── command.ts # PlaceOrderCommand
│ ├── handler.ts # PlaceOrderHandler
│ └── port.ts # IPlaceOrderUseCase
infrastructure/
├── postgres/
│ ├── order_repository.ts # PostgresOrderRepository
│ └── mappers/
│ └── order_mapper.ts # Domain <-> DB mapping---
Resources
Books & Primary Articles
- Clean Architecture (Robert C. Martin, 2017)
- Domain-Driven Design (Eric Evans, 2003)
- Implementing Domain-Driven Design (Vaughn Vernon, 2013)
- Onion Architecture (Jeffrey Palermo, 2008 article series)
- Hexagonal Architecture Explained (Alistair Cockburn, 2024)
- Get Your Hands Dirty on Clean Architecture (Tom Hombergs, 2019)
Supplemental Syntheses
- Herberto Graça, Clean Architecture comparison and Explicit Architecture articles (opinionated synthesis, not canonical source)
- Tom Hombergs, practical Clean Architecture examples
Reference Implementations
- Go: bxcodec/go-clean-arch
- Rust: flosse/clean-architecture-with-rust
- Python: cdddg/py-clean-arch
- TypeScript: jbuget/nodejs-clean-architecture-app
- .NET: jasontaylordev/CleanArchitecture
- Java: thombergs/buckpal
Official Documentation
- https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- https://alistair.cockburn.us/hexagonal-architecture/
- https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
- https://www.domainlanguage.com/ddd/
- https://martinfowler.com/tags/domain%20driven%20design.html
CQRS & Domain Events
Sources:
- CQRS — Martin Fowler
- Event Sourcing — Martin Fowler
- CQRS Pattern — Microsoft Azure
- Transactional Outbox — microservices.io
- Domain Events – Salvation — Udi Dahan
- Strengthening Your Domain: Domain Events — Jimmy Bogard
- Domain Events: Design and Implementation — Microsoft
CQRS Overview
Command Query Responsibility Segregation separates read and write operations into different models.
flowchart TB
API["API Layer"]
API --> Commands
API --> Queries
subgraph WriteSide["Write Side"]
Commands["Commands"]
CmdHandler["Command Handler\n(Use Case)"]
DomainModel["Domain Model\n(Aggregates)"]
WriteDB[("Write Database")]
Commands --> CmdHandler
CmdHandler --> DomainModel
DomainModel --> WriteDB
end
subgraph ReadSide["Read Side"]
Queries["Queries"]
QryHandler["Query Handler\n(Read Model)"]
ReadDB[("Read Database\n(Optimized)")]
Queries --> QryHandler
QryHandler --> ReadDB
end
WriteDB -->|Domain Events| EventHandler["Event Handler"]
EventHandler -->|Updates| ReadDB
style WriteSide fill:#3b82f6,stroke:#2563eb,color:white
style ReadSide fill:#10b981,stroke:#059669,color:white
style EventHandler fill:#f59e0b,stroke:#d97706,color:white---
Commands vs Queries
Commands (Write Side)
Commands represent intent to change state. They mutate data.
// application/commands/place_order_command.ts
export interface PlaceOrderCommand {
type: 'PlaceOrder';
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
export interface ConfirmOrderCommand {
type: 'ConfirmOrder';
orderId: string;
}
export interface CancelOrderCommand {
type: 'CancelOrder';
orderId: string;
reason: string;
}
export class PlaceOrderHandler {
async handle(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(CustomerId.from(command.customerId));
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
order.addItem(product.id, item.quantity, product.price);
}
await this.orderRepo.save(order);
await this.eventPublisher.publishAll(order.domainEvents);
return order.id;
}
}Queries (Read Side)
Queries retrieve data without side effects. They never mutate state.
// application/queries/get_order_query.ts
export interface GetOrderQuery {
orderId: string;
}
export interface GetOrdersByCustomerQuery {
customerId: string;
status?: OrderStatus;
page?: number;
pageSize?: number;
}
export interface OrderDTO {
id: string;
customerId: string;
customerName: string;
status: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
total: number;
createdAt: string;
confirmedAt?: string;
}
export class GetOrderHandler {
constructor(private readonly readDb: IOrderReadModel) {}
async handle(query: GetOrderQuery): Promise<OrderDTO | null> {
return this.readDb.findById(query.orderId);
}
}
export class GetOrdersByCustomerHandler {
constructor(private readonly readDb: IOrderReadModel) {}
async handle(query: GetOrdersByCustomerQuery): Promise<PaginatedResult<OrderDTO>> {
return this.readDb.findByCustomer(
query.customerId,
query.status,
query.page ?? 1,
query.pageSize ?? 20
);
}
}---
Read Model (Projection)
Optimized database structure for queries. Can denormalize data for performance.
interface IOrderReadModel:
findById(orderId: string) -> OrderDTO | null
findByCustomer(customerId, status?, page?, pageSize?) -> PaginatedResult<OrderDTO>
search(criteria: OrderSearchCriteria) -> List<OrderDTO>
class PostgresOrderReadModel implements IOrderReadModel:
db: Database
findById(orderId: string) -> OrderDTO | null:
row = db.ordersRead
.where(id: orderId)
.join("customer")
.withRelated("items.product")
.first()
return row ? this.mapToDTO(row) : nullSeparate write and read databases (optional): write is normalized for transactions, read is denormalized for queries.
---
Domain Events
Notifications that something happened in the domain. Used for:
- Updating read models
- Cross-aggregate communication
- Integration with other bounded contexts
Event Structure
// domain/shared/domain_event.ts
export abstract class DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
readonly aggregateId: string;
abstract readonly eventType: string;
constructor(aggregateId: string) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date();
this.aggregateId = aggregateId;
}
abstract toPayload(): Record<string, unknown>;
}
// domain/order/events.ts
export class OrderCreated extends DomainEvent {
readonly eventType = 'order.created';
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
customerId: this.customerId.value,
};
}
}
export class OrderConfirmed extends DomainEvent {
readonly eventType = 'order.confirmed';
constructor(
readonly orderId: OrderId,
readonly total: Money,
readonly items: ReadonlyArray<{ productId: string; quantity: number }>,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
total: { amount: this.total.amount, currency: this.total.currency },
items: this.items,
};
}
}
export class OrderShipped extends DomainEvent {
readonly eventType = 'order.shipped';
constructor(
readonly orderId: OrderId,
readonly trackingNumber: string,
readonly carrier: string,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
trackingNumber: this.trackingNumber,
carrier: this.carrier,
};
}
}Event Handlers
class OrderCreatedHandler:
db: Database
handle(event: OrderCreated):
db.ordersRead.insert({
id: event.orderId.value,
customerId: event.customerId.value,
status: "draft",
createdAt: event.occurredAt
})
class OrderConfirmedHandler:
db: Database
handle(event: OrderConfirmed):
db.ordersRead
.where(id: event.orderId.value)
.update({
status: "confirmed",
total: event.total.amount,
confirmedAt: event.occurredAt
})
export class SendShippingNotificationHandler {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly notifier: INotificationService,
) {}
async handle(event: OrderShipped): Promise<void> {
const order = await this.orderRepo.findById(OrderId.from(event.orderId.value));
if (!order) return;
await this.notifier.sendEmail(order.customerEmail, {
template: 'order-shipped',
data: {
orderId: event.orderId.value,
trackingNumber: event.trackingNumber,
carrier: event.carrier,
},
});
}
}---
Domain Events vs Integration Events
Domain Events
- Stay within bounded context
- Fine-grained, low-level
- Trigger internal processes
- Named in domain language
class OrderItemQuantityIncreased extends DomainEvent {
constructor(
readonly orderId: OrderId,
readonly productId: ProductId,
readonly oldQuantity: number,
readonly newQuantity: number,
) { super(orderId.value); }
}Integration Events
- Cross bounded context boundaries
- Coarser-grained
- Published to message broker
- Versioned schema
interface OrderConfirmedIntegrationEvent {
eventType: 'sales.order.confirmed';
eventId: string;
version: '1.0';
occurredAt: string;
payload: {
orderId: string;
customerId: string;
total: { amount: number; currency: string };
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
shippingAddress: {
street: string;
city: string;
postalCode: string;
country: string;
};
};
}Publishing Integration Events
// application/event_handlers/publish_integration_events.ts
export class PublishOrderConfirmedIntegrationEvent {
constructor(
private readonly messageBroker: IMessageBroker,
private readonly orderRepo: IOrderRepository,
) {}
async handle(domainEvent: OrderConfirmed): Promise<void> {
const order = await this.orderRepo.findById(domainEvent.orderId);
if (!order) return;
const integrationEvent: OrderConfirmedIntegrationEvent = {
eventType: 'sales.order.confirmed',
eventId: crypto.randomUUID(),
version: '1.0',
occurredAt: new Date().toISOString(),
payload: {
orderId: order.id.value,
customerId: order.customerId.value,
total: {
amount: order.total.amount,
currency: order.total.currency,
},
items: order.items.map(item => ({
productId: item.productId.value,
quantity: item.quantity.value,
unitPrice: item.unitPrice.amount,
})),
shippingAddress: order.shippingAddress
? {
street: order.shippingAddress.street,
city: order.shippingAddress.city,
postalCode: order.shippingAddress.postalCode,
country: order.shippingAddress.country,
}
: null,
},
};
await this.messageBroker.publish('order-events', integrationEvent);
}
}---
Event Dispatcher Pattern
// infrastructure/events/event_dispatcher.ts
export interface IEventHandler<T extends DomainEvent> {
handle(event: T): Promise<void>;
}
export class EventDispatcher {
private handlers: Map<string, IEventHandler<any>[]> = new Map();
register<T extends DomainEvent>(
eventType: string,
handler: IEventHandler<T>,
): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler);
this.handlers.set(eventType, existing);
}
async dispatch(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.eventType) ?? [];
await Promise.all(handlers.map(h => h.handle(event)));
}
async dispatchAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.dispatch(event);
}
}
}
const dispatcher = new EventDispatcher();
dispatcher.register('order.created', new OrderCreatedHandler(readDb));
dispatcher.register('order.confirmed', new OrderConfirmedHandler(readDb));
dispatcher.register('order.confirmed', new PublishOrderConfirmedIntegrationEvent(broker, orderRepo));
dispatcher.register('order.shipped', new SendShippingNotificationHandler(orderRepo, notifier));---
Outbox Pattern
Ensures events are published reliably (exactly-once semantics).
interface OutboxMessage:
id: string
eventType: string
payload: string
createdAt: DateTime
processedAt: DateTime | null
class OutboxRepository:
db: Database
save(event: DomainEvent, tx: Transaction):
tx.outbox.insert({
id: event.eventId,
eventType: event.eventType,
payload: serialize(event.toPayload()),
createdAt: event.occurredAt
})
getUnprocessed(limit: int = 100) -> List<OutboxMessage>:
return db.outbox
.where(processedAt: null)
.orderBy("createdAt")
.limit(limit)
.lockForUpdate()
markProcessed(id: string):
db.outbox.where(id: id).update({processedAt: now()})
class PlaceOrderHandler:
orderRepo: IOrderRepository
outbox: OutboxRepository
db: Database
handle(command: PlaceOrderCommand) -> OrderId:
order = Order.create(CustomerId.from(command.customerId))
db.transaction((tx) => {
orderRepo.save(order, tx)
for event in order.domainEvents:
outbox.save(event, tx)
})
return order.id
class OutboxProcessor:
outbox: OutboxRepository
messageBroker: IMessageBroker
process():
messages = outbox.getUnprocessed()
for message in messages:
try:
messageBroker.publish(message.eventType, message.payload)
outbox.markProcessed(message.id)
catch error:
log.error("Failed to process outbox message", message.id)---
When to Use CQRS
Warning: "You should be very cautious about using CQRS... the majority of cases I've run into have not been so good." — Martin Fowler
CQRS adds significant complexity. Most applications don't need it.
Use CQRS When:
- Read and write workloads have dramatically different scaling requirements
- Complex queries that genuinely don't map well to domain model
- Different teams work on read vs write sides
- Event sourcing is used (CQRS pairs naturally with ES)
- You've proven simpler approaches are insufficient
Skip CQRS When:
- Simple CRUD application (most applications)
- Read/write patterns are similar
- Small team, simple domain
- You haven't tried a simple reporting database first
- Adding it "just in case"
CQRS applies to specific bounded contexts, never entire systems.
Simplified CQRS (Start Here)
Start simple—same database, different query paths:
class OrderService {
async placeOrder(cmd: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(...);
await this.orderRepo.save(order);
return order.id;
}
async getOrder(id: string): Promise<OrderDTO | null> {
return this.readModel.findById(id);
}
}Evolve to separate databases only when needed.
---
Event Sourcing: Critical Considerations
Warning: "Extremely difficult to add Event Sourcing to systems not originally designed for it." — Martin Fowler
When Event Sourcing Makes Sense
- Complete audit trail is a business requirement
- Need to reconstruct state at any point in time
- Domain is inherently event-driven (financial transactions, workflows)
- Debugging requires understanding "how did we get here?"
When to Avoid Event Sourcing
- Simple CRUD with no audit requirements
- Team unfamiliar with event-driven patterns
- Adding it retroactively to existing system
- No clear business need for temporal queries
Event Sourcing Requirements
1. Events must store deltas — Not final state, but what changed (enables reversal) 2. Snapshots for performance — Rebuild from snapshots, not from event 0 3. External system handling:
- Disable notifications during replays
- Cache external query results with timestamps
4. Schema evolution strategy — Events are forever; plan for versioning
---
Saga Pattern (Cross-Aggregate Workflows)
For workflows spanning multiple aggregates, use sagas instead of trying to coordinate via raw domain events.
Saga: PlaceOrderSaga
├── Step 1: Reserve inventory (Inventory aggregate)
├── Step 2: Process payment (Payment aggregate)
├── Step 3: Confirm order (Order aggregate)
└── Compensating actions if any step failsSaga types:
- Choreography: Each service listens/publishes events (simpler, harder to trace)
- Orchestration: Central coordinator manages steps (explicit, easier to debug)
---
Idempotent Consumer Pattern
Required for reliable event processing. Messages may be delivered more than once.
class OrderConfirmedHandler:
processedIds: Set<string>
handle(event: OrderConfirmed):
if event.eventId in processedIds:
return
doWork(event)
processedIds.add(event.eventId)Implementation options:
- Store processed message IDs in database
- Use message broker's deduplication features
- Design handlers to be naturally idempotent
DDD Strategic Patterns
Sources:
- Domain-Driven Design: The Blue Book — Eric Evans (2003)
- DDD Resources — Domain Language (Eric Evans)
- Bounded Context — Martin Fowler
- Domain Driven Design — Martin Fowler
- Anti-Corruption Layer — AWS
- Domain Analysis for Microservices — Microsoft
Overview
Strategic DDD patterns help decompose large systems into manageable parts with clear boundaries. They answer: "How do we divide a complex domain?"
DDD is fundamentally collaborative. The patterns below emerge from conversations, whiteboarding, and modeling sessions with domain experts—not from coding alone.
---
Domain Discovery Techniques
Event Storming
A workshop technique for discovering domain events, aggregates, and bounded contexts.
Orange sticky: Domain Event (past tense: "OrderPlaced")
Blue sticky: Command (imperative: "Place Order")
Yellow sticky: Aggregate (noun: "Order")
Pink sticky: External System / Policy
Purple sticky: Problem / QuestionWorkshop flow: 1. Chaotic exploration — Everyone adds events they know about 2. Timeline ordering — Arrange events chronologically 3. Identify aggregates — Group related events 4. Find boundaries — Where language changes = bounded context boundary 5. Surface problems — Mark unclear areas for follow-up
Context Mapping Workshop
For existing systems, map how bounded contexts currently interact: 1. List all systems/services 2. Identify which team owns each 3. Draw relationships (upstream/downstream) 4. Label relationship types (ACL, Conformist, etc.) 5. Identify pain points in current integrations
---
Ubiquitous Language
The foundation of DDD. A shared vocabulary between developers and domain experts that appears in:
- Code (class names, method names)
- Documentation
- Conversations
- UI labels
Principles
1. One language per bounded context - Different contexts may use the same word differently 2. Code reflects the language - Order.confirm() not Order.setStatus("confirmed") 3. Evolve together - When language changes, code changes
Example
❌ Technical language:
"Set the order entity's status field to 2 and insert a record"
✅ Ubiquitous language:
"Confirm the order and record that it was confirmed"// ❌ Technical, not ubiquitous
class Order {
setStatus(status: number): void { this.status = status; }
}
// ✅ Ubiquitous language
class Order {
confirm(): void {
if (this.status !== OrderStatus.Pending) {
throw new OrderCannotBeConfirmedException(this.id);
}
this.status = OrderStatus.Confirmed;
this.confirmedAt = new Date();
this.addDomainEvent(new OrderConfirmed(this.id));
}
}---
Bounded Contexts
A semantic boundary where a particular domain model applies. Within a bounded context, terms have precise, unambiguous meaning.
Key insight: Polysemy (same word, different meanings) across departments is natural, not a problem. The same term meaning different things in different contexts is expected—"the dominant boundary factor is human culture and language variation." — Martin Fowler
Key Concepts
- Each bounded context has its own ubiquitous language
- Each bounded context has its own model
- The same real-world concept may have different representations in different contexts
Example: E-Commerce System
flowchart TB
subgraph ECommerce["E-Commerce System"]
subgraph Sales["Sales Context"]
SC1["Customer: id, email, preferences"]
SC2["Order: items, total, status"]
end
subgraph Shipping["Shipping Context"]
SH1["Recipient: name, address, phone"]
SH2["Shipment: packages, carrier, trackingNo"]
end
subgraph Billing["Billing Context"]
BC1["Payer: name, billingAddress, paymentMethod"]
BC2["Invoice: lineItems, total, dueDate"]
end
subgraph Catalog["Catalog Context"]
CC1["Product: name, description, price"]
CC2["(no customer concept)"]
end
end
style Sales fill:#3b82f6,stroke:#2563eb,color:white
style Shipping fill:#10b981,stroke:#059669,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white
style Catalog fill:#8b5cf6,stroke:#7c3aed,color:white"Customer" means different things:
- Sales: Email, preferences, order history
- Shipping: Delivery address, phone number
- Billing: Payment methods, billing address
Bounded Context = Microservice Boundary
In microservices, each bounded context typically becomes a separate service:
flowchart LR
subgraph Sales["Sales Service"]
S1["Orders DB"]
S2["Order API"]
end
subgraph Shipping["Shipping Service"]
SH1["Shipments DB"]
SH2["Shipping API"]
end
subgraph Billing["Billing Service"]
B1["Invoices DB"]
B2["Billing API"]
end
Sales -->|events| Shipping
Shipping -->|events| Billing
Sales -.->|Integration Events| Events[("Event Bus")]
Shipping -.-> Events
Billing -.-> Events
style Sales fill:#3b82f6,stroke:#2563eb,color:white
style Shipping fill:#10b981,stroke:#059669,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white---
Subdomains
Areas of business expertise. Subdomains are discovered, not designed.
Types
| Type | Description | Investment | Example |
|---|---|---|---|
| Core | Competitive advantage | High | Product recommendation engine |
| Supporting | Necessary but not unique | Medium | Order management |
| Generic | Commodity, buy/outsource | Low | Email sending, payments |
Identification Questions
1. What makes us different from competitors? → Core 2. What do we need but isn't our specialty? → Supporting 3. What does everyone need the same way? → Generic
Example: E-Commerce
flowchart TB
subgraph Subdomains["Subdomains"]
subgraph Core["CORE"]
C1["Product search & recommendations"]
C2["Pricing engine"]
C3["Personalization"]
end
subgraph Supporting["SUPPORTING"]
S1["Order management"]
S2["Inventory"]
S3["Customer support"]
S4["Reporting"]
end
subgraph Generic["GENERIC"]
G1["Authentication (Auth0)"]
G2["Payments (Stripe)"]
G3["Email (SendGrid)"]
G4["File storage (S3)"]
end
end
Core --> CoreStrat["Build in-house\nBest developers"]
Supporting --> SuppStrat["Build or buy\nSolid but simple"]
Generic --> GenStrat["Use third-party\nDon't reinvent"]
style Core fill:#ef4444,stroke:#dc2626,color:white
style Supporting fill:#f59e0b,stroke:#d97706,color:white
style Generic fill:#6b7280,stroke:#4b5563,color:white---
Context Mapping
Describes relationships between bounded contexts.
Relationship Patterns
Partnership
Two contexts succeed or fail together. Teams coordinate closely.
flowchart LR
A["Context A"] <-->|"Partnership\nJoint planning\nShared success"| B["Context B"]
style A fill:#3b82f6,stroke:#2563eb,color:white
style B fill:#3b82f6,stroke:#2563eb,color:whiteShared Kernel
Two contexts share a subset of the domain model.
flowchart LR
subgraph A["Context A"]
SK["Shared Kernel"]
end
subgraph B["Context B"]
B1[" "]
end
SK <-->|shared| B
style A fill:#3b82f6,stroke:#2563eb,color:white
style B fill:#10b981,stroke:#059669,color:white
style SK fill:#f59e0b,stroke:#d97706,color:whiteWarning: Shared kernels create coupling. Use sparingly.
Customer-Supplier
Upstream context provides what downstream needs.
flowchart LR
U["Upstream\n(Supplier)"] -->|"Provides API"| D["Downstream\n(Customer)"]
style U fill:#3b82f6,stroke:#2563eb,color:white
style D fill:#10b981,stroke:#059669,color:whiteConformist
Downstream conforms to upstream's model with no negotiation power.
flowchart LR
U["Upstream\n(Dictator)"] -->|"Take it or leave it"| D["Downstream\n(Conformist)\nUses their model"]
style U fill:#ef4444,stroke:#dc2626,color:white
style D fill:#6b7280,stroke:#4b5563,color:whiteExample: Integrating with a third-party API (Stripe, AWS).
Anti-Corruption Layer (ACL)
Translation layer protecting your model from external models.
flowchart LR
Ext["External\nContext"] --> ACL["ACL\nTranslator + Adapter"]
ACL --> Your["Your\nContext"]
ACL -.->|"Translates external\nmodel to your model"| Note[" "]
style Ext fill:#ef4444,stroke:#dc2626,color:white
style ACL fill:#f59e0b,stroke:#d97706,color:white
style Your fill:#10b981,stroke:#059669,color:white
style Note fill:none,stroke:noneUse when:
- Integrating with legacy systems
- Integrating with third-party APIs
- External model is messy or poorly designed
// Anti-Corruption Layer Example
// infrastructure/external/stripe/stripe_payment_acl.ts
import Stripe from 'stripe';
import { Payment, PaymentStatus } from '@/domain/payment/payment';
import { Money } from '@/domain/shared/money';
export class StripePaymentACL {
constructor(private readonly stripe: Stripe) {}
async createPayment(payment: Payment): Promise<string> {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: payment.amount.cents,
currency: payment.amount.currency.toLowerCase(),
metadata: {
orderId: payment.orderId.value,
customerId: payment.customerId.value,
},
});
return paymentIntent.id;
}
translateStatus(stripeStatus: string): PaymentStatus {
const mapping: Record<string, PaymentStatus> = {
'requires_payment_method': PaymentStatus.Pending,
'requires_confirmation': PaymentStatus.Pending,
'requires_action': PaymentStatus.Pending,
'processing': PaymentStatus.Processing,
'succeeded': PaymentStatus.Completed,
'canceled': PaymentStatus.Cancelled,
'requires_capture': PaymentStatus.Authorized,
};
return mapping[stripeStatus] ?? PaymentStatus.Unknown;
}
translateWebhook(event: Stripe.Event): DomainEvent | null {
switch (event.type) {
case 'payment_intent.succeeded':
const intent = event.data.object as Stripe.PaymentIntent;
return new PaymentCompleted(
PaymentId.from(intent.metadata.orderId),
Money.fromCents(intent.amount, intent.currency.toUpperCase())
);
case 'payment_intent.payment_failed':
return null;
default:
return null;
}
}
}Open Host Service / Published Language
Expose a well-defined protocol for integration.
flowchart TB
subgraph OHS["Open Host Service"]
PL["Published Language\n(REST API, gRPC, Events Schema)"]
BC["Your Bounded Context"]
end
PL --> A["Consumer A"]
PL --> B["Consumer B"]
PL --> C["Consumer C"]
style OHS fill:#3b82f6,stroke:#2563eb,color:white
style PL fill:#10b981,stroke:#059669,color:white
style A fill:#6b7280,stroke:#4b5563,color:white
style B fill:#6b7280,stroke:#4b5563,color:white
style C fill:#6b7280,stroke:#4b5563,color:white---
Context Map Diagram
Visual representation of all bounded contexts and their relationships:
flowchart TB
Identity["Identity Context\n(Generic - Auth0)"]
Legacy["Legacy Catalog\n(Legacy)"]
Sales["Sales Context\n(Core)"]
Shipping["Shipping Context\n(Supporting)"]
Billing["Billing Context\n(Supporting)"]
Stripe["Stripe Gateway\n(Generic)"]
Identity -->|Conformist| Sales
Legacy -->|ACL| Sales
Sales <-->|Customer-Supplier| Shipping
Sales -->|Open Host Service| Billing
Billing -->|Conformist| Stripe
style Identity fill:#6b7280,stroke:#4b5563,color:white
style Legacy fill:#9ca3af,stroke:#6b7280,color:white
style Sales fill:#ef4444,stroke:#dc2626,color:white
style Shipping fill:#f59e0b,stroke:#d97706,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white
style Stripe fill:#6b7280,stroke:#4b5563,color:white---
Integration Patterns
Domain Events for Context Integration
interface OrderPlaced {
eventType: 'sales.order.placed';
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
shippingAddress: Address;
occurredAt: string;
}
class ShippingOrderPlacedHandler {
async handle(event: OrderPlaced): Promise<void> {
const shipment = Shipment.create({
orderId: ShipmentOrderId.from(event.orderId),
recipient: Recipient.fromAddress(event.shippingAddress),
packages: this.calculatePackages(event.items),
});
await this.shipmentRepository.save(shipment);
}
}
class BillingOrderPlacedHandler {
async handle(event: OrderPlaced): Promise<void> {
const invoice = Invoice.create({
orderId: InvoiceOrderId.from(event.orderId),
customerId: BillingCustomerId.from(event.customerId),
lineItems: event.items.map(item => ({
description: `Product ${item.productId}`,
quantity: item.quantity,
unitPrice: Money.fromNumber(item.price),
})),
total: Money.fromNumber(event.total),
});
await this.invoiceRepository.save(invoice);
}
}Event Schema Registry
Define and version integration event schemas:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.company.com/events/sales/order-placed/v1.json",
"title": "OrderPlaced",
"description": "Published when an order is successfully placed",
"type": "object",
"required": ["eventType", "eventId", "orderId", "occurredAt"],
"properties": {
"eventType": { "const": "sales.order.placed" },
"eventId": { "type": "string", "format": "uuid" },
"orderId": { "type": "string", "format": "uuid" },
"customerId": { "type": "string", "format": "uuid" },
"total": { "type": "number", "minimum": 0 },
"occurredAt": { "type": "string", "format": "date-time" }
}
}---
Strategic Design Checklist
- [ ] Identify ubiquitous language terms with domain experts
- [ ] Map subdomains (core, supporting, generic)
- [ ] Define bounded context boundaries
- [ ] Document context map with relationships
- [ ] Design anti-corruption layers for external systems
- [ ] Define integration event schemas
- [ ] Ensure each context has its own data store
DDD Tactical Patterns
Sources:
- Domain-Driven Design: The Blue Book — Eric Evans (2003)
- Implementing Domain-Driven Design — Vaughn Vernon (2013)
- Effective Aggregate Design — Vaughn Vernon
- Repository Pattern — Martin Fowler (PoEAA)
Building Blocks Overview
flowchart TB
subgraph Aggregate["Aggregate"]
subgraph AggRoot["Aggregate Root (Entity)"]
E1["Entity"]
E2["Entity"]
VO1["Value Object"]
VO2["Value Object"]
DE["Domain Event"]
end
end
Aggregate -->|Repository| Persistence[("Persistence")]
style Aggregate fill:#3b82f6,stroke:#2563eb,color:white
style AggRoot fill:#10b981,stroke:#059669,color:white
style Persistence fill:#6b7280,stroke:#4b5563,color:white---
Entity
An object with identity that persists through time. Two entities are equal if they have the same identity, regardless of attribute values.
Characteristics
- Has a unique identifier
- Identity persists through lifecycle
- Can change attributes but remains the same entity
- Contains behavior (not just data)
Pattern
abstract class Entity<ID>:
id: ID
equals(other: Entity<ID>) -> bool:
return this.id == other.id
class OrderItem extends Entity<OrderItemId>:
productId: ProductId
quantity: Quantity
unitPrice: Money
static create(productId, quantity, unitPrice) -> OrderItem:
return new OrderItem(
id: OrderItemId.generate(),
productId: productId,
quantity: quantity,
unitPrice: unitPrice
)
increaseQuantity(amount: int):
this.quantity = this.quantity.add(amount)
subtotal() -> Money:
return this.unitPrice.multiply(this.quantity.value)---
Value Object
An object defined by its attributes, not identity. Two value objects are equal if all their attributes are equal.
Characteristics
- Immutable (no setters)
- No identity
- Equality by value (all attributes)
- Self-validating
- Side-effect-free methods
Common Value Objects
| Value Object | Attributes | Validation |
|---|---|---|
| Money | amount, currency | amount >= 0 |
| address | valid email format | |
| Address | street, city, zip, country | required fields |
| DateRange | start, end | start <= end |
| Quantity | value | value > 0 |
Pattern
abstract class ValueObject<Props>:
props: Props
equals(other: ValueObject<Props>) -> bool:
return deepEqual(this.props, other.props)
class Money extends ValueObject<{amount, currency}>:
static create(amount, currency) -> Money:
guard: amount >= 0
guard: currency in SUPPORTED_CURRENCIES
return new Money({amount, currency})
static zero(currency = "USD") -> Money:
return Money.create(0, currency)
add(other: Money) -> Money:
guard: this.currency == other.currency
return Money.create(this.amount + other.amount, this.currency)
subtract(other: Money) -> Money:
guard: this.currency == other.currency
return Money.create(this.amount - other.amount, this.currency)
multiply(factor: number) -> Money:
return Money.create(this.amount * factor, this.currency)
class Email extends ValueObject<{value}>:
static create(email: string) -> Email:
normalized = email.lowercase().trim()
guard: isValidEmailFormat(normalized)
return new Email({value: normalized})
domain() -> string:
return this.value.split("@")[1]
class OrderId extends ValueObject<{value}>:
static generate() -> OrderId:
return new OrderId({value: generateUUID()})
static from(value: string) -> OrderId:
guard: value is not empty
return new OrderId({value})---
Aggregate
A cluster of entities and value objects treated as a single unit for data changes. Has a consistency boundary.
Rules
1. One aggregate root - Single entry point for all modifications 2. Reference by ID only - Aggregates reference others by identity, never by direct object reference 3. Transaction boundary - One aggregate per transaction (eventual consistency between aggregates) 4. Invariants within boundary - Aggregate ensures its own consistency 5. Small aggregates - Prefer smaller over larger
Aggregate Sizing Heuristics
| Metric | Healthy | Warning | Action |
|---|---|---|---|
| Entities per aggregate | 1-5 | 6-10 | >10: Split |
| Lines of code (root) | <500 | 500-1000 | >1000: Split |
| Transaction lock time | <100ms | 100-500ms | >500ms: Split |
| Concurrent modification conflicts | Rare | Occasional | Frequent: Split |
Questions to ask:
- Can parts be eventually consistent? → Separate aggregates
- Do all parts change together? → Same aggregate
- Are there independent lifecycles? → Separate aggregates
Design Guidelines
Good: Small Aggregates
flowchart LR
subgraph Order["Order Aggregate"]
O["Order"]
OI["OrderItems (embedded)"]
end
subgraph Customer["Customer Aggregate"]
C["Customer (standalone)"]
end
subgraph Product["Product Aggregate"]
P["Product (standalone)"]
end
Order -.->|customerId| Customer
Order -.->|productId| Product
style Order fill:#10b981,stroke:#059669,color:white
style Customer fill:#3b82f6,stroke:#2563eb,color:white
style Product fill:#3b82f6,stroke:#2563eb,color:whiteReference by ID only
Bad: God Aggregate
flowchart TB
subgraph GodOrder["Order (God Aggregate)"]
O2["Order"]
C2["Customer (embedded)"]
P2["Products (embedded)"]
SA["ShippingAddress (embedded)"]
end
style GodOrder fill:#ef4444,stroke:#dc2626,color:whiteToo large, too many reasons to change, contention issues
Pattern
abstract class AggregateRoot<ID> extends Entity<ID>:
domainEvents: List<DomainEvent> = []
version: int = 0
addDomainEvent(event: DomainEvent):
this.domainEvents.append(event)
clearDomainEvents():
this.domainEvents = []
class Order extends AggregateRoot<OrderId>:
customerId: CustomerId
items: List<OrderItem> = []
status: OrderStatus
shippingAddress: Address | null
createdAt: DateTime
static create(customerId: CustomerId) -> Order:
order = new Order(
id: OrderId.generate(),
customerId: customerId,
status: DRAFT,
createdAt: now()
)
order.addDomainEvent(OrderCreated{orderId, customerId})
return order
static reconstitute(id, customerId, items, status, ...) -> Order:
order = new Order(...)
return order
addItem(productId, quantity, unitPrice):
guard: status != CANCELLED
guard: status != SHIPPED
guard: quantity > 0
existingItem = this.items.find(i => i.productId == productId)
if existingItem:
existingItem.increaseQuantity(quantity)
else:
this.items.append(OrderItem.create(productId, quantity, unitPrice))
this.addDomainEvent(OrderItemAdded{orderId, productId, quantity})
removeItem(productId):
guard: status != CANCELLED
guard: status != SHIPPED
guard: item exists
this.items.remove(productId)
this.addDomainEvent(OrderItemRemoved{orderId, productId})
confirm():
guard: status == DRAFT
guard: items.length > 0
guard: shippingAddress != null
this.status = CONFIRMED
this.addDomainEvent(OrderConfirmed{orderId, total})
ship(trackingNumber):
guard: status == CONFIRMED
this.status = SHIPPED
this.addDomainEvent(OrderShipped{orderId, trackingNumber})
cancel(reason: string):
guard: status not in [SHIPPED, DELIVERED]
this.status = CANCELLED
this.addDomainEvent(OrderCancelled{orderId, reason})
total() -> Money:
return this.items.reduce((sum, item) => sum.add(item.subtotal()), Money.zero())
itemCount() -> int:
return this.items.reduce((sum, item) => sum + item.quantity.value, 0)---
Repository
Provides collection-like access to aggregates. Abstracts persistence.
Rules
1. One repository per aggregate - Not per entity or table 2. Domain interface - Interface in domain, implementation in infrastructure 3. Aggregate-focused - Save/load entire aggregates 4. No query logic - Complex queries belong in separate read models
Pattern
interface OrderRepository:
findById(id: OrderId) -> Order | null
findByCustomerId(customerId: CustomerId) -> List<Order>
save(order: Order)
delete(order: Order)
nextId() -> OrderId
interface Repository<T extends AggregateRoot<ID>, ID>:
findById(id: ID) -> T | null
save(aggregate: T)
delete(aggregate: T)Common Mistakes
Wrong: Repository per entity
interface OrderItemRepository:
findByOrderId(orderId) -> List<OrderItem>
save(item: OrderItem)Wrong: Query methods in repository
interface OrderRepository:
findByStatus(status) -> List<Order>
findByDateRange(start, end)
countByCustomer(customerId)Correct: Aggregate-focused + separate read model
interface OrderRepository:
findById(id: OrderId) -> Order | null
save(order: Order)
interface OrderReadModel:
findByStatus(status) -> List<OrderSummaryDTO>
findByDateRange(start, end) -> List<OrderSummaryDTO>
countByCustomer(customerId) -> int---
Domain Event
Records something significant that happened in the domain.
Characteristics
- Immutable
- Past tense naming (
OrderPlaced, notPlaceOrder) - Contains data needed by consumers
- Timestamp when it occurred
Pattern
abstract class DomainEvent:
eventId: string = generateUUID()
occurredAt: DateTime = now()
abstract eventType: string
abstract toPayload() -> Map
class OrderCreated extends DomainEvent:
eventType = "order.created"
orderId: OrderId
customerId: CustomerId
toPayload():
return {orderId: orderId.value, customerId: customerId.value}
class OrderConfirmed extends DomainEvent:
eventType = "order.confirmed"
orderId: OrderId
total: Money
toPayload():
return {orderId: orderId.value, total: {amount, currency}}
class OrderShipped extends DomainEvent:
eventType = "order.shipped"
orderId: OrderId
trackingNumber: TrackingNumber---
Domain Service
Stateless operations that don't naturally fit within an entity or value object.
When to Use
- Operation involves multiple aggregates
- Operation requires external information
- Significant business logic that doesn't belong to one entity
Pattern
interface PricingService:
calculateDiscount(order: Order, customer: Customer) -> Money
class PricingServiceImpl implements PricingService:
calculateDiscount(order, customer) -> Money:
discount = Money.zero()
if order.itemCount() > 10:
discount = discount.add(order.total().multiply(0.05))
if customer.isVIP:
discount = discount.add(order.total().multiply(0.10))
maxDiscount = order.total().multiply(0.20)
return min(discount, maxDiscount)
interface ShippingCostCalculator:
calculate(items: List<OrderItem>, destination: Address) -> Money
class ShippingCostCalculatorImpl implements ShippingCostCalculator:
calculate(items, destination) -> Money:
baseRate = Money.create(5.99, "USD")
perItemRate = Money.create(1.50, "USD")
total = baseRate.add(perItemRate.multiply(items.length))
if destination.country != "US":
total = total.add(Money.create(15.00, "USD"))
return total---
Factory
Encapsulates complex aggregate/entity creation.
When to Use
- Creation logic is complex
- Need to enforce invariants during creation
- Need to create object graphs
Pattern
interface OrderFactory:
createFromCart(cart: Cart, customer: Customer) -> Order
class OrderFactoryImpl implements OrderFactory:
pricingService: PricingService
createFromCart(cart, customer) -> Order:
guard: not cart.isEmpty
order = Order.create(customer.id)
for cartItem in cart.items:
order.addItem(
cartItem.productId,
Quantity.create(cartItem.quantity),
cartItem.unitPrice
)
if customer.defaultAddress:
order.setShippingAddress(customer.defaultAddress)
return order---
Specification Pattern
Encapsulates business rules for querying or validation.
interface Specification<T>:
isSatisfiedBy(candidate: T) -> bool
and(other: Specification<T>) -> Specification<T>
or(other: Specification<T>) -> Specification<T>
not() -> Specification<T>
class OrderOverValueSpec implements Specification<Order>:
minValue: Money
isSatisfiedBy(order) -> bool:
return order.total().amount >= minValue.amount
class OrderHasItemsSpec implements Specification<Order>:
isSatisfiedBy(order) -> bool:
return order.items.length > 0
canShipFree = OrderOverValueSpec(Money.create(100, "USD"))
.and(OrderHasItemsSpec())
if canShipFree.isSatisfiedBy(order):
applyFreeShipping()Hexagonal Architecture (Ports & Adapters)
Sources:
Primary:
- Hexagonal Architecture — Alistair Cockburn (2005)
- Hexagonal Architecture Explained — Alistair Cockburn & Juan Manuel Garrido de Paz (2024)
- Interview with Alistair Cockburn — Juan Manuel Garrido de Paz
Implementation guide:
- Hexagonal Architecture Pattern — AWS
Core Concept
"Allow an application to equally be driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
— Alistair Cockburn
Design validation technique: The pattern was designed with FIT testing in mind—business experts can write test cases before any GUI exists. If you can run your entire application from test fixtures, your hexagonal boundaries are correct.
The hexagon is conceptual. Most applications have 2-4 ports, not six. The shape emphasizes that all external interactions go through ports, regardless of direction.
This file uses a Hexagonal-focused layout where driven ports live under application/ports/driven/. In a DDD-centered layout, aggregate repository interfaces often live beside the aggregate in domain/. The important rule is ownership: the application/domain defines the abstractions it needs, and technology adapters implement them from the outside.
flowchart TB
subgraph DriverSide["DRIVER SIDE (Primary / Inbound / Left)"]
REST["REST API Adapter"]
CLI["CLI Adapter"]
DriverPorts["DRIVER PORTS\n(Use Case Interfaces)"]
REST --> DriverPorts
CLI --> DriverPorts
end
subgraph Hexagon["THE HEXAGON"]
subgraph AppCore["APPLICATION CORE"]
subgraph Domain["DOMAIN\n(Business Logic)"]
BL[" "]
end
end
end
subgraph DrivenSide["DRIVEN SIDE (Secondary / Outbound / Right)"]
DrivenPorts["DRIVEN PORTS\n(Repository Interfaces)"]
Postgres["Postgres Adapter"]
RabbitMQ["RabbitMQ Adapter"]
DrivenPorts --> Postgres
DrivenPorts --> RabbitMQ
end
DriverPorts --> AppCore
AppCore --> DrivenPorts
style DriverSide fill:#3b82f6,stroke:#2563eb,color:white
style Hexagon fill:#10b981,stroke:#059669,color:white
style DrivenSide fill:#f59e0b,stroke:#d97706,color:white
style Domain fill:#059669,stroke:#047857,color:white---
Ports
Interfaces defining how the application communicates with the outside world.
Explicit port interfaces are useful when multiple adapters, testing seams, or team boundaries justify them. For small codebases, a public use-case handler method can be enough as the driver port.
Driver Ports (Primary / Inbound)
Define how the world uses your application.
- Entry points to the application
- Called by adapters
- Represent use cases
// application/ports/driver/place_order_port.ts
export interface IPlaceOrderPort {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
// application/ports/driver/get_order_port.ts
export interface IGetOrderPort {
execute(query: GetOrderQuery): Promise<OrderDTO | null>;
}
// application/ports/driver/cancel_order_port.ts
export interface ICancelOrderPort {
execute(command: CancelOrderCommand): Promise<void>;
}Driven Ports (Secondary / Outbound)
Define how your application uses external systems.
- Dependencies the application needs
- Implemented by adapters
- Application calls these interfaces
// application/ports/driven/order_repository_port.ts
export interface IOrderRepositoryPort {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}
// application/ports/driven/event_publisher_port.ts
export interface IEventPublisherPort {
publish(event: DomainEvent): Promise<void>;
publishAll(events: DomainEvent[]): Promise<void>;
}
// application/ports/driven/payment_gateway_port.ts
export interface IPaymentGatewayPort {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
refund(paymentId: PaymentId, amount: Money): Promise<RefundResult>;
}
// application/ports/driven/notification_port.ts
export interface INotificationPort {
sendEmail(to: Email, template: EmailTemplate): Promise<void>;
sendSMS(to: PhoneNumber, message: string): Promise<void>;
}---
Adapters
Concrete implementations that connect ports to external technologies.
Driver Adapters (Primary / Inbound)
Convert external inputs to port calls.
// infrastructure/adapters/driver/rest/order_controller.ts
import { Router, Request, Response } from 'express';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { IGetOrderPort } from '@/application/ports/driver/get_order_port';
export class OrderController {
constructor(
private readonly placeOrder: IPlaceOrderPort,
private readonly getOrder: IGetOrderPort,
) {}
async create(req: Request, res: Response): Promise<void> {
const command: PlaceOrderCommand = {
customerId: req.user.id,
items: req.body.items.map((item: any) => ({
productId: item.product_id,
quantity: item.quantity,
})),
};
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ id: orderId.value });
}
async show(req: Request, res: Response): Promise<void> {
const order = await this.getOrder.execute({ orderId: req.params.id });
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
}
}
// infrastructure/adapters/driver/grpc/order_service.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { OrderServiceServer, PlaceOrderRequest, PlaceOrderResponse } from './generated/order_pb';
export class GrpcOrderService implements OrderServiceServer {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async placeOrder(
request: PlaceOrderRequest,
): Promise<PlaceOrderResponse> {
const command: PlaceOrderCommand = {
customerId: request.getCustomerId(),
items: request.getItemsList().map(item => ({
productId: item.getProductId(),
quantity: item.getQuantity(),
})),
};
const orderId = await this.placeOrder.execute(command);
const response = new PlaceOrderResponse();
response.setOrderId(orderId.value);
return response;
}
}
// infrastructure/adapters/driver/cli/place_order_command.ts
import { Command } from 'commander';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export function createPlaceOrderCommand(placeOrder: IPlaceOrderPort): Command {
return new Command('place-order')
.description('Place a new order')
.requiredOption('-c, --customer <id>', 'Customer ID')
.requiredOption('-p, --product <id>', 'Product ID')
.requiredOption('-q, --quantity <number>', 'Quantity', parseInt)
.action(async (options) => {
const orderId = await placeOrder.execute({
customerId: options.customer,
items: [{ productId: options.product, quantity: options.quantity }],
});
console.log(`Order created: ${orderId.value}`);
});
}
// infrastructure/adapters/driver/message/order_message_handler.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export class OrderMessageHandler {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async handlePlaceOrderMessage(message: PlaceOrderMessage): Promise<void> {
await this.placeOrder.execute({
customerId: message.customerId,
items: message.items,
});
}
}Driven Adapters (Secondary / Outbound)
Implement port interfaces using specific technologies.
class PostgresOrderRepository implements IOrderRepositoryPort:
db: Database
findById(id: OrderId) -> Order | null:
row = db.orders.where(id: id.value).first()
if not row:
return null
return OrderMapper.toDomain(row)
save(order: Order):
data = OrderMapper.toPersistence(order)
db.orders.upsert(data)
delete(order: Order):
db.orders.where(id: order.id.value).delete()In-Memory (for tests):
class InMemoryOrderRepository implements IOrderRepositoryPort:
orders: Map<string, Order> = {}
findById(id: OrderId) -> Order | null:
return orders.get(id.value) or null
save(order: Order):
orders.set(order.id.value, order)
delete(order: Order):
orders.delete(order.id.value)
clear():
orders.clear()Payment Gateway:
class StripePaymentGateway implements IPaymentGatewayPort:
stripe: StripeClient
charge(amount: Money, paymentMethod: PaymentMethod) -> PaymentResult:
try:
intent = stripe.paymentIntents.create({
amount: amount.cents,
currency: amount.currency,
paymentMethod: paymentMethod.stripeId,
confirm: true
})
return PaymentResult.success(PaymentId.from(intent.id))
catch CardError as error:
return PaymentResult.failed(error.message)
refund(paymentId: PaymentId, amount: Money) -> RefundResult:
refund = stripe.refunds.create({paymentIntent: paymentId.value, amount: amount.cents})
return RefundResult.success(RefundId.from(refund.id))Event Publisher:
class RabbitMQEventPublisher implements IEventPublisherPort:
channel: Channel
publish(event: DomainEvent):
channel.publish("domain_events", event.eventType, serialize({
eventId: event.eventId,
eventType: event.eventType,
occurredAt: event.occurredAt,
payload: event.toPayload()
}))
publishAll(events: List<DomainEvent>):
for event in events:
publish(event)---
Naming Conventions
Alistair Cockburn's Recommended Pattern
Ports: For[Doing][Something]
- Driver:
ForPlacingOrders,ForConfiguringSettings - Driven:
ForStoringUsers,ForNotifyingAlerts
Adapters: Reference the technology
CliCommandForPlacingOrdersMysqlDatabaseForStoringUsersSlackNotifierForAlerts
Alternative Patterns
| Pattern | Port | Adapter |
|---|---|---|
| Interface/Impl | IOrderRepository | PostgresOrderRepository |
| Port suffix | OrderRepositoryPort | PostgresOrderAdapter |
| Using prefix | IOrderStorage | OrderStorageUsingPostgres |
Project Structure
Use this structure when you want all Hexagonal ports grouped by direction. If the codebase follows the DDD-centered default from SKILL.md, keep aggregate repositories in domain/{aggregate}/repository and reserve application/ports/driven/ for application-owned dependencies such as payment gateways, notification gateways, clocks, or event publishers.
src/
├── application/
│ ├── ports/
│ │ ├── driver/ # Inbound ports
│ │ │ ├── place_order_port.ts
│ │ │ ├── get_order_port.ts
│ │ │ └── cancel_order_port.ts
│ │ └── driven/ # Outbound ports
│ │ ├── order_repository_port.ts
│ │ ├── event_publisher_port.ts
│ │ └── payment_gateway_port.ts
│ └── use_cases/
│ ├── place_order/
│ │ └── handler.ts # Implements driver port
│ └── get_order/
│ └── handler.ts
├── infrastructure/
│ └── adapters/
│ ├── driver/ # Inbound adapters
│ │ ├── rest/
│ │ │ └── order_controller.ts
│ │ ├── grpc/
│ │ │ └── order_service.ts
│ │ └── cli/
│ │ └── commands.ts
│ └── driven/ # Outbound adapters
│ ├── postgres/
│ │ └── order_repository.ts
│ ├── rabbitmq/
│ │ └── event_publisher.ts
│ ├── stripe/
│ │ └── payment_gateway.ts
│ └── in_memory/ # Test adapters
│ ├── order_repository.ts
│ └── event_publisher.ts
└── domain/
└── ...---
Key Asymmetry
flowchart TB
subgraph Driver["DRIVER (Left)"]
direction TB
DA["Adapter\n(Controller)"]
DP["Port\n(Interface)"]
DA -->|calls| DP
end
subgraph Driven["DRIVEN (Right)"]
direction TB
DRP["Port\n(Interface)"]
DRA["Adapter\n(Postgres)"]
DRA -->|implements| DRP
end
Driver -.->|"Application defines\nwhat it OFFERS"| Note1[" "]
Driven -.->|"Application defines\nwhat it NEEDS"| Note2[" "]
style Driver fill:#3b82f6,stroke:#2563eb,color:white
style Driven fill:#f59e0b,stroke:#d97706,color:white
style Note1 fill:none,stroke:none
style Note2 fill:none,stroke:none---
Configurability via Adapters
The power of hexagonal architecture: swap adapters without changing the core.
// infrastructure/config/container.ts
function configureDevelopment(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(InMemoryEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(FakePaymentGateway);
}
function configureTest(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(SpyEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(MockPaymentGateway);
}
function configureProduction(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(PostgresOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(RabbitMQEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(StripePaymentGateway);
}
function configureWithMongoDB(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(MongoDBOrderRepository);
}---
Strong vs Weak Hexagonal
Weak Implementation
Port is technology-aware (not truly abstract):
// ❌ Weak: Leaks SQL concepts
interface IOrderRepository {
findByQuery(sql: string, params: any[]): Promise<Order[]>;
}Strong Implementation
Port is fully technology-agnostic:
// ✅ Strong: Pure domain concepts
interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}---
Benefits
1. Testability - Swap real adapters for test doubles 2. Flexibility - Change technologies without changing core 3. Independence - Develop core without external systems 4. Clear boundaries - Explicit interfaces between layers 5. Parallel development - Teams work on different adapters
Layer Structure - Complete Reference
Sources:
Primary:
- The Clean Architecture — Robert C. Martin
- Onion Architecture — Jeffrey Palermo
Implementation guide:
- Designing a DDD-oriented Microservice — Microsoft
Supplemental synthesis:
- Clean Architecture: Standing on the Shoulders of Giants — Herberto Graça
The Four Layers
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business logic, entities, rules | None (pure) |
| Application | Use cases, orchestration | Domain |
| Infrastructure | External systems, frameworks | Application, Domain |
| Presentation | API/UI entry points | Application |
This reference uses a DDD-centered variant: aggregate repository interfaces live in the Domain layer, while use-case ports and application-owned outbound ports live in the Application layer. A stricter Hexagonal layout may put all driven ports under application/ports/driven/ instead. Both are acceptable when dependencies still point inward and infrastructure implements, rather than owns, the abstractions.
---
Domain Layer (Innermost)
The heart of the system. Contains business logic and rules with zero external dependencies.
Contents
domain/
├── order/ # Aggregate folder
│ ├── order.ts # Aggregate root entity
│ ├── order_item.ts # Child entity
│ ├── value_objects.ts # Money, Address, OrderStatus
│ ├── events.ts # OrderPlaced, OrderShipped
│ ├── repository.ts # IOrderRepository interface (DDD repository port)
│ ├── services.ts # PricingService, DiscountService
│ └── errors.ts # InsufficientStockError
├── customer/
│ └── ...
├── product/
│ └── ...
└── shared/
├── entity.ts # Base Entity class
├── aggregate_root.ts # Base AggregateRoot class
├── value_object.ts # Base ValueObject class
├── domain_event.ts # Base DomainEvent class
└── errors.ts # DomainError baseRules
1. No framework imports - No ORM decorators, no HTTP libraries 2. No infrastructure concerns - No database, no message queues 3. Pure business logic - Only language primitives and domain types 4. Rich behavior - Methods that enforce business rules
Example: Domain Entity
// domain/order/order.ts
import { AggregateRoot } from '../shared/aggregate_root';
import { OrderItem } from './order_item';
import { Money } from './value_objects';
import { OrderPlaced, OrderShipped } from './events';
import { InsufficientStockError } from './errors';
export class Order extends AggregateRoot<OrderId> {
private items: OrderItem[] = [];
private status: OrderStatus;
private constructor(id: OrderId, customerId: CustomerId) {
super(id);
this.customerId = customerId;
this.status = OrderStatus.Draft;
}
static create(id: OrderId, customerId: CustomerId): Order {
const order = new Order(id, customerId);
order.addDomainEvent(new OrderPlaced(id, customerId));
return order;
}
addItem(product: Product, quantity: number): void {
if (quantity <= 0) {
throw new InvalidQuantityError(quantity);
}
if (!product.hasStock(quantity)) {
throw new InsufficientStockError(product.id, quantity);
}
const existingItem = this.items.find(i => i.productId.equals(product.id));
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(OrderItem.create(product.id, product.price, quantity));
}
}
ship(): void {
if (this.status !== OrderStatus.Confirmed) {
throw new InvalidOrderStateError('Cannot ship unconfirmed order');
}
this.status = OrderStatus.Shipped;
this.addDomainEvent(new OrderShipped(this.id));
}
get total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero()
);
}
}---
Application Layer
Orchestrates use cases by coordinating domain objects. Contains application-specific business rules.
Contents
application/
├── orders/
│ ├── place_order/
│ │ ├── command.ts # PlaceOrderCommand DTO
│ │ ├── handler.ts # PlaceOrderHandler
│ │ └── port.ts # IPlaceOrderUseCase interface
│ ├── ship_order/
│ │ └── ...
│ └── get_order/
│ ├── query.ts # GetOrderQuery DTO
│ ├── handler.ts # GetOrderHandler
│ └── result.ts # OrderDTO response
├── shared/
│ ├── unit_of_work.ts # IUnitOfWork interface
│ ├── event_publisher.ts # IEventPublisher interface
│ └── errors.ts # ApplicationError base
└── index.ts # Public API exportsRules
1. Depends only on Domain - No infrastructure imports 2. Defines application ports - Use-case interfaces and application-owned outbound dependencies 3. Orchestrates, doesn't implement - Calls domain methods 4. Transaction boundary - Manages unit of work
Example: Use Case Handler
// application/orders/place_order/handler.ts
import { Order } from '@/domain/order/order';
import { IOrderRepository } from '@/domain/order/repository';
import { IProductRepository } from '@/domain/product/repository';
import { IUnitOfWork } from '@/application/shared/unit_of_work';
import { IEventPublisher } from '@/application/shared/event_publisher';
import { PlaceOrderCommand } from './command';
import { OrderNotFoundError, ProductNotFoundError } from '@/application/shared/errors';
export interface IPlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
export class PlaceOrderHandler implements IPlaceOrderUseCase {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly productRepo: IProductRepository,
private readonly uow: IUnitOfWork,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
await this.uow.begin();
try {
const orderId = OrderId.generate();
const order = Order.create(orderId, command.customerId);
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
if (!product) {
throw new ProductNotFoundError(item.productId);
}
order.addItem(product, item.quantity);
}
await this.orderRepo.save(order);
await this.uow.commit();
await this.eventPublisher.publishAll(order.domainEvents);
return orderId;
} catch (error) {
await this.uow.rollback();
throw error;
}
}
}Command/Query DTOs
// application/orders/place_order/command.ts
export interface PlaceOrderCommand {
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
// application/orders/get_order/query.ts
export interface GetOrderQuery {
orderId: string;
}
// application/orders/get_order/result.ts
export interface OrderDTO {
id: string;
customerId: string;
status: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
total: number;
createdAt: string;
}---
Infrastructure Layer
Implements interfaces defined in Domain and Application layers. Contains all external concerns.
Contents
infrastructure/
├── persistence/
│ ├── postgres/
│ │ ├── order_repository.ts # PostgresOrderRepository
│ │ ├── product_repository.ts
│ │ ├── unit_of_work.ts # PostgresUnitOfWork
│ │ ├── migrations/
│ │ └── mappers/
│ │ └── order_mapper.ts # Domain <-> DB mapping
│ └── in_memory/
│ ├── order_repository.ts # InMemoryOrderRepository (tests)
│ └── unit_of_work.ts
├── messaging/
│ ├── rabbitmq/
│ │ └── event_publisher.ts # RabbitMQEventPublisher
│ └── in_memory/
│ └── event_publisher.ts # InMemoryEventPublisher (tests)
├── external/
│ ├── payment/
│ │ └── stripe_gateway.ts # StripePaymentGateway
│ └── shipping/
│ └── fedex_service.ts # FedExShippingService
├── http/
│ ├── rest/
│ │ ├── controllers/
│ │ │ └── order_controller.ts # REST API adapter
│ │ ├── middleware/
│ │ └── routes.ts
│ └── graphql/
│ └── resolvers/
├── grpc/
│ └── order_service.ts # gRPC adapter
└── config/
├── container.ts # DI container setup
└── env.ts # Environment configRules
1. Implements ports - Concrete classes for domain/application interfaces 2. Contains framework code - ORM, HTTP frameworks, etc. 3. Maps between layers - Domain ↔ Database/DTO mapping 4. Easily replaceable - Can swap Postgres for MongoDB
Example: Repository Implementation
class PostgresOrderRepository implements IOrderRepository:
db: Database
findById(id: OrderId) -> Order | null:
row = db.orders
.where(id: id.value)
.withRelated("items")
.first()
if not row:
return null
return OrderMapper.toDomain(row)
save(order: Order):
data = OrderMapper.toPersistence(order)
db.orders.upsert(data)
delete(order: Order):
db.orders.where(id: order.id.value).delete()---
Presentation Layer
Entry points to the application. Adapts external requests to application commands/queries.
Contents
presentation/
├── rest/
│ ├── controllers/
│ │ ├── order_controller.ts
│ │ └── product_controller.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ ├── error_handler.ts
│ │ └── validation.ts
│ ├── dto/
│ │ ├── requests/
│ │ └── responses/
│ └── routes.ts
├── grpc/
│ └── ...
├── graphql/
│ └── ...
└── cli/
└── ...Example: REST Controller
// presentation/rest/controllers/order_controller.ts
import { Request, Response, NextFunction } from 'express';
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
import { IGetOrderUseCase } from '@/application/orders/get_order/port';
import { PlaceOrderRequest } from '../dto/requests/place_order_request';
export class OrderController {
constructor(
private readonly placeOrder: IPlaceOrderUseCase,
private readonly getOrder: IGetOrderUseCase,
) {}
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const request = req.body as PlaceOrderRequest;
const orderId = await this.placeOrder.execute({
customerId: req.user.id,
items: request.items.map(item => ({
productId: item.product_id,
quantity: item.quantity,
})),
});
res.status(201).json({ id: orderId.value });
} catch (error) {
next(error);
}
}
async show(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const order = await this.getOrder.execute({ orderId: req.params.id });
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
} catch (error) {
next(error);
}
}
}---
Dependency Flow
flowchart TB
subgraph Presentation["Presentation"]
REST["REST Controller"]
end
subgraph Application["Application"]
Handler["PlaceOrderHandler"]
Port1["IPlaceOrderUseCase (port)"]
Port2["IOrderRepository"]
Handler -.->|implements| Port1
Handler -->|uses| Port2
end
subgraph Domain["Domain"]
Aggregate["Order (Aggregate Root)"]
RepoInterface["IOrderRepository (interface)"]
end
subgraph Infrastructure["Infrastructure"]
PgRepo["PostgresOrderRepository"]
RabbitMQ["RabbitMQEventPublisher"]
PgRepo -.->|implements| RepoInterface
RabbitMQ -.->|implements| EventPub["IEventPublisher"]
end
REST -->|calls| Handler
Application -->|defines interfaces| Domain
Infrastructure -->|implements| Domain
style Presentation fill:#f59e0b,stroke:#d97706,color:white
style Application fill:#3b82f6,stroke:#2563eb,color:white
style Domain fill:#10b981,stroke:#059669,color:white
style Infrastructure fill:#6366f1,stroke:#4f46e5,color:white---
Composition Root
All dependencies are wired together at the application entry point.
import { Pool } from 'pg';
import { Container } from 'inversify';
import { IOrderRepository } from '@/domain/order/repository';
import { IProductRepository } from '@/domain/product/repository';
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
import { IUnitOfWork } from '@/application/shared/unit_of_work';
import { IEventPublisher } from '@/application/shared/event_publisher';
import { PlaceOrderHandler } from '@/application/orders/place_order/handler';
import { PostgresOrderRepository } from '@/infrastructure/persistence/postgres/order_repository';
import { PostgresProductRepository } from '@/infrastructure/persistence/postgres/product_repository';
import { PostgresUnitOfWork } from '@/infrastructure/persistence/postgres/unit_of_work';
import { RabbitMQEventPublisher } from '@/infrastructure/messaging/rabbitmq/event_publisher';
import { OrderController } from '@/presentation/rest/controllers/order_controller';
export function configureContainer(): Container {
const container = new Container();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
container.bind<Pool>('Pool').toConstantValue(pool);
container.bind<IOrderRepository>('IOrderRepository').to(PostgresOrderRepository);
container.bind<IProductRepository>('IProductRepository').to(PostgresProductRepository);
container.bind<IUnitOfWork>('IUnitOfWork').to(PostgresUnitOfWork);
container.bind<IEventPublisher>('IEventPublisher').to(RabbitMQEventPublisher);
container.bind<IPlaceOrderUseCase>('IPlaceOrderUseCase').to(PlaceOrderHandler);
container.bind<OrderController>(OrderController).toSelf();
return container;
}---
Language-Agnostic Structure
The same layered structure applies to any language:
Go
internal/
├── domain/
├── application/
├── infrastructure/
└── interfaces/ # PresentationRust
src/
├── domain/
├── application/
├── infrastructure/
└── presentation/Python
src/
├── domain/
├── application/
├── infrastructure/
└── presentation/The key is dependency direction: outer layers import inner layers, never the reverse.
Testing Patterns
Sources:
- The Clean Architecture — Robert C. Martin
- Hexagonal Architecture — Alistair Cockburn
- Unit Testing — Martin Fowler
- Test Pyramid — Martin Fowler
Testing strategies for Clean Architecture + DDD + Hexagonal systems.
Testing Pyramid
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
flowchart TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests\nFew, slow, expensive"]
Integration["Integration Tests\nSome, moderate speed"]
Unit["Unit Tests (Domain & Application)\nMany, fast, cheap"]
end
E2E --- Integration
Integration --- Unit
style E2E fill:#ef4444,stroke:#dc2626,color:white
style Integration fill:#f59e0b,stroke:#d97706,color:white
style Unit fill:#10b981,stroke:#059669,color:white---
Unit Tests
Domain Layer Tests
Test business logic in isolation. No mocks needed—domain has no dependencies.
// tests/domain/order/order.test.ts
describe('Order', () => {
describe('create', () => {
it('creates order with draft status', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.status).toBe(OrderStatus.Draft);
expect(order.customerId).toEqual(customerId);
expect(order.items).toHaveLength(0);
});
it('emits OrderCreated event', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.domainEvents).toHaveLength(1);
expect(order.domainEvents[0]).toBeInstanceOf(OrderCreated);
});
});
describe('addItem', () => {
it('adds item to order', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const quantity = Quantity.create(2);
const price = Money.create(10.00, 'USD');
order.addItem(productId, quantity, price);
expect(order.items).toHaveLength(1);
expect(order.items[0].productId).toEqual(productId);
expect(order.items[0].quantity).toEqual(quantity);
});
it('increases quantity for existing product', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const price = Money.create(10.00, 'USD');
order.addItem(productId, Quantity.create(2), price);
order.addItem(productId, Quantity.create(3), price);
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity.value).toBe(5);
});
it('throws when order is cancelled', () => {
const order = createCancelledOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
}).toThrow(InvalidOrderStateError);
});
it('throws when quantity is zero', () => {
const order = createDraftOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(0), Money.create(10, 'USD'));
}).toThrow(InvalidQuantityError);
});
});
describe('confirm', () => {
it('changes status to confirmed', () => {
const order = createOrderWithItems();
order.confirm();
expect(order.status).toBe(OrderStatus.Confirmed);
});
it('emits OrderConfirmed event', () => {
const order = createOrderWithItems();
order.confirm();
const events = order.domainEvents.filter(e => e instanceof OrderConfirmed);
expect(events).toHaveLength(1);
});
it('throws when order is empty', () => {
const order = createDraftOrder();
expect(() => order.confirm()).toThrow(EmptyOrderError);
});
it('throws when already confirmed', () => {
const order = createConfirmedOrder();
expect(() => order.confirm()).toThrow(InvalidOrderStateError);
});
});
describe('total', () => {
it('calculates total from all items', () => {
const order = createDraftOrder();
order.addItem(ProductId.from('p1'), Quantity.create(2), Money.create(10, 'USD'));
order.addItem(ProductId.from('p2'), Quantity.create(1), Money.create(25, 'USD'));
expect(order.total.amount).toBe(45); // 2*10 + 1*25
});
it('returns zero for empty order', () => {
const order = createDraftOrder();
expect(order.total.amount).toBe(0);
});
});
});
// Test helpers (builders)
function createDraftOrder(): Order {
return Order.create(CustomerId.from('cust-123'));
}
function createOrderWithItems(): Order {
const order = createDraftOrder();
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
return order;
}
function createConfirmedOrder(): Order {
const order = createOrderWithItems();
order.setShippingAddress(createTestAddress());
order.confirm();
return order;
}
function createCancelledOrder(): Order {
const order = createOrderWithItems();
order.cancel('Test cancellation');
return order;
}Value Object Tests
// tests/domain/shared/money.test.ts
describe('Money', () => {
describe('create', () => {
it('creates money with valid amount', () => {
const money = Money.create(10.50, 'USD');
expect(money.amount).toBe(10.50);
expect(money.currency).toBe('USD');
});
it('throws for negative amount', () => {
expect(() => Money.create(-1, 'USD')).toThrow(InvalidMoneyError);
});
});
describe('add', () => {
it('adds two money values with same currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
const result = a.add(b);
expect(result.amount).toBe(30);
expect(result.currency).toBe('USD');
});
it('throws for different currencies', () => {
const usd = Money.create(10, 'USD');
const eur = Money.create(10, 'EUR');
expect(() => usd.add(eur)).toThrow(CurrencyMismatchError);
});
});
describe('equality', () => {
it('equals money with same amount and currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(10, 'USD');
expect(a.equals(b)).toBe(true);
});
it('not equal with different amount', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
expect(a.equals(b)).toBe(false);
});
});
});Application Layer Tests
Test use cases with mocked ports.
// tests/application/place_order/handler.test.ts
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepo: MockOrderRepository;
let productRepo: MockProductRepository;
let eventPublisher: MockEventPublisher;
beforeEach(() => {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
});
it('creates order with items and saves', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
productRepo.addProduct(createTestProduct('prod-2', 20.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
};
const orderId = await handler.handle(command);
expect(orderId).toBeDefined();
const savedOrder = await orderRepo.findById(OrderId.from(orderId));
expect(savedOrder).not.toBeNull();
expect(savedOrder!.items).toHaveLength(2);
expect(savedOrder!.total.amount).toBe(40); // 2*10 + 1*20
});
it('publishes domain events', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await handler.handle(command);
expect(eventPublisher.publishedEvents).toHaveLength(1);
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
});
it('throws when product not found', async () => {
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'nonexistent', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow(ProductNotFoundError);
});
it('rolls back on error', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
orderRepo.simulateErrorOnSave();
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow();
expect(orderRepo.savedOrders).toHaveLength(0);
});
});
// Mock implementations
class MockOrderRepository implements IOrderRepository {
savedOrders: Order[] = [];
private shouldError = false;
async findById(id: OrderId): Promise<Order | null> {
return this.savedOrders.find(o => o.id.equals(id)) ?? null;
}
async save(order: Order): Promise<void> {
if (this.shouldError) {
throw new Error('Simulated save error');
}
this.savedOrders.push(order);
}
async delete(order: Order): Promise<void> {
const index = this.savedOrders.findIndex(o => o.id.equals(order.id));
if (index >= 0) {
this.savedOrders.splice(index, 1);
}
}
simulateErrorOnSave(): void {
this.shouldError = true;
}
}
class MockEventPublisher implements IEventPublisher {
publishedEvents: DomainEvent[] = [];
async publish(event: DomainEvent): Promise<void> {
this.publishedEvents.push(event);
}
async publishAll(events: DomainEvent[]): Promise<void> {
this.publishedEvents.push(...events);
}
}---
Integration Tests
Test adapters with real infrastructure (databases, message brokers).
// tests/integration/postgres/order_repository.test.ts
describe('PostgresOrderRepository', () => {
let pool: Pool;
let repository: PostgresOrderRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new PostgresOrderRepository(pool);
});
beforeEach(async () => {
await pool.query('TRUNCATE orders, order_items CASCADE');
});
afterAll(async () => {
await pool.end();
});
describe('save and findById', () => {
it('persists and retrieves order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(2), Money.create(10, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id.value).toBe(order.id.value);
expect(retrieved!.items).toHaveLength(1);
expect(retrieved!.items[0].quantity.value).toBe(2);
});
it('updates existing order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(1), Money.create(10, 'USD'));
await repository.save(order);
order.addItem(ProductId.from('prod-2'), Quantity.create(3), Money.create(20, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved!.items).toHaveLength(2);
});
it('returns null for nonexistent order', async () => {
const result = await repository.findById(OrderId.from('nonexistent'));
expect(result).toBeNull();
});
});
describe('delete', () => {
it('removes order from database', async () => {
const order = Order.create(CustomerId.from('cust-123'));
await repository.save(order);
await repository.delete(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).toBeNull();
});
});
});API Integration Tests
// tests/integration/http/orders_api.test.ts
describe('Orders API', () => {
let app: Express;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
app = createApp(pool); // Configures real repositories
});
beforeEach(async () => {
await db.truncate("orders", "order_items", "products");
await db.products.insertMany([
{ id: "prod-1", name: "Product 1", price: 1000 },
{ id: "prod-2", name: "Product 2", price: 2000 }
]);
});
afterAll(async () => {
await pool.end();
});
describe('POST /orders', () => {
it('creates order and returns 201', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [
{ product_id: 'prod-1', quantity: 2 },
{ product_id: 'prod-2', quantity: 1 },
],
});
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
});
it('returns 400 for invalid product', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'nonexistent', quantity: 1 }],
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Product not found');
});
});
describe('GET /orders/:id', () => {
it('returns order details', async () => {
const createResponse = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'prod-1', quantity: 2 }],
});
const orderId = createResponse.body.id;
const response = await request(app).get(`/orders/${orderId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(orderId);
expect(response.body.items).toHaveLength(1);
});
it('returns 404 for nonexistent order', async () => {
const response = await request(app).get('/orders/nonexistent');
expect(response.status).toBe(404);
});
});
});---
Architecture Tests
Verify architectural rules are followed.
// tests/architecture/dependency_rules.test.ts
import { filesOfProject } from 'ts-arch';
describe('Architecture', () => {
describe('Dependency Rules', () => {
it('domain should not depend on application', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.inFolder('application');
await expect(rule).toPassAsync();
});
it('domain should not depend on infrastructure', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.inFolder('infrastructure');
await expect(rule).toPassAsync();
});
it('application should not depend on infrastructure', async () => {
const rule = filesOfProject()
.inFolder('application')
.shouldNot()
.dependOnFiles()
.inFolder('infrastructure');
await expect(rule).toPassAsync();
});
it('domain should have no external framework dependencies', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.matchingPattern('node_modules/(express|pg|axios|typeorm)/');
await expect(rule).toPassAsync();
});
});
describe('Naming Conventions', () => {
it('repositories should be named *Repository', async () => {
const rule = filesOfProject()
.inFolder('domain/**/repository')
.should()
.matchPattern('.*Repository\\.ts$');
await expect(rule).toPassAsync();
});
it('domain events should be named in past tense', async () => {
const rule = filesOfProject()
.inFolder('domain/**/events')
.should()
.matchPattern('.*(Created|Updated|Deleted|Confirmed|Shipped|Cancelled)\\.ts$');
await expect(rule).toPassAsync();
});
});
});---
Test Organization
tests/
├── unit/
│ ├── domain/
│ │ ├── order/
│ │ │ ├── order.test.ts
│ │ │ ├── order_item.test.ts
│ │ │ └── value_objects.test.ts
│ │ └── shared/
│ │ ├── money.test.ts
│ │ └── email.test.ts
│ └── application/
│ ├── place_order/
│ │ └── handler.test.ts
│ └── confirm_order/
│ └── handler.test.ts
├── integration/
│ ├── persistence/
│ │ └── postgres_order_repository.test.ts
│ ├── messaging/
│ │ └── rabbitmq_event_publisher.test.ts
│ └── http/
│ └── orders_api.test.ts
├── e2e/
│ └── order_workflow.test.ts
├── architecture/
│ └── dependency_rules.test.ts
├── fixtures/
│ ├── order_fixtures.ts
│ └── product_fixtures.ts
└── helpers/
├── test_database.ts
└── mock_factories.ts---
Test Fixtures & Builders
// tests/fixtures/order_fixtures.ts
export class OrderBuilder {
private customerId: CustomerId = CustomerId.from('default-customer');
private items: Array<{ productId: ProductId; quantity: Quantity; price: Money }> = [];
private status: 'draft' | 'confirmed' | 'shipped' | 'cancelled' = 'draft';
withCustomer(id: string): this {
this.customerId = CustomerId.from(id);
return this;
}
withItem(productId: string, quantity: number, price: number): this {
this.items.push({
productId: ProductId.from(productId),
quantity: Quantity.create(quantity),
price: Money.create(price, 'USD'),
});
return this;
}
confirmed(): this {
this.status = 'confirmed';
return this;
}
build(): Order {
const order = Order.create(this.customerId);
for (const item of this.items) {
order.addItem(item.productId, item.quantity, item.price);
}
if (this.status === 'confirmed') {
order.setShippingAddress(new AddressBuilder().build());
order.confirm();
}
order.clearEvents(); // Clear events from building
return order;
}
}
// Usage
const order = new OrderBuilder()
.withCustomer('cust-123')
.withItem('prod-1', 2, 10.00)
.withItem('prod-2', 1, 25.00)
.confirmed()
.build();---
Key Testing Principles
1. Test behavior, not implementation - Focus on what, not how 2. Domain tests need no mocks - Domain layer is pure 3. Mock at port boundaries - Application tests mock driven ports 4. Integration tests use real infra - Test actual database, message broker 5. Fast unit tests, slower integration - Run unit tests frequently 6. Test business rules in domain - Not in application or infrastructure
Related skills
How it compares
Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture
FAQ
Who is clean-ddd-hexagonal for?
Developers applying clean-ddd-hexagonal from its SKILL.md guidance.
When should I use clean-ddd-hexagonal?
Proactively apply when designing APIs, microservices, or scalable backend structure. Triggers on DDD, Clean Architecture, Hexagonal, ports and adapters, entitie
Is clean-ddd-hexagonal safe to install?
Review the Security Audits panel on this page before installing in production.