Clean Architecture in Practice

Robert C. Martin's Clean Architecture is one of those ideas that looks almost too simple on paper — four concentric circles, one rule about dependencies — yet consistently proves to be one of the most effective structural decisions you can make on a long-lived codebase. The payoff isn't elegance; it's the ability to defer infrastructure decisions, swap frameworks without rewriting business logic, and test the entire application core without spinning up a database or HTTP server.

In this article, I'll build a concrete TypeScript example — a payment processing module — to show exactly how each layer works, where the boundaries sit, and what discipline is required to keep the dependency rule from eroding over time.

The One Rule That Matters

Clean Architecture is built on a single constraint called the Dependency Rule: source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle.

Frameworks & Drivers
Interface Adapters
Use Cases
Entities
← dependencies point inward only →
The four layers of Clean Architecture — each circle can only depend on circles inside it

Project Structure

src/ ├── domain/ │ └── entities/ │ ├── Payment.ts # Enterprise business rules │ ├── Money.ts # Value object │ └── PaymentStatus.ts │ ├── application/ │ ├── use-cases/ │ │ ├── ProcessPayment.ts # Use case implementation │ │ └── RefundPayment.ts │ └── ports/ │ ├── PaymentRepository.ts # Output boundary (interface) │ └── PaymentGateway.ts # Output boundary (interface) │ ├── adapters/ │ ├── controllers/ │ │ └── PaymentController.ts # HTTP → use case │ ├── presenters/ │ │ └── PaymentPresenter.ts # Use case result → HTTP response │ └── gateways/ │ ├── PrismaPaymentRepository.ts # Implements PaymentRepository │ └── StripePaymentGateway.ts # Implements PaymentGateway │ └── infrastructure/ ├── http/ │ └── server.ts # Express setup └── database/ └── prisma.ts # Prisma client setup

Entities: Pure Business Rules

Entities encode invariants — rules that are always true regardless of the application context. A Payment must always have a positive amount. This check belongs here, not in the use case or the controller.

// domain/entities/Money.ts export class Money { private constructor( readonly amount: number, readonly currency: string ) {} static of(amount: number, currency: string): Money { if (amount <= 0) throw new Error("Amount must be positive"); if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code"); return new Money(amount, currency); } add(other: Money): Money { if (this.currency !== other.currency) throw new Error("Currency mismatch"); return Money.of(this.amount + other.amount, this.currency); } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; } } // domain/entities/Payment.ts import { Money } from "./Money"; import { PaymentStatus } from "./PaymentStatus"; export class Payment { private status: PaymentStatus = PaymentStatus.PENDING; private constructor( readonly id: string, readonly customerId: string, readonly amount: Money, readonly createdAt: Date ) {} static create(customerId: string, amount: Money): Payment { return new Payment( crypto.randomUUID(), customerId, amount, new Date() ); } authorize(): void { if (this.status !== PaymentStatus.PENDING) throw new Error("Only pending payments can be authorized"); this.status = PaymentStatus.AUTHORIZED; } fail(reason: string): void { if (this.status !== PaymentStatus.PENDING) throw new Error("Only pending payments can fail"); this.status = PaymentStatus.FAILED; this._failureReason = reason; } getStatus(): PaymentStatus { return this.status; } private _failureReason?: string; getFailureReason(): string | undefined { return this._failureReason; } }

Use Cases: Application Business Rules

Each use case implements a single, well-named operation. It depends only on entities and on output port interfaces — never on Express, Prisma, or Stripe directly. This is the key discipline that enables testing without infrastructure.

// application/ports/PaymentRepository.ts export interface PaymentRepository { save(payment: Payment): Promise<void>; findById(id: string): Promise<Payment | null>; } // application/ports/PaymentGateway.ts export interface PaymentGateway { charge(customerId: string, amount: Money): Promise<ChargeResult>; } export interface ChargeResult { success: boolean; transactionId?: string; failureReason?: string; } // application/use-cases/ProcessPayment.ts import { Payment } from "../../domain/entities/Payment"; import { Money } from "../../domain/entities/Money"; import { PaymentRepository } from "../ports/PaymentRepository"; import { PaymentGateway } from "../ports/PaymentGateway"; export interface ProcessPaymentInput { customerId: string; amountInCents: number; currency: string; } export interface ProcessPaymentOutput { paymentId: string; status: string; transactionId?: string; } export class ProcessPayment { constructor( private readonly paymentRepository: PaymentRepository, private readonly paymentGateway: PaymentGateway ) {} async execute(input: ProcessPaymentInput): Promise<ProcessPaymentOutput> { const amount = Money.of(input.amountInCents / 100, input.currency); const payment = Payment.create(input.customerId, amount); const result = await this.paymentGateway.charge( input.customerId, amount ); if (result.success) { payment.authorize(); } else { payment.fail(result.failureReason ?? "Unknown error"); } await this.paymentRepository.save(payment); return { paymentId: payment.id, status: payment.getStatus(), transactionId: result.transactionId, }; } }

Interface Adapters: Translating Between Worlds

Controller

The controller's only job is to parse the HTTP request into the use case input DTO and invoke the use case. It has no business logic — none. Any conditional logic here is likely a sign that something belongs in the use case or entity.

// adapters/controllers/PaymentController.ts import { Request, Response } from "express"; import { ProcessPayment } from "../../application/use-cases/ProcessPayment"; export class PaymentController { constructor(private readonly processPayment: ProcessPayment) {} async handle(req: Request, res: Response): Promise<void> { const { customerId, amountInCents, currency } = req.body; const output = await this.processPayment.execute({ customerId, amountInCents, currency, }); res.status(201).json(output); } }

Persistence Gateway (Prisma)

The gateway translates between the domain entity and the persistence model. Notice that the domain Payment entity has no Prisma decorators — the gateway absorbs all that mapping complexity.

// adapters/gateways/PrismaPaymentRepository.ts import { PrismaClient } from "@prisma/client"; import { Payment } from "../../domain/entities/Payment"; import { Money } from "../../domain/entities/Money"; import { PaymentRepository } from "../../application/ports/PaymentRepository"; export class PrismaPaymentRepository implements PaymentRepository { constructor(private readonly prisma: PrismaClient) {} async save(payment: Payment): Promise<void> { await this.prisma.payment.upsert({ where: { id: payment.id }, update: { status: payment.getStatus(), failureReason: payment.getFailureReason() ?? null, }, create: { id: payment.id, customerId: payment.customerId, amountInCents: Math.round(payment.amount.amount * 100), currency: payment.amount.currency, status: payment.getStatus(), createdAt: payment.createdAt, }, }); } async findById(id: string): Promise<Payment | null> { const row = await this.prisma.payment.findUnique({ where: { id } }); if (!row) return null; return Payment.reconstitute( row.id, row.customerId, Money.of(row.amountInCents / 100, row.currency), row.status, row.createdAt ); } }

Testability: The Real Return on Investment

Because neither entities nor use cases have any dependency on infrastructure, you can test all business logic with pure in-memory fakes — no database, no HTTP server, no Stripe sandbox account required.

// In-memory fake for tests — not a mock, a real implementation class InMemoryPaymentRepository implements PaymentRepository { private store = new Map<string, Payment>(); async save(p: Payment) { this.store.set(p.id, p); } async findById(id: string) { return this.store.get(id) ?? null; } } class FakePaymentGateway implements PaymentGateway { private shouldSucceed = true; failNextCharge(reason = "Insufficient funds") { this.shouldSucceed = false; this._reason = reason; } private _reason?: string; async charge(): Promise<ChargeResult> { if (this.shouldSucceed) return { success: true, transactionId: "txn_test" }; return { success: false, failureReason: this._reason }; } } // Tests — millisecond execution, zero infrastructure describe("ProcessPayment", () => { let repo: InMemoryPaymentRepository; let gateway: FakePaymentGateway; let useCase: ProcessPayment; beforeEach(() => { repo = new InMemoryPaymentRepository(); gateway = new FakePaymentGateway(); useCase = new ProcessPayment(repo, gateway); }); it("authorizes payment when gateway succeeds", async () => { const output = await useCase.execute({ customerId: "cust_1", amountInCents: 5000, currency: "BRL", }); expect(output.status).toBe("AUTHORIZED"); expect(output.transactionId).toBe("txn_test"); const saved = await repo.findById(output.paymentId); expect(saved?.getStatus()).toBe("AUTHORIZED"); }); it("marks payment as failed when gateway rejects", async () => { gateway.failNextCharge("Card declined"); const output = await useCase.execute({ customerId: "cust_1", amountInCents: 5000, currency: "BRL", }); expect(output.status).toBe("FAILED"); }); it("rejects non-positive amounts at the entity level", async () => { await expect( useCase.execute({ customerId: "cust_1", amountInCents: 0, currency: "BRL" }) ).rejects.toThrow("Amount must be positive"); }); });

Key insight: The InMemoryPaymentRepository is not a mock — it's a real implementation of the port interface. Using in-memory fakes instead of mocks means tests verify behavior, not implementation details. Swap the fake for a Testcontainers-backed Prisma instance only for adapter-level integration tests.

Clean Architecture vs. Hexagonal Architecture

These two patterns share the same core idea — inward-pointing dependencies, isolated domain — but differ in vocabulary and emphasis:

Feature | Clean Architecture | Hexagonal Architecture ---------------------|---------------------------|------------------------- Vocabulary | Entities, Use Cases, | Domain, Ports, Adapters | Interface Adapters | Boundary abstraction | Input/Output boundaries | Input/Output ports | (interfaces in use cases) | (interfaces owned by core) Layers | 4 explicit circles | 3 zones (core + adapters) Primary distinction | Separates Entities from | Emphasizes driving vs. | Use Cases explicitly | driven adapters Framework coupling | Zero in inner two circles | Zero in core Typical stack | Any — language-agnostic | Java/Spring Boot common

In practice, they are complementary. Many teams use Clean Architecture's layer model with Hexagonal Architecture's port/adapter terminology. The concepts that matter — the Dependency Rule, framework isolation, port interfaces — are shared by both.

The Disciplines That Actually Matter

The pattern erodes when teams don't maintain two specific habits:

1. No infrastructure imports in entities or use cases. If you see import { PrismaClient } or import { Request } from 'express' inside application/ or domain/, the boundary has been violated. Enforce this with ESLint import rules or architectural fitness functions (using dependency-cruiser).

2. Use cases speak in domain terms, not HTTP terms. The input DTO to a use case should be a plain object with domain-meaningful field names, not a shaped-after-the-HTTP-request body. Controllers translate; use cases don't know about HTTP.

When Not to Apply It

Clean Architecture has a real overhead: more files, more interfaces, more mapping. It pays off when:

For simple CRUD APIs, a three-tier structure (route → service → repository) often serves better. Clean Architecture's overhead is proportional to the richness of the domain it protects. Don't apply it uniformly across a monorepo — apply it where the domain warrants it.

Key Takeaways

Back to all articles