How Dependency Injection Is Handled for Repositories and Use Cases in the TCC Repository

The tcc repository implements a lightweight, manual constructor-based dependency injection pattern where concrete repository implementations are instantiated as singletons in index.ts files and injected into use-case classes via their constructors, eliminating the need for an IoC container while preserving loose coupling through interface-driven design.

The castrozan/tcc codebase demonstrates a pragmatic approach to dependency injection (DI) that prioritizes clarity and low overhead over framework-heavy solutions. By leveraging TypeScript interfaces and manual constructor injection, the architecture keeps domain logic decoupled from infrastructure concerns without introducing reflection-based containers or complex decorators. This pattern is consistently applied across multiple domains including professionals and equipment management.

The Manual DI Pattern Overview

The architecture follows a strict layered approach where dependencies always point inward, from infrastructure toward domain logic. This manual DI strategy relies on three distinct structural elements working in concert.

Repository Contracts and Interfaces

Repository contracts are defined as TypeScript interfaces in the src/domain/interfaces/ directory. These interfaces abstract the persistence details entirely, allowing use cases to depend only on the contract rather than concrete implementations.

// src/domain/interfaces/professionals/IProfessionalRepository.ts
export interface IProfessionalRepository {
  create(dto: CreateProfessionalDto): Promise<Professional>;
  findAll(): Promise<Professional[]>;
  findById(id: number): Promise<Professional | null>;
  update(dto: UpdateProfessionalDto): Promise<Professional>;
  delete(id: number): Promise<void>;
}

Concrete Repository Implementations

Concrete implementations reside in src/infrastructure/database/repositories/. For example, SQLiteProfessionalRepository implements the IProfessionalRepository interface using SQLite-specific logic, while SQLiteEquipmentRepository handles equipment persistence following the same pattern.

// src/infrastructure/database/repositories/professionals/SQLiteProfessionalRepository.ts
import { IProfessionalRepository } from 'domain/interfaces/professionals/IProfessionalRepository';
import { Professional } from 'domain/entities/professionals/Professional';

export class SQLiteProfessionalRepository implements IProfessionalRepository {
  async create(dto) { /* SQLite INSERT logic */ }
  async findAll() { /* SELECT * */ }
  async findById(id) { /* SELECT WHERE id */ }
  async update(dto) { /* UPDATE */ }
  async delete(id) { /* DELETE */ }
}

Singleton Export Pattern

Each repository folder contains an index.ts file that instantiates the concrete class once and exports it as a singleton. This serves as the composition root for that specific repository type.

// src/infrastructure/database/repositories/professionals/index.ts
import { IProfessionalRepository } from 'domain/interfaces/professionals/IProfessionalRepository';
import { SQLiteProfessionalRepository } from './SQLiteProfessionalRepository';

const ProfessionalRepository: IProfessionalRepository = new SQLiteProfessionalRepository();
export default ProfessionalRepository;

Constructor Injection in Use Cases

Use-case classes receive their repository dependencies through constructors, with the parameter typed to the interface rather than the concrete class. This enables the Dependency Inversion Principle, allowing any implementation satisfying IProfessionalRepository to be substituted without modifying the use-case code.

// src/application/use-cases/professionals/CreateProfessionalUseCase.ts
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);
    }
}

Similarly, UpdateProfessionalUseCase follows identical injection semantics:

// src/application/use-cases/professionals/UpdateProfessionalUseCase.ts
export class UpdateProfessionalUseCase {
  constructor(private ProfessionalRepository: IProfessionalRepository) {}

  async execute(dto: UpdateProfessionalDto): Promise<Professional> {
    return this.ProfessionalRepository.update(dto);
  }
}

Wiring Dependencies in Controllers

Controllers (or route handlers) act as the composition layer, importing the singleton repository instances and manually wiring them into use-case constructors. This represents the only "glue" code in the system; no自动 wiring or reflection mechanisms are employed.

// src/presentation/controllers/professionals/CreateProfessionalController.ts
import professionalRepository from 'infrastructure/database/repositories/professionals';
import { CreateProfessionalUseCase } from 'application/use-cases/professionals/CreateProfessionalUseCase';

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

The same wiring pattern appears in UpdateProfessionalController.ts and other presentation layer files, maintaining consistency across the application's surface area.

Domain Reusability Across the Codebase

This dependency injection pattern is not limited to the professionals domain. The equipment domain (equipments-dummy-app) mirrors the identical structure:

  • IEquipmentRepository interface in src/domain/interfaces/equipments/
  • SQLiteEquipmentRepository concrete class in src/infrastructure/database/repositories/equipments/
  • Singleton export via src/infrastructure/database/repositories/equipments/index.ts
  • CreateEquipmentUseCase receiving the repository via constructor injection

This repetition confirms the pattern is a deliberate architectural standard rather than an isolated implementation detail.

Benefits of the Manual Approach

Choosing manual constructor injection over framework-based DI provides specific advantages for this codebase:

  • Zero framework overhead: No decorators, metadata reflection, or container configuration files are required, reducing bundle size and cognitive load.
  • Explicit dependency graphs: Developers can trace dependency wiring by simply following import statements and constructor calls, making the system's composition transparent.
  • Testability: Because use cases depend only on interfaces, unit tests can inject mock repositories implementing IProfessionalRepository without touching SQLite or filesystem resources.
  • Singleton lifecycle management: The index.ts export pattern ensures that exactly one repository instance exists per application lifecycle, preventing connection pool exhaustion or resource leaks.

Summary

  • Repository contracts are defined as TypeScript interfaces in src/domain/interfaces/*, establishing clear persistence contracts.
  • Concrete implementations (e.g., SQLiteProfessionalRepository) live in src/infrastructure/database/repositories/*.
  • Singleton instances are created and exported from index.ts files within each repository directory, serving as the composition root.
  • Use cases receive repositories via constructor injection typed to the interface, ensuring loose coupling.
  • Controllers import these singletons and manually instantiate use cases, completing the dependency graph without DI frameworks.
  • The pattern is replicated across domains (professionals and equipment), demonstrating a consistent architectural standard in the castrozan/tcc repository.

Frequently Asked Questions

Why does the tcc repository use manual DI instead of a framework like InversifyJS or TSyringe?

The manual approach eliminates external dependencies and configuration overhead while maintaining full type safety. By using standard TypeScript constructors and module exports, the codebase avoids reflection metadata, decorator syntax, and container registration boilerplate. This makes the dependency graph immediately visible through static analysis of imports and constructor signatures.

How do you unit test use cases that depend on repositories?

Unit tests import the use-case class and provide a mock object implementing the repository interface. Since CreateProfessionalUseCase depends only on IProfessionalRepository (not the concrete SQLite class), tests can pass a simple in-memory implementation or a Jest mock without configuring databases or filesystem access.

Are repository instances truly singletons in this pattern?

Yes. Each repository module exports a single instance created via new SQLiteProfessionalRepository() in its index.ts file. Because ES modules cache exports, every subsequent import receives the same object reference, ensuring singleton behavior across the application without explicit singleton pattern boilerplate.

Can I swap SQLite for PostgreSQL or MongoDB without changing use cases?

Absolutely. You would create a new class implementing IProfessionalRepository (e.g., PostgresProfessionalRepository), update the instantiation in src/infrastructure/database/repositories/professionals/index.ts, and leave all use-case and controller code untouched. The interface-driven design makes the persistence layer fully interchangeable.

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 →