Voltar para Artigos
Back-end11 min de leitura

Injeção de Dependências no Node.js com tsyringe

IoC Container com tsyringe: register vs registerSingleton, scoped vs singleton, tokens de string vs class tokens, registro dinâmico de providers por variável de ambiente e isolamento de testes com container temporário.

12 de agosto de 2026

À medida que um projeto Node.js cresce, o acoplamento entre camadas cria um emaranhado difícil de manter: controller cria o service, service cria o repositório, repositório importa o TypeORM diretamente. Para trocar o TypeORM pelo Prisma, você edita N arquivos. Para testar o service, você precisa de uma conexão real com o banco.

Injeção de Dependências (DI) inverte esse controle: em vez de cada classe criar seus colaboradores, um container central resolve e injeta as dependências. O tsyringe (Microsoft) implementa isso para TypeScript com uma API de decorators limpa e um container global que pode ser substituído em testes.

Conceitos: Singleton vs Transient vs Scoped

  • Singleton — uma única instância é criada e reutilizada em toda a aplicação. Use para conexões (repositórios, clients Redis). registerSingleton()
  • Transient — uma nova instância é criada a cada resolve(). Use para objetos stateful que não devem ser compartilhados. register()
  • Scoped — uma instância por escopo definido (ex: por requisição HTTP). Requer configuração manual no tsyringe.

Configuração Inicial

bash
npm install tsyringe reflect-metadata
tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true
  }
}
server.ts (entry point)
// reflect-metadata DEVE ser o primeiro import da aplicação inteira
import 'reflect-metadata';

// Registra todos os providers no container
import './infra/di/container';

import express from 'express';
// ...

As Interfaces (Ports)

IUsersRepository.ts
export interface CreateUserDTO {
  name: string;
  email: string;
  passwordHash: string;
}

export interface IUsersRepository {
  create(data: CreateUserDTO): Promise<User>;
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}

Use Case com @injectable e @inject

CreateUserUseCase.ts
import { injectable, inject } from 'tsyringe';
import { hash } from 'bcrypt';
import type { IUsersRepository } from '../../domain/repositories/IUsersRepository';
import type { IMailProvider } from '../../domain/providers/IMailProvider';
import type { ICacheProvider } from '../../domain/providers/ICacheProvider';

interface CreateUserDTO {
  name: string;
  email: string;
  password: string;
}

@injectable()
export class CreateUserUseCase {
  constructor(
    // Os tokens de string mapeiam para as implementações concretas registradas no container
    @inject('UsersRepository')
    private usersRepository: IUsersRepository,

    @inject('MailProvider')
    private mailProvider: IMailProvider,

    @inject('CacheProvider')
    private cacheProvider: ICacheProvider
  ) {}

  async execute({ name, email, password }: CreateUserDTO): Promise<User> {
    const existingUser = await this.usersRepository.findByEmail(email);
    if (existingUser) throw new AppError('E-mail já cadastrado.', 409);

    const passwordHash = await hash(password, Number(process.env.BCRYPT_COST ?? 12));

    const user = await this.usersRepository.create({ name, email, passwordHash });

    // Envia e-mail de boas-vindas (sem conhecer qual provider está sendo usado)
    await this.mailProvider.send({
      to: email,
      subject: `Bem-vindo, ${name}!`,
      html: `<p>Sua conta foi criada com sucesso.</p>`,
    });

    // Invalida cache de listagem de usuários
    await this.cacheProvider.del('users:list');

    return user;
  }
}

Container: Registro das Implementações

O Container é o coração do sistema de DI: ele mapeia tokens de string (como 'UsersRepository') para implementações concretas. A seleção dinâmica por variável de ambiente é uma das funcionalidades mais poderosas: trocar o provedor de e-mail do Nodemailer para o Amazon SES em produção é só alterar MAIL_PROVIDER=ses no .env — nenhuma linha de código precisa mudar. Isso facilita também testes: registre CACHE_PROVIDER=memory nos testes de integração para evitar dependência do Redis.

container.ts
import { container } from 'tsyringe';
import type { IUsersRepository } from '../../domain/repositories/IUsersRepository';
import type { IMailProvider } from '../../domain/providers/IMailProvider';
import type { ICacheProvider } from '../../domain/providers/ICacheProvider';

// ── Repositories ─────────────────────────────────────────────────────────────
import { TypeORMUsersRepository } from '../typeorm/repositories/TypeORMUsersRepository';

// Singleton: todos os Use Cases compartilham a mesma instância do repositório
// (que internamente usa o pool de conexões do TypeORM)
container.registerSingleton<IUsersRepository>('UsersRepository', TypeORMUsersRepository);

// ── Providers (Mail) ──────────────────────────────────────────────────────────
// Seleção dinâmica por variável de ambiente — troca sem alterar código
const mailProviderKey = process.env.MAIL_PROVIDER ?? 'nodemailer';
const mailProviders = {
  nodemailer: () => import('../providers/mail/NodemailerMailProvider')
    .then((m) => m.NodemailerMailProvider),
  ses: () => import('../providers/mail/SESMailProvider')
    .then((m) => m.SESMailProvider),
  resend: () => import('../providers/mail/ResendMailProvider')
    .then((m) => m.ResendMailProvider),
};

// @ts-expect-error dynamic import
mailProviders[mailProviderKey]?.().then((MailProvider) => {
  container.registerSingleton<IMailProvider>('MailProvider', MailProvider);
}) ?? console.error(`Provider de e-mail desconhecido: ${mailProviderKey}`);

// ── Providers (Cache) ─────────────────────────────────────────────────────────
const cacheProviderKey = process.env.CACHE_PROVIDER ?? 'redis';
const cacheProviders = {
  redis: () => import('../providers/cache/RedisCacheProvider')
    .then((m) => m.RedisCacheProvider),
  memory: () => import('../providers/cache/InMemoryCacheProvider')
    .then((m) => m.InMemoryCacheProvider),
};

// @ts-expect-error dynamic import
cacheProviders[cacheProviderKey]?.().then((CacheProvider) => {
  container.registerSingleton<ICacheProvider>('CacheProvider', CacheProvider);
});

Controller

O Controller com container.resolve(CreateUserUseCase) é a sintátese de todo o padrão: uma linha resolve o Use Case e injeta todas as suas dependências automaticamente. O Controller não precisa conhecer o TypeORMUsersRepository, o NodemailerMailProvider ou o RedisCacheProvider — ele só conhece o Use Case. Isso torna o Controller trivial de testar: apenas verifique que ele chama createUser.execute() com o DTO correto.

UsersController.ts
import type { Request, Response } from 'express';
import { container } from 'tsyringe';
import { CreateUserUseCase } from '../../../usecases/CreateUserUseCase';

export class UsersController {
  async create(req: Request, res: Response): Promise<void> {
    // O container resolve o UseCase e todas as suas dependências automaticamente
    // O UsersController não conhece repositórios, mail providers ou cache
    const createUser = container.resolve(CreateUserUseCase);
    const user = await createUser.execute(req.body);
    res.status(201).json(user);
  }
}

Testes: Container Isolado

CreateUserUseCase.spec.ts
import 'reflect-metadata';
import { container } from 'tsyringe';
import { CreateUserUseCase } from '../../usecases/CreateUserUseCase';
import { InMemoryUsersRepository } from '../repositories/InMemoryUsersRepository';
import { FakeMailProvider } from '../providers/FakeMailProvider';
import { InMemoryCacheProvider } from '../providers/InMemoryCacheProvider';

describe('CreateUserUseCase', () => {
  beforeEach(() => {
    // Registra implementações fake no container para este teste
    // (sobrescreve as implementações reais registradas no container.ts)
    container.register('UsersRepository', { useClass: InMemoryUsersRepository });
    container.register('MailProvider', { useClass: FakeMailProvider });
    container.register('CacheProvider', { useClass: InMemoryCacheProvider });
  });

  afterEach(() => {
    // Limpa os registros após cada teste para não contaminar outros
    container.clearInstances();
  });

  it('deve criar um usuário e enviar e-mail de boas-vindas', async () => {
    const useCase = container.resolve(CreateUserUseCase);
    const mailProvider = container.resolve<FakeMailProvider>('MailProvider');

    const user = await useCase.execute({
      name: 'João Silva',
      email: 'joao@exemplo.com',
      password: 'senha@123',
    });

    expect(user.email).toBe('joao@exemplo.com');
    expect(mailProvider.sentMails).toHaveLength(1);
    expect(mailProvider.sentMails[0].to).toBe('joao@exemplo.com');
  });

  it('deve rejeitar e-mail já cadastrado', async () => {
    const useCase = container.resolve(CreateUserUseCase);

    await useCase.execute({ name: 'João', email: 'joao@exemplo.com', password: '123456' });

    await expect(
      useCase.execute({ name: 'João 2', email: 'joao@exemplo.com', password: '654321' })
    ).rejects.toThrow('E-mail já cadastrado.');
  });
});

Para projetos que não precisam de decorators (não ativam experimentalDecorators), considere o padrão Factory como alternativa mais simples. O tsyringe brilha em projetos com muitas dependências encadeadas onde a montagem manual fica verbosa — se você tem apenas 2-3 dependências por Use Case, Factory functions podem ser mais diretas.

Conclusão

O tsyringe reduz o acoplamento entre as camadas da aplicação ao máximo: Use Cases conhecem apenas interfaces, o container decide qual implementação usar. Quando você troca o TypeORM pelo Prisma ou o Nodemailer pelo SES, edita apenas o arquivo de container — o resto da aplicação não muda. E nos testes, registre fakes no container e teste a lógica de negócio sem banco de dados real.