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:
// We create an interface that defines the shape of the EmailService
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) {
// Real implementation that calls SendGrid API
}
}
class PaymentService {
private emailService: EmailService;
// When we create PaymentService, we need to inject an email service
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!);
// Here the service is "injected"
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;
}
// We can pass the dependency as a parameter
const createUser = (logger: Logger, username: string) => {
const user = { id: 1, username };
// Usage of the dependency
logger.info(`User created: ${username}`);
return user;
};
const consoleLogger = {
info: console.log,
warn: console.warn,
error: console.error,
}
// We inject the logger in the function call
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) {
// Implementation
}
// other methods
}
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:
// Represents an employee
interface Employee {
id: number;
name: string;
email: string;
baseSalary: number;
hireDate: Date;
}
// Represents a payroll
interface PaymentRoll {
employees: Employee[];
period: { start: Date; end: Date };
totalAmount: number;
}
// User preferences for export
interface UserPreference {
separator: string;
dateFormat: string;
jsonIndentation: number;
}
// Result of a payment operation
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) {
// Implementation
}
}
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:
// Correctly uses the user preference
paymentManager.exportToCsv(payment, savedSeparator);
// Elsewhere in the code, we forget to use this preference
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) {
// Implementation
// Here we will use 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) {
// Implementation, we use this.separator and 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 {
// Implementation that builds CSV with this.separator and 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);
// We can easily create another exporter with its own settings
class JSONPaymentExporter implements PaymentExporter {
export(paymentRoll: PaymentRoll): void {
// JSON Implementation
}
}
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);
// We can verify that export was called with the correct data
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: typeof userPreferences): 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("Unknown format");
}
}
// User clicks "Download CSV"
const csvStrategy = getExporter('csv', userPreferences);
paymentManager.export(csvStrategy, paymentRoll);
// The same user, in the same session, clicks "Download 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>;
}
// In production, we call a real API
class TaxInfoAPI implements TaxInfoService {
async getTaxPercentage(salary: number) {
// fetch(...)
}
}
// In tests, we use a fixed value to avoid real calls
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
// Used to define how long a value will stay in cache
private cacheTTL: number;
constructor(cacheTTL: number) {
this.cacheTTL = cacheTTL;
}
async getTaxPercentage(salary: number) {
// Check cache, verifying if it is still valid based on this.cacheTTL
// fetch(...)
}
}
const taxInfoServiceTest = new TaxInfoTestImpl()
// More aggressive cache in dev, more conservative in production
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.
// Domain pure code. This code will likely never change, unless the domain itself changes
function getBonusValue(employee: Employee, bonusPolicy: BonusPolicy, bonusDate: Date) {
// Checks if the employee can receive the bonus
const isEligible = bonusPolicy.isEligible(employee, bonusDate);
if (isEligible) {
return bonusPolicy.calculateBonus(employee);
}
return 0;
}
// Abstractions for bonus policies
interface BonusPolicy {
isEligible(employee: Employee, bonusDate: Date): boolean;
calculateBonus(employee: Employee): number;
}
interface PaymentProcessor {
process(employeeId: number, amount: number): Promise<PaymentResult>;
}
class PaymentService {
// We inject dependencies, without needing a database or external APIs
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.
// Simple mock for tests - always returns 10% of salary
class AlwaysEligibleBonus implements BonusPolicy {
isEligible(): boolean {
return true;
}
calculateBonus(employee: Employee): number {
return employee.baseSalary * 0.1;
}
}
// Simple mock for tests
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(`Processing payment of $${amount} for employee ${employeeId}`);
return { success: true, transactionId: 'mock-123' };
}
}
// Testing our 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: Processing payment of $5500 for employee 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