| name | hexagonal-architecture |
| description | Use when designing or reviewing system architecture. Hexagonal + DDD + CQRS patterns. |
Hexagonal Architecture + DDD + CQRS
Reference implementations:
1. Four-Layer Model
Presentation → Infrastructure → Application → Domain
(inward-only dependencies)
∀ layer L: imports(L) ⊆ inner_layers(L) — NO EXCEPTIONS
| Layer | Depends On | Never Depends On |
|---|
| Domain | nothing (pure) | App, Infra, Presentation |
| Application | Domain | Infra, Presentation |
| Infrastructure | Domain, Application | Presentation |
| Presentation | Application | Domain directly, Infra directly |
Dependency arrow always points inward. Outer layers know about inner layers; inner layers oblivious to outside world.
2. Domain Layer
Innermost ring. Pure business logic, zero framework dependencies.
Allowed imports: zod, node:crypto, Result type — NOTHING else.
Entities
- Private constructor + public static factory returning
Result<Entity, DomainError>
- Identity by unique ID (¬ by attribute equality)
- Encapsulate behavior — ¬anemic (methods mutate internal state, enforce invariants)
import { z } from 'zod';
import { Result, ok, err } from '@/shared/result';
const UserPropsSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(255),
createdAt: z.date(),
});
type UserProps = z.infer<typeof UserPropsSchema>;
export class User {
private constructor(private readonly props: UserProps) {}
static create(input: { email: string; name: string }): Result<User, DomainError> {
const parsed = UserPropsSchema.safeParse({
id: crypto.randomUUID(),
email: input.email,
name: input.name,
createdAt: new Date(),
});
if (!parsed.success) return err(DomainError.validation(parsed.error));
return ok(new User(parsed.data));
}
static reconstitute(props: UserProps): User {
return new User(props);
}
get id(): string { return this.props.id; }
get email(): string { return this.props.email; }
get name(): string { return this.props.name; }
rename(newName: string): Result<void, DomainError> {
const parsed = z.string().min(1).max(255).safeParse(newName);
if (!parsed.success) return err(DomainError.validation(parsed.error));
(this.props as any).name = parsed.data;
return ok(undefined);
}
}
Value Objects
- Immutable (all fields
readonly)
- Equality by value (¬ reference)
- No identity — interchangeable if same value
- Validated at construction via Zod
const EmailSchema = z.string().email();
export class Email {
private constructor(readonly value: string) {}
static create(raw: string): Result<Email, DomainError> {
const parsed = EmailSchema.safeParse(raw);
if (!parsed.success) return err(DomainError.validation(parsed.error));
return ok(new Email(parsed.data));
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
Aggregates
- Cluster of entities + value objects with single aggregate root
- Consistency boundary: ∀ invariants enforced within aggregate
- External references by ID only — ¬direct object references across aggregates
- One transaction = one aggregate
Domain Events
- Record what happened (past tense):
UserCreatedEvent, OrderPlacedEvent
- Immutable data carriers — ¬ behavior
- Named
{entity}-{action}.event.ts
export class UserCreatedEvent {
readonly occurredAt = new Date();
constructor(
readonly userId: string,
readonly email: string,
) {}
}
Domain Services
- Business logic that doesn't belong to single entity
- Stateless — operates on entities/VOs passed ∈
- Still pure domain: ¬I/O, ¬framework imports
Repository Ports (Interfaces)
- Defined ∈ domain — implemented ∈ infrastructure
- Named
{aggregate}.repository.ts
- Return
Result<T, DomainError> for fallible ops
export interface UserRepository {
findById(id: string): Promise<Result<User | null, DomainError>>;
save(user: User): Promise<Result<void, DomainError>>;
delete(id: string): Promise<Result<void, DomainError>>;
}
3. Application Layer — CQRS
Orchestrates domain objects. One service per use case. Depends on domain only.
Commands (Write Side)
- State-changing operations → return
Result<void, AppError> ∨ Result<Id, AppError>
- Named
{action}-{entity}.command.ts
- Each command has exactly one handler
export class CreateUserCommand {
constructor(
readonly email: string,
readonly name: string,
) {}
}
export class CreateUserHandler {
constructor(private readonly userRepo: UserRepository) {}
async execute(cmd: CreateUserCommand): Promise<Result<string, AppError>> {
const userResult = User.create({ email: cmd.email, name: cmd.name });
if (!userResult.ok) return err(AppError.fromDomain(userResult.error));
const saveResult = await this.userRepo.save(userResult.value);
if (!saveResult.ok) return err(AppError.fromDomain(saveResult.error));
return ok(userResult.value.id);
}
}
Queries (Read Side)
- Read-only operations → return data (DTOs, projections)
- Named
{action}-{entity}.query.ts
- ¬modify state, ¬emit events
export class GetUserQuery {
constructor(readonly userId: string) {}
}
export class GetUserHandler {
constructor(private readonly userRepo: UserRepository) {}
async execute(query: GetUserQuery): Promise<Result<UserDto | null, AppError>> {
return this.userRepo.findById(query.userId);
}
}
Application Services
- Thin orchestration layer: validate input → call domain → persist via port
- One public method per use case (∨ one handler class)
- ¬business logic here — delegate to domain entities/services
- Inject ports (interfaces), ¬concrete implementations
4. Infrastructure Layer
Implements domain ports with real I/O. Depends on Domain ∧ Application.
Repository Adapters
- Implement domain port interfaces
- Handle serialization, DB queries, external API calls
- Named
sql-{aggregate}.repository.ts ∨ in-memory-{aggregate}.repository.ts
export class SqlUserRepository implements UserRepository {
constructor(private readonly db: DatabaseClient) {}
async findById(id: string): Promise<Result<User | null, DomainError>> {
try {
const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!row) return ok(null);
return ok(SqlUserMapper.toDomain(row));
} catch (e) {
return err(DomainError.infrastructure('Database query failed'));
}
}
async save(user: User): Promise<Result<void, DomainError>> {
try {
const row = SqlUserMapper.toPersistence(user);
await this.db.query(
'INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET name = $4',
[row.id, row.email, row.name, row.createdAt],
);
return ok(undefined);
} catch (e) {
return err(DomainError.infrastructure('Database write failed'));
}
}
}
Data Mappers
- Convert between DB rows ∧ domain entities
- Named
sql-{aggregate}.mapper.ts
- Use
Entity.reconstitute() to rebuild domain objects from persistence
export class SqlUserMapper {
static toDomain(row: UserRow): User {
return User.reconstitute({
id: row.id,
email: row.email,
name: row.name,
createdAt: new Date(row.created_at),
});
}
static toPersistence(user: User): UserRow {
return {
id: user.id,
email: user.email,
name: user.name,
created_at: user.createdAt.toISOString(),
};
}
}
External Service Adapters
- Wrap third-party APIs behind domain port interfaces
- Isolate external contracts from domain logic
- Handle retries, timeouts, error mapping at this layer
5. Presentation Layer
Outermost ring. Entry points, wiring, user-facing I/O.
Depends on Application only (invokes commands/queries, ¬touches domain directly).
Controllers / Resolvers
- Parse incoming request → build Command/Query → delegate to handler → format response
- ¬business logic, ¬direct DB access
DTOs (Request/Response)
- Zod schemas for request validation
- Separate from domain schemas — presentation concern
- Named
{action}-{entity}.dto.ts
import { z } from 'zod';
export const CreateUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(255),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const CreateUserResponseSchema = z.object({
id: z.string().uuid(),
});
export type CreateUserResponse = z.infer<typeof CreateUserResponseSchema>;
Exception Filters / Error Mapping
- Map
Result errors to HTTP status codes (∨ CLI exit codes)
- Centralized error handling at presentation boundary
- ¬leak domain error internals to consumers
Dependency Injection Wiring
- Presentation layer is where DI container lives
- Bind port interfaces → concrete adapters here
- Use Symbol-based DI tokens (¬string tokens)
export const USER_REPOSITORY = Symbol('UserRepository');
container.bind<UserRepository>(USER_REPOSITORY).to(SqlUserRepository);
6. Validation — Zod Only
- ¬
class-validator, ¬class-transformer — Zod is the single validation library
- Zod schemas = single source of truth for shape + constraints
z.infer<typeof Schema> for all derived types
- Domain validation: ∈ entity factories + value objects
- Presentation validation: ∈ DTOs at the boundary
- ∀ schemas export both schema ∧ inferred type
7. Type Safety
| Rule | Rationale |
|---|
¬as casting | Breaks type safety; use type guards ∨ Zod .parse() |
¬any | Use unknown + narrowing ∨ generics |
| Symbol-based DI tokens | Type-safe injection, ¬magic strings |
| Generics for collections | Repository<T> base when patterns repeat |
| Type guards for narrowing | function isUser(x: unknown): x is User |
| Discriminated ∪s | { type: 'success'; data: T } | { type: 'error'; error: E } |
function isValidStatus(value: unknown): value is OrderStatus {
return OrderStatusSchema.safeParse(value).success;
}
interface ReadRepository<T, Id = string> {
findById(id: Id): Promise<Result<T | null, DomainError>>;
findAll(): Promise<Result<T[], DomainError>>;
}
8. Testing
Strategy
| Layer | Test Type | Dependencies |
|---|
| Domain | Unit | ∅ (pure logic) |
| Application | Unit | ∈-memory adapters for ports |
| Infrastructure | Integration | Real DB / external services |
| Presentation | E2E | Full stack ∨ supertest |
∈-Memory Adapters
- Implement the real port interface — ¬mocks, ¬stubs
- Deterministic, fast, ¬ I/O
- Named
in-memory-{aggregate}.repository.ts
export class InMemoryUserRepository implements UserRepository {
private readonly store = new Map<string, User>();
async findById(id: string): Promise<Result<User | null, DomainError>> {
return ok(this.store.get(id) ?? null);
}
async save(user: User): Promise<Result<void, DomainError>> {
this.store.set(user.id, user);
return ok(undefined);
}
async delete(id: string): Promise<Result<void, DomainError>> {
this.store.delete(id);
return ok(undefined);
}
}
Test Colocation
user.entity.spec.ts next to user.entity.ts
create-user.handler.spec.ts next to create-user.handler.ts
- Test behavior through application services, ¬internal implementation details
9. Anti-Patterns
| Anti-Pattern | Why It's Wrong | Fix |
|---|
| Anemic domain model | Entities are data bags; logic lives ∈ services | Move behavior into entity methods |
| Domain imports infra | Violates dependency rule | Use ports (interfaces ∈ domain) |
App layer imports node:fs | Direct I/O coupling | Define a port, inject adapter |
throw ∈ domain | Untyped, unchecked error flow | Return Result<T, E> |
| Over-abstraction | Port ∀ trivial operation | Abstract only at architectural boundaries |
| Framework coupling ∈ domain | Domain tied to NestJS/Express decorators | Domain must be framework-agnostic |
| Massive inheritance | Fragile base class, tight coupling | Prefer composition over inheritance |
| String-based DI tokens | No type safety, collision risk | Use Symbol('TokenName') |
| Leaking domain types to API | Coupling consumers to internals | Map to DTOs at presentation boundary |
| God services | One service doing everything | One handler per command/query |
10. Naming Conventions
| Artifact | Pattern | Example |
|---|
| Entity | {entity}.entity.ts | user.entity.ts |
| Value Object | {vo}.value-object.ts | email.value-object.ts |
| Domain Event | {entity}-{action}.event.ts | user-created.event.ts |
| Repository Port | {aggregate}.repository.ts | user.repository.ts |
| Repository Adapter | sql-{aggregate}.repository.ts | sql-user.repository.ts |
| ∈-Memory Adapter | in-memory-{aggregate}.repository.ts | in-memory-user.repository.ts |
| Data Mapper | sql-{aggregate}.mapper.ts | sql-user.mapper.ts |
| Command | {action}-{entity}.command.ts | create-user.command.ts |
| Command Handler | {action}-{entity}.handler.ts | create-user.handler.ts |
| Query | {action}-{entity}.query.ts | get-user.query.ts |
| Query Handler | {action}-{entity}.handler.ts | get-user.handler.ts |
| DTO | {action}-{entity}.dto.ts | create-user.dto.ts |
| Domain Service | {name}.domain-service.ts | pricing.domain-service.ts |
| DI Token | UPPER_SNAKE symbol | USER_REPOSITORY |
Directory Structure
src/
{module}/
domain/
entities/
value-objects/
events/
ports/
services/
errors/
application/
commands/
queries/
services/
infrastructure/
repositories/
mappers/
adapters/
presentation/
controllers/
dto/
filters/
di/