O que Todo Dev Deve Saber sobre Inversão de Dependência com TypeScript: tsyringe e InversifyJS Já leu

Compreendendo Inversão de Dependência A inversão de dependência é um princípio fundamental em arquitetura de software que inverte a forma como os módulos se relacionam. Em vez de classes de alto nível dependerem diretamente de classes de baixo nível, ambas devem depender de abstrações. Isso reduz acoplamento, facilita testes e torna o código mais flexível e sustentável. Sem inversão de dependência, uma classe que gerencia usuários precisaria instanciar diretamente um banco de dados específico, criando um vínculo forte. Se você precisasse trocar de banco de dados, teria que modificar a classe de usuários. Com inversão de dependência, a classe depende de uma interface, e qualquer implementação dessa interface pode ser injetada. Diferenças Práticas Entre tsyringe e InversifyJS tsyringe: Simplicidade e Decoradores O tsyringe é uma biblioteca leve de injeção de dependência criada pela Microsoft. Ele usa decoradores TypeScript de forma intuitiva e requer menos configuração inicial. É ideal para projetos que valorizam simplicidade sem sacrificar funcionalidades essenciais. O tsyringe é

Compreendendo Inversão de Dependência

A inversão de dependência é um princípio fundamental em arquitetura de software que inverte a forma como os módulos se relacionam. Em vez de classes de alto nível dependerem diretamente de classes de baixo nível, ambas devem depender de abstrações. Isso reduz acoplamento, facilita testes e torna o código mais flexível e sustentável.

Sem inversão de dependência, uma classe que gerencia usuários precisaria instanciar diretamente um banco de dados específico, criando um vínculo forte. Se você precisasse trocar de banco de dados, teria que modificar a classe de usuários. Com inversão de dependência, a classe depende de uma interface, e qualquer implementação dessa interface pode ser injetada.

Diferenças Práticas Entre tsyringe e InversifyJS

tsyringe: Simplicidade e Decoradores

O tsyringe é uma biblioteca leve de injeção de dependência criada pela Microsoft. Ele usa decoradores TypeScript de forma intuitiva e requer menos configuração inicial. É ideal para projetos que valorizam simplicidade sem sacrificar funcionalidades essenciais.

import { injectable, inject, container } from 'tsyringe';

// Definir uma abstração
interface DatabaseConnection {
  connect(): Promise<void>;
  query(sql: string): Promise<any[]>;
}

// Implementação concreta
@injectable()
class PostgresConnection implements DatabaseConnection {
  async connect(): Promise<void> {
    console.log('Conectando ao PostgreSQL...');
  }

  async query(sql: string): Promise<any[]> {
    return [{ id: 1, name: 'João' }];
  }
}

// Serviço que depende da abstração
@injectable()
class UserRepository {
  constructor(@inject('DatabaseConnection') private db: DatabaseConnection) {}

  async getUsers() {
    await this.db.connect();
    return this.db.query('SELECT * FROM users');
  }
}

// Registrar no container
container.register<DatabaseConnection>('DatabaseConnection', {
  useClass: PostgresConnection,
});

container.registerSingleton(UserRepository);

// Usar
const userRepo = container.resolve(UserRepository);
userRepo.getUsers().then(console.log);

O tsyringe é direto: você decora classes com @injectable(), registra no container e resolve quando necessário. A curva de aprendizado é rápida, e é excelente para aplicações de pequeno a médio porte.

InversifyJS: Flexibilidade e Controle Avançado

O InversifyJS é mais robusto e oferece controle fino sobre a injeção de dependências. Ele usa identificadores simbólicos e permite configurações mais complexas. É a escolha para projetos grandes com requisitos avançados de DI.

import { Container, injectable, inject } from 'inversify';

// Definir símbolos para melhor tipagem
const TYPES = {
  DatabaseConnection: Symbol.for('DatabaseConnection'),
  UserRepository: Symbol.for('UserRepository'),
};

// Interface
interface DatabaseConnection {
  connect(): Promise<void>;
  query(sql: string): Promise<any[]>;
}

// Implementação
@injectable()
class PostgresConnection implements DatabaseConnection {
  async connect(): Promise<void> {
    console.log('Conectando ao PostgreSQL...');
  }

  async query(sql: string): Promise<any[]> {
    return [{ id: 1, name: 'Maria' }];
  }
}

// Repositório
@injectable()
class UserRepository {
  constructor(
    @inject(TYPES.DatabaseConnection)
    private db: DatabaseConnection
  ) {}

  async getUsers() {
    await this.db.connect();
    return this.db.query('SELECT * FROM users');
  }
}

// Configurar container
const container = new Container();
container.bind<DatabaseConnection>(TYPES.DatabaseConnection)
  .to(PostgresConnection)
  .inSingletonScope();
container.bind<UserRepository>(TYPES.UserRepository).to(UserRepository);

// Usar
const userRepo = container.get<UserRepository>(TYPES.UserRepository);
userRepo.getUsers().then(console.log);

A principal vantagem do InversifyJS é o uso de símbolos, que previne colisões de nomes e oferece melhor segurança de tipo. Escopos avançados (singleton, transient, request) e suporte a factories complexas o tornam mais poderoso.

Casos de Uso Reais e Implementação

Exemplo: Sistema de Autenticação com tsyringe

Considere um aplicativo onde você precisa suportar múltiplas estratégias de autenticação. Com inversão de dependência, você alterna estratégias sem modificar o código que as utiliza.

import { injectable, inject, container } from 'tsyringe';

interface AuthStrategy {
  authenticate(username: string, password: string): Promise<boolean>;
}

@injectable()
class JWTAuthStrategy implements AuthStrategy {
  async authenticate(username: string, password: string): Promise<boolean> {
    console.log(`Autenticando ${username} com JWT...`);
    return password === 'senha_correta';
  }
}

@injectable()
class OAuth2Strategy implements AuthStrategy {
  async authenticate(username: string, password: string): Promise<boolean> {
    console.log(`Autenticando ${username} com OAuth2...`);
    return true; // Simulado
  }
}

@injectable()
class AuthService {
  constructor(@inject('AuthStrategy') private strategy: AuthStrategy) {}

  async login(username: string, password: string): Promise<boolean> {
    return this.strategy.authenticate(username, password);
  }
}

// Registrar a estratégia desejada
const strategyType = process.env.AUTH_STRATEGY || 'jwt';

if (strategyType === 'oauth2') {
  container.register<AuthStrategy>('AuthStrategy', {
    useClass: OAuth2Strategy,
  });
} else {
  container.register<AuthStrategy>('AuthStrategy', {
    useClass: JWTAuthStrategy,
  });
}

container.registerSingleton(AuthService);

// Usar
const authService = container.resolve(AuthService);
authService.login('usuario', 'senha_correta').then((success) => {
  console.log(`Login bem-sucedido: ${success}`);
});

Esse padrão permite trocar a estratégia apenas alterando uma variável de ambiente, mantendo o AuthService totalmente desacoplado.

Exemplo: Sistema de Notificações com InversifyJS

Um caso mais complexo onde você envia notificações por diferentes canais. InversifyJS brilha quando você precisa de factories e escopos avançados.

import { Container, injectable, inject, interfaces } from 'inversify';

const TYPES = {
  NotificationService: Symbol.for('NotificationService'),
  EmailSender: Symbol.for('EmailSender'),
  SMSSender: Symbol.for('SMSSender'),
  NotificationFactory: Symbol.for('NotificationFactory'),
};

interface NotificationSender {
  send(to: string, message: string): Promise<void>;
}

@injectable()
class EmailSender implements NotificationSender {
  async send(to: string, message: string): Promise<void> {
    console.log(`Email enviado para ${to}: ${message}`);
  }
}

@injectable()
class SMSSender implements NotificationSender {
  async send(to: string, message: string): Promise<void> {
    console.log(`SMS enviado para ${to}: ${message}`);
  }
}

@injectable()
class NotificationService {
  constructor(
    @inject(TYPES.EmailSender) private emailSender: NotificationSender,
    @inject(TYPES.SMSSender) private smsSender: NotificationSender
  ) {}

  async notifyByEmail(email: string, message: string): Promise<void> {
    await this.emailSender.send(email, message);
  }

  async notifyBySMS(phone: string, message: string): Promise<void> {
    await this.smsSender.send(phone, message);
  }
}

// Factory para criar senders sob demanda
const notificationFactory = (context: interfaces.Context) => {
  return {
    sendEmail: (to: string, msg: string) =>
      context.container.get<NotificationSender>(TYPES.EmailSender).send(to, msg),
    sendSMS: (to: string, msg: string) =>
      context.container.get<NotificationSender>(TYPES.SMSSender).send(to, msg),
  };
};

// Configurar container
const container = new Container();
container.bind<NotificationSender>(TYPES.EmailSender).to(EmailSender).inSingletonScope();
container.bind<NotificationSender>(TYPES.SMSSender).to(SMSSender).inSingletonScope();
container.bind<NotificationService>(TYPES.NotificationService).to(NotificationService);
container
  .bind(TYPES.NotificationFactory)
  .toFactory<any>(notificationFactory);

// Usar
const notificationService = container.get<NotificationService>(
  TYPES.NotificationService
);
notificationService.notifyByEmail('user@example.com', 'Bem-vindo!');
notificationService.notifyBySMS('+5511999999999', 'Código: 123456');

Neste exemplo, cada sender é um singleton, economizando recursos, e você pode adicionar novos canais de notificação sem modificar NotificationService.

Boas Práticas e Armadilhas Comuns

Evitar Over-Engineering

Um erro frequente é criar abstrações para tudo, mesmo quando não é necessário. Se uma classe nunca será substituída, não precisa de uma interface. Inversão de dependência é uma ferramenta, não um dogma.

// ❌ Excessivo
interface StringUtility {
  toUpperCase(str: string): string;
}

@injectable()
class StringUtilityImpl implements StringUtility {
  toUpperCase(str: string): string {
    return str.toUpperCase();
  }
}

// ✅ Apropriado
interface PaymentGateway {
  charge(amount: number): Promise<boolean>;
}

// Isso faz sentido porque você terá múltiplas implementações
// (Stripe, PayPal, Square, etc.)

Gerenciar Ciclos de Vida Corretamente

Escopos (singleton, transient, request) afetam profundamente o comportamento da aplicação. Use singleton para serviços stateless; use transient quando cada requisição precisa de uma nova instância.

// tsyringe
container.registerSingleton(DatabasePool); // Uma instância para toda a app
container.register(UserSession); // Nova instância cada vez

// InversifyJS
container.bind<DatabasePool>(TYPES.DatabasePool)
  .to(DatabasePool)
  .inSingletonScope(); // Uma instância

container.bind<UserSession>(TYPES.UserSession)
  .to(UserSession)
  .inTransientScope(); // Nova instância cada vez

Testar com DI

A verdadeira vantagem da inversão de dependência aparece nos testes. Você injeta mocks facilmente.

import { container } from 'tsyringe';

describe('UserRepository', () => {
  it('deve buscar usuários do banco de dados', async () => {
    const mockDb: DatabaseConnection = {
      connect: jest.fn().mockResolvedValue(undefined),
      query: jest
        .fn()
        .mockResolvedValue([{ id: 1, name: 'Teste' }]),
    };

    container.register<DatabaseConnection>('DatabaseConnection', {
      useValue: mockDb,
    });

    const userRepo = container.resolve(UserRepository);
    const users = await userRepo.getUsers();

    expect(users).toHaveLength(1);
    expect(users[0].name).toBe('Teste');
  });
});

Conclusão

Aprendemos que inversão de dependência é sobre depender de abstrações, não de implementações concretas. O tsyringe oferece simplicidade e é perfeito para a maioria dos projetos, enquanto o InversifyJS fornece controle avançado para aplicações complexas. A chave está em usar a ferramenta certa para o problema certo — nem mais, nem menos — e sempre pensar em testes desde o início do design.

Referências


Artigos relacionados