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.
À 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
npm install tsyringe reflect-metadata{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true
}
}// 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)
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
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.
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.
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
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.