Architecture

Understanding Clean Architecture and Domain-Driven Design in CleanStack

Overview

CleanStack follows Clean Architecture principles combined with Domain-Driven Design (DDD) patterns. This approach ensures:

  • Separation of concerns - Each layer has a single responsibility
  • Testability - Business logic independent of frameworks
  • Maintainability - Clear boundaries and dependencies
  • Scalability - Easy to extend and modify

Core Principle: The Dependency Rule

All dependencies point INWARD toward the Domain. The Domain is the core and has ZERO dependencies on outer layers.

Infrastructure → Application → Domain
     ↓              ↓
  Database      Use Cases    Core Business Logic
  External                   (No dependencies)
  APIs

Layer Structure

┌─────────────────────────────────────────┐
│ Domain (Core)                           │
│ - Entities                              │
│ - Value Objects                         │
│ - Aggregates                            │
│ - Domain Events                         │
│                                         │
│ DEPENDS ON: NOTHING                     │
└─────────────────────────────────────────┘
               ▲
               │
               │ depends on
               │
┌─────────────────────────────────────────┐
│ Application                             │
│ - Use Cases                             │
│ - Commands / Queries                    │
│ - Application Services                  │
│ - PORTS (interfaces):                   │
│     • Repository interfaces             │
│     • Gateway interfaces                │
│                                         │
│ DEPENDS ON: Domain only                 │
└─────────────────────────────────────────┘
               ▲
               │
               │ depends on
               │
┌─────────────────────────────────────────┐
│ Interface Adapters                      │
│ - Controllers                           │
│ - Next.js Route Handlers                │
│ - DTOs / Presenters                     │
│ - Input validation (Zod)                │
│                                         │
│ DEPENDS ON: Application + Domain        │
└─────────────────────────────────────────┘
               ▲
               │
               │ depends on
               │
┌─────────────────────────────────────────┐
│ Infrastructure (Outermost)              │
│ - ORM (Drizzle)                         │
│ - Database                              │
│ - External APIs                         │
│ - Repository IMPLEMENTATIONS            │
│ - DI Container configuration            │
│                                         │
│ DEPENDS ON: Application ports           │
└─────────────────────────────────────────┘

Request Flow

HTTP Request
    ↓
Route Handler (adapters/in/api/)
    ↓
Controller validates input & maps to DTO
    ↓
Use Case (application/) - orchestrates business logic
    ↓
Domain Logic (domain/) - enforces business rules
    ↓
Repository PORT (application/ports/) - interface
    ↓
Repository IMPL (adapters/out/) - concrete implementation
    ↓
Database

Return: Database → Option<T> → Result<T> → DTO → JSON Response

The Layers

1. Domain Layer (Core)

The heart of the application. Contains pure business logic with zero external dependencies.

Location: apps/nextjs/src/domain/

Contains:

  • Entities
  • Value Objects
  • Aggregates
  • Domain Events

Example:

export class Email extends ValueObject<EmailProps> {
  protected validate(props: EmailProps): Result<EmailProps> {
    if (!emailRegex.test(props.value)) {
      return Result.fail("Invalid email");
    }
    return Result.ok(props);
  }
}

Critical Rules:

  • NEVER import from Application, Adapters, or Infrastructure layers
  • Only import from @packages/ddd-kit
  • All business rules live here
  • Immutable by design (use Object.freeze())

2. Application Layer

Orchestrates the flow of data. Contains Use Cases that implement business workflows.

Location: apps/nextjs/src/application/

Contains:

  • Use Cases
  • DTOs (Data Transfer Objects)
  • Application Services
  • Port interfaces (Repository, Gateway interfaces)

Example:

export class CreateUserUseCase implements UseCase<CreateUserInput, User> {
  constructor(private readonly userRepo: IUserRepository) {}

  async execute(input: CreateUserInput): Promise<Result<User>> {
    const emailOrError = Email.create(input.email);
    if (emailOrError.isFailure) {
      return Result.fail(emailOrError.error);
    }

    const userOrError = User.create({
      email: emailOrError.value
    });
    if (userOrError.isFailure) {
      return Result.fail(userOrError.error);
    }

    const savedOrError = await this.userRepo.create(userOrError.value);
    return savedOrError;
  }
}

Critical Rules:

  • Depends ONLY on Domain layer
  • Defines interfaces (ports) for infrastructure
  • Never imports Infrastructure implementations
  • Always returns Result&lt;T&gt;

3. Interface Adapters Layer

Converts data between external formats and domain models.

Location: apps/nextjs/src/adapters/

Contains:

  • In adapters: Controllers, Route Handlers
  • Out adapters: Repository implementations, External API clients
  • Presenters (Response formatters)
  • Mappers (Domain ↔ Database)

Example:

// Route Handler (in adapter)
export async function POST(request: Request) {
  const useCase = getInjection('CreateUserUseCase');
  const body = await request.json();

  const result = await useCase.execute(body);

  if (result.isFailure) {
    return Response.json({ error: result.error }, { status: 400 });
  }

  return Response.json(result.value);
}

Critical Rules:

  • Depends on Application and Domain
  • Implements port interfaces defined in Application
  • Validates HTTP input (Zod schemas)
  • Maps between domain and external representations

4. Infrastructure Layer

Manages external dependencies and technical implementations.

Location: apps/nextjs/src/infrastructure/

Contains:

  • Database connections
  • External API clients
  • File system operations
  • Email services
  • Cache implementations

Critical Rules:

  • Implements interfaces from Application layer
  • Never imported by Domain or Application
  • All external dependencies live here

Core Patterns

Result<T> Pattern

Type-safe error handling without exceptions. Forces explicit error handling.

const result = Email.create("invalid@");

if (result.isFailure) {
  console.error(result.error); // Handle error explicitly
  return;
}

const email = result.value; // Type-safe access

Why?

  • No silent failures
  • Compiler forces error handling
  • Errors are values, not exceptions
  • Better for domain logic

Option<T> Pattern

Eliminates null/undefined with functional patterns. No more null pointer exceptions.

const userOrNone = await userRepo.findById(id);

userOrNone.match({
  some: (user) => console.log(user.email),
  none: () => console.log("User not found")
});

// Or with mapping
const email = userOrNone
  .map(user => user.email.value)
  .unwrapOr("no-email@example.com");

Why?

  • Explicit absence of value
  • Chainable operations
  • No null reference errors

Domain Events Pattern

Decouple side effects from business logic. Enable event-driven architecture.

// 1. Define event
export class UserCreatedEvent {
  constructor(
    public readonly userId: UUID,
    public readonly email: string,
    public readonly occurredAt: Date = new Date()
  ) {}
}

// 2. Raise event in Aggregate
export class User extends Aggregate<UserProps> {
  static create(props: CreateUserProps): Result<User> {
    const user = new User(props);
    user.addEvent(new UserCreatedEvent(user.id, user.email.value));
    return Result.ok(user);
  }
}

// 3. Subscribe to events
DomainEvents.subscribe(
  UserCreatedEvent,
  async (event: UserCreatedEvent) => {
    await emailService.sendWelcomeEmail(event.email);
  }
);

// 4. Dispatch after persistence
const userOrError = await userRepo.create(user);
if (userOrError.isSuccess) {
  user.markEventsForDispatch();
  DomainEvents.dispatch(user.id);
}

Why?

  • Loose coupling
  • Domain logic stays focused
  • Side effects are explicit
  • Easy to test

Key Principles

1. The Dependency Rule

Dependencies only point inward. Inner layers know nothing about outer layers.

  • ✅ Application can import Domain
  • ✅ Infrastructure can import Application interfaces
  • ❌ Domain CANNOT import Application
  • ❌ Application CANNOT import Infrastructure

2. Domain Independence

Business logic has zero framework dependencies.

  • No Next.js imports in Domain
  • No Drizzle ORM in Domain
  • No React in Domain
  • Only @packages/ddd-kit primitives

3. Explicit over Implicit

  • Use Result&lt;T&gt; instead of throwing exceptions
  • Use Option&lt;T&gt; instead of null/undefined
  • Use interfaces instead of concrete types
  • Use dependency injection instead of direct instantiation

4. Single Responsibility

Each class, function, and module does one thing well.

  • One Use Case = One business operation
  • One Value Object = One business concept
  • One Entity = One business identity
  • One Repository = One aggregate root

Anti-Patterns to AVOID

❌ Anemic Domain Model

// WRONG: No business logic
class User {
  constructor(public email: string, public name: string) {}
  setEmail(e: string) { this.email = e }
}

❌ Fat Controllers

// WRONG: Business logic in controller
export async function POST(request: Request) {
  const body = await request.json();
  if (!body.email.includes('@')) {
    return Response.json({ error: 'Invalid' });
  }
  const user = { id: crypto.randomUUID(), ...body };
  await db.insert(users).values(user);
}

❌ Direct Database Access from Domain

// WRONG: Domain importing database
import { db } from '@packages/drizzle'  // ❌ in domain layer

class User extends Entity<UserProps> {
  async save() {
    await db.insert(users).values(this.toObject())  // ❌❌❌
  }
}

❌ Null instead of Option

// WRONG
async findById(id: UUID): Promise<User | null> {
  return await db.query.users.findFirst(...) ?? null;
}

// CORRECT
async findById(id: UUID): Promise<Result<Option<User>>> {
  const row = await db.query.users.findFirst(...);
  if (!row) return Result.ok(None());
  return Result.ok(Some(UserMapper.toDomain(row)));
}

Testing Strategy

Unit Tests - Domain Layer

Test business logic in isolation:

describe('Email Value Object', () => {
  it('should create valid email', () => {
    const result = Email.create('test@example.com');
    expect(result.isSuccess).toBe(true);
  });

  it('should reject invalid email format', () => {
    const result = Email.create('invalid');
    expect(result.isFailure).toBe(true);
  });
});

Integration Tests - Use Cases

Test with real or mock repositories:

describe('CreateUserUseCase', () => {
  it('should create user successfully', async () => {
    const mockRepo = createMockRepository();
    const useCase = new CreateUserUseCase(mockRepo);

    const result = await useCase.execute({
      email: 'test@example.com',
      name: 'Test User'
    });

    expect(result.isSuccess).toBe(true);
  });
});

Complete Example: User Registration

Let's walk through a complete implementation showing all layers working together.

Domain Layer - Value Objects

File: src/domain/user/value-objects/email.ts

import { Result, ValueObject } from '@packages/ddd-kit';

interface EmailProps {
  value: string;
}

export class Email extends ValueObject<EmailProps> {
  private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  protected validate(props: EmailProps): Result<EmailProps> {
    if (!props.value) {
      return Result.fail('Email is required');
    }

    if (!Email.EMAIL_REGEX.test(props.value)) {
      return Result.fail('Invalid email format');
    }

    if (props.value.length > 255) {
      return Result.fail('Email too long');
    }

    return Result.ok(props);
  }

  get value(): string {
    return this.props.value;
  }

  static create(email: string): Result<Email> {
    return super.createInternal({ value: email.toLowerCase().trim() });
  }
}

File: src/domain/user/value-objects/username.ts

import { Result, ValueObject } from '@packages/ddd-kit';

interface UsernameProps {
  value: string;
}

export class Username extends ValueObject<UsernameProps> {
  private static readonly MIN_LENGTH = 3;
  private static readonly MAX_LENGTH = 50;
  private static readonly VALID_PATTERN = /^[a-zA-Z0-9_-]+$/;

  protected validate(props: UsernameProps): Result<UsernameProps> {
    if (!props.value) {
      return Result.fail('Username is required');
    }

    if (props.value.length < Username.MIN_LENGTH) {
      return Result.fail(`Username must be at least ${Username.MIN_LENGTH} characters`);
    }

    if (props.value.length > Username.MAX_LENGTH) {
      return Result.fail(`Username must be at most ${Username.MAX_LENGTH} characters`);
    }

    if (!Username.VALID_PATTERN.test(props.value)) {
      return Result.fail('Username can only contain letters, numbers, underscores, and hyphens');
    }

    return Result.ok(props);
  }

  get value(): string {
    return this.props.value;
  }

  static create(username: string): Result<Username> {
    return super.createInternal({ value: username.trim() });
  }
}

Domain Layer - Domain Events

File: src/domain/user/events/user-created.event.ts

import { UUID } from '@packages/ddd-kit';

export class UserCreatedEvent {
  public readonly eventName = 'UserCreated';
  public readonly occurredAt: Date;

  constructor(
    public readonly userId: UUID,
    public readonly email: string,
    public readonly username: string,
  ) {
    this.occurredAt = new Date();
  }
}

Domain Layer - Aggregate Root

File: src/domain/user/user.aggregate.ts

import { Aggregate, Result, UUID } from '@packages/ddd-kit';
import { Email } from './value-objects/email';
import { Username } from './value-objects/username';
import { UserCreatedEvent } from './events/user-created.event';

interface UserProps {
  email: Email;
  username: Username;
  createdAt: Date;
  updatedAt: Date;
}

interface CreateUserProps {
  email: Email;
  username: Username;
}

export class User extends Aggregate<UserProps> {
  private constructor(props: UserProps, id?: UUID) {
    super(props, id);
  }

  get email(): Email {
    return this.props.email;
  }

  get username(): Username {
    return this.props.username;
  }

  get createdAt(): Date {
    return this.props.createdAt;
  }

  get updatedAt(): Date {
    return this.props.updatedAt;
  }

  /**
   * Factory method to create a new User aggregate
   */
  static create(props: CreateUserProps): Result<User> {
    const now = new Date();

    const user = new User(
      {
        email: props.email,
        username: props.username,
        createdAt: now,
        updatedAt: now,
      },
      UUID.create()
    );

    // Raise domain event
    user.addEvent(
      new UserCreatedEvent(
        user.id,
        user.email.value,
        user.username.value
      )
    );

    return Result.ok(user);
  }

  /**
   * Business logic: Update username
   */
  updateUsername(newUsername: Username): Result<void> {
    // Business rule: username can only be changed if not changed in last 30 days
    const daysSinceUpdate = Math.floor(
      (Date.now() - this.props.updatedAt.getTime()) / (1000 * 60 * 60 * 24)
    );

    if (daysSinceUpdate < 30) {
      return Result.fail('Username can only be changed once every 30 days');
    }

    this.props.username = newUsername;
    this.props.updatedAt = new Date();

    return Result.ok();
  }
}

Application Layer - Repository Interface (Port)

File: src/application/ports/user-repository.interface.ts

import { BaseRepository } from '@packages/ddd-kit';
import { User } from '@/domain/user/user.aggregate';
import { Email } from '@/domain/user/value-objects/email';
import { Username } from '@/domain/user/value-objects/username';
import type { Result, Option } from '@packages/ddd-kit';
import type { Transaction } from 'drizzle-orm/node-postgres';

export interface IUserRepository extends BaseRepository<User> {
  /**
   * Find user by email address
   */
  findByEmail(email: Email, trx?: Transaction): Promise<Result<Option<User>>>;

  /**
   * Find user by username
   */
  findByUsername(username: Username, trx?: Transaction): Promise<Result<Option<User>>>;

  /**
   * Check if email already exists
   */
  existsByEmail(email: Email, trx?: Transaction): Promise<Result<boolean>>;

  /**
   * Check if username already exists
   */
  existsByUsername(username: Username, trx?: Transaction): Promise<Result<boolean>>;
}

Application Layer - Use Case

File: src/application/use-cases/create-user/create-user.use-case.ts

import { Result, UseCase } from '@packages/ddd-kit';
import type { Transaction } from 'drizzle-orm/node-postgres';
import { User } from '@/domain/user/user.aggregate';
import { Email } from '@/domain/user/value-objects/email';
import { Username } from '@/domain/user/value-objects/username';
import { IUserRepository } from '@/application/ports/user-repository.interface';

export interface CreateUserInput {
  email: string;
  username: string;
}

export class CreateUserUseCase implements UseCase<CreateUserInput, User> {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(input: CreateUserInput, trx?: Transaction): Promise<Result<User>> {
    // 1. Create Value Objects with validation
    const emailOrError = Email.create(input.email);
    if (emailOrError.isFailure) {
      return Result.fail(emailOrError.error);
    }

    const usernameOrError = Username.create(input.username);
    if (usernameOrError.isFailure) {
      return Result.fail(usernameOrError.error);
    }

    const email = emailOrError.value;
    const username = usernameOrError.value;

    // 2. Check business rule: Email must be unique
    const emailExistsOrError = await this.userRepository.existsByEmail(email, trx);
    if (emailExistsOrError.isFailure) {
      return Result.fail(emailExistsOrError.error);
    }

    if (emailExistsOrError.value) {
      return Result.fail('Email already registered');
    }

    // 3. Check business rule: Username must be unique
    const usernameExistsOrError = await this.userRepository.existsByUsername(username, trx);
    if (usernameExistsOrError.isFailure) {
      return Result.fail(usernameExistsOrError.error);
    }

    if (usernameExistsOrError.value) {
      return Result.fail('Username already taken');
    }

    // 4. Create User aggregate (raises UserCreatedEvent)
    const userOrError = User.create({ email, username });
    if (userOrError.isFailure) {
      return Result.fail(userOrError.error);
    }

    const user = userOrError.value;

    // 5. Persist user
    const savedUserOrError = await this.userRepository.create(user, trx);
    if (savedUserOrError.isFailure) {
      return Result.fail(savedUserOrError.error);
    }

    // 6. Mark events for dispatch (will happen after transaction commits)
    user.markEventsForDispatch();

    return Result.ok(savedUserOrError.value);
  }
}

Infrastructure Layer - Repository Implementation

File: src/adapters/out/repositories/user.repository.ts

import { Result, Option, Some, None, UUID } from '@packages/ddd-kit';
import type { Transaction } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm';
import { db } from '@packages/drizzle';
import { users } from '@packages/drizzle/schema';
import { User } from '@/domain/user/user.aggregate';
import { Email } from '@/domain/user/value-objects/email';
import { Username } from '@/domain/user/value-objects/username';
import { IUserRepository } from '@/application/ports/user-repository.interface';
import { DatabaseOperationError } from '@packages/ddd-kit';

export class UserRepository implements IUserRepository {
  async create(user: User, trx?: Transaction): Promise<Result<User>> {
    try {
      const database = trx ?? db;

      await database.insert(users).values({
        id: user.id.value,
        email: user.email.value,
        username: user.username.value,
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      });

      return Result.ok(user);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to create user', { cause: error })
      );
    }
  }

  async update(user: User, trx?: Transaction): Promise<Result<User>> {
    try {
      const database = trx ?? db;

      await database
        .update(users)
        .set({
          email: user.email.value,
          username: user.username.value,
          updatedAt: new Date(),
        })
        .where(eq(users.id, user.id.value));

      return Result.ok(user);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to update user', { cause: error })
      );
    }
  }

  async delete(id: UUID, trx?: Transaction): Promise<Result<void>> {
    try {
      const database = trx ?? db;
      await database.delete(users).where(eq(users.id, id.value));
      return Result.ok(undefined);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to delete user', { cause: error })
      );
    }
  }

  async findById(id: UUID, trx?: Transaction): Promise<Result<Option<User>>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.id, id.value),
      });

      if (!row) {
        return Result.ok(None<User>());
      }

      const userOrError = this.toDomain(row);
      if (userOrError.isFailure) {
        return Result.fail(userOrError.error);
      }

      return Result.ok(Some(userOrError.value));
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to find user by id', { cause: error })
      );
    }
  }

  async findByEmail(email: Email, trx?: Transaction): Promise<Result<Option<User>>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.email, email.value),
      });

      if (!row) {
        return Result.ok(None<User>());
      }

      const userOrError = this.toDomain(row);
      if (userOrError.isFailure) {
        return Result.fail(userOrError.error);
      }

      return Result.ok(Some(userOrError.value));
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to find user by email', { cause: error })
      );
    }
  }

  async findByUsername(username: Username, trx?: Transaction): Promise<Result<Option<User>>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.username, username.value),
      });

      if (!row) {
        return Result.ok(None<User>());
      }

      const userOrError = this.toDomain(row);
      if (userOrError.isFailure) {
        return Result.fail(userOrError.error);
      }

      return Result.ok(Some(userOrError.value));
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to find user by username', { cause: error })
      );
    }
  }

  async existsByEmail(email: Email, trx?: Transaction): Promise<Result<boolean>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.email, email.value),
        columns: { id: true },
      });

      return Result.ok(!!row);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to check email existence', { cause: error })
      );
    }
  }

  async existsByUsername(username: Username, trx?: Transaction): Promise<Result<boolean>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.username, username.value),
        columns: { id: true },
      });

      return Result.ok(!!row);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to check username existence', { cause: error })
      );
    }
  }

  async findAll(trx?: Transaction): Promise<Result<User[]>> {
    try {
      const database = trx ?? db;
      const rows = await database.query.users.findMany();

      const users: User[] = [];
      for (const row of rows) {
        const userOrError = this.toDomain(row);
        if (userOrError.isFailure) {
          return Result.fail(userOrError.error);
        }
        users.push(userOrError.value);
      }

      return Result.ok(users);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to find all users', { cause: error })
      );
    }
  }

  async exists(id: UUID, trx?: Transaction): Promise<Result<boolean>> {
    try {
      const database = trx ?? db;
      const row = await database.query.users.findFirst({
        where: eq(users.id, id.value),
        columns: { id: true },
      });

      return Result.ok(!!row);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to check user existence', { cause: error })
      );
    }
  }

  async count(trx?: Transaction): Promise<Result<number>> {
    try {
      const database = trx ?? db;
      const rows = await database.query.users.findMany({ columns: { id: true } });
      return Result.ok(rows.length);
    } catch (error) {
      return Result.fail(
        new DatabaseOperationError('Failed to count users', { cause: error })
      );
    }
  }

  /**
   * Map database row to domain model
   */
  private toDomain(row: any): Result<User> {
    const emailOrError = Email.create(row.email);
    if (emailOrError.isFailure) {
      return Result.fail(emailOrError.error);
    }

    const usernameOrError = Username.create(row.username);
    if (usernameOrError.isFailure) {
      return Result.fail(usernameOrError.error);
    }

    // Reconstruct aggregate (without raising events)
    return Result.ok(
      Object.assign(
        Object.create(User.prototype),
        {
          _id: UUID.createFrom(row.id),
          props: {
            email: emailOrError.value,
            username: usernameOrError.value,
            createdAt: row.createdAt,
            updatedAt: row.updatedAt,
          },
        }
      )
    );
  }
}

Adapters Layer - Route Handler & Controller

File: src/adapters/in/api/users/create/route.ts

import { z } from 'zod';
import { getInjection } from '@/common/di/container';
import { DomainEvents } from '@packages/ddd-kit';

// Input validation schema
const createUserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(50),
});

export async function POST(request: Request) {
  try {
    // 1. Parse and validate input
    const body = await request.json();
    const validationResult = createUserSchema.safeParse(body);

    if (!validationResult.success) {
      return Response.json(
        { error: 'Validation failed', details: validationResult.error.errors },
        { status: 400 }
      );
    }

    // 2. Get use case from DI container
    const createUserUseCase = getInjection('CreateUserUseCase');
    const transactionManager = getInjection('ITransactionManagerService');

    // 3. Execute use case within transaction
    const result = await transactionManager.execute(async (trx) => {
      return await createUserUseCase.execute(validationResult.data, trx);
    });

    // 4. Handle result
    if (result.isFailure) {
      return Response.json(
        { error: result.error },
        { status: 400 }
      );
    }

    const user = result.value;

    // 5. Dispatch domain events (after successful transaction)
    DomainEvents.dispatch(user.id);

    // 6. Return response
    return Response.json(
      {
        id: user.id.value,
        email: user.email.value,
        username: user.username.value,
        createdAt: user.createdAt.toISOString(),
      },
      { status: 201 }
    );
  } catch (error) {
    console.error('Failed to create user:', error);
    return Response.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Infrastructure - Dependency Injection Setup

File: src/common/di/modules/user.module.ts

import type { ApplicationContainer } from '../container';
import { UserRepository } from '@/adapters/out/repositories/user.repository';
import { CreateUserUseCase } from '@/application/use-cases/create-user/create-user.use-case';

export function registerUserModule(container: ApplicationContainer) {
  // Register repository
  container.bind('IUserRepository').toClass(UserRepository, []);

  // Register use cases
  container.bind('CreateUserUseCase').toClass(CreateUserUseCase, ['IUserRepository']);
}

File: src/common/di/types.ts

import type { CreateUserUseCase } from '@/application/use-cases/create-user/create-user.use-case';
import type { IUserRepository } from '@/application/ports/user-repository.interface';

export const DI_TYPES = {
  // Repositories
  IUserRepository: Symbol.for('IUserRepository'),

  // Use Cases
  CreateUserUseCase: Symbol.for('CreateUserUseCase'),

  // ... other types
} as const;

export interface DITokenMapping {
  IUserRepository: IUserRepository;
  CreateUserUseCase: CreateUserUseCase;
  // ... other mappings
}

Domain Events - Event Handlers

File: src/application/event-handlers/user-created.handler.ts

import { DomainEvents } from '@packages/ddd-kit';
import { UserCreatedEvent } from '@/domain/user/events/user-created.event';

// Subscribe to UserCreatedEvent
DomainEvents.subscribe(
  UserCreatedEvent,
  async (event: UserCreatedEvent) => {
    console.log(`[UserCreatedEvent] User ${event.username} created with email ${event.email}`);

    // Side effects:
    // - Send welcome email
    // - Create user profile
    // - Log analytics event
    // - etc.
  }
);

Request Flow Summary

Here's what happens when a client creates a user:

1. POST /api/users
   ↓
2. Route Handler validates input with Zod
   ↓
3. Get CreateUserUseCase & TransactionManager from DI container
   ↓
4. Execute use case within transaction:
   ↓
   4a. Create Email value object (validates format)
   ↓
   4b. Create Username value object (validates length/pattern)
   ↓
   4c. Check email uniqueness (business rule)
   ↓
   4d. Check username uniqueness (business rule)
   ↓
   4e. Create User aggregate (raises UserCreatedEvent)
   ↓
   4f. Persist via repository
   ↓
5. Transaction commits successfully
   ↓
6. Dispatch domain events (UserCreatedEvent)
   ↓
7. Event handlers run asynchronously (send email, etc.)
   ↓
8. Return JSON response to client

Key Takeaways

  1. Domain is pure: No database, no HTTP, no frameworks - just business logic
  2. Use Cases orchestrate: They coordinate between domain and infrastructure
  3. Repositories abstract data: Domain doesn't know about Drizzle or PostgreSQL
  4. Result<T> everywhere: Explicit error handling, no exceptions
  5. Option<T> for queries: No null, explicit absence of value
  6. Transaction at controller: Compose multiple use cases in one transaction
  7. Events for side effects: Decouple email sending from user creation
  8. DI for flexibility: Easy to swap implementations, test in isolation

Next Steps:

  • Read CLAUDE.md for complete implementation details
  • Explore @packages/ddd-kit for DDD primitives
  • Check example implementations in src/domain/examples/