Software Architecture & Best Practices · Clean Architecture

Clean Architecture Explained With a Tiny Example App

Separate business rules from frameworks—without overengineering.

Reading time: ~8–12 min
Level: All levels
Updated:

Clean Architecture is a simple promise: your business rules should survive framework changes. The tricky part is doing that without turning a small app into a cathedral of folders. In this post we’ll build a tiny “Tasks” app on paper (create + complete + list tasks) and use it to explain the layers, the dependency rule, and the few boundaries that give you most of the benefits.


Quickstart

If you want the benefit of Clean Architecture quickly, don’t start by drawing circles. Start by moving IO (web, DB, queue, filesystem) to the edges and keeping use cases in the middle. These steps work even if you never create more than 3–4 folders.

Fast win #1 — Write one use case as pure code

Pick one operation that matters (e.g., CreateTask) and implement it without importing your web framework, ORM, or SDKs. You should be able to unit test it with no network and no database.

  • Define input/output types (DTOs)
  • Keep validation that’s “business-critical” inside the use case
  • Use ports (interfaces) for anything that touches the outside world

Fast win #2 — Make dependencies point inward

Controllers call use cases. Use cases call interfaces. Adapters implement interfaces. The innermost code never imports outer code.

  • Domain/use cases define the interfaces (ports)
  • Infrastructure implements them (adapters)
  • Wire everything in one “composition root” file

Fast win #3 — Introduce one boundary (not five)

You don’t need every Clean Architecture layer on day one. The minimum useful split: core (domain + use cases) vs edges (web + DB).

  • Move business rules out of controllers
  • Stop returning ORM entities from use cases
  • Keep framework types at the edges (Request/Response, DB rows)

Fast win #4 — Add one “swap test”

Prove the architecture by swapping an adapter: in-memory repo ↔ real DB, fake clock ↔ system clock. If swapping is painful, your boundaries aren’t real yet.

  • Use an in-memory repository in unit tests
  • Keep the DB adapter behind an interface
  • Don’t let ORM models leak into the core
Quick mental model

Clean Architecture is less about “layers” and more about direction: dependencies point to business rules, not to tools.

Minimal folder map (copy/paste)

This is a tiny structure that still captures the key idea: core code is independent; adapters do the messy IO. You can rename folders to match your stack—what matters is the dependency direction.

# tiny-clean-arch/
#   Core: domain + use cases (no framework imports)
#   Edges: adapters (db/web) + composition root (wiring)
.
├─ src/
│  ├─ core/
│  │  ├─ domain/              # entities + value objects
│  │  └─ usecases/            # application business rules
│  ├─ ports/                  # interfaces owned by the core
│  ├─ adapters/
│  │  ├─ http/                # controllers + request/response mapping
│  │  └─ persistence/         # db/orm implementations of ports
│  └─ main.ts                 # composition root (wires dependencies)
└─ test/
   └─ core/                   # unit tests (no db, no http)

Overview

“Clean Architecture” (popularized by Robert C. Martin) is often taught with concentric circles: entities at the center, then use cases, then adapters, then frameworks. That diagram is helpful, but it can also lead to overengineering if you treat it like a mandatory folder structure.

What this post covers

  • The dependency rule: what “dependencies point inward” actually means in code.
  • Where logic belongs: domain vs use cases vs controllers vs persistence.
  • A tiny example app: a “Tasks” app and how each layer looks.
  • Pragmatic boundaries: the minimum split that gives maximum leverage.
  • Pitfalls: common mistakes (and how to avoid a folder explosion).

Why it matters: most codebases don’t rot because the framework is “bad”. They rot because the framework becomes the only place where decisions live. When business rules are scattered across controllers, ORMs, cron jobs, and background workers, changes get slow, tests become fragile, and “simple features” turn into risky refactors.

Problem you feel Likely cause Clean Architecture move
Every change requires DB + HTTP integration tests Business rules live in controllers/ORM hooks Move rules into use cases; mock ports
Hard to reuse logic across CLI/API/worker Logic tied to a single delivery mechanism Use cases callable from anywhere
Switching DB/framework feels impossible Core imports infrastructure types Invert dependencies with ports/adapters
“It works” but nobody knows the rules Rules implicit in scattered code paths Centralize rules in use cases with explicit inputs/outputs
What Clean Architecture is not

It’s not “more layers for the sake of layers.” It’s not “interfaces everywhere.” It’s a way to keep the code that defines what your system does separate from how it’s delivered.

Core concepts

1) The dependency rule (the whole game)

The rule is simple: source code dependencies must point inward. Your business rules should not import your database library, web framework, or third-party SDKs. Instead, the business rules define what they need (interfaces/ports), and the outer layers implement those needs.

In practice, “inward” means

  • Use cases can be tested without HTTP, DB, queues
  • Controllers are thin: parse request → call use case → map response
  • Repositories/clients are adapters: implement interfaces defined by the core
  • Wiring happens in one place (composition root)

A quick smell test

If your domain or use-case code imports any of these, your boundary is leaking:

  • HTTP request/response types
  • ORM models / query builders
  • Framework annotations/decorators
  • Cloud SDKs
  • Environment/config readers

2) The four circles (as responsibilities, not folders)

You’ll see variations (Hexagonal/Ports & Adapters, Onion Architecture, etc.), but the responsibilities usually map like this:

Layer What belongs here What should not leak in
Entities (Domain) Business objects, invariants, value objects Framework types, DB rows, HTTP concerns
Use cases (Application) Business workflows (CreateTask, CompleteTask), policy decisions ORM queries, controllers, JSON schema validators tied to transport
Interface adapters Controllers, presenters, gateways, repository implementations Business rules (keep them in core)
Frameworks & Drivers HTTP server, DB engine, message brokers, UI frameworks Don’t let it dictate your core model

3) Ports & adapters (how the core talks to the world)

A port is an interface owned by the core. It describes a capability the core needs. An adapter is an implementation owned by the outside. It uses real libraries to satisfy the port.

Examples of ports in a tiny “Tasks” app

  • TaskRepository — load/save tasks (DB, in-memory, file…)
  • IdGenerator — create unique IDs (UUID lib, DB sequence…)
  • Clock — current time (system clock, fake clock in tests)
Don’t invert everything

You don’t need an interface for every class. Invert the things that change for “environment reasons”: storage, transport, time, randomness, external services. Keep the rest concrete.

Step-by-step

Let’s design a tiny example app: Tasks. Requirements (intentionally small):

  • Create a task with a title
  • Mark a task as completed
  • List tasks (with completed state)

Step 1 — Define the core model (entity + invariants)

Start with the rule-bearing object. Keep it boring and enforce the rules that must always hold. For tasks, a title cannot be empty; completing sets a timestamp.

Step 2 — Write a use case that depends on ports, not implementations

The use case is the “application brain”: it orchestrates the workflow. It should read like a story: validate input → load/save via repository → return a result. Notice how it doesn’t know anything about HTTP or databases.

export type TaskId = string;

export class Task {
  constructor(
    public readonly id: TaskId,
    public readonly title: string,
    public readonly createdAt: Date,
    public readonly completedAt: Date | null
  ) {
    if (title.trim().length === 0) throw new Error("Title must not be empty");
  }

  complete(at: Date): Task {
    if (this.completedAt) return this; // idempotent
    return new Task(this.id, this.title, this.createdAt, at);
  }
}

export interface TaskRepository {
  getById(id: TaskId): Promise<Task | null>;
  save(task: Task): Promise<void>;
}

export interface Clock {
  now(): Date;
}

export interface IdGenerator {
  nextId(): TaskId;
}

export type CreateTaskInput = { title: string };
export type CreateTaskOutput = { id: TaskId; title: string; createdAt: string };

export class CreateTask {
  constructor(
    private readonly repo: TaskRepository,
    private readonly ids: IdGenerator,
    private readonly clock: Clock
  ) {}

  async execute(input: CreateTaskInput): Promise<CreateTaskOutput> {
    const title = input.title?.trim() ?? "";
    if (title.length === 0) throw new Error("Title is required");

    const task = new Task(this.ids.nextId(), title, this.clock.now(), null);
    await this.repo.save(task);

    return {
      id: task.id,
      title: task.title,
      createdAt: task.createdAt.toISOString()
    };
  }
}
Why this is “clean”

The use case depends on abstractions (TaskRepository/Clock/IdGenerator), and those abstractions are defined next to the use case. That makes the core portable and testable.

Step 3 — Build adapters (HTTP + persistence) that map in/out

Adapters translate between the messy world and your clean core. This is where frameworks belong: JSON parsing, status codes, ORM calls, retries, logging, headers, auth middleware, and so on.

HTTP adapter responsibilities

  • Parse request (params/body)
  • Call the correct use case
  • Map domain errors → HTTP responses
  • Serialize output (DTO → JSON)

Persistence adapter responsibilities

  • Translate between DB rows and domain objects
  • Hide query shape from the core
  • Handle transactions (often at adapter or orchestration boundary)
  • Deal with performance: indexes, joins, pagination

Step 4 — Wire dependencies in a composition root

Clean Architecture doesn’t mean “dependency injection framework.” It means: build concrete implementations once, then pass them into use cases. Keep this wiring in one file so it’s easy to see how the app is assembled.

A practical wiring checklist

  • Create real adapters (DB repo, system clock, UUID generator)
  • Create use cases by passing ports
  • Create controllers/handlers by passing use cases
  • Export the HTTP router/server from the outermost layer

Step 5 — Prove the architecture with a unit test

This is where the approach pays off: you can test business rules with a fake repository and fake time. No Docker. No test DB. No HTTP server. Just code.

import { CreateTask, TaskRepository, Clock, IdGenerator, Task } from "../src/core/usecases/CreateTask";

class InMemoryTaskRepo implements TaskRepository {
  public saved: Task[] = [];
  async getById(): Promise<Task | null> { return null; }
  async save(task: Task): Promise<void> { this.saved.push(task); }
}

class FixedClock implements Clock {
  constructor(private readonly d: Date) {}
  now(): Date { return this.d; }
}

class FixedIds implements IdGenerator {
  constructor(private readonly id: string) {}
  nextId(): string { return this.id; }
}

test("CreateTask saves a task and returns DTO", async () => {
  const repo = new InMemoryTaskRepo();
  const ids = new FixedIds("t_123");
  const clock = new FixedClock(new Date("2026-01-01T00:00:00.000Z"));

  const uc = new CreateTask(repo, ids, clock);
  const out = await uc.execute({ title: "  buy milk  " });

  expect(out.id).toBe("t_123");
  expect(out.title).toBe("buy milk");
  expect(out.createdAt).toBe("2026-01-01T00:00:00.000Z");
  expect(repo.saved).toHaveLength(1);
});
The “tiny example” takeaway

If your core is testable with fakes, you’re already doing the most valuable part of Clean Architecture. Everything else (folders, naming, patterns) is optional tuning.

Common mistakes

Clean Architecture fails in two ways: too little boundary (everything is glued to frameworks) or too much ceremony (interfaces for every method, dozens of layers, no speed). Here are the most common traps and the fixes that keep things practical.

Mistake 1 — “Clean Architecture” as a folder structure

You can create the perfect directory tree and still have business rules in controllers. Clean Architecture is about dependency direction, not naming.

  • Fix: pick 1–2 use cases and make them framework-free.
  • Fix: enforce “core doesn’t import adapters” via module boundaries or lint rules.

Mistake 2 — Leaking ORM models into the core

Returning ORM entities from use cases couples your business rules to persistence decisions (lazy loading, annotations, DB schema).

  • Fix: map DB rows ↔ domain entities inside the persistence adapter.
  • Fix: return DTOs from use cases (simple data shapes).

Mistake 3 — “Interfaces everywhere” (abstraction overload)

If every class has an interface, you add indirection without gaining flexibility. Abstractions should protect you from meaningful change, not hypothetical change.

  • Fix: invert only IO boundaries (DB, HTTP, time, external services).
  • Fix: keep pure domain code concrete and straightforward.

Mistake 4 — Putting business rules into validators/middleware

Input validation is good, but business rules belong in use cases so they apply everywhere (API, CLI, worker).

  • Fix: do transport validation at the edge (shape/types).
  • Fix: enforce invariants and policies inside the core (use case/domain).

Mistake 5 — Treating controllers as “mini use cases”

Controllers that contain branching policy (“admins can do X unless…”) become impossible to reuse and hard to test.

  • Fix: controllers only translate request/response and call one use case.
  • Fix: policy decisions live in the use case.

Mistake 6 — No ownership of “where transactions live”

Multi-step workflows sometimes require transactions. If you sprinkle transactions randomly, you’ll get inconsistent behavior.

  • Fix: choose one place: a use-case orchestrator or a persistence adapter boundary.
  • Fix: keep the core unaware of the DB engine (transaction is a port or adapter concern).
The sneakiest failure mode

If you cannot write a unit test for a use case without booting the app, you likely have framework leakage. Don’t “accept it”—trace the imports and move the boundary outward.

FAQ

Is Clean Architecture the same as Hexagonal (Ports & Adapters) or Onion Architecture?

They’re closely related. The vocabulary differs, but the core idea is the same: keep business rules independent and push IO concerns to the edges via ports/adapters. If your dependencies point inward and you can swap adapters, you’re effectively doing all of them.

Do I need Clean Architecture for a small project?

Not the full ceremony. The high-value move for small apps is just: extract use cases into framework-free functions/classes and keep controllers thin. That gives you testability and reuse without multiplying layers.

Where should input validation live?

Split it. Do transport validation at the edge (JSON shape, required fields, type parsing), and do business validation in the core (rules like “title must not be empty”, “cannot complete twice”, “limit tasks per user”, etc.). That way the rules apply no matter how the use case is called.

Where do DTOs and mapping belong?

Mapping belongs in adapters. The core can define input/output DTOs for use cases, but the act of converting HTTP requests or DB rows into those DTOs should happen at the edge. The core should not know about headers, cookies, query params, or table schemas.

How do I handle database transactions with Clean Architecture?

Decide an explicit strategy. A common approach is: the use case calls a repository method that is transaction-aware, or you introduce a “UnitOfWork” port that wraps multiple repository actions. What matters is that transaction mechanics stay out of the domain model and don’t leak database types into the core.

What if my framework pushes me toward annotations/decorators in the core?

Treat annotations as an adapter concern when possible. If you can’t avoid them entirely, minimize the footprint: keep framework-specific classes in the outer layer and call the core from there. It’s better to have one “dirty boundary file” than to spread framework imports throughout the core.

What’s the easiest way to know if I did it right?

Run the “swap test” and the “unit test test”: you should be able to unit test use cases with fakes, and you should be able to swap persistence (in-memory ↔ DB) without rewriting business rules. If both are true, your architecture is doing its job.

Cheatsheet

Scan this when you’re refactoring or starting a new feature.

Do this

  • Keep use cases free of HTTP/DB/framework imports
  • Define ports (interfaces) in the core
  • Implement ports in adapters (DB, HTTP, external services)
  • Map request/response and rows/entities at the edges
  • Wire dependencies in one composition root
  • Unit test business rules with fakes (repo/clock/ids)

Avoid this

  • Business rules inside controllers, middleware, ORM hooks
  • Returning ORM entities directly from use cases
  • Interfaces for everything (abstraction without purpose)
  • Letting framework types leak into domain entities
  • Scattering wiring/DI across the codebase
  • “Architecture-first” redesigns with no tests to prove value

Dependency direction checklist (printable)

Question Good sign Bad sign
Can I run use-case tests without the app? Yes (no DB, no HTTP) No (needs server/DB)
Who owns repository interfaces? Core (ports) Infrastructure (ORM layer)
Where are mapping decisions? Adapters (HTTP/DB) Core returns framework/ORM types
Where is wiring? One place (main/bootstrap) Everywhere (hard to trace)
Minimum viable Clean Architecture

If you only do one thing: move business rules into use cases and treat controllers/DB code as replaceable adapters. That’s 80% of the benefit with 20% of the structure.

Wrap-up

Clean Architecture is a way to keep the meaning of your system (business rules) stable while the outside world changes. The tiny “Tasks” example shows the practical core: use cases that depend on ports, plus adapters that do the IO.

What to do next

  • Pick one feature and extract it into a use case
  • Create a port for the IO it needs (repo/service)
  • Write one unit test with fakes
  • Only then decide if you need more layers

How to know you didn’t overengineer

  • Your core is small and readable
  • You added fewer integration tests, not more
  • Controllers got thinner
  • Swapping persistence/testing got easier

Keep the goal in mind: not “perfect architecture”, but cheaper change. If a new feature becomes easier to build and safer to test, the architecture is paying rent.

Quiz

Quick self-check: can you apply the core ideas of Clean Architecture without overengineering?

1) What does the “dependency rule” mean in Clean Architecture?
2) Where should business rules like “title must not be empty” live?
3) What’s the best role for an HTTP controller in Clean Architecture?
4) What is a practical sign your boundaries are working?