Skip to content
Learni
View all tutorials
Architecture

How to Implement Clean Architecture in 2026

Lire en français

Introduction

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into concentric layers to maximize maintainability and testability. At the center are business entities independent of any frameworks. Surrounding them, use cases orchestrate business logic. Next come adapters (controllers, repositories) that interface with the outside world, and finally frameworks/drivers (databases, UI).

Why adopt it in 2026? Modern apps evolve rapidly: APIs, microservices, AI. Clean Architecture keeps code technology-independent (switch DBs without altering business logic), easy to test (mock dependencies), and scalable (add features without rewrites). Picture a TODO app: the 'create task' logic stays untouched when moving from SQLite to PostgreSQL.

This beginner tutorial builds a TypeScript/Node.js REST TODO API. Complete, working code: copy-paste and run in 10 minutes. We progress from entities to controllers. (128 words)

Prerequisites

  • Node.js 20+ installed
  • Basic knowledge of TypeScript and Express
  • An editor like VS Code
  • Terminal to run commands

The 4 Layers of Clean Architecture

Think of Clean Architecture as an onion: the core (entities) is pure, outer layers depend on inner ones, never the reverse (dependency rule: arrows point inward).

  1. Entities: Pure business objects (e.g., Todo with id, title, done).
  2. Use Cases: Application logic (e.g., create a Todo).
  3. Interface Adapters: Translators (HTTP controllers → use cases, repositories implementing interfaces).
  4. Frameworks & Drivers: Express, databases (depend on adapters).
We start from the center.

Define the Todo Entity

src/domain/entities/Todo.ts
export interface Todo {
  id: string;
  title: string;
  description?: string;
  done: boolean;
  createdAt: Date;
}

export class TodoEntity implements Todo {
  public readonly id: string;
  public readonly title: string;
  public readonly description?: string;
  public done: boolean;
  public readonly createdAt: Date;

  constructor(id: string, title: string, description?: string, done = false) {
    this.id = id;
    this.title = title;
    this.description = description;
    this.done = done;
    this.createdAt = new Date();
  }

  markAsDone(): void {
    this.done = true;
  }

  toggleDone(): void {
    this.done = !this.done;
  }
}

This entity encapsulates the Todo's data and business behaviors (e.g., toggleDone). It's pure: no DB, no HTTP. Use a class for partial immutability and business methods. Pitfall: avoid direct getters/setters; prefer explicit methods.

Implement Use Cases

Use cases act as orchestrators: they use entities and depend on interfaces (dependency inversion). Example: CreateTodoUseCase calls a repository (interface only, no implementation).

Repository Interface

src/domain/repositories/TodoRepository.ts
import { Todo } from '../entities/Todo';

export interface TodoRepository {
  save(todo: Todo): Promise<Todo>;
  findById(id: string): Promise<Todo | null>;
  findAll(): Promise<Todo[]>;
  delete(id: string): Promise<void>;
}

The interface defines the contract without implementation. The use case depends on this interface (injected), not a specific DB. This makes testing easy with mocks.

Use Case: Create a Todo

src/application/use-cases/CreateTodoUseCase.ts
import { v4 as uuidv4 } from 'uuid';
import { Todo, TodoEntity } from '../../domain/entities/Todo';
import { TodoRepository } from '../../domain/repositories/TodoRepository';

export class CreateTodoUseCase {
  constructor(private todoRepository: TodoRepository) {}

  async execute(title: string, description?: string): Promise<Todo> {
    if (!title || title.trim().length < 3) {
      throw new Error('Le titre doit faire au moins 3 caractères');
    }

    const id = uuidv4();
    const todo = new TodoEntity(id, title.trim(), description?.trim());
    const savedTodo = await this.todoRepository.save(todo);
    return savedTodo;
  }
}

This use case validates inputs, creates the entity, and persists via the repository. Repository injected in constructor for testability. Pitfall: always validate here, not in the controller.

Use Case: List Todos

src/application/use-cases/GetTodosUseCase.ts
import { Todo } from '../../domain/entities/Todo';
import { TodoRepository } from '../../domain/repositories/TodoRepository';

export class GetTodosUseCase {
  constructor(private todoRepository: TodoRepository) {}

  async execute(): Promise<Todo[]> {
    return await this.todoRepository.findAll();
  }
}

Simple: calls the repo and returns data. No complex business logic here, but extensible (e.g., future filters).

Interface Adapters

Adapters convert between layers: controllers (HTTP → use cases), repositories (use cases → DB). We'll first implement an in-memory repo for testing.

In-Memory Repository

src/infrastructure/repositories/InMemoryTodoRepository.ts
import { Todo } from '../../../domain/entities/Todo';
import { TodoRepository } from '../../../domain/repositories/TodoRepository';

export class InMemoryTodoRepository implements TodoRepository {
  private todos: Todo[] = [];

  async save(todo: Todo): Promise<Todo> {
    this.todos.push(todo);
    return todo;
  }

  async findById(id: string): Promise<Todo | null> {
    return this.todos.find(t => t.id === id) || null;
  }

  async findAll(): Promise<Todo[]> {
    return [...this.todos];
  }

  async delete(id: string): Promise<void> {
    const index = this.todos.findIndex(t => t.id === id);
    if (index > -1) {
      this.todos.splice(index, 1);
    }
  }
}

Implements the interface with in-memory storage. Defensive copy in findAll for immutability. Easy to replace later with PostgreSQLRepository.

Express Controller

src/adapters/controllers/TodoController.ts
import { Request, Response } from 'express';
import { CreateTodoUseCase } from '../../../application/use-cases/CreateTodoUseCase';
import { GetTodosUseCase } from '../../../application/use-cases/GetTodosUseCase';

export class TodoController {
  constructor(
    private createTodoUseCase: CreateTodoUseCase,
    private getTodosUseCase: GetTodosUseCase
  ) {}

  async create(req: Request, res: Response): Promise<void> {
    try {
      const { title, description } = req.body;
      const todo = await this.createTodoUseCase.execute(title, description);
      res.status(201).json(todo);
    } catch (error) {
      res.status(400).json({ error: (error as Error).message });
    }
  }

  async list(req: Request, res: Response): Promise<void> {
    try {
      const todos = await this.getTodosUseCase.execute();
      res.json(todos);
    } catch (error) {
      res.status(500).json({ error: 'Erreur serveur' });
    }
  }
}

Controller translates HTTP to use cases (injected). Handles HTTP errors. No business logic here: pure adapter role.

Main Server

src/main.ts
import express from 'express';
import { InMemoryTodoRepository } from './infrastructure/repositories/InMemoryTodoRepository';
import { CreateTodoUseCase } from './application/use-cases/CreateTodoUseCase';
import { GetTodosUseCase } from './application/use-cases/GetTodosUseCase';
import { TodoController } from './adapters/controllers/TodoController';

const app = express();
app.use(express.json());

const todoRepo = new InMemoryTodoRepository();
const createTodoUseCase = new CreateTodoUseCase(todoRepo);
const getTodosUseCase = new GetTodosUseCase(todoRepo);
const todoController = new TodoController(createTodoUseCase, getTodosUseCase);

app.post('/todos', (req, res) => todoController.create(req, res));
app.get('/todos', (req, res) => todoController.list(req, res));

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Serveur sur http://localhost:${PORT}`);
});

Wires dependencies: repo → use cases → controller → Express. Outer layer depends on inner ones. Run with ts-node src/main.ts after npm i express uuid ts-node @types/express @types/node.

Test the Implementation

Install deps: npm init -y && npm i express uuid ts-node typescript @types/node @types/express. Create folders like src/domain, src/application, etc. Run npx ts-node src/main.ts. Test:

  • POST /todos {title:'Buy milk'} → 201
  • GET /todos → list.

Perfect for beginners: everything works!

Best Practices

  • Dependency Inversion: Use cases depend on interfaces, not implementations.
  • Single Responsibility per Layer: Entities = pure business, use cases = orchestration.
  • Unit Tests: Easily mock repos/interfaces.
  • Clear Naming: domain/ for core, infrastructure/ for DB.
  • Scalability: Add PrismaRepository without touching the core.

Common Mistakes to Avoid

  • Inverted Dependencies: Don't put DB code in entities.
  • Bloated Controllers: Don't validate inputs there; delegate to use cases.
  • Missing Interfaces: Always define TodoRepository before implementations.
  • Mutable State: Use readonly on entities where possible.

Next Steps

How to Implement Clean Architecture in 2026 | Learni