How to Implement Use Cases Following the Clean Architecture Pattern in TypeScript

Implement use cases following the Clean Architecture pattern by defining pure domain entities, creating repository port interfaces in the application layer, implementing use case classes that orchestrate business rules through dependency injection, and wiring concrete infrastructure implementations to interface adapters like controllers.

The TCC repository demonstrates a practical TypeScript implementation of Clean Architecture (also known as Hexagonal or Ports-and-Adapters). When you implement use cases following the Clean Architecture pattern, you isolate business logic from frameworks, UI, and external services, creating a testable and maintainable codebase that survives technology changes.

Understanding the Clean Architecture Layers

Clean Architecture organizes code into concentric layers, with dependencies pointing inward toward the domain core.

Entities (Enterprise Business Rules)

Entities are pure business objects containing no framework or I/O code. In professionals-dummy-app/src/domain/entities/professionals/Professional.ts, the Professional class lives independently of any database, HTTP server, or UI:

export class Professional {
    constructor(
        public id: number | null,
        public name: string,
        public role: string,
        public bio: string | null,
        public imageUrl: string | null,
        public createdAt: Date,
        public hierarchy: number
    ) {}
}

Use Cases (Application Business Rules)

Use cases orchestrate the flow of data to and from entities and direct those entities to use their enterprise-wide business rules. They receive input DTOs, perform work, and return output DTOs, delegating all data access to port interfaces.

Interface Adapters

Controllers, presenters, and gateways convert data between the format most convenient for use cases and entities and the format most convenient for external agencies such as the database and the web.

Frameworks and Drivers

The outermost layer contains concrete implementations of interfaces defined in the inner layers, such as database drivers, HTTP servers, and UI frameworks.

Step-by-Step Implementation Guide

Define Domain Entities

Start by modeling your core business object. The Professional entity in professionals-dummy-app/src/domain/entities/professionals/Professional.ts contains only data and no persistence logic.

Create Repository Ports (Interfaces)

Define the contract for data access in the application layer. The IProfessionalRepository interface in professionals-dummy-app/src/domain/interfaces/professionals/IProfessionalRepository.ts declares methods without implementation details:

export interface IProfessionalRepository {
    create(article: Partial<Professional>): Promise<Professional>;
    findById(id: number): Promise<Professional | null>;
    findAll(): Promise<Professional[]>;
    update(article: Partial<Professional>): Promise<Professional>;
    delete(id: number): Promise<void>;
}

Implement Use Cases with Dependency Injection

Create use case classes that depend on repository interfaces, not concrete implementations. In professionals-dummy-app/src/application/use-cases/professionals/CreateProfessionalUseCase.ts, the constructor receives IProfessionalRepository:

export class CreateProfessionalUseCase {
    constructor(private ProfessionalRepository: IProfessionalRepository) {}

    async execute(dto: CreateProfessionalDto): Promise<Professional> {
        if (!dto) {
            throw new Error('Professional not found');
        }
        return this.ProfessionalRepository.create(dto);
    }
}

Key characteristics of this implementation:

  • Constructor injection of a repository interface decouples the use-case from concrete persistence (SQLite, Postgres, etc.).
  • No framework imports guarantees the class can run in any environment (Node, browser, tests).
  • Throws domain-specific errors allows higher layers to map them to HTTP status codes or UI messages.

Build Concrete Repository Implementations

Implement the port interface in the infrastructure layer. The SQLiteProfessionalRepository in professionals-dummy-app/src/infrastructure/database/repositories/professionals/SQLiteProfessionalRepository.ts handles database-specific logic:

export class SQLiteProfessionalRepository implements IProfessionalRepository {
    async create(professional: Partial<Professional>): Promise<Professional> {
        const db = getDatabase();
        const createdAt = new Date();
        const stmt = db.prepare(`
            INSERT INTO Professional (name, role, bio, imageUrl, createdAt, hierarchy)
            VALUES (?, ?, ?, ?, ?, ?)
        `);
        const info = stmt.run(
            professional.name,
            professional.role,
            professional.bio,
            professional.imageUrl,
            createdAt.toISOString(),
            professional.hierarchy
        );
        return new Professional(
            Number(info.lastInsertRowid),
            professional.name!,
            professional.role!,
            professional.bio ?? null,
            professional.imageUrl ?? null,
            createdAt,
            professional.hierarchy!
        );
    }
    // …findById, findAll, update, delete omitted for brevity…
}

Because the use-case only knows the IProfessionalRepository contract, swapping SQLite for another persistence (e.g., MongoDB) requires only a new implementation in the infrastructure layer—no changes to business rules.

Wire Up Controllers and Routes

Controllers translate HTTP requests into DTOs, invoke use-cases, and shape HTTP responses. The CreateProfessionalController in professionals-dummy-app/src/presentation/controllers/professionals/CreateProfessionalController.ts depends on the application layer but never on framework internals:

export class CreateProfessionalController extends OpenAPIRoute {
    // … OpenAPI schema omitted for brevity …

    @withErrorHandling
    async handle(): Promise<object> {
        const data = await this.getValidatedData<typeof this.schema>();
        const { name, role, bio, imageUrl, hierarchy } = data.body;

        const createProfessionalUseCase = new CreateProfessionalUseCase(professionalRepository);
        const professional = await createProfessionalUseCase.execute({
            name, role, bio, imageUrl, hierarchy
        });

        return {
            success: true,
            result: {
                id: professional.id,
                name: professional.name,
                role: professional.role,
                bio: professional.bio,
                imageUrl: professional.imageUrl,
                createdAt: professional.createdAt.toISOString(),
                hierarchy: professional.hierarchy
            }
        };
    }
}

The decorator withErrorHandling (defined in professionals-dummy-app/src/presentation/decorators/handleErrors.ts) catches exceptions and translates them into consistent JSON envelopes, keeping controller logic clean.

Finally, the entry point in professionals-dummy-app/src/index.ts wires everything together:

import professionalRepository from './infrastructure/database/repositories/professionals';
import { CreateProfessionalController } from './presentation/controllers/professionals/CreateProfessionalController';
import { OpenAPI } from 'chanfana';

const api = new OpenAPI();
api.addRoute(new CreateProfessionalController());
api.listen(3000);

This file belongs to the framework layer and ties the whole onion together without containing business logic.

Testing Clean Architecture Use Cases

Because use cases depend on interfaces rather than concrete implementations, you can unit test them with mocked repositories. Here is a Vitest example testing CreateProfessionalUseCase:

import { CreateProfessionalUseCase } from '../../src/application/use-cases/professionals/CreateProfessionalUseCase';
import { IProfessionalRepository } from '../../src/domain/interfaces/professionals/IProfessionalRepository';

test('creates a professional', async () => {
  const repo: IProfessionalRepository = {
    create: vi.fn().mockResolvedValue({ id: 1, name: 'Bob', role: 'Dev', bio: null, imageUrl: null, createdAt: new Date(), hierarchy: 0 }),
    findById: vi.fn(),
    findAll: vi.fn(),
    update: vi.fn(),
    delete: vi.fn()
  };

  const useCase = new CreateProfessionalUseCase(repo);
  const result = await useCase.execute({ name: 'Bob', role: 'Dev', hierarchy: 0 });

  expect(repo.create).toHaveBeenCalledWith({ name: 'Bob', role: 'Dev', hierarchy: 0 });
  expect(result.id).toBe(1);
});

Because the use-case depends only on the IProfessionalRepository interface, the test provides a lightweight mock without touching SQLite.

Summary

  • Entities live in src/domain/entities and contain pure business logic with zero dependencies.
  • Repository ports in src/domain/interfaces define data access contracts, enabling dependency inversion.
  • Use cases in src/application/use-cases orchestrate business rules through constructor-injected interfaces, remaining framework-agnostic.
  • Infrastructure repositories in src/infrastructure/database/repositories provide concrete implementations of ports, allowing persistence technology changes without touching business logic.
  • Controllers in src/presentation/controllers translate HTTP requests to DTOs and invoke use cases, while decorators handle cross-cutting concerns like error handling.

Frequently Asked Questions

What is the difference between a use case and a controller in Clean Architecture?

A use case resides in the application layer and contains pure business logic, orchestrating entities and repository ports without knowing about HTTP, UI, or databases. A controller lives in the interface adapters layer, translating HTTP requests into DTOs, invoking the appropriate use case, and formatting the response for the client. Controllers depend on use cases, but use cases remain completely independent of delivery mechanisms.

Why should repositories be defined as interfaces rather than concrete classes?

Defining repositories as interfaces (ports) in the domain or application layer enforces the Dependency Inversion Principle. Use cases depend on the abstract interface, not a concrete implementation like SQLiteProfessionalRepository. This allows you to swap SQLite for PostgreSQL, MongoDB, or an in-memory store for testing without modifying any business logic, making the codebase significantly more testable and adaptable to infrastructure changes.

How do you handle errors in Clean Architecture use cases?

Use cases throw domain-specific errors when business rules are violated (e.g., validation failures or entity not found). These exceptions bubble up to the interface adapters layer, where decorators like withErrorHandling in professionals-dummy-app/src/presentation/decorators/handleErrors.ts catch them and translate them into standardized HTTP responses. This separation ensures that use cases remain framework-agnostic while the outer layer handles protocol-specific error formatting.

Can use cases be tested without a real database?

Yes, use cases are designed to be tested in complete isolation from external systems. Because they depend only on repository interfaces, you can provide mock implementations during unit tests. Using testing frameworks like Vitest or Jest, you create a mock object that implements IProfessionalRepository, configure it to return specific values, and inject it into the use case constructor. This approach validates business logic without touching SQLite or any real persistence layer, resulting in fast, deterministic tests.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →