Clean Architecture, proposed by Robert C. Martin, organizes code into concentric layers with a strict dependency rule: dependencies always point inward. The inner layers know nothing about the outer layers.

The Layers

┌───────────────────────────────────────────┐
│            Frameworks & Drivers           │  Express, PostgreSQL, Redis
│  ┌───────────────────────────────────┐    │
│  │        Interface Adapters         │    │  Controllers, Repositories, Presenters
│  │  ┌───────────────────────────┐    │    │
│  │  │      Application Layer    │    │    │  Use Cases, DTOs
│  │  │  ┌───────────────────┐    │    │    │
│  │  │  │  Domain Entities  │    │    │    │  Business rules, Value Objects
│  │  │  └───────────────────┘    │    │    │
│  │  └───────────────────────────┘    │    │
│  └───────────────────────────────────┘    │
└───────────────────────────────────────────┘

Project Structure

src/
├── domain/           # Enterprise business rules (innermost)
│   ├── entities/
│   │   ├── User.ts
│   │   └── Order.ts
│   ├── value-objects/
│   │   ├── Email.ts
│   │   └── Money.ts
│   └── errors/
│       └── DomainError.ts

├── application/      # Application business rules
│   ├── use-cases/
│   │   ├── CreateOrder.ts
│   │   └── GetUserOrders.ts
│   ├── ports/        # Interfaces for outer layers
│   │   ├── OrderRepository.ts
│   │   ├── PaymentGateway.ts
│   │   └── EmailService.ts
│   └── dtos/
│       └── CreateOrderDTO.ts

├── infrastructure/   # Frameworks & drivers (outermost)
│   ├── persistence/
│   │   ├── PostgresOrderRepository.ts
│   │   └── InMemoryOrderRepository.ts
│   ├── services/
│   │   ├── StripePaymentGateway.ts
│   │   └── SendGridEmailService.ts
│   └── web/
│       ├── routes/
│       │   └── orderRoutes.ts
│       ├── controllers/
│       │   └── OrderController.ts
│       └── middleware/
│           └── errorHandler.ts

└── main.ts           # Composition root (wires everything)

Domain Layer (No Dependencies)

// domain/entities/Order.ts
import { Money } from '../value-objects/Money';

export class Order {
  readonly id: string;
  readonly items: OrderItem[];
  private _status: OrderStatus;

  constructor(props: { id: string; items: OrderItem[]; status?: OrderStatus }) {
    if (props.items.length === 0) {
      throw new DomainError('Order must have at least one item');
    }
    this.id = props.id;
    this.items = props.items;
    this._status = props.status ?? 'pending';
  }

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

  get total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      Money.zero('USD'),
    );
  }

  confirm(): void {
    if (this._status !== 'pending') {
      throw new DomainError(`Cannot confirm order in status: ${this._status}`);
    }
    this._status = 'confirmed';
  }

  cancel(): void {
    if (this._status === 'shipped') {
      throw new DomainError('Cannot cancel shipped order');
    }
    this._status = 'cancelled';
  }
}

Application Layer (Depends Only on Domain)

// application/ports/OrderRepository.ts — an interface
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
  save(order: Order): Promise<void>;
}

// application/use-cases/CreateOrder.ts
export class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,    // Port (interface)
    private paymentGateway: PaymentGateway, // Port (interface)
    private emailService: EmailService,     // Port (interface)
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // Business logic — no framework dependencies
    const order = new Order({
      id: generateId(),
      items: input.items.map(i => new OrderItem(i.productId, i.quantity, i.price)),
    });

    const payment = await this.paymentGateway.charge(order.total, input.paymentMethod);
    if (!payment.success) {
      throw new PaymentFailedError(payment.reason);
    }

    order.confirm();
    await this.orderRepo.save(order);
    await this.emailService.sendOrderConfirmation(input.userEmail, order);

    return { orderId: order.id, total: order.total.format() };
  }
}

Infrastructure Layer (Implements Ports)

// infrastructure/persistence/PostgresOrderRepository.ts
export class PostgresOrderRepository implements OrderRepository {
  constructor(private pool: Pool) {}

  async findById(id: string): Promise<Order | null> {
    const result = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id]);
    return result.rows[0] ? this.toDomain(result.rows[0]) : null;
  }

  async save(order: Order): Promise<void> {
    await this.pool.query(
      `INSERT INTO orders (id, status, total) VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE SET status = $2, total = $3`,
      [order.id, order.status, order.total.amount]
    );
  }

  private toDomain(row: any): Order {
    return new Order({ id: row.id, items: JSON.parse(row.items), status: row.status });
  }
}

// infrastructure/web/controllers/OrderController.ts
export class OrderController {
  constructor(private createOrder: CreateOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    const result = await this.createOrder.execute({
      items: req.body.items,
      paymentMethod: req.body.paymentMethod,
      userEmail: req.user.email,
    });
    res.status(201).json(result);
  }
}

Composition Root

// main.ts — the only place that knows about ALL layers
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = new PostgresOrderRepository(pool);
const paymentGateway = new StripePaymentGateway(process.env.STRIPE_KEY!);
const emailService = new SendGridEmailService(process.env.SENDGRID_KEY!);

const createOrder = new CreateOrderUseCase(orderRepo, paymentGateway, emailService);
const orderController = new OrderController(createOrder);

app.post('/api/orders', (req, res) => orderController.create(req, res));

Why This Works

  • Domain logic is framework-free: Swap Express for Fastify? Only change the web layer.
  • Database is swappable: Switch from Postgres to MongoDB by implementing a new repository.
  • Testing is trivial: Use in-memory implementations for the ports.
  • Business rules are protected: They can’t accidentally depend on HTTP or SQL.

“The center of your application is not the database. Nor is it the framework. The center is the use cases of the application.” — Robert C. Martin