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.