Clean Architecture Explained With a Real Application Example

A comprehensive guide to Clean Architecture: the Dependency Rule, layer boundaries, domain invariants, repository ports, and practical TypeScript implementation.

September 24, 202512 min readOpenStair Engineering
Illustration of modular software architecture layers connected across a production system

Clean Architecture Explained With a Real Application Example

Software applications rarely fail in production because their web frameworks lack speed or their database engines lack query capability. They fail because over months and years of development, the boundaries between business logic, data persistence, and transport protocols blur into an unmaintainable tangle.

In a conventional database-driven project, development begins with the relational schema or an Object-Relational Mapping (ORM) model. Business logic is gradually scattered across database triggers, ORM lifecycle hooks, web controllers, and monolithic service classes.

When a breaking change occurs—such as upgrading the HTTP framework, migrating from PostgreSQL to a distributed document store, or introducing an asynchronous message consumer—the entire codebase resists modification. Testing requires spinning up Docker containers and mock HTTP servers merely to verify that a financial calculation or an invariant validation rule behaves correctly.

Clean Architecture addresses this coupling. Formulated by Robert C. Martin ("Uncle Bob") in 2012 and expanded in his 2017 book Clean Architecture: A Craftsman's Guide to Software Structure and Design, it builds upon Alistair Cockburn's Hexagonal Architecture (Ports and Adapters) and Jeffrey Palermo's Onion Architecture.

Its core thesis is straightforward: business logic must form the independent core of an application, while frameworks, transport mechanisms, databases, and third-party services remain interchangeable implementation details on the periphery.

This guide breaks down the architectural principles of Clean Architecture, compares it with related structural patterns, walks through a complete end-to-end TypeScript vertical slice, examines common anti-patterns, and provides concrete criteria for deciding when Clean Architecture is warranted and when it introduces unnecessary overhead.


The Problem With Conventional Layered Architectures

To understand why Clean Architecture exists, consider the traditional 3-tier layered architecture common in web development:

[ Presentation Layer (Controllers, Route Handlers, Views) ]
                           │
                           ▼ (depends on)
[ Business Logic Layer (Services, Managers, Handlers) ]
                           │
                           ▼ (depends on)
[ Data Access Layer (ORM Entities, Repositories, DAOs) ]
                           │
                           ▼ (depends on)
[ Database Engine (PostgreSQL, MySQL, DynamoDB) ]

In this traditional model, dependencies flow downwards toward data storage. This creates three systemic engineering problems:

1. Database-First Thinking

Because the business logic layer depends directly on the data access layer, developers design the database tables first. ORM models (such as Prisma schemas, TypeORM entities, or Hibernate mappings) define the core objects of the system.

When business rules require validation, developers place decorators (such as @Column({ length: 100 }) or @Unique) directly onto the domain structures. The business logic is forced to mirror the relational schema of the database rather than the behavioral requirements of the domain.

2. Framework Coupling

Web controllers frequently pass transport-specific objects (HttpRequest, FastifyReply, NextRequest, or HttpContext) directly into service methods.

Over time, service methods begin reading headers, manipulating cookies, or returning HTTP-specific redirect codes. What was intended as a reusable business service becomes unusable outside an active HTTP request-response cycle, preventing reuse in CLI tools, background queue workers, or WebSocket servers.

3. Fragile, Slow Automated Tests

Because business rules are interwoven with database drivers and ORM instances, writing a fast, deterministic unit test becomes virtually impossible. Engineers must either:

  • Stand up an active test database (such as PostgreSQL running in Docker), introducing network I/O, disk writes, and concurrency bottlenecks that slow the test suite down to minutes; or
  • Maintain complex mock libraries that simulate internal ORM query builders, coupling test suites to third-party library internals rather than verifying actual business behavior.

The Dependency Rule: Concentric Architectural Rings

Clean Architecture reorganizes the system into concentric rings. The defining rule governing this structure is The Dependency Rule:

The Dependency Rule: Source code dependencies must point strictly inward, toward higher-level policies and business rules. An inner ring must know nothing whatsoever about an outer ring.

               ┌─────────────────────────────────────────────────────────┐
               │                  Frameworks & Drivers                   │
               │  (Express / Fastify / Next.js / PostgreSQL / Redis / UI)│
               │         ┌─────────────────────────────────────┐         │
               │         │         Interface Adapters          │         │
               │         │  (Controllers / Presenters / Gateways│         │
               │         │       ┌─────────────────────┐       │         │
               │         │       │     Application     │       │         │
               │         │       │     (Use Cases)     │       │         │
               │         │       │       ┌─────────┐   │       │         │
               │         │       │       │ Domain  │   │       │         │
               │         │       │       │(Entities│   │       │         │
               │         │       │       │ & Rules)│   │       │         │
               │         │       │       └─────────┘   │       │         │
               │         │       └─────────────────────┘       │         │
               │         └─────────────────────────────────────┘         │
               └─────────────────────────────────────────────────────────┘
                                   Dependency Direction
                       Outer Layers ───────────────────► Inner Layers

Nothing in an inner circle can mention the name of an entity, class, function, variable, or data format declared in an outer circle.

The Concentric Rings Defined

| Architectural Ring | Scope & Purpose | External Dependencies Allowed | Examples in Code | | :--- | :--- | :--- | :--- | | Entities (Domain) | Enterprise business rules, domain entities, value objects, domain events, and state invariants. | None. Pure native language types and standard library only. | Order, Money, Customer, OrderConfirmedEvent | | Use Cases (Application) | Application-specific workflows, business process orchestration, input/output boundary contracts (DTOs), and repository port interfaces. | Depends only on Entities. No external frameworks, databases, or transport libraries. | ConfirmOrderUseCase, OrderRepositoryPort, PaymentGatewayPort | | Interface Adapters | Translators that bridge Use Cases and external agencies. Inbound controllers parse HTTP/gRPC requests into Use Case DTOs; outbound adapters implement repository ports to speak to databases or third-party APIs. | Depends on Use Cases and Entities. Uses driver libraries via dependency inversion. | OrderController, PostgresOrderRepository, StripePaymentAdapter | | Frameworks & Drivers | External tools, database drivers, web frameworks, caching systems, message brokers, and infrastructure entry points. | Outermost layer. Glues adapters to runtime platforms and libraries. | Express app, Fastify server, pg pool, Redis client, AWS SDK |


Architectural Genealogies: Clean Architecture vs Hexagonal vs Onion

Clean Architecture is not an isolated invention; it represents the formal convergence of three distinct architectural movements developed between 2005 and 2012:

   Alistair Cockburn (2005)              Jeffrey Palermo (2008)               Robert C. Martin (2012)
   Hexagonal Architecture                  Onion Architecture                   Clean Architecture
   (Ports & Adapters)                    (Domain-Centric Inversion)           (Unified Concentric Rings)
           │                                         │                                    │
           ├─────────────────────────────────────────┴────────────────────────────────────┤
           ▼                                                                              ▼
   Inside: Core Application Logic                                             Inside: Entities & Use Cases
   Boundary: Inbound & Outbound Ports                                         Boundary: Input/Output Ports & Gateways
   Outside: Concrete Adapters (HTTP, DB, CLI)                                 Outside: Interface Adapters & Framework Drivers

Architectural Comparison

| Dimension | Clean Architecture (Martin, 2012) | Hexagonal Architecture (Cockburn, 2005) | Onion Architecture (Palermo, 2008) | Traditional 3-Tier Layered | | :--- | :--- | :--- | :--- | :--- | | Core Metaphor | Concentric circles with strict inward Dependency Rule | Hexagon with Ports (interfaces) and Adapters (implementations) | Concentric layers surrounding a central Domain Core | Linear vertical stack (Presentation →\to Logic →\to Data) | | Dependency Direction | Strictly inward toward Entities and Use Cases | Strictly inward toward the Application Core | Strictly inward toward Domain Model and Domain Services | Downward toward Data Access and Database | | Core Isolation | High: Entities know nothing of Use Cases or persistence | High: Application core knows nothing of transport or DB | High: Domain Model sits at the center, free of external tech | Low: Business logic imports and depends directly on ORM/DB | | Primary Structural Abstraction | Entities, Use Cases, Adapters, Frameworks | Driving Ports/Adapters vs Driven Ports/Adapters | Domain Model, Domain Services, Application Services | Controllers, Services, Repositories/DAOs | | Boundary Mechanics | Input/Output Boundary DTOs and Gateway Ports | Primary (Driving) API and Secondary (Driven) SPI | Interfaces defined in core, implemented in Infrastructure | Direct method calls across vertical layers | | Ceremony & Boilerplate | High (distinct DTOs, mappers, and port interfaces) | Moderate (ports and adapters for external integrations) | Moderate to High (domain services, application services) | Low (direct model usage across all layers) | | Primary Failure Mode | Over-engineering simple CRUD systems into mapper-heavy boilerplate | Ambiguity around internal domain structuring within the hexagon | Confusion between Domain Services and Application Services | Schema leakage, framework lock-in, and untestable business logic |


Decoupling Persistence: The Dependency Inversion Principle (DIP)

A frequent question when engineers encounter Clean Architecture is: If the Use Case layer cannot depend on the Infrastructure layer, how can a use case save an entity to a PostgreSQL database?

The answer lies in the Dependency Inversion Principle (DIP):

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

In Clean Architecture, the Use Case layer defines an Outbound Port—an interface that expresses what the application needs without specifying how it is fulfilled:

// Defined inside the Application (Use Case) layer:
export interface OrderRepositoryPort {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

The concrete implementation resides in the outer Interface Adapters layer:

// Defined inside the Infrastructure (Adapters) layer:
import { OrderRepositoryPort } from "../application/ports";
import { Order } from "../domain/order";

export class PostgresOrderRepository implements OrderRepositoryPort {
  constructor(private readonly dbPool: PgPool) {}

  async findById(id: string): Promise<Order | null> {
    // SQL query, row mapping, re-hydrating pure Domain Order
  }

  async save(order: Order): Promise<void> {
    // Persist order state to relational tables
  }
}

Notice the inversion:

  • At compile time, the source code dependency points inward: PostgresOrderRepository imports and implements OrderRepositoryPort.
  • At runtime, the control flow passes outward: the Use Case calls methods on the port interface, which dispatches to the PostgreSQL adapter.
COMPILE-TIME DEPENDENCY:
[ Interface Adapter: PostgresOrderRepository ] ───implements───► [ Application: OrderRepositoryPort ]
                                                                             ▲
                                                                             │ references
                                                                 [ Application: ConfirmOrderUseCase ]

RUNTIME CONTROL FLOW:
[ Client Request ] ──► [ OrderController ] ──► [ ConfirmOrderUseCase ] ──► [ PostgresOrderRepository ] ──► [ Database ]

A Complete Production Slice in TypeScript

To ground these concepts in production code, consider an e-commerce order confirmation workflow. The requirements are:

  1. An order contains line items with validated prices and quantities.
  2. An order cannot be confirmed if it contains zero items, is already confirmed, or has been cancelled.
  3. Upon confirmation, payment must be processed through an external payment gateway.
  4. If payment succeeds, the order state is saved as confirmed, and an OrderConfirmed domain event is emitted.
  5. If payment fails, the order state is cancelled, the change is persisted, and an error is returned.

Below is the complete, self-contained implementation demonstrating every architectural boundary.

1. The Domain Layer (Enterprise Business Rules)

The domain layer contains zero dependencies on external libraries, ORMs, or frameworks. It enforces domain invariants immediately upon creation and during state transitions.

// domain/money.ts
export class Money {
  private constructor(
    public readonly amountCents: number,
    public readonly currency: string
  ) {
    if (!Number.isInteger(amountCents)) {
      throw new Error("Money amount must be an integer represented in minor units (cents)");
    }
    if (amountCents < 0) {
      throw new Error("Money amount cannot be negative");
    }
    if (!/^[A-Z]{3}$/.test(currency)) {
      throw new Error("Currency must be a valid 3-letter ISO code");
    }
  }

  public static create(amountCents: number, currency: string): Money {
    return new Money(amountCents, currency.toUpperCase());
  }

  public add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error(`Cannot add mismatched currencies: ${this.currency} and ${other.currency}`);
    }
    return new Money(this.amountCents + other.amountCents, this.currency);
  }

  public multiply(quantity: number): Money {
    if (!Number.isInteger(quantity) || quantity < 0) {
      throw new Error("Multiplier must be a non-negative integer");
    }
    return new Money(this.amountCents * quantity, this.currency);
  }
}
// domain/order.ts
import { Money } from "./money";

export type OrderStatus = "DRAFT" | "CONFIRMED" | "CANCELLED";

export interface OrderLineItem {
  readonly productId: string;
  readonly unitPrice: Money;
  readonly quantity: number;
}

export class Order {
  private constructor(
    public readonly id: string,
    public readonly customerId: string,
    private _status: OrderStatus,
    private _items: OrderLineItem[],
    public readonly createdAt: Date
  ) {}

  public static create(id: string, customerId: string): Order {
    if (!id.trim()) {
      throw new Error("Order ID cannot be empty");
    }
    if (!customerId.trim()) {
      throw new Error("Customer ID cannot be empty");
    }
    return new Order(id, customerId, "DRAFT", [], new Date());
  }

  // Used by repository adapters to re-hydrate existing database records
  public static reconstitute(
    id: string,
    customerId: string,
    status: OrderStatus,
    items: OrderLineItem[],
    createdAt: Date
  ): Order {
    return new Order(id, customerId, status, [...items], createdAt);
  }

  public get status(): OrderStatus {
    return this._status;
  }

  public get items(): readonly OrderLineItem[] {
    return Object.freeze([...this._items]);
  }

  public addItem(productId: string, unitPrice: Money, quantity: number): void {
    if (this._status !== "DRAFT") {
      throw new Error(`Cannot modify order items when status is ${this._status}`);
    }
    if (!Number.isInteger(quantity) || quantity <= 0) {
      throw new Error("Item quantity must be a positive integer greater than zero");
    }
    this._items.push({ productId, unitPrice, quantity });
  }

  public calculateTotal(): Money {
    if (this._items.length === 0) {
      return Money.create(0, "USD");
    }
    const currency = this._items[0].unitPrice.currency;
    return this._items.reduce(
      (acc, item) => acc.add(item.unitPrice.multiply(item.quantity)),
      Money.create(0, currency)
    );
  }

  public confirm(): void {
    if (this._status === "CONFIRMED") {
      throw new Error("Order is already confirmed");
    }
    if (this._status === "CANCELLED") {
      throw new Error("Cannot confirm a cancelled order");
    }
    if (this._items.length === 0) {
      throw new Error("Cannot confirm an order with zero line items");
    }
    this._status = "CONFIRMED";
  }

  public cancel(): void {
    if (this._status === "CANCELLED") {
      throw new Error("Order is already cancelled");
    }
    this._status = "CANCELLED";
  }
}

2. The Application Layer (Use Cases & Ports)

The application layer contains use case interactors and defines outbound port interfaces. It uses an explicit Result type to model operational success and failure without throwing unhandled exceptions.

// application/result.ts
export type Result<T, E = Error> =
  | { readonly success: true; readonly value: T }
  | { readonly success: false; readonly error: E };

export const ok = <T>(value: T): Result<T, never> => ({ success: true, value });
export const err = <E>(error: E): Result<never, E> => ({ success: false, error });
// application/ports.ts
import { Order } from "../domain/order";
import { Money } from "../domain/money";
import { Result } from "./result";

export interface OrderRepositoryPort {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

export interface PaymentGatewayPort {
  charge(
    orderId: string,
    amount: Money,
    paymentMethodId: string
  ): Promise<Result<{ transactionId: string }, string>>;
}

export interface EventPublisherPort {
  publish(eventName: string, payload: Record<string, unknown>): Promise<void>;
}
// application/confirm-order.usecase.ts
import { OrderRepositoryPort, PaymentGatewayPort, EventPublisherPort } from "./ports";
import { Result, ok, err } from "./result";
import { OrderStatus } from "../domain/order";

export interface ConfirmOrderInput {
  readonly orderId: string;
  readonly paymentMethodId: string;
}

export interface ConfirmOrderOutput {
  readonly orderId: string;
  readonly status: OrderStatus;
  readonly totalAmountCents: number;
  readonly currency: string;
  readonly transactionId: string;
}

export class ConfirmOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepositoryPort,
    private readonly paymentGateway: PaymentGatewayPort,
    private readonly eventPublisher: EventPublisherPort
  ) {}

  public async execute(
    input: ConfirmOrderInput
  ): Promise<Result<ConfirmOrderOutput, string>> {
    // 1. Fetch domain aggregate via repository port
    const order = await this.orderRepo.findById(input.orderId);
    if (!order) {
      return err(`Order with ID '${input.orderId}' was not found`);
    }

    // 2. Validate domain invariants and transition state
    try {
      order.confirm();
    } catch (domainError: unknown) {
      const message = domainError instanceof Error ? domainError.message : "Domain validation failed";
      return err(message);
    }

    const total = order.calculateTotal();

    // 3. Process payment through external gateway port
    const paymentResult = await this.paymentGateway.charge(
      order.id,
      total,
      input.paymentMethodId
    );

    if (!paymentResult.success) {
      // Revert state to cancelled upon payment rejection
      order.cancel();
      await this.orderRepo.save(order);
      return err(`Payment authorization failed: ${paymentResult.error}`);
    }

    // 4. Persist updated domain aggregate
    await this.orderRepo.save(order);

    // 5. Emit domain event via publisher port
    await this.eventPublisher.publish("OrderConfirmed", {
      orderId: order.id,
      customerId: order.customerId,
      totalCents: total.amountCents,
      currency: total.currency,
      transactionId: paymentResult.value.transactionId,
      timestamp: new Date().toISOString(),
    });

    // 6. Return decoupled presentation DTO
    return ok({
      orderId: order.id,
      status: order.status,
      totalAmountCents: total.amountCents,
      currency: total.currency,
      transactionId: paymentResult.value.transactionId,
    });
  }
}

3. The Interface Adapters Layer

Interface adapters sit between use cases and external tools. They translate HTTP payloads into use case inputs, and map domain structures to SQL tables.

// adapters/postgres-order-repository.ts
import { OrderRepositoryPort } from "../application/ports";
import { Order, OrderLineItem, OrderStatus } from "../domain/order";
import { Money } from "../domain/money";

// Minimal structural interface for an SQL database client
export interface SqlDatabaseClient {
  query<T = unknown>(sql: string, params: unknown[]): Promise<{ rows: T[] }>;
}

interface OrderRow {
  id: string;
  customer_id: string;
  status: OrderStatus;
  created_at: string;
}

interface OrderItemRow {
  product_id: string;
  unit_price_cents: number;
  currency: string;
  quantity: number;
}

export class PostgresOrderRepository implements OrderRepositoryPort {
  constructor(private readonly db: SqlDatabaseClient) {}

  public async findById(id: string): Promise<Order | null> {
    const orderResult = await this.db.query<OrderRow>(
      "SELECT id, customer_id, status, created_at FROM orders WHERE id = $1 LIMIT 1",
      [id]
    );

    if (orderResult.rows.length === 0) {
      return null;
    }

    const row = orderResult.rows[0];

    const itemsResult = await this.db.query<OrderItemRow>(
      "SELECT product_id, unit_price_cents, currency, quantity FROM order_items WHERE order_id = $1",
      [id]
    );

    const items: OrderLineItem[] = itemsResult.rows.map((itemRow) => ({
      productId: itemRow.product_id,
      unitPrice: Money.create(itemRow.unit_price_cents, itemRow.currency),
      quantity: itemRow.quantity,
    }));

    return Order.reconstitute(
      row.id,
      row.customer_id,
      row.status,
      items,
      new Date(row.created_at)
    );
  }

  public async save(order: Order): Promise<void> {
    await this.db.query(
      `INSERT INTO orders (id, customer_id, status, created_at)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status`,
      [order.id, order.customerId, order.status, order.createdAt.toISOString()]
    );

    for (const item of order.items) {
      await this.db.query(
        `INSERT INTO order_items (order_id, product_id, unit_price_cents, currency, quantity)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (order_id, product_id) DO NOTHING`,
        [order.id, item.productId, item.unitPrice.amountCents, item.unitPrice.currency, item.quantity]
      );
    }
  }
}
// adapters/order-controller.ts
import { ConfirmOrderUseCase } from "../application/confirm-order.usecase";

export interface HttpRequest {
  params: Record<string, string | undefined>;
  body: Record<string, unknown>;
}

export interface HttpResponse {
  statusCode: number;
  body: Record<string, unknown>;
}

export class OrderController {
  constructor(private readonly confirmOrderUseCase: ConfirmOrderUseCase) {}

  public async handleConfirmOrder(request: HttpRequest): Promise<HttpResponse> {
    const orderId = request.params.id;
    const paymentMethodId = request.body.paymentMethodId;

    if (!orderId || typeof orderId !== "string") {
      return {
        statusCode: 400,
        body: { error: "Path parameter 'id' is required and must be a string" },
      };
    }

    if (!paymentMethodId || typeof paymentMethodId !== "string") {
      return {
        statusCode: 400,
        body: { error: "Field 'paymentMethodId' is required in request body" },
      };
    }

    const result = await this.confirmOrderUseCase.execute({
      orderId,
      paymentMethodId,
    });

    if (!result.success) {
      if (result.error.includes("not found")) {
        return { statusCode: 404, body: { error: result.error } };
      }
      if (result.error.includes("already confirmed") || result.error.includes("cancelled")) {
        return { statusCode: 409, body: { error: result.error } };
      }
      return { statusCode: 422, body: { error: result.error } };
    }

    return {
      statusCode: 200,
      body: { data: result.value },
    };
  }
}

4. The Composition Root (Wiring at Runtime)

The outermost layer instantiates dependencies and injects them inward. This is the only place in the entire codebase where concrete implementations meet:

// infrastructure/composition-root.ts
import { ConfirmOrderUseCase } from "../application/confirm-order.usecase";
import { PostgresOrderRepository, SqlDatabaseClient } from "../adapters/postgres-order-repository";
import { OrderController } from "../adapters/order-controller";
import { PaymentGatewayPort, EventPublisherPort } from "../application/ports";
import { Money } from "../domain/money";
import { ok, Result } from "../application/result";

// Concrete adapter simulating external Stripe SDK
class StripePaymentAdapter implements PaymentGatewayPort {
  constructor(private readonly apiKey: string) {}

  async charge(
    orderId: string,
    amount: Money,
    paymentMethodId: string
  ): Promise<Result<{ transactionId: string }, string>> {
    if (!this.apiKey) {
      return { success: false, error: "Missing Stripe API credential" };
    }
    // Simulation: In real code, invokes Stripe SDK client
    return ok({ transactionId: `ch_${orderId}_${Date.now()}` });
  }
}

// Concrete adapter simulating AWS SNS/SQS or Kafka emitter
class ConsoleEventPublisher implements EventPublisherPort {
  async publish(eventName: string, payload: Record<string, unknown>): Promise<void> {
    // In production: write to AWS SNS, RabbitMQ, or Apache Kafka
    void eventName;
    void payload;
  }
}

export function buildOrderModule(dbClient: SqlDatabaseClient, stripeKey: string) {
  // 1. Instantiate Outbound Adapters (Driven)
  const orderRepo = new PostgresOrderRepository(dbClient);
  const paymentGateway = new StripePaymentAdapter(stripeKey);
  const eventPublisher = new ConsoleEventPublisher();

  // 2. Instantiate Application Use Case
  const confirmOrderUseCase = new ConfirmOrderUseCase(
    orderRepo,
    paymentGateway,
    eventPublisher
  );

  // 3. Instantiate Inbound Controller (Driving)
  const orderController = new OrderController(confirmOrderUseCase);

  return { orderController };
}

Testing Clean Architecture: High Velocity and Zero Flakiness

The defining operational benefit of Clean Architecture is the speed and determinism of its automated test suite.

Because the use case interactor depends exclusively on interfaces (OrderRepositoryPort, PaymentGatewayPort, EventPublisherPort), we can test complex business scenarios in memory without spinning up Docker, PostgreSQL, or network stubs:

// test/confirm-order.usecase.test.ts
import { ConfirmOrderUseCase } from "../application/confirm-order.usecase";
import { OrderRepositoryPort, PaymentGatewayPort, EventPublisherPort } from "../application/ports";
import { Order } from "../domain/order";
import { Money } from "../domain/money";
import { ok, Result } from "../application/result";

class InMemoryOrderRepository implements OrderRepositoryPort {
  private readonly storage = new Map<string, Order>();

  public async findById(id: string): Promise<Order | null> {
    return this.storage.get(id) || null;
  }

  public async save(order: Order): Promise<void> {
    this.storage.set(order.id, order);
  }
}

async function runTests() {
  const repo = new InMemoryOrderRepository();
  const paymentGateway: PaymentGatewayPort = {
    charge: async () => ok({ transactionId: "ch_test_123" }),
  };
  const publishedEvents: string[] = [];
  const eventPublisher: EventPublisherPort = {
    publish: async (name) => { publishedEvents.push(name); },
  };

  // Seed domain state
  const order = Order.create("ord_101", "cust_202");
  order.addItem("prod_keyboard", Money.create(12000, "USD"), 1); // $120.00
  await repo.save(order);

  // Execute use case
  const useCase = new ConfirmOrderUseCase(repo, paymentGateway, eventPublisher);
  const result = await useCase.execute({
    orderId: "ord_101",
    paymentMethodId: "pm_card_valid",
  });

  // Assertions
  if (!result.success) {
    throw new Error(`Expected success, received error: ${result.error}`);
  }

  const savedOrder = await repo.findById("ord_101");
  if (savedOrder?.status !== "CONFIRMED") {
    throw new Error(`Expected status CONFIRMED, got ${savedOrder?.status}`);
  }

  if (result.value.totalAmountCents !== 12000) {
    throw new Error(`Expected total 12000, got ${result.value.totalAmountCents}`);
  }

  if (publishedEvents.length !== 1 || publishedEvents[0] !== "OrderConfirmed") {
    throw new Error("Expected OrderConfirmed event to be emitted");
  }
}

The Clean Architecture Test Pyramid

               ┌────────────────────────┐
               │    E2E & HTTP Tests    │  Low quantity, tests full wiring & transport
               │  (Supertest, Cypress)  │  Execution: ~500ms - 2s per test
               ├────────────────────────┴──────┐
               │   Adapter Integration Tests   │  Moderate quantity, verifies SQL queries & real schemas
               │   (Testcontainers, DB Pool)   │  Execution: ~20ms - 100ms per test
               ├───────────────────────────────┴──────┐
               │  Use Case Interactor Unit Tests     │  High quantity, tests business workflows with in-memory stubs
               │  (Pure TypeScript, Mock Ports)       │  Execution: ~0.5ms - 2ms per test
               ├──────────────────────────────────────┴──────┐
               │         Domain Entity & Invariant Tests     │  Highest quantity, pure functions & state transitions
               │         (Pure Native Language Assertions)   │  Execution: < 0.1ms per test
               └─────────────────────────────────────────────┘

By concentrating tests in the Use Case and Domain layers, test suites execute in seconds rather than tens of minutes, providing instantaneous feedback to developers during refactoring.


Critical Architectural Anti-Patterns and How to Avoid Them

When engineering teams attempt Clean Architecture, they frequently fall into subtle anti-patterns that defeat its intended benefits.

Anti-Pattern 1: The Leaky ORM Entity

The Mistake: Importing Prisma, TypeORM, or Mongoose models directly into the Domain or Use Case layers and treating database records as domain entities.

// BAD: Domain entity coupled to TypeORM and database column details
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity("orders")
export class Order {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  status: string; // Database column dictates domain type
}

Why It Fails: The database schema dictates the domain rules. If the engineering team alters a column type or migrates from an SQL datastore to a key-value store, the domain model breaks. Furthermore, ORM lazy-loading proxies create catastrophic runtime bugs when accessed outside active database transactions.

The Fix: Maintain a strict separation between domain entities (e.g., Order) and database records (e.g., OrderRow or OrderModel). The Repository Adapter maps between the two during findById and save.

Anti-Pattern 2: The Anemic Domain Model

The Mistake: Treating domain entities as dumb data bags with public getters and setters, while locating all validation and state transition logic inside Use Case services.

// BAD: Anemic domain entity with public mutation
export class Order {
  public status: string = "DRAFT";
  public items: any[] = [];
}

// In the use case:
if (order.status === "DRAFT" && order.items.length > 0) {
  order.status = "CONFIRMED"; // State mutated arbitrarily
}

Why It Fails: Invariants are not enforced by the entity. Any use case, background job, or script can manipulate the internal properties of Order, putting the system into an illegal state (such as confirming an order with zero items or a negative balance).

The Fix: Encapsulate invariants inside private properties and expose intention-revealing methods (order.confirm(), order.addItem()) that reject invalid transitions.

Anti-Pattern 3: The Naked Entity in the Controller

The Mistake: Returning pure domain entities directly in HTTP responses (res.json(order)).

// BAD: Controller returning raw domain entity
export class OrderController {
  async getOrder(req, res) {
    const order = await this.useCase.execute(req.params.id);
    res.json(order); // Exposes internal domain structure directly
  }
}

Why It Fails: Leaks internal entity structure, private fields, or computed calculations to public API clients. If an engineer refactors an internal domain field name, public API consumers experience a breaking change.

The Fix: Return explicit Output DTOs from Use Cases or map them to presentation view models inside the Controller or Presenter.

Anti-Pattern 4: Transaction Leakage Across Repositories

The Mistake: Passing database connection clients or ORM transaction handles (such as PrismaClient or QueryRunner) through Use Case methods to coordinate multiple repository updates atomically.

// BAD: Use case receiving and managing an ORM transaction
export class CheckoutUseCase {
  async execute(input: CheckoutInput, dbTransaction: PrismaTransaction) {
    await this.orderRepo.save(order, dbTransaction);
    await this.inventoryRepo.decrement(items, dbTransaction);
  }
}

Why It Fails: The Use Case becomes bound to a specific database driver API, violating the Dependency Rule.

The Fix: Employ one of three architectural patterns:

  1. Aggregate Root Pattern: Model the boundary so that a single repository operation encapsulates the entire atomic mutation.
  2. Unit of Work Port: Define a storage-agnostic UnitOfWorkPort interface in the application layer with runInTransaction(work: () => Promise<void>) implemented in infrastructure.
  3. Outbox Pattern with Eventual Consistency: Persist an event record in the same atomic database write as the primary entity, then process secondary operations asynchronously.

The Boilerplate Trap: When to Use vs When to Avoid Clean Architecture

Clean Architecture is an investment in long-term maintainability, but it carries a distinct operational overhead. Introducing it indiscriminately to every project leads to The Boilerplate Trap: writing four layers of mapping (Database Record →\to Domain Entity →\to Use Case DTO →\to HTTP Response) for endpoints that merely read a row from a table and display it on a screen.

The Boilerplate Trap in Simple CRUD:
HTTP Request ──► Controller ──► Input DTO ──► Use Case ──► Repository Port ──► Adapter ──► DB Record ──► DB
                                                                                                      │
HTTP Response ◄── Controller ◄── Output DTO ◄── Domain Entity ◄── Repository Adapter ◄────────────────┘
Total: 4 Mappers, 3 DTOs, 2 Interfaces, and 5 Files to execute a basic SQL SELECT.

Architectural Selection Matrix

| System Characteristics | Recommended Architecture | Rationale | | :--- | :--- | :--- | | Simple CRUD or Admin Dashboards | Pragmatic Layered / Active Record (e.g., Prisma + Next.js Route Handlers) | Low business complexity; domain rules do not justify the cost of DTO mappings and port abstractions. | | Early-Stage Prototypes / Pre-PMF Startups | Modular Monolith with Lightweight Services | Data schemas change daily; heavy architectural layers increase iteration friction without providing value. | | High-Complexity Core Domain (Fintech, Logistics, Healthcare) | Clean Architecture / Hexagonal Architecture | Invariants must be guarded; regulatory compliance requires isolation of business calculations from delivery systems. | | Multi-Channel Delivery Systems (REST, gRPC, CLI, Webhook, Kafka Workers) | Clean Architecture / Hexagonal Architecture | Identical business logic and use cases must be executed across heterogeneous transport layers without duplication. | | Long-Lived Enterprise Applications (5+ Year Horizon) | Clean Architecture / Hexagonal Architecture | Insulation against third-party dependency deprecation, framework churn, and database migrations over multi-year lifecycles. |


Production Verification Checklist

Before considering an implementation of Clean Architecture production-ready, verify that the following architectural checks pass:

  • [ ] Dependency Direction Verification: No file in domain/ or application/ contains an import from adapters/, infrastructure/, or external runtime frameworks (express, fastify, @prisma/client, typeorm).
  • [ ] Pure Domain Types: Domain entities rely exclusively on native language types and standard library primitives; zero ORM decorators or framework attributes exist on domain classes.
  • [ ] State Invariant Protection: Domain entities validate their own constraints upon construction and mutation; no public property setters permit bypass of business validation.
  • [ ] Explicit Port Boundaries: All external dependencies (databases, payment processors, email gateways, message queues) are accessed through outbound port interfaces declared in the application layer.
  • [ ] Result-Based Error Handling: Use cases return typed Result<T, E> unions or domain error hierarchies rather than leaking unexpected database driver or transport exceptions.
  • [ ] No Naked Domain Entities: Controllers and presenters map use case outputs to explicit presentation schemas before responding to clients; domain entities are never serialized directly to HTTP JSON.
  • [ ] Isolated Use Case Tests: Core use cases have automated unit test suites running in memory against stubbed repository ports with zero database network I/O.
  • [ ] Explicit Composition Root: Concrete adapter instantiation and dependency injection occur exclusively in a dedicated infrastructure bootstrap module.

Conclusion

Clean Architecture is not a dogma or a prescribed folder hierarchy. It is a disciplined engineering strategy for managing dependencies and protecting high-value business logic from the volatility of external tools.

By keeping business entities and use cases at the center of the application and forcing dependencies to point strictly inward, teams gain software that is resilient to framework churn, effortless to test, and adaptable across multiple delivery channels.

When applied to complex, evolving domains, Clean Architecture transforms software from a fragile web of database queries and framework callbacks into a durable asset capable of scaling gracefully over years of production operation.

Related articles