Injeção de dependência (Dependency Injection) é um desses termos que parece mais complexo do que realmente é. A ideia é simples: em vez de um componente criar suas próprias dependências, elas são fornecidas de fora.
Existem alguns frameworks de injeção de dependência, mas é algo muito fácil de fazer "na mão", sem precisar de truques:
// Criamos uma interface que define como será o "Serviço" de enviar email
interface EmailService {
sendEmail(to: string, template: string): Promise<unknown>;
}
class SendGridEmailService implements EmailService {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async sendEmail(to: string, template: string) {
// Implementação real que chama API do SendGrid
}
}
class PaymentService {
private emailService: EmailService;
// Quando criamos o PaymentService, precisamos injetar um serviço de e-mail
constructor(emailService: EmailService) {
this.emailService = emailService
}
async processPayment(employee: Employee) {
// ...
this.emailService.sendEmail(employee.email, 'new-payment')
// ...
}
}
const emailService = new SendGridEmailService(process.env.SENDGRID_API_KEY!);
// Aqui o serviço é "injetado"
const paymentService = new PaymentService(emailService);
O que são interfaces?
Interfaces definem um "contrato" que especifica quais métodos e propriedades uma classe deve ter, sem fornecer a implementação. No TypeScript, usamos interfaces para definir a forma que um objeto deve ter.
Para criar uma classe que segue esse contrato, utilizamos o implements. Quando uma classe implementa uma interface,
ela precisa fornecer implementações para todos os métodos definidos na interface.
Isso garante que diferentes implementações tenham a mesma "forma".
Vale notar que Injeção de Dependência não é exclusiva da Orientação a Objetos. Também é muito popular na programação funcional:
type Logger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
// Podemos passar a dependência como parâmetro
const createUser = (logger: Logger, username: string) => {
const user = { id: 1, username };
// Uso da dependência
logger.info(`Usuário criado: ${username}`);
return user;
};
const consoleLogger = {
info: console.log,
warn: console.warn,
error: console.error,
}
// Injetamos o logger na chamada da função
createUser(consoleLogger, 'Renato')
Agora vamos ver um exemplo que não utiliza Injeção de dependência, para entendermos a diferença. Considere um cenário em que você está trabalhando em um projeto que possui um serviço de pagamento. Esse serviço é responsável, entre outras coisas, por exportar uma versão de CSV de relatórios de pagamento.
class PaymentManager {
exportToCSV(paymentRoll: PaymentRoll) {
// Implementação
}
// outros métodos
}
Tipos utilizados nos exemplos
Ao longo do artigo, vou utilizar alguns tipos para representar entidades do domínio. Eles não são o foco desse artigo, mas se você quiser ter uma ideia, eles poderiam ser representados da seguinte forma:
// Representa um funcionário
interface Employee {
id: number;
name: string;
email: string;
baseSalary: number;
hireDate: Date;
}
// Representa uma folha de pagamento
interface PaymentRoll {
employees: Employee[];
period: { start: Date; end: Date };
totalAmount: number;
}
// Preferências do usuário para exportação
interface UserPreference {
separator: string;
dateFormat: string;
jsonIndentation: number;
}
// Resultado de uma operação de pagamento
interface PaymentResult {
success: boolean;
transactionId: string;
}
Não tem nada de errado com esse código. Entretanto, agora precisamos fazer uma pequena mudança: dar a opção de escolher qual é o caracter de separação. Uma solução mais simples seria incluir isso como um parâmetro do método:
class PaymentManager {
exportToCSV(paymentRoll: PaymentRoll, separator: string) {
// Implementação
}
}
Mas vamos considerar um cenário mais complexo, onde o usuário define suas preferências para serem usadas. Uma boa prática é evitar que alguém "esqueça" de utilizar essa preferência:
// Utiliza corretamente a preferência do usuário
paymentManager.exportToCsv(payment, savedSeparator);
// Em outra parte do código, a gente esquece de utilizar essa preferência
paymentManager.exportToCsv(payment, ",");
Podemos então incluir essas preferências no PaymentManager:
class PaymentManager {
private separator: string;
constructor(separator: string) {
this.separator = separator;
}
exportToCSV(paymentRoll: PaymentRoll) {
// Implementação
// Aqui vamos utilizar this.separator
}
}
const paymentManager = new PaymentManager(savedSeparator);
Agora começamos a ter um problema de acoplamento. Estamos misturando responsabilidades: PaymentManager é responsável pela lógica de pagamentos,
mas agora também precisa saber sobre regras de formatação CSV. Isso cria vários problemas:
Princípio da Responsabilidade Única: Por que PaymentManager deveria se preocupar com separadores CSV? Isso é uma preocupação de formatação, não de pagamento.
Impossível Testar Isoladamente: Você não consegue testar a lógica de formatação CSV sem envolver todo o PaymentManager.
Amplificação de Mudanças: Quando temos uma nova feature, como: "Deveríamos ter uma opção para escolher formato de data," modificamos PaymentManager mesmo que a lógica de pagamento não tenha mudado.
Podemos melhorar esse código utilizando Injeção de Dependência:
class CSVPaymentExporter {
private separator: string;
private dateFormat: string;
constructor(separator: string, dateFormat: string) {
this.separator = separator;
this.dateFormat = dateFormat;
}
export(paymentRoll: PaymentRoll) {
// Implementação, utilizamos this.separator e this.dateFormat
}
}
class PaymentManager {
private csvExporter: CSVPaymentExporter
constructor(csvExporter: CSVPaymentExporter) {
this.csvExporter = csvExporter;
}
export(paymentRoll: PaymentRoll) {
this.csvExporter.export(paymentRoll)
}
}
const csvExporter = new CSVPaymentExporter(savedSeparator, savedDateFormat)
const paymentManager = new PaymentManager(csvExporter);
Agora, podemos livremente modificar a classe CSVPaymentExporter, sem se preocupar em modificar
o PaymentManager. Podemos até abstrair essa classe, permitindo outras formas de exportar:
interface PaymentExporter {
export(paymentRoll: PaymentRoll): void;
}
class CSVPaymentExporter implements PaymentExporter {
private separator: string;
private dateFormat: string;
constructor(separator: string, dateFormat: string) {
this.separator = separator;
this.dateFormat = dateFormat;
}
export(paymentRoll: PaymentRoll): void {
// Implementação que monta CSV com this.separator e this.dateFormat
}
}
class PaymentManager {
private exporter: PaymentExporter;
constructor(exporter: PaymentExporter) {
this.exporter = exporter;
}
export(paymentRoll: PaymentRoll): void {
this.exporter.export(paymentRoll);
}
}
const csvExporter = new CSVPaymentExporter(savedSeparator, savedDateFormat);
const paymentManager = new PaymentManager(csvExporter);
// Podemos facilmente criar outro exporter com suas próprias configurações
class JSONPaymentExporter implements PaymentExporter {
export(paymentRoll: PaymentRoll): void {
// Implementação JSON
}
}
Agora conseguimos criar novos exportadores sem afetar PaymentManager ou CSVPaymentExporter.
Outra vantagem é que agora fica mais fácil escrever testes
injetando versões mocks (implementações falsas criadas apenas para testes):
class TestPaymentExporter implements PaymentExporter {
public exportedRolls: PaymentRoll[] = [];
export(paymentRoll: PaymentRoll): void {
this.exportedRolls.push(paymentRoll);
}
}
const testExporter = new TestPaymentExporter();
const paymentManager = new PaymentManager(testExporter);
paymentManager.export(myPaymentRoll);
// Podemos verificar que export foi chamado com os dados corretos
expect(testExporter.exportedRolls).toHaveLength(1);
expect(testExporter.exportedRolls[0]).toBe(myPaymentRoll);
Mas Injeção de Dependência não resolve todos nossos problemas. Nesse caso,
precisamos definir o exporter assim que criamos uma instância de PaymentManager. Mas precisamos
pensar na possibilidade que um mesmo usuário vai querer exportar como CSV e como JSON.
Uma alternativa seria criar um método updateExporter(exporter: PaymentExporter) para mudar a estratégia.
Porém, isso introduz estado mutável, o que complica testes e raciocínio sobre o código.
Aqui podemos lembrar que Injeção de Dependência não é algo exclusivo de classes. Podemos passar um exporter no método:
class PaymentManager {
export(exporter: PaymentExporter, paymentRoll: PaymentRoll) {
exporter.export(paymentRoll)
}
}
Esse padrão funciona muito bem com o padrão de estratégia ("Strategy Pattern"):
function getExporter(format: 'csv' | 'json', prefs: UserPreference): PaymentExporter {
switch (format) {
case 'csv':
return new CSVPaymentExporter(prefs.separator, prefs.dateFormat);
case 'json':
return new JSONPaymentExporter(prefs.jsonIndentation, prefs.dateFormat);
default:
throw new Error("Formato desconhecido");
}
}
// O usuário clica em "Baixar CSV"
const csvStrategy = getExporter('csv', userPreferences);
paymentManager.export(csvStrategy, paymentRoll);
// O mesmo usuário, na mesma sessão, clica em "Baixar JSON"
const jsonStrategy = getExporter('json', userPreferences);
paymentManager.export(jsonStrategy, paymentRoll);
Com essas ideias, agora também é fácil ver uma forma de modificar o comportamento do nosso código baseado no ambiente. Por exemplo, se precisamos de uma API externa para obter algumas informações, podemos começar criando uma abstração com uma interface:
interface TaxInfoService {
getTaxPercentage(salary: number): Promise<number>;
}
// Em produção, chamamos uma API real
class TaxInfoAPI implements TaxInfoService {
async getTaxPercentage(salary: number) {
// fetch(...)
}
}
// Em testes, usamos um valor fixo para evitar chamadas reais
class TaxInfoTestImpl implements TaxInfoService {
async getTaxPercentage() {
return 0.2;
}
}
const taxInfoService = isTesting() ? new TaxInfoTestImpl() : new TaxInfoAPI();
Mas não precisamos ficar presos em escolher qual classe vamos utilizar, podemos também definir qual instância injetar, modificando configurações.
class TaxInfoAPI implements TaxInfoService {
// TTL = Time to Live
// Utilizamos para definir por quanto tempo um valor vai ficar em cache
private cacheTTL: number;
constructor(cacheTTL: number) {
this.cacheTTL = cacheTTL;
}
async getTaxPercentage(salary: number) {
// Verifica cache, verificando se ainda é valido baseado no this.cacheTTL
// fetch(...)
}
}
const taxInfoServiceTest = new TaxInfoTestImpl()
// Cache mais agressivo em dev, mais conservador em produção
const taxInfoServiceLive = isDev() ? new TaxInfoAPI(36000) : new TaxInfoAPI(300);
Outra vantagem: ao depender de abstrações, você pode desenvolver serviços sem implementar cada detalhe.
Se você já tem TaxInfoService, pode continuar desenvolvendo sem se preocupar ainda como ele vai ser implementado.
O interessante é que você consegue fazer um desenvolvimento "de dentro pra fora". Fica fácil seguir padrões como Arquitetura Hexagonal/Clean Architecture, onde camadas externas dependem somente de camadas internas, mas nunca o contrário.
// Código de domínio puro. Esse código só vai mudar se o próprio domínio mudar
function getBonusValue(employee: Employee, bonusPolicy: BonusPolicy, bonusDate: Date) {
// Verifica se o funcionário pode receber o bônus
const isEligible = bonusPolicy.isEligible(employee, bonusDate);
if (isEligible) {
return bonusPolicy.calculateBonus(employee);
}
return 0;
}
// Abstrações para políticas de bônus
interface BonusPolicy {
isEligible(employee: Employee, bonusDate: Date): boolean;
calculateBonus(employee: Employee): number;
}
interface PaymentProcessor {
process(employeeId: number, amount: number): Promise<PaymentResult>;
}
class PaymentService {
// Injetamos dependências, sem precisar de banco de dados ou APIs externas
constructor(
private bonusPolicy: BonusPolicy,
private paymentProcessor: PaymentProcessor
) {}
async processPayment(employee: Employee, paymentDate: Date) {
const bonusAmount = getBonusValue(employee, this.bonusPolicy, paymentDate);
const paymentAmount = bonusAmount + employee.baseSalary;
const paymentResult = await this.paymentProcessor.process(employee.id, paymentAmount);
return paymentResult;
}
}
Note que mesmo sem implementar BonusPolicy, já podemos escrever testes.
// Mock simples para testes - sempre retorna 10% do salário
class AlwaysEligibleBonus implements BonusPolicy {
isEligible(): boolean {
return true;
}
calculateBonus(employee: Employee): number {
return employee.baseSalary * 0.1;
}
}
// Mock simples para testes
class NeverEligibleBonus implements BonusPolicy {
isEligible(): boolean {
return false;
}
calculateBonus(): number {
return 0;
}
}
class MockPaymentProcessor implements PaymentProcessor {
async process(employeeId: number, amount: number): Promise<PaymentResult> {
console.log(`Processando pagamento de $${amount} para o funcionário ${employeeId}`);
return { success: true, transactionId: 'mock-123' };
}
}
// Testando nosso design
const bonusPolicy = new AlwaysEligibleBonus();
const paymentProcessor = new MockPaymentProcessor();
const paymentService = new PaymentService(bonusPolicy, paymentProcessor);
const employee = {
id: 1,
name: 'John',
email: 'john@example.com',
baseSalary: 5000,
hireDate: new Date('2020-01-15')
};
paymentService.processPayment(employee, new Date('2025-01-15'));
// Output: Processando pagamento de $5500 para o funcionário 1
Não só isso, agora podemos começar a pensar em questões importantes de design:
- Estamos retornando todas as informações necessárias?
- Devemos registrar por que um funcionário não recebeu bônus?
- Como a política afeta o cálculo?
- Tratamos corretamente falhas no sistema de pagamento?
Podemos escrever e iterar em código de produção real, sem nem precisar saber qual banco de dados vamos usar.
Considerações finais
O uso de Injeção de dependência permite a inversão de controle. Agora o serviço só é responsável por chamar as funções e métodos necessários, sem se preocupar como algo é feito.
Entretanto, tudo na programação tem seus prós e contras, e para Injeção de Dependência não é diferente:
Vantagens:
- Código mais testável e desacoplado
- Fácil trocar implementações sem modificar classes dependentes
- Arquitetura mais limpa e organizada
- Desenvolvimento mais iterativo (abstrações primeiro)
Desvantagens:
- Complexidade aumenta, especialmente com muitas dependências
- Novo desenvolvedor precisa "caçar" implementações concretas
- Abstrações ruins complicam mais que ajudam
- Pode levar a over-engineering em projetos simples
Portanto, eu não usaria esse padrão em projetos pequenos ou em protótipos. Para mim, os cenários em que a Injeção de Dependência traz as maiores vantagens são:
- Código com dependências externas (APIs, bancos de dados)
- Sistemas com múltiplas implementações possíveis
- Projetos que precisam ser facilmente testáveis
- Aplicações de médio a grande porte com múltiplos desenvolvedores