Clean Architecture Explained: Layers, Boundaries and When It's Overkill

Clean architecture explained simply — the dependency rule, domain, use cases, adapters, ports and adapters (hexagonal), a practical folder structure, and when it's overkill.

By Danyal Ahmed - Data Scientist · · 4 min read

"Clean architecture" is one of those terms that means different things to different people. For some it's a strict set of concentric layers with a dozen folders per feature. For others it's a vague aspiration toward "clean code". Underneath is a simple, valuable idea: keep your business logic independent of frameworks, databases and delivery mechanisms, so it's easy to test and change.

This guide explains the core concepts, shows a practical structure, and is honest about when it's too much.

Key takeaways

  • The central idea is the dependency rule: source code dependencies point inward, toward business logic.
  • Domain and use cases know nothing about HTTP, SQL or UI frameworks.
  • Outer layers (adapters) translate between the outside world and your core through interfaces (ports).
  • The payoff: testable business logic, swappable infrastructure, and code organized around what the system does.
  • For simple CRUD apps it can be overkill. Apply it where business logic is genuinely complex.

The layers

Clean architecture, popularized by Robert C. Martin, is usually drawn as concentric circles. In practice, four layers matter:

text┌─────────────────────────────────────────────┐
│  Frameworks & drivers (web, DB, queues, UI) │
│  ┌───────────────────────────────────────┐  │
│  │  Interface adapters (controllers,     │  │
│  │  repositories, presenters, gateways)  │  │
│  │  ┌─────────────────────────────────┐  │  │
│  │  │  Use cases (application logic)  │  │  │
│  │  │  ┌───────────────────────────┐  │  │  │
│  │  │  │  Domain (entities, rules) │  │  │  │
│  │  │  └───────────────────────────┘  │  │  │
│  │  └─────────────────────────────────┘  │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘
          Dependencies point inward →
  1. Domain (entities): core business concepts and rules. An Invoice knows how to calculate its total and whether it can be voided. Pure code, no I/O.
  2. Use cases (application services): orchestrate domain objects to accomplish a task, such as "issue invoice" or "apply discount". They define the interfaces they need, like InvoiceRepository and PaymentGateway.
  3. Interface adapters: implement those interfaces and translate formats. HTTP controllers, database repositories and third-party API clients live here.
  4. Frameworks and drivers: Express/FastAPI, ORMs, message brokers, UI frameworks. The details.

The dependency rule

Source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle.

Your use case can't import your ORM. Instead, it declares what it needs, and an outer layer provides it. This is dependency inversion:

ts// Use case layer: defines the port
export interface InvoiceRepository {
  findById(id: InvoiceId): Promise<Invoice | null>;
  save(invoice: Invoice): Promise<void>;
}

export class VoidInvoice {
  constructor(private invoices: InvoiceRepository, private clock: Clock) {}

  async execute(id: InvoiceId, reason: string) {
    const invoice = await this.invoices.findById(id);
    if (!invoice) throw new NotFoundError("invoice", id);
    invoice.void(reason, this.clock.now());   // domain rule lives on the entity
    await this.invoices.save(invoice);
  }
}
ts// Adapter layer: implements the port with a real database
export class PostgresInvoiceRepository implements InvoiceRepository {
  constructor(private db: Pool) {}
  async findById(id: InvoiceId) { /* SQL → domain object */ }
  async save(invoice: Invoice)   { /* domain object → SQL */ }
}

The use case is testable with an in-memory fake repository and a fixed clock. No database, no HTTP server, and tests that run in milliseconds.

Hexagonal architecture (ports and adapters)

Hexagonal architecture expresses the same idea with different vocabulary:

  • Ports: interfaces defined by the application core (driving ports for incoming requests, driven ports for outgoing needs).
  • Adapters: implementations that connect ports to the outside world (a REST controller drives the app; a Postgres repository is driven by it).

Clean, hexagonal and onion architecture are close cousins. Don't get lost in the naming; the dependency direction is what matters.

A practical folder structure

Organize by feature first, then by layer inside each feature:

textsrc/
  billing/
    domain/
      invoice.ts            # entity + business rules
      money.ts              # value object
    application/
      void-invoice.ts       # use case
      ports.ts              # InvoiceRepository, PaymentGateway interfaces
    adapters/
      http/invoice-routes.ts
      db/postgres-invoice-repository.ts
      stripe-payment-gateway.ts
  orders/
    ...
  shared/
    kernel/                 # truly shared domain primitives
  main.ts                   # composition root: wires adapters into use cases

The composition root (main.ts) is the only place that knows about every concrete implementation. This structure also aligns naturally with a modular monolith.

Benefits you actually feel

  • Fast, reliable tests of business logic without infrastructure (testing strategy guide).
  • Framework independence: upgrading or swapping a web framework or ORM touches adapters, not rules.
  • Clarity: use cases read like a list of what the system does.
  • Parallel work: teams can build adapters and core logic against agreed interfaces.

When clean architecture is overkill

Architecture has a cost: more files, more indirection, more mapping between layers. It's often not worth it when:

  • The app is mostly CRUD: forms in, rows out, minimal business rules.
  • It's a prototype or MVP where learning speed matters more than longevity (how to build an MVP).
  • The team is small and the codebase is small enough to hold in your head.
  • The framework is the product: a thin admin panel on top of an ORM.

A pragmatic path: start simple, keep business rules out of controllers and UI components anyway, and introduce ports and adapters where complexity appears, such as payments, pricing rules, integrations with flaky third parties, or anything you'll want to test exhaustively.

Common mistakes

  1. Anemic domain models: entities are bags of getters and setters, and all logic lives in "services". Put rules where the data is.
  2. Interfaces for everything: an interface with exactly one implementation that will never change adds noise. Use ports for real boundaries (I/O, third parties, time, randomness).
  3. Leaking ORM entities: returning ORM models from repositories couples the core to the database. Map to domain objects.
  4. Layer-first folders across the whole app: a giant controllers/ and services/ split scatters each feature across the codebase.
  5. Ceremony over value: DTOs mapped to commands mapped to entities mapped to DTOs, for a form that saves a name.

FAQ

Is clean architecture the same as microservices?

No. Clean architecture is about the internal structure of a codebase; microservices are about deployment and runtime boundaries. You can apply clean architecture inside a monolith or inside each microservice.

Does clean architecture work with React or other frontend frameworks?

The principles apply: keep business logic and API access out of components, put domain logic in plain TypeScript modules, and treat the framework as a delivery detail. Full layering is rarely necessary on the frontend.

How do I introduce clean architecture into an existing codebase?

Incrementally. Pick the module with the most complex business rules, extract the logic into pure domain and use-case code, and wrap existing database and API calls behind interfaces. Expand as it proves useful.

Does it hurt performance?

The extra indirection has a negligible runtime cost in almost all business applications. Performance problems come from database access patterns and I/O, which clean architecture tends to make easier to optimize because data access is centralized.