Clean Architecture: The Core Principles

TT
Clean Architecture: The Core Principles

Clean Architecture: The Core Principles

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), organizes code so that business logic has no dependencies on frameworks, databases, or external services. The goal is a codebase where you can change the database, swap the web framework, or replace an external service without touching a single line of business logic—and where every business rule can be tested without running a server or connecting to a database.


The Dependency Rule

The central rule: dependencies point inward. Outer layers depend on inner layers; inner layers know nothing about outer layers.

text
┌───────────────────────────────────┐
│  Frameworks & Drivers             │  ← Express, PostgreSQL, React
│  ┌─────────────────────────────┐  │
│  │  Interface Adapters         │  │  ← Controllers, Repositories, Presenters
│  │  ┌───────────────────────┐  │  │
│  │  │  Application (Use     │  │  │  ← Use Cases, Interactors
│  │  │  Cases)               │  │  │
│  │  │  ┌─────────────────┐  │  │  │
│  │  │  │  Entities       │  │  │  │  ← Business rules, Domain models
│  │  │  └─────────────────┘  │  │  │
│  │  └───────────────────────┘  │  │
│  └─────────────────────────────┘  │
└───────────────────────────────────┘
         ← dependencies point inward only

Entities: Core business objects with enterprise-wide rules. A User entity's rules apply regardless of whether it's in a web app, a CLI, or a batch job.

Use Cases: Application-specific business rules. "Create an order" is a use case—it orchestrates entities to fulfill a user's intent. Use cases know about entities but not about HTTP or databases.

Interface Adapters: Convert data between the format useful to use cases and the format useful to frameworks. Controllers parse HTTP requests into use case inputs; repositories translate domain objects to/from SQL rows.

Frameworks and Drivers: The outermost layer. Express, PostgreSQL, Redis, React. All the details that change frequently and should have no influence on business logic.


Entities: Business Rules

Entities contain enterprise-wide business logic. They are plain objects with no framework dependencies.

typescript
// Domain entity — pure TypeScript, no dependencies
export class Order {
  private constructor(
    public readonly id: string,
    public readonly userId: string,
    public readonly items: OrderItem[],
    private _status: OrderStatus,
    public readonly createdAt: Date
  ) {}

  static create(userId: string, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new DomainError('Order must contain at least one item');
    }
    const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    if (total > 50_000) {
      throw new DomainError('Order total exceeds maximum allowed amount');
    }
    return new Order(generateId(), userId, items, OrderStatus.PENDING, new Date());
  }

  confirm(): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new DomainError(`Cannot confirm order in ${this._status} status`);
    }
    this._status = OrderStatus.CONFIRMED;
  }

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

  get status(): OrderStatus { return this._status; }
  get total(): number { return this.items.reduce((s, i) => s + i.price * i.quantity, 0); }
}

export enum OrderStatus {
  PENDING = 'PENDING',
  CONFIRMED = 'CONFIRMED',
  SHIPPED = 'SHIPPED',
  CANCELLED = 'CANCELLED',
}

Notice: no imports from Express, Prisma, or any framework. This entity can be tested with zero setup.


Use Cases: Application Logic

Use cases orchestrate entities to fulfill a user's intent. They depend on abstractions (interfaces) for I/O, never on concrete implementations.

typescript
// Port (interface) — defined in the application layer
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

export interface PaymentGateway {
  charge(userId: string, amount: number): Promise<{ transactionId: string }>;
}

export interface NotificationService {
  sendOrderConfirmation(userId: string, orderId: string): Promise<void>;
}

// Input/Output DTOs
export interface CreateOrderInput {
  userId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
}

export interface CreateOrderOutput {
  orderId: string;
  total: number;
  status: string;
}

// Use Case
export class CreateOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly notifications: NotificationService
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // Create entity (validates business rules)
    const order = Order.create(
      input.userId,
      input.items.map(i => new OrderItem(i.productId, i.quantity, i.price))
    );

    // Charge payment
    const payment = await this.paymentGateway.charge(input.userId, order.total);

    // Confirm order
    order.confirm();

    // Persist
    await this.orderRepo.save(order);

    // Notify
    await this.notifications.sendOrderConfirmation(input.userId, order.id);

    return {
      orderId: order.id,
      total: order.total,
      status: order.status,
    };
  }
}

This use case is completely testable with mock implementations—no database, no HTTP, no payment API required.


Interface Adapters: Controllers and Repositories

Controllers adapt HTTP requests into use case inputs and use case outputs into HTTP responses.

typescript
// Express controller — knows about HTTP but delegates all logic to use case
export class OrderController {
  constructor(private readonly createOrder: CreateOrderUseCase) {}

  create = async (req: Request, res: Response): Promise<void> => {
    try {
      const result = await this.createOrder.execute({
        userId: req.user.id,  // set by auth middleware
        items: req.body.items,
      });
      res.status(201).json(result);
    } catch (error) {
      if (error instanceof DomainError) {
        res.status(400).json({ error: error.message });
      } else {
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  };
}

Repository implementations are in the outer layer, depending on database libraries:

typescript
// Infrastructure layer: depends on pg (PostgreSQL driver)
import { Pool } from 'pg';
import { Order, OrderRepository } from '../../domain';

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly pool: Pool) {}

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

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

  private toDomain(row: Record<string, unknown>): Order {
    // reconstruct domain object from DB row
    return Order.reconstitute(row.id as string, row.user_id as string, /* ... */);
  }
}

Dependency Injection

The application layer defines the interfaces; the composition root (main entry point) wires the concrete implementations together.

typescript
// src/main.ts — the composition root
import express from 'express';
import { Pool } from 'pg';
import { PostgresOrderRepository } from './infrastructure/repositories';
import { StripePaymentGateway } from './infrastructure/payment';
import { SendGridNotificationService } from './infrastructure/notifications';
import { CreateOrderUseCase } from './application/usecases';
import { OrderController } from './adapters/controllers';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Wire the dependencies
const orderRepo = new PostgresOrderRepository(pool);
const paymentGateway = new StripePaymentGateway(process.env.STRIPE_KEY!);
const notifications = new SendGridNotificationService(process.env.SENDGRID_KEY!);
const createOrderUseCase = new CreateOrderUseCase(orderRepo, paymentGateway, notifications);
const orderController = new OrderController(createOrderUseCase);

const app = express();
app.post('/orders', orderController.create);
app.listen(3000);

Swap the entire database by changing one line in main.ts. No business logic changes required.


Testing at Each Layer

typescript
// Entity test: pure unit test, zero setup
describe('Order', () => {
  it('rejects empty item list', () => {
    expect(() => Order.create('user-1', [])).toThrow('at least one item');
  });

  it('cannot cancel a shipped order', () => {
    const order = Order.create('user-1', [testItem]);
    order.confirm();
    order.ship();
    expect(() => order.cancel()).toThrow('Cannot cancel a shipped order');
  });
});

// Use case test: mock the ports
describe('CreateOrderUseCase', () => {
  let useCase: CreateOrderUseCase;
  let mockOrderRepo: jest.Mocked<OrderRepository>;
  let mockPayment: jest.Mocked<PaymentGateway>;
  let mockNotifications: jest.Mocked<NotificationService>;

  beforeEach(() => {
    mockOrderRepo = { findById: jest.fn(), save: jest.fn() };
    mockPayment = { charge: jest.fn().mockResolvedValue({ transactionId: 'txn-1' }) };
    mockNotifications = { sendOrderConfirmation: jest.fn().mockResolvedValue(undefined) };
    useCase = new CreateOrderUseCase(mockOrderRepo, mockPayment, mockNotifications);
  });

  it('saves order and sends notification', async () => {
    const result = await useCase.execute({
      userId: 'user-1',
      items: [{ productId: 'prod-1', quantity: 2, price: 25 }],
    });

    expect(result.status).toBe('CONFIRMED');
    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1);
    expect(mockNotifications.sendOrderConfirmation).toHaveBeenCalledWith('user-1', expect.any(String));
  });
});

Directory Structure

text
src/
├── domain/                     # Entities, value objects, domain errors
│   ├── order/
│   │   ├── Order.ts
│   │   ├── OrderItem.ts
│   │   └── OrderStatus.ts
│   └── user/
│       └── User.ts
├── application/                # Use cases, ports (interfaces)
│   ├── usecases/
│   │   ├── CreateOrder.ts
│   │   └── CancelOrder.ts
│   └── ports/                  # Interfaces for repositories, services
│       ├── OrderRepository.ts
│       └── PaymentGateway.ts
├── adapters/                   # Controllers, presenters
│   └── controllers/
│       └── OrderController.ts
├── infrastructure/             # Concrete implementations
│   ├── repositories/
│   │   └── PostgresOrderRepository.ts
│   ├── payment/
│   │   └── StripePaymentGateway.ts
│   └── notifications/
│       └── SendGridNotificationService.ts
└── main.ts                     # Composition root

Frequently Asked Questions

Q: Isn't Clean Architecture over-engineering for small projects?

For a CRUD service with 5 endpoints and no complex business rules, yes. Clean Architecture pays dividends when: (1) business rules are non-trivial and need to be tested in isolation, (2) you need to swap infrastructure components (different database in tests, different payment provider in production), (3) multiple teams work on the same codebase and need clear ownership boundaries. For a small project, start with a simpler layered architecture, but understand Clean Architecture so you can refactor toward it when complexity demands it.

Q: How do I handle database transactions that span multiple repositories in Clean Architecture?

This is a real tension point. Options: (1) A UnitOfWork port in the application layer that wraps a transaction, with a concrete implementation that uses the DB's transaction mechanism. The use case calls unitOfWork.begin() and commit(). (2) Domain events: the use case emits events, a transaction middleware collects them, and a single transaction handler applies all changes. (3) Outbox pattern: all writes go to one DB transaction that includes an outbox table. The cleanest is the Unit of Work pattern—it keeps transactions visible in the use case without leaking DB specifics.

Q: What is the difference between Clean Architecture and Hexagonal Architecture?

They are conceptually very similar and often used interchangeably. Hexagonal Architecture (Ports and Adapters, by Alistair Cockburn) focuses on the idea of ports (interfaces) and adapters (implementations)—a primary port for driving the application (HTTP controller) and secondary ports for driven adapters (database, email). Clean Architecture is a more detailed elaboration that adds the concentric circle model with named layers (entities, use cases, adapters, frameworks) and the explicit Dependency Rule. The key insight—business logic in the center, dependencies pointing inward—is identical in both.

Q: How strict should the layer boundaries be? Can a controller access the domain directly?

In strict Clean Architecture, a controller should only interact with use cases, never with domain entities directly. This enforces that all business logic flows through use cases. In practice, many teams allow read-only access to domain entities from controllers for simple cases (e.g., a controller reading order.id to build a response), but insist that all writes go through use cases. The test: if business logic (validation, state transitions, invariants) ever appears in a controller, you have breached the boundary and that code needs to move into a use case or entity.