Inversion of Control e injeção de dependência com tipos

1. Fundamentos de Inversion of Control (IoC) em TypeScript

1.1. O princípio: quem controla o quê?

Inversion of Control (IoC) é um princípio onde o fluxo de controle de um programa é invertido: em vez de um componente criar e gerenciar suas próprias dependências, ele recebe essas dependências de uma fonte externa. Em TypeScript, isso significa que as classes não instanciam diretamente seus colaboradores, mas os recebem prontos.

Dependências explícitas vs. implícitas:

// ❌ Dependência implícita (acoplamento forte)
class UserService {
  private db = new Database(); // Quem criou? Como testar?
}

// ✅ Dependência explícita (IoC)
class UserService {
  constructor(private db: Database) {} // Recebe de fora
}

1.2. Problemas resolvidos

O IoC resolve três problemas fundamentais:
- Acoplamento forte: classes não dependem de implementações concretas
- Testabilidade: dependências podem ser substituídas por mocks
- Escalabilidade: novos comportamentos são adicionados sem modificar código existente

1.3. Exemplo inicial: código acoplado vs. código com IoC manual

// ❌ Código acoplado
class EmailService {
  sendEmail(to: string, message: string) {
    console.log(`Enviando email para ${to}: ${message}`);
  }
}

class NotificationService {
  private email = new EmailService(); // Acoplamento rígido

  notify(user: string) {
    this.email.sendEmail(user, "Notificação importante");
  }
}

// ✅ Código com IoC manual
interface IEmailService {
  sendEmail(to: string, message: string): void;
}

class NotificationService {
  constructor(private email: IEmailService) {} // Inversão de controle

  notify(user: string) {
    this.email.sendEmail(user, "Notificação importante");
  }
}

2. Injeção de Dependência (DI) na Prática com TypeScript

2.1. Construtor injection: tipagem segura de dependências

O construtor injection é a forma mais comum e recomendada de DI em TypeScript:

interface ILogger {
  log(message: string): void;
}

interface IConfig {
  getApiUrl(): string;
}

class ApiClient {
  constructor(
    private logger: ILogger,
    private config: IConfig
  ) {} // TypeScript garante que ambos sejam passados

  async fetchData() {
    this.logger.log(`Fetching from ${this.config.getApiUrl()}`);
    // ...
  }
}

2.2. Property injection e method injection

Quando usar cada um:

// Property injection (útil para dependências opcionais)
class DashboardService {
  logger?: ILogger; // Opcional

  setLogger(logger: ILogger) {
    this.logger = logger;
  }
}

// Method injection (quando a dependência varia por chamada)
class PaymentProcessor {
  processPayment(amount: number, gateway: IPaymentGateway) {
    // gateway é injetado no método
    return gateway.charge(amount);
  }
}

2.3. Interfaces como contratos

Interfaces garantem type safety e permitem substituição de implementações:

interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

class PostgresUserRepository implements IUserRepository {
  async findById(id: string): Promise<User | null> {
    // Implementação real
  }

  async save(user: User): Promise<void> {
    // Implementação real
  }
}

class UserService {
  constructor(private repo: IUserRepository) {} // Type-safe
}

3. Containers de DI com Tipagem Estática

3.1. Criando um container type-safe do zero

type ServiceIdentifier<T> = string | symbol | { new (...args: any[]): T };

class Container {
  private services = new Map<string, any>();

  register<T>(key: string, instance: T): void {
    this.services.set(key, instance);
  }

  resolve<T>(key: string): T {
    const service = this.services.get(key);
    if (!service) throw new Error(`Service ${key} not found`);
    return service as T;
  }
}

// Uso com inferência de tipos
const container = new Container();
container.register<IUserRepository>('UserRepo', new PostgresUserRepository());
const repo = container.resolve<IUserRepository>('UserRepo'); // Tipo inferido

3.2. Registro e resolução com inferência

class TypedContainer {
  private factories = new Map<string, () => any>();

  register<T>(key: string, factory: () => T): void {
    this.factories.set(key, factory);
  }

  resolve<T>(key: string): T {
    const factory = this.factories.get(key);
    if (!factory) throw new Error(`Service ${key} not found`);
    return factory() as T;
  }
}

const container = new TypedContainer();
container.register('Logger', () => new ConsoleLogger());
const logger = container.resolve<ILogger>('Logger'); // Type-safe

3.3. Escopos de dependência

type Scope = 'singleton' | 'transient' | 'scoped';

class ScopedContainer {
  private singletons = new Map<string, any>();
  private factories = new Map<string, { factory: () => any; scope: Scope }>();

  register<T>(key: string, factory: () => T, scope: Scope = 'transient') {
    this.factories.set(key, { factory, scope });
  }

  resolve<T>(key: string): T {
    const entry = this.factories.get(key);
    if (!entry) throw new Error(`Service ${key} not found`);

    if (entry.scope === 'singleton') {
      if (!this.singletons.has(key)) {
        this.singletons.set(key, entry.factory());
      }
      return this.singletons.get(key) as T;
    }

    return entry.factory() as T;
  }
}

4. Abstrações e Desacoplamento com Genéricos

4.1. Tipos genéricos para repositórios

interface IRepository<T> {
  getById(id: string): Promise<T | null>;
  getAll(): Promise<T[]>;
  create(item: T): Promise<T>;
  update(id: string, item: Partial<T>): Promise<T>;
}

class GenericRepository<T> implements IRepository<T> {
  constructor(private db: Database) {}

  async getById(id: string): Promise<T | null> {
    return this.db.query(`SELECT * FROM table WHERE id = $1`, [id]);
  }

  async getAll(): Promise<T[]> {
    return this.db.query(`SELECT * FROM table`);
  }

  async create(item: T): Promise<T> {
    return this.db.insert(item);
  }

  async update(id: string, item: Partial<T>): Promise<T> {
    return this.db.update(id, item);
  }
}

4.2. Factory functions tipadas

type ServiceFactory<T> = (...args: any[]) => T;

function createServiceFactory<T>(
  implementation: new (...args: any[]) => T,
  ...dependencies: any[]
): ServiceFactory<T> {
  return () => new implementation(...dependencies);
}

// Uso
const userRepoFactory = createServiceFactory(PostgresUserRepository, dbConnection);
const userRepo = userRepoFactory();

4.3. Mapeamento de interfaces para implementações

type ServiceMap = {
  [K: string]: new (...args: any[]) => any;
};

class ServiceMapper {
  private map = new Map<string, new (...args: any[]) => any>();

  mapInterface<T>(interfaceName: string, implementation: new (...args: any[]) => T) {
    this.map.set(interfaceName, implementation);
  }

  resolve<T>(interfaceName: string): T {
    const implementation = this.map.get(interfaceName);
    if (!implementation) throw new Error(`No implementation for ${interfaceName}`);
    return new implementation() as T;
  }
}

5. DI com Frameworks e Bibliotecas TypeScript

5.1. InversifyJS: decorators e metadados

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

@injectable()
class Logger {
  log(message: string) { console.log(message); }
}

@injectable()
class UserService {
  constructor(@inject('Logger') private logger: Logger) {}
}

const container = new Container();
container.bind<Logger>('Logger').to(Logger);
container.bind<UserService>('UserService').to(UserService);

5.2. tsyringe: container leve

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

@injectable()
class Database {
  connect() { /* ... */ }
}

@injectable()
class UserRepository {
  constructor(private db: Database) {}
}

const repo = container.resolve(UserRepository); // Injeção automática

5.3. awilix: resolução por nome

import { createContainer, asClass } from 'awilix';

const container = createContainer();

container.register({
  userService: asClass(UserService),
  logger: asClass(Logger)
});

const userService = container.resolve<UserService>('userService');

6. Padrões Avançados de DI com Tipos

6.1. Lazy injection

type Lazy<T> = () => T;

class HeavyService {
  constructor() {
    console.log('HeavyService created');
  }

  doWork() { /* ... */ }
}

class Consumer {
  private heavyService: Lazy<HeavyService>;

  constructor() {
    this.heavyService = () => new HeavyService(); // Só cria quando chamado
  }

  useHeavyService() {
    const service = this.heavyService(); // Criação sob demanda
    service.doWork();
  }
}

6.2. Named injections e qualificadores

type Qualifier = 'primary' | 'secondary' | 'fallback';

interface IDatabase {
  query(sql: string): Promise<any>;
}

class QualifierContainer {
  private databases = new Map<string, IDatabase>();

  registerDatabase(qualifier: Qualifier, db: IDatabase) {
    this.databases.set(qualifier, db);
  }

  getDatabase(qualifier: Qualifier = 'primary'): IDatabase {
    const db = this.databases.get(qualifier);
    if (!db) throw new Error(`Database ${qualifier} not found`);
    return db;
  }
}

6.3. Factories injetáveis

interface IWidgetFactory {
  createWidget(type: string, config: WidgetConfig): Widget;
}

class WidgetFactory implements IWidgetFactory {
  createWidget(type: string, config: WidgetConfig): Widget {
    switch(type) {
      case 'button': return new Button(config);
      case 'input': return new Input(config);
      default: throw new Error(`Unknown widget type: ${type}`);
    }
  }
}

class WidgetManager {
  constructor(private factory: IWidgetFactory) {}

  addWidget(type: string, config: WidgetConfig) {
    const widget = this.factory.createWidget(type, config);
    // ...
  }
}

7. Testabilidade e Mocking com Tipos Fortes

7.1. Substituindo dependências por mocks tipados

interface IEmailService {
  send(to: string, body: string): Promise<boolean>;
}

class MockEmailService implements IEmailService {
  async send(to: string, body: string): Promise<boolean> {
    console.log(`Mock: Email sent to ${to}`);
    return true;
  }
}

// Teste unitário
describe('NotificationService', () => {
  it('should send notification', async () => {
    const mockEmail = new MockEmailService();
    const service = new NotificationService(mockEmail);

    await service.notify('user@test.com');

    expect(mockEmail.send).toHaveBeenCalledWith('user@test.com', expect.any(String));
  });
});

7.2. Testes com container e tipos parciais

type PartialContainer = Partial<{
  userRepo: IUserRepository;
  emailService: IEmailService;
  logger: ILogger;
}>;

function createTestContainer(overrides: PartialContainer = {}) {
  return {
    userRepo: overrides.userRepo ?? new MockUserRepository(),
    emailService: overrides.emailService ?? new MockEmailService(),
    logger: overrides.logger ?? new MockLogger()
  };
}

// Uso em testes
const container = createTestContainer({
  userRepo: new MockUserRepository()
});

7.3. Garantindo contratos com mocks

// TypeScript garante que o mock implemente a interface
const mockRepo: IUserRepository = {
  findById: jest.fn().mockResolvedValue(null),
  save: jest.fn().mockResolvedValue(undefined)
};

// Erro de compilação se faltar método
// const invalidMock: IUserRepository = {}; // ❌ Erro!

8. Armadilhas e Boas Práticas em TypeScript

8.1. Evitando dependências circulares

// ❌ Dependência circular
class A {
  constructor(private b: B) {}
}

class B {
  constructor(private a: A) {} // Circular!
}

// ✅ Solução: interface e lazy injection
interface IA { /* ... */ }
interface IB { /* ... */ }

class A implements IA {
  private b?: IB;

  setB(b: IB) { this.b = b; }
}

class B implements IB {
  constructor(private a: IA) {}
}

8.2. Performance e inferência de tipos

// ❌ Inferência complexa pode ser lenta
const complexService = container.resolve<SomeComplexType>('complexService');

// ✅ Use tipos explícitos para resolução
interface IUserService { /* ... */ }
const userService = container.resolve<IUserService>('userService');

8.3. Quando NÃO usar DI

// ❌ DI desnecessária para classes simples
class MathUtils {
  static add(a: number, b: number) { return a + b; }
}

// ✅ Use DI quando houver:
// - Múltiplas implementações da mesma interface
// - Necessidade de testar com mocks
// - Dependências que mudam em runtime
// - Ciclo de vida complexo das dependências

Referências