Almir Dev

Injeção de Dependências com TypeScript e Express: Sem Frameworks

18 de julho de 2026 • 5 min de leitura

Injeção de Dependências com TypeScript e Express: Sem Frameworks

Se você já tentou escrever testes unitários para uma aplicação Node.js usando Express, provavelmente já esbarrou em um dos maiores obstáculos da testabilidade: o alto acoplamento.

Quando importamos o Prisma, um Logger ou serviços externos diretamente no escopo dos nossos controllers, criamos uma dependência rígida. Mockar essas dependências globais usando vi.mock() frequentemente resulta em testes frágeis e de difícil manutenção.

Neste artigo, exploraremos como solucionar esse cenário arquitetural através da Injeção de Dependências via Construtor (Constructor Injection). A ideia é implementar esse padrão utilizando TypeScript puro, sem a necessidade de frameworks complexos como NestJS ou InversifyJS.

O Problema do Acoplamento

Geralmente, a implementação de uma rota simples no Express para checagem de saúde (Health Check) se assemelha a isto:

// controllers/health.controller.ts
import { Request, Response } from "express";
import { prisma } from "../config/db"; // Dependência rígida implícita

export async function handleIndex(req: Request, res: Response) {
  const records = await prisma.healthCheck.findMany();
  return res.status(200).json({ data: records });
}

E o arquivo de rotas ficaria encarregado apenas do roteamento:

// routes/health.routes.ts
import { Router } from "express";
import { handleIndex } from "../controllers/health.controller";

const router = Router();
router.get("/", handleIndex);

export { router };

Se quisermos testar a função handleIndex, como injetamos um prisma simulado (mock) para ela? A assinatura da função não aceita parâmetros além de req e res. Seríamos obrigados a utilizar mecanismos do Vitest para interceptar o import, o que além de tornar o teste mais lento, acopla a suíte de testes à estrutura de arquivos da aplicação.

A Solução: Injeção via Construtor

Um dos princípios fundamentais do design orientado a objetos é a Inversão de Controle. Se uma classe precisa de um recurso para funcionar, ela não deve instanciá-lo ou importá-lo diretamente, mas sim exigi-lo através do seu construtor.

Vamos refatorar nosso Controller transformando-o em uma classe:

// controllers/health.controller.ts
import { Request, Response } from "express";
import { ListHealthCheckUseCase } from "../usecases/list-health-check.usecase";

export class HealthController {
  // Injetando as dependências de forma explícita no construtor
  constructor(
    private readonly listHealthCheckUseCase: ListHealthCheckUseCase
  ) {}

  async handleIndex(req: Request, res: Response) {
    const records = await this.listHealthCheckUseCase.execute();
    return res.status(200).json({ data: records, status: "ok" });
  }
}

Seguindo o mesmo princípio, o caso de uso ListHealthCheckUseCase não importará o Prisma diretamente. Ele exigirá um repositório no momento de sua instanciação:

// usecases/list-health-check.usecase.ts
export class ListHealthCheckUseCase {
  constructor(private readonly repository: HealthCheckRepository) {}

  async execute() {
    return this.repository.findMany();
  }
}

Testes Unitários Descomplicados

Ao isolarmos as dependências, o ambiente de testes torna-se previsível. Podemos fornecer instâncias simuladas que respeitam a mesma interface sem depender de manipulações complexas de módulos:

// __tests__/health.controller.test.ts
import { vi, test, expect } from "vitest";
import { Request } from "express";
import { HealthController } from "../controllers/health.controller";

test("deve retornar os registros com status 200", async () => {
  // Criamos um objeto simulado (stub) para a dependência
  const mockUseCase = { 
    execute: async () => [{ id: 1, status: "ok" }] 
  } as any;
  
  // Injetamos a dependência manualmente
  const controller = new HealthController(mockUseCase);
  
  const req = {} as Request;
  const res = { 
    status: vi.fn().mockReturnThis(), 
    json: vi.fn() 
  } as any;
  
  await controller.handleIndex(req, res);
  
  expect(res.status).toHaveBeenCalledWith(200);
  expect(res.json).toHaveBeenCalledWith({ data: [{ id: 1, status: "ok" }], status: "ok" });
});

A ausência de vi.mock() torna a execução do teste mais rápida e garante que o isolamento foi feito a nível de design de software, e não via tooling.

O Composition Root (Module Factories)

A pergunta natural que surge é: “Se o controller não instancia suas dependências, quem é responsável por isso?”

Toda aplicação precisa de um ponto central responsável pela criação do grafo de dependências, conhecido como Composition Root. Em aplicações Express, uma excelente abordagem é a utilização de Module Factories (Fábricas de Módulos).

graph LR
    App[app.ts] --> Router[health<br>routes.ts]
    Router --> Controller[Health<br>Controller]
    Controller --> UseCase[ListHealthCheck<br>UseCase]
    UseCase --> Repository[PrismaHealthCheck<br>Repository]
    Repository --> Prisma[(Prisma<br>Client)]
// routes/health.routes.ts
import { Router } from "express";
import { HealthController } from "../controllers/health.controller";

export function createHealthRouter(controller: HealthController) {
  const router = Router();
  
  // O uso do .bind garante que o contexto do 'this' não seja perdido
  router.get("/", controller.handleIndex.bind(controller));
  
  return router;
}

No ponto de entrada da aplicação (app.ts ou server.ts), encadeamos a inicialização dessas instâncias:

// app.ts
import express from "express";
import { PrismaClient } from "@prisma/client";
import { HealthController } from "./controllers/health.controller";
import { ListHealthCheckUseCase } from "./usecases/list-health-check.usecase";
import { PrismaHealthCheckRepository } from "./repositories/prisma-health-check.repository";
import { createHealthRouter } from "./routes/health.routes";

export function createHealthModule(prisma: PrismaClient) {
  // 1. Instanciamos a camada de dados (Repositório)
  const repository = new PrismaHealthCheckRepository(prisma);

  // 2. Instanciamos a camada de negócio (Caso de Uso)
  const listHealthCheckUseCase = new ListHealthCheckUseCase(repository);

  // 3. Instanciamos a camada de apresentação (Controller)
  const controller = new HealthController(listHealthCheckUseCase);

  // 4. Retornamos o roteador devidamente configurado
  return createHealthRouter(controller);
}

export function createApplication() {
  const app = express();
  const prisma = new PrismaClient(); // Conexão real estabelecida na raiz

  app.use("/health", createHealthModule(prisma));

  return app;
}

Conclusão

Embora o ecossistema TypeScript possua ótimos frameworks de injeção de dependências como o TSyringe, Inversify ou todo o ecossistema do NestJS baseados em decorators, eles podem introduzir uma camada de abstração que muitas vezes não se justifica em projetos menores ou microserviços.

Por que evitar decorators?

Frameworks robustos são excelentes para aplicações complexas, mas o uso de decorators exige configuração adicional (experimentalDecorators) e pacotes de terceiros como o reflect-metadata, já que o TypeScript não possui Reflection nativa no runtime como o Java ou C#. Para APIs focadas em clareza, a abordagem manual elimina essa “magia” e mantém as dependências explícitas.

Ao aplicarmos conceitos clássicos como Injeção via Construtor aliada a padrões de fábrica, obtemos uma arquitetura extremamente legível, testável e sem dependências ocultas, aproveitando o que o TypeScript oferece nativamente de melhor.

Almir Dev
Autor

Almir Dev

Engenheiro de Software focado no ecossistema TypeScript, desenvolvendo aplicações web escaláveis e centradas no usuário.