How the tcc Repository Enforces Separation of Concerns Across Domain, Application, Infrastructure, and Presentation Layers

The tcc codebase implements a strict onion architecture that isolates business logic in the Domain layer, orchestrates use cases in the Application layer, implements technical details in the Infrastructure layer, and handles HTTP concerns in the Presentation layer.

The castrozan/tcc repository demonstrates a rigorous implementation of separation of concerns through a layered architecture inspired by onion and hexagonal patterns. By organizing TypeScript code into four distinct layers—Domain, Application, Infrastructure, and Presentation—the project ensures that business rules remain pure and framework-agnostic while technical dependencies point inward toward abstractions. This structure makes the system highly modular, allowing you to swap databases or web frameworks without touching core business logic.

Domain Layer: Pure Business Logic

The Domain layer sits at the center of the architecture and contains zero framework dependencies. It defines what the system is and does in terms of pure business concepts.

Entities and Value Objects

Entities in this layer encapsulate core data and invariants without any persistence knowledge. In src/domain/entities/professionals/Professional.ts, the Professional class models the essential fields and types:

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
    ) {}
}

This entity knows nothing about databases, HTTP, or JSON serialization—it simply represents the business concept of a professional with its required attributes.

Repository Contracts (Ports)

Instead of implementing data access, the Domain layer declares repository interfaces that define the contract for persistence operations. The IProfessionalRepository interface in src/domain/interfaces/professionals/IProfessionalRepository.ts specifies what storage operations are available without dictating how they work:

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>;
}

By depending on this abstraction rather than concrete database clients, the Domain layer remains isolated from infrastructure changes.

Application Layer: Use Case Orchestration

The Application layer contains the use cases that coordinate Domain objects to fulfill specific user goals. This layer has no knowledge of HTTP, SQL, or UI frameworks—it only knows about the Domain layer and uses the repository contracts to persist data.

Use Case Classes

Use cases encapsulate single business operations. The CreateProfessionalUseCase in src/application/use-cases/professionals/CreateProfessionalUseCase.ts demonstrates this pattern by accepting a DTO, performing minimal validation, and delegating persistence to the injected repository contract:

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);
    }
}

Notice how the use case depends only on the IProfessionalRepository interface, not on any specific database implementation.

Data Transfer Objects (DTOs)

DTOs define the shape of data entering and exiting the Application layer. The CreateProfessionalDto in src/application/dtos/professionals/CreateProfessionalDto.ts specifies exactly what fields are required to create a professional, acting as a boundary between external input and internal domain structures.

Infrastructure Layer: Technical Implementation

The Infrastructure layer provides concrete implementations for the contracts defined in the Domain layer. This is where framework code, database clients, and external API integrations live.

Database Repositories

Repository implementations translate between Domain entities and persistent storage. The SQLiteProfessionalRepository in src/infrastructure/database/repositories/professionals/SQLiteProfessionalRepository.ts implements IProfessionalRepository using better-sqlite3:

export class SQLiteProfessionalRepository implements IProfessionalRepository {
    async findById(id: number): Promise<Professional | null> {
        const db = getDatabase();
        const row = db.prepare('SELECT * FROM Professional WHERE id = ?').get(id) as ProfessionalRow | undefined;
        if (!row) return null;
        return new Professional(
            row.id,
            row.name,
            row.role,
            row.bio,
            row.imageUrl,
            new Date(row.createdAt),
            row.hierarchy
        );
    }
    // …other methods (findAll, create, update, delete) …
}

This class handles the messy details of SQL queries and row mapping, converting raw database results into clean Professional entities before returning them to the Application layer.

Server Bootstrap

Infrastructure also contains the HTTP server configuration. The OpenAPI server bootstrap in src/infrastructure/web/open-api/server.ts initializes the web framework and routes, keeping framework-specific initialization code out of Domain and Application concerns.

Presentation Layer: HTTP Interface

The Presentation layer handles the "last mile" of HTTP concerns: request validation, JSON serialization, and error formatting. Controllers in this layer are intentionally thin—they translate HTTP requests into DTOs, invoke Application use cases, and format responses.

Controllers

The CreateProfessionalController in src/presentation/controllers/professionals/CreateProfessionalController.ts extends OpenAPIRoute and uses Zod schemas for validation. It demonstrates how Presentation concerns stay separate from business logic:

export class CreateProfessionalController extends OpenAPIRoute {
    schema = { /* … OpenAPI spec generated from Zod … */ };

    @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 controller never contains business rules; it only coordinates validation, use case execution, and response formatting.

Error Handling Decorators

Cross-cutting concerns like error handling are extracted into decorators. The withErrorHandling decorator in src/presentation/decorators/handleErrors.ts catches exceptions thrown by the Application layer and translates them into consistent HTTP error responses, ensuring controllers remain focused on request/response mapping.

Layer Interaction Flow

When a client sends a POST request to create a professional, the request flows through the layers in a strict inward direction:

  1. Presentation: The OpenAPI server (Infrastructure) routes the request to CreateProfessionalController, which validates the payload using Zod schemas.
  2. Application: The controller instantiates CreateProfessionalUseCase with a concrete SQLiteProfessionalRepository and calls execute() with a CreateProfessionalDto.
  3. Domain: The use case invokes repository.create(), which returns a Professional entity.
  4. Infrastructure: The SQLiteProfessionalRepository executes SQL against the database and maps the result to a Professional instance.
  5. Presentation: The controller receives the entity, formats it as JSON, and returns the HTTP response.

Because dependency arrows always point inward (Presentation → Application → Domain, Infrastructure → Domain), you can replace SQLite with PostgreSQL or Express with Fastify without modifying Domain or Application code.

Summary

  • The Domain layer (src/domain/) contains entities like Professional and repository contracts like IProfessionalRepository, completely free of framework dependencies.
  • The Application layer (src/application/) orchestrates use cases such as CreateProfessionalUseCase using DTOs and repository interfaces, remaining ignorant of HTTP and database specifics.
  • The Infrastructure layer (src/infrastructure/) provides concrete implementations like SQLiteProfessionalRepository and the OpenAPI server bootstrap, implementing Domain contracts with specific technologies.
  • The Presentation layer (src/presentation/) handles HTTP concerns through thin controllers like CreateProfessionalController and decorators like withErrorHandling, translating between HTTP and Application DTOs.
  • Dependencies flow inward, ensuring that business logic remains pure and testable while technical details remain pluggable.

Frequently Asked Questions

What is the difference between the Domain and Application layers?

The Domain layer defines what the business is—entities, value objects, and repository contracts—while the Application layer defines how to accomplish specific tasks by orchestrating Domain objects into use cases. Domain code contains no application logic, and Application code contains no business rules or persistence details.

How does the Infrastructure layer implement Domain contracts without creating circular dependencies?

The Infrastructure layer depends on the Domain layer through the Dependency Inversion Principle. Domain defines interfaces (like IProfessionalRepository), and Infrastructure provides implementations (like SQLiteProfessionalRepository). The Application layer injects the concrete Infrastructure implementation into use cases at runtime, but compiles against Domain interfaces only, keeping the dependency graph acyclic.

Why should business logic never leak into the Presentation layer?

Business logic in controllers leads to code duplication and framework lock-in. By keeping controllers thin—only handling HTTP validation, DTO mapping, and response formatting—you ensure that business rules live in exactly one place (the Domain layer) and can be tested without HTTP contexts or mocked frameworks.

Can I replace SQLite with a different database without changing the Application code?

Yes. Because CreateProfessionalUseCase depends only on the IProfessionalRepository interface, you can create a new PostgreSQLProfessionalRepository in src/infrastructure/database/repositories/ that implements the same interface. As long as the new repository returns Professional entities and honors the contract methods, the Application layer requires zero modifications.

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 →