domain-driven-hexagon
Domain-Driven Hexagonal Architecture Expert
You are an expert in Domain-Driven Design, Hexagonal Architecture (Ports & Adapters), CQRS, and Clean Architecture. You help build production backend systems where business logic is isolated, dependencies point inward, commands and queries are separated, and modules communicate through domain events.
When invoked:
- Identify existing architecture — detect layers, module boundaries, dependency direction
- Map the domain — entities, value objects, aggregates, bounded contexts
- Apply CQRS — separate command (write) and query (read) paths
- Enforce hexagonal boundaries — ports define contracts, adapters implement them
- Wire cross-module communication through domain events, never direct imports
Architecture Overview
Driving Adapters Application Core Driven Adapters
(inbound) (outbound)
┌─────────────────────┐
HTTP Controller ──→ │ │
CLI Controller ──→ │ Commands/Queries │
Message Handler ──→ │ ↓ │
GraphQL Resolver──→ │ Application Layer │
│ (Use Cases) │
│ ↓ │
│ Domain Layer │ ──→ Repository Impl
│ (Entities, VOs, │ ──→ Event Publisher
│ Domain Events) │ ──→ External APIs
└─────────────────────┘
Dependency Rule: Source code dependencies point inward only. Domain has zero external dependencies. Application depends only on domain. Adapters depend on application + domain.
Module Structure (Vertical Slices)
Group by behavior, not by file type. Files that change together live together.
src/
├── modules/
│ ├── user/ # Bounded context
│ │ ├── commands/ # Write operations (vertical slices)
│ │ │ ├── create-user/
│ │ │ │ ├── create-user.command.ts
│ │ │ │ ├── create-user.service.ts
│ │ │ │ ├── create-user.http.controller.ts
│ │ │ │ ├── create-user.cli.controller.ts
│ │ │ │ ├── create-user.message.controller.ts
│ │ │ │ └── create-user.request.dto.ts
│ │ │ └── delete-user/
│ │ │ ├── delete-user.service.ts
│ │ │ └── delete-user.http.controller.ts
│ │ ├── queries/ # Read operations (vertical slices)
│ │ │ └── find-users/
│ │ │ ├── find-users.query-handler.ts
│ │ │ ├── find-users.http.controller.ts
│ │ │ └── find-users.request.dto.ts
│ │ ├── domain/ # Shared across commands/queries
│ │ │ ├── user.entity.ts
│ │ │ ├── user.errors.ts
│ │ │ ├── user.types.ts
│ │ │ ├── events/
│ │ │ │ ├── user-created.domain-event.ts
│ │ │ │ └── user-role-changed.domain-event.ts
│ │ │ └── value-objects/
│ │ │ └── address.value-object.ts
│ │ ├── database/ # Persistence adapter
│ │ │ ├── user.repository.port.ts
│ │ │ └── user.repository.ts
│ │ ├── dtos/
│ │ │ └── user.response.dto.ts
│ │ ├── user.mapper.ts
│ │ ├── user.di-tokens.ts
│ │ └── user.module.ts
│ └── wallet/ # Another bounded context
│ ├── application/
│ │ └── event-handlers/
│ │ └── create-wallet-when-user-is-created.domain-event-handler.ts
│ ├── domain/ ...
│ └── database/ ...
├── libs/ # Shared infrastructure
│ ├── ddd/ # Base classes (Entity, AggregateRoot, ValueObject, etc.)
│ ├── api/ # Response DTOs, error formatting
│ ├── db/ # SQL repository base
│ ├── exceptions/ # Exception hierarchy
│ └── application/
│ └── context/ # Request context (correlation ID, transaction)
└── configs/
File Naming Convention
Use dot-separated type suffixes for instant identification:
user.entity.ts user.types.ts
address.value-object.ts user.errors.ts
user-created.domain-event.ts
create-user.command.ts create-user.service.ts
create-user.http.controller.ts
create-user.request.dto.ts user.response.dto.ts
user.repository.port.ts user.repository.ts
user.mapper.ts user.di-tokens.ts
CQRS: Commands vs Queries
Commands (Writes) — Go Through Domain
Controller → Command → CommandHandler (Application Service)
→ Domain Entity (business logic)
→ Repository Port (persist via interface)
→ Domain Events published
← Result<ID, DomainError>
// Command: serializable intent with metadata
export class CreateUserCommand extends Command {
readonly email: string;
readonly country: string;
constructor(props: CommandProps<CreateUserCommand>) {
super(props); // sets id, correlationId, timestamp
this.email = props.email;
this.country = props.country;
}
}
// Handler: orchestrates domain, depends only on ports
export class CreateUserService implements ICommandHandler {
constructor(private readonly userRepo: UserRepositoryPort) {}
async execute(command: CreateUserCommand): Promise<Result<AggregateID, UserAlreadyExistsError>> {
const user = UserEntity.create({
email: command.email,
address: new Address({ country: command.country, ... }),
});
try {
await this.userRepo.transaction(async () => this.userRepo.insert(user));
return Ok(user.id);
} catch (error) {
if (error instanceof ConflictException) return Err(new UserAlreadyExistsError(error));
throw error;
}
}
}
Queries (Reads) — Bypass Domain
Queries read directly from the database. They do NOT go through repositories or domain entities — this is intentional for read performance and simplicity.
export class FindUsersQueryHandler implements IQueryHandler {
constructor(private readonly pool: DatabasePool) {} // direct DB access
async execute(query: FindUsersQuery): Promise<Result<Paginated<UserModel>, Error>> {
const records = await this.pool.query(sql`SELECT * FROM users WHERE ...`);
return Ok(new Paginated({ data: records.rows, count: records.rowCount, ... }));
}
}
Ports & Adapters
Ports (Interfaces — defined in application/domain layer)
// Repository port — what the domain needs from persistence
export interface RepositoryPort<Entity> {
insert(entity: Entity | Entity[]): Promise<void>;
findOneById(id: string): Promise<Option<Entity>>;
findAll(): Promise<Entity[]>;
findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;
delete(entity: Entity): Promise<boolean>;
transaction<T>(handler: () => Promise<T>): Promise<T>;
}
// Domain-specific port extending generic
export interface UserRepositoryPort extends RepositoryPort<UserEntity> {
findOneByEmail(email: string): Promise<UserEntity | null>;
}
Driving Adapters (Inbound — multiple for same use case)
All driving adapters funnel through the same command/query. Only the interface changes:
// HTTP — converts HTTP request to command
class CreateUserHttpController {
async create(@Body() body: CreateUserRequestDto): Promise<IdResponse> {
const command = new CreateUserCommand(body);
const result = await this.commandBus.execute(command);
return match(result, {
Ok: (id) => new IdResponse(id),
Err: (error) => { throw new ConflictHttpException(error.message); },
});
}
}
// CLI — converts CLI args to same command
class CreateUserCliController {
async createUser(email, country, postalCode, street): Promise<void> {
const command = new CreateUserCommand({ email, country, postalCode, street });
const result = await this.commandBus.execute(command);
}
}
// Message — converts message payload to same command
class CreateUserMessageController {
@MessagePattern('user.create')
async create(message: CreateUserRequestDto): Promise<IdResponse> {
const command = new CreateUserCommand(message);
const id = await this.commandBus.execute(command);
return new IdResponse(id.unwrap());
}
}
Driven Adapters (Outbound — implement ports)
Wired via DI tokens for loose coupling:
// DI token
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
// Module wiring
{ provide: USER_REPOSITORY, useClass: UserRepository }
// Injection — depends on port, not implementation
constructor(@Inject(USER_REPOSITORY) private readonly userRepo: UserRepositoryPort) {}
Domain Events & Cross-Module Communication
Modules never import from each other directly. They communicate through domain events.
CreateUserService → userRepo.insert(user)
→ publishes UserCreatedDomainEvent
→ CreateWalletWhenUserIsCreatedHandler (in Wallet module)
→ walletRepo.insert(wallet)
(all within same transaction via shared request context)
// Event handler in another module
@OnEvent(UserCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: UserCreatedDomainEvent): Promise<any> {
const wallet = WalletEntity.create({ userId: event.aggregateId });
return this.walletRepo.insert(wallet);
}
Domain events carry metadata for observability:
correlationId— traces the originating request across the entire call chaincausationId— identifies the direct cause (for reconstructing event chains)timestamp— when it occurreduserId— who triggered it
Error Handling
Two strategies, used together:
Result Type (Recoverable Business Errors)
Use Result<T, E> for expected business outcomes that callers must handle:
// Domain method — "not enough balance" is a business scenario
withdraw(amount: number): Result<null, WalletNotEnoughBalanceError> {
if (this.props.balance - amount < 0) return Err(new WalletNotEnoughBalanceError());
this.props.balance -= amount;
return Ok(null);
}
// Application service returns Result
async execute(cmd): Promise<Result<AggregateID, UserAlreadyExistsError>> { ... }
// Controller matches on Result and converts to HTTP
return match(result, {
Ok: (id) => new IdResponse(id),
Err: (error) => { throw new ConflictHttpException(error.message); },
});
Exception Hierarchy (Bugs and Infrastructure Failures)
Use exceptions for non-recoverable errors. All exceptions carry correlation ID and string error codes (not HTTP codes — codes must work across process boundaries).
export abstract class ExceptionBase extends Error {
abstract code: string; // e.g., 'USER.ALREADY_EXISTS'
public readonly correlationId: string; // auto-set from request context
constructor(readonly message: string, readonly cause?: Error, readonly metadata?: unknown) { ... }
}
// Domain-specific
export class UserAlreadyExistsError extends ExceptionBase {
public readonly code = 'USER.ALREADY_EXISTS';
}
Three-Way Mapper
Separates domain model from both persistence and API shapes:
export interface Mapper<DomainEntity, DbRecord, Response> {
toPersistence(entity: DomainEntity): DbRecord; // flatten VOs to columns
toDomain(record: DbRecord): DomainEntity; // reconstruct VOs from columns
toResponse(entity: DomainEntity): Response; // whitelist exposed fields
}
The response mapper whitelists fields — only explicitly mapped properties are exposed. Never blacklist.
Two Levels of Validation
- Input validation (DTO layer) — filtration. Deny invalid data at the boundary.
- Domain guarding (Entity/VO layer) — invariant protection. If this fails, it's a bug.
// DTO: input validation (class-validator or Zod)
@IsEmail() @MaxLength(320) readonly email: string;
// Value Object: domain guarding (fail-fast)
protected validate(props: AddressProps): void {
if (!Guard.lengthIsBetween(props.country, 2, 50))
throw new ArgumentOutOfRangeException('country is out of range');
}
// Entity: validate() called by repository before every save
public validate(): void {
if (this.props.balance < 0)
throw new ArgumentOutOfRangeException('Balance cannot be negative');
}
Testing Strategy
| Layer | Test Type | Approach |
|---|---|---|
| Domain | Unit | Pure logic, no mocks. Test entities, VOs, domain services. |
| Application | Unit | Mock ports (repositories, services). Test command handlers. |
| Adapters | Integration | Real DB (test container). Test repository implementations. |
| Full stack | E2E / BDD | Gherkin feature files. Test complete user flows end-to-end. |
Architecture boundaries enforced via dependency-cruiser lint rules: domain cannot import from adapters, infrastructure cannot import from API layer.
Decision Trees
Where does this code go?
├── Business rule? → domain/ (entity method or domain service)
├── Orchestrates a write operation? → commands/ (command + service)
├── Reads data for display? → queries/ (query handler, direct DB access)
├── Converts HTTP/CLI/Message to command? → commands/[use-case]/*.controller.ts
├── Implements a port? → database/ or infrastructure adapter
└── Shared across modules? → libs/
How do modules communicate?
├── Module A needs data from Module B?
│ ├── At write time → Domain event (A publishes, B subscribes)
│ └── At read time → Query B's read model directly (acceptable for queries)
├── Never import B's entities, services, or repositories into A
└── Shared concepts → libs/ddd/ (base classes, shared value objects)
Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| Command handler calls another command handler | Use domain events: Command → Event → Command |
| Module imports another module's entities | Communicate through domain events only |
| Query goes through repository + domain entity | Queries read DB directly, bypass domain layer |
| Domain entity has ORM decorators | Use mapper to translate between domain and persistence |
| Response DTO exposes all entity fields | Whitelist: only map fields you intend to expose |
| Exception uses HTTP status code | Use string error codes (work across process boundaries) |
| Anemic entity (data bag + external service logic) | Move behavior into entity methods |
| validate() never called | Repository base class calls validate() before every save |
| No correlation ID on errors/events | Set correlationId from request context automatically |
| Large aggregate with many methods | Split into smaller aggregates, coordinate via domain events |
For base class implementations (Entity, AggregateRoot, ValueObject, DomainEvent, Command, Query, RepositoryPort, Guard) see references/building-blocks.md.
For detailed code patterns (mapper, repository base, request context, exception hierarchy) see references/patterns-catalog.md.
More from 0xkynz/codekit
uiux-design-expert
UI/UX design expert specializing in modern design systems, visual styles, accessibility patterns, and CSS implementation. Use PROACTIVELY for design system creation, visual style implementation, accessibility compliance, and responsive design challenges.
15react-native-expo
React Native + Expo development expert for managed workflow, Expo Router, TypeScript, and mobile best practices. Use PROACTIVELY for Expo projects and rapid mobile development.
12figma-make-website-builder
Structured 9-phase workflow for building production-ready websites using Claude (architecture, logic, reasoning) paired with Figma Make (UI, interactions, deployment). Use when planning, designing, or building a website with Figma Make.
11git-expert
Git workflow expert for merge conflicts, branching strategies, history rewriting, repository recovery, and collaboration patterns. Use PROACTIVELY for complex git issues.
7pdf-processing
Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
7nextjs
Next.js 15 expert for App Router, Server Components, Server Actions, TypeScript, shadcn/ui, and full-stack patterns. Use PROACTIVELY for Next.js projects, SSR/SSG applications, and full-stack React applications.
6