Clean Architecture in Practice

FRAMEWORKS & DRIVERSDB / Web / External APIsINTERFACE ADAPTERSControllers / Gateways / PresentersUSE CASESApplication LogicENTITIESDomain CoreDependenciespoint inwardPostgreSQLExpress.jsAWS SDKStripe APIREST ControllerDB RepositoryJSON PresenterEmail GatewayCLEAN ARCHITECTURE LAYERSTHE DEPENDENCY RULESource code dependencies must point only inward toward higher-level policies

The Dependency Rule

Clean Architecture has one non-negotiable principle: source code dependencies point inward.

Outer layers depend on inner layers. Inner layers know nothing about outer layers. The domain core (entities) has zero dependencies on use cases, controllers, databases, or frameworks. Use cases depend on entities, but not on controllers or databases. Controllers depend on use cases, but use cases don’t depend on controllers.

This is the entire architecture. Everything else is implementation detail.

Why does this matter? Because it inverts the default coupling in most codebases. In a typical layered architecture, the domain logic depends on the database. You can’t change databases without touching business rules. You can’t test business logic without a test database. The infrastructure dictates the domain.

Clean Architecture flips this. The domain is independent. You can swap PostgreSQL for DynamoDB without touching a single domain entity. You can test business logic with in-memory repositories. The domain dictates; the infrastructure adapts.

The Four Layers

Layer 1: Entities (Domain Core)

Entities are pure domain logic. No frameworks, no databases, no HTTP. Just plain objects (or classes, or structs) that represent core business concepts.

An entity in a manufacturing system might be a Part:

class Part {
  constructor(
    public readonly id: PartId,
    public readonly number: PartNumber,
    public readonly revision: Revision,
    public status: PartStatus
  ) {}

  release(): void {
    if (this.status !== PartStatus.Approved) {
      throw new Error('Cannot release unapproved part');
    }
    this.status = PartStatus.Released;
  }

  obsolete(): void {
    if (this.status !== PartStatus.Released) {
      throw new Error('Cannot obsolete unreleased part');
    }
    this.status = PartStatus.Obsolete;
  }
}

No dependencies. No imports. Just domain rules: “You can’t release an unapproved part. You can’t obsolete an unreleased part.”

This is testable with zero infrastructure. No mocks, no test database, no HTTP server. Just instantiate a Part and call methods.

Layer 2: Use Cases (Application Logic)

Use cases orchestrate domain entities to accomplish application-specific workflows. A use case might be “Release Part” or “Create Engineering Change Order.”

class ReleasePartUseCase {
  constructor(private partRepository: PartRepository) {}

  async execute(partId: PartId): Promise<void> {
    const part = await this.partRepository.findById(partId);
    if (!part) {
      throw new Error('Part not found');
    }
    
    part.release(); // Domain logic
    await this.partRepository.save(part);
  }
}

The use case depends on Part (the entity) and PartRepository (an interface, not an implementation). It doesn’t depend on DynamoDB, PostgreSQL, or any specific database. It depends on an abstraction.

This is the key inversion. The use case defines what it needs (PartRepository interface), and the outer layer provides it (via dependency injection).

Layer 3: Interface Adapters (Controllers, Gateways, Repositories)

Interface adapters translate between the use case layer and the framework layer. They implement the interfaces that use cases depend on.

A repository implementation:

class DynamoDBPartRepository implements PartRepository {
  constructor(private dynamodb: DynamoDB.DocumentClient) {}

  async findById(partId: PartId): Promise<Part | null> {
    const result = await this.dynamodb.get({
      TableName: 'Parts',
      Key: { id: partId.value }
    }).promise();
    
    if (!result.Item) return null;
    
    return new Part(
      new PartId(result.Item.id),
      new PartNumber(result.Item.number),
      new Revision(result.Item.revision),
      result.Item.status as PartStatus
    );
  }

  async save(part: Part): Promise<void> {
    await this.dynamodb.put({
      TableName: 'Parts',
      Item: {
        id: part.id.value,
        number: part.number.value,
        revision: part.revision.value,
        status: part.status
      }
    }).promise();
  }
}

The repository knows about DynamoDB (outer layer), but ReleasePartUseCase doesn’t. The use case depends on PartRepository interface, not DynamoDBPartRepository implementation.

This is how you swap databases without touching business logic. Just write a PostgreSQLPartRepository that implements the same interface.

Layer 4: Frameworks & Drivers (DB, Web, External APIs)

The outermost layer is where frameworks live: Express.js, PostgreSQL, AWS SDK, Stripe API. This is the layer most likely to change, so it’s isolated at the boundary.

An HTTP controller:

class PartController {
  constructor(private releasePartUseCase: ReleasePartUseCase) {}

  async releasePart(req: Request, res: Response): Promise<void> {
    try {
      const partId = new PartId(req.params.id);
      await this.releasePartUseCase.execute(partId);
      res.status(200).json({ message: 'Part released' });
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
}

The controller depends on the use case, but the use case doesn’t depend on Express. You can replace Express with Fastify, or replace HTTP entirely with a CLI, without touching the use case.

Why It Works

Clean Architecture provides three critical benefits:

1. Domain logic is testable without infrastructure. You can unit test entities with zero setup. No Docker containers, no test databases, no HTTP mocks. Just instantiate objects and assert behavior.

2. You can swap infrastructure without touching business rules. Change databases, change web frameworks, change external APIs — the domain layer remains untouched. The cost of migration is isolated to the adapter layer.

3. Multiple delivery mechanisms share the same core. The same use cases can power a REST API, a GraphQL API, a CLI tool, and a background job processor. The interface adapters differ, but the core logic is reused.

Where It Breaks Down in Practice

Clean Architecture is not a silver bullet. It introduces overhead, and in some contexts, that overhead isn’t justified.

1. Over-Engineering for Simple CRUD

If your application is just saving and retrieving data with minimal business logic, Clean Architecture is overkill. A “Create User” use case that just calls userRepository.save() adds no value. You’re writing boilerplate for the sake of architecture.

For simple CRUD, a traditional layered architecture (or even Active Record) is fine. The cost of Clean Architecture only pays off when domain logic is non-trivial.

2. The Mapper Explosion Problem

Clean Architecture requires translation at every boundary. HTTP request → DTO → Entity. Entity → DTO → Database row. This creates a proliferation of mapper functions:

// HTTP → Use Case
function toPartId(req: Request): PartId { ... }

// Entity → Database
function toPartRow(part: Part): PartRow { ... }

// Database → Entity
function toPart(row: PartRow): Part { ... }

// Entity → HTTP Response
function toPartDTO(part: Part): PartDTO { ... }

In a large system, you end up with hundreds of mappers. This is necessary (you don’t want HTTP concerns leaking into domain entities), but it’s verbose.

Some teams mitigate this with code generation or metaprogramming. Others accept the verbosity as the cost of clean boundaries.

3. When Use Cases Are Just Pass-Through

Sometimes a use case is just a thin wrapper around a repository call. If GetPartUseCase is just return partRepository.findById(id), why does it exist?

The answer is consistency. Even if 80% of use cases are trivial, the 20% that contain real logic benefit from the structure. And having a consistent architecture (all requests go through use cases) is easier to reason about than a mixed model (some requests go through use cases, some bypass them).

But in practice, many teams bend the rule. For simple queries, they let controllers call repositories directly. For complex workflows, they use use cases. Pragmatism beats purity.

When to Use Clean Architecture

Clean Architecture shines in three scenarios:

1. Complex domain logic. If your system has non-trivial business rules — constraints, validations, state transitions — then isolating that logic in testable entities is invaluable.

2. Long-lived systems. If your codebase will be maintained for 5+ years, the upfront cost of Clean Architecture pays dividends. You will change databases. You will add new delivery mechanisms. The architecture makes those changes cheap.

3. Multiple delivery mechanisms. If the same core logic needs to support REST, GraphQL, CLI, and background jobs, Clean Architecture prevents duplication. Write the use case once, wrap it in different adapters.

When to Skip It

Clean Architecture is overkill in three scenarios:

1. Prototypes. If you’re building a proof-of-concept to validate an idea, don’t architect for longevity. Ship fast, learn, throw it away.

2. Simple APIs. If your API is just CRUD over a database with minimal logic, a layered architecture (or even a framework-first approach like Rails) is faster and simpler.

3. Scripts and one-offs. If you’re writing a data migration script or a one-time batch job, don’t structure it like a long-lived system. It’s a script. Write it as a script.

The Hexagonal / Ports-and-Adapters Equivalence

Clean Architecture is a refinement of Hexagonal Architecture (also called Ports and Adapters). The core idea is identical: the domain is at the center, and infrastructure adapts to the domain.

The difference is presentation. Hexagonal Architecture talks about “ports” (interfaces) and “adapters” (implementations). Clean Architecture talks about layers and the dependency rule. Same concept, different framing.

If you understand one, you understand the other.

Real Example: Manufacturing System

Let’s see how Clean Architecture plays out in a real manufacturing PLM system.

Domain (Entities):

  • Part — encapsulates part lifecycle (Draft → Approved → Released → Obsolete)
  • BOM — bill of materials with quantity constraints
  • ECO — engineering change order with approval workflow

Use Cases:

  • CreatePartUseCase — validates part number format, checks for duplicates, initializes in Draft state
  • ApproveECOUseCase — orchestrates approval workflow, applies changes to affected parts
  • ReleasePartUseCase — verifies part is approved, triggers downstream notifications

Adapters:

  • DynamoDBPartRepository — stores parts in DynamoDB
  • S3AttachmentGateway — stores CAD files in S3
  • SNSEventPublisher — publishes domain events to SNS

Frameworks:

  • API Gateway + Lambda (HTTP interface)
  • EventBridge (event-driven integration with MES)
  • DynamoDB (persistence)

The key win: when the team needed to add a CLI tool for bulk part imports, they reused the existing use cases. No domain logic was duplicated. They just wrote a new adapter (CLIPartController) that invoked the same CreatePartUseCase.

Conclusion

Clean Architecture is not about layering for its own sake. It’s about protecting the domain from infrastructure churn.

Databases change. Web frameworks change. External APIs change. The core business rules should not.

By enforcing the dependency rule — source code dependencies point inward — you create a system where the domain is independent, testable, and reusable across delivery mechanisms.

But it’s not free. It requires discipline, introduces verbosity, and adds complexity. Use it when the benefits justify the cost: complex domains, long-lived systems, multiple interfaces.

For simple CRUD, a layered architecture is fine. For prototypes, skip architecture entirely. For everything else, the dependency rule is your guide.

Inner layers define the contract. Outer layers implement it. Dependencies point inward.

That’s Clean Architecture.