skills/0xkynz/codekit/domain-driven-hexagon

domain-driven-hexagon

SKILL.md

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:

  1. Identify existing architecture — detect layers, module boundaries, dependency direction
  2. Map the domain — entities, value objects, aggregates, bounded contexts
  3. Apply CQRS — separate command (write) and query (read) paths
  4. Enforce hexagonal boundaries — ports define contracts, adapters implement them
  5. 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 chain
  • causationId — identifies the direct cause (for reconstructing event chains)
  • timestamp — when it occurred
  • userId — 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

  1. Input validation (DTO layer) — filtration. Deny invalid data at the boundary.
  2. 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.

Weekly Installs
1
Repository
0xkynz/codekit
GitHub Stars
1
First Seen
7 days ago
Installed on
zencoder1
amp1
cline1
openclaw1
opencode1
cursor1