O que é Inversão de Dependência?
Motivação:
- Qual é o problema que a Inversão de Dependência resolve?
Conforme as linguagens de programação foram evoluindo, foram surgindo diversas formas de escrever e organizar código. Com isso, algumas dessas práticas se tornaram guidelines e até "regras" quando se trata de criar um código de alta qualidade, escalável e de fácil manutenção.
Algumas dessas práticas se tornaram bem conhecidas, e até foram condensadas em uma sigla: SOLID. Essa sigla é composta por cinco princípios que, quando aplicados corretamente, podem ajudar a criar um código mais limpo e sustentável. Neste artigo, vamos falar sobre o último princípio da sigla: Dependency inversion principle (Princípio da Inversão de dependência).
Contexto é sempre importante:
Nesse artigo, irei utilizar as seguintes tecnologias: TypeScript como linguagem de programação e a biblioteca Jest para testes unitários. Porém, o conteúdo aqui apresentado é independente de tecnologia específica, e pode ser aplicado em qualquer linguagem. O que importa é o conceito e como ele pode ser aplicado.
Desenvolvimento:
O que é dependência?
Isso é bastante simples: uma dependência é algo que é necessário para que outra coisa funcione. Uma parte que o código depende para ser executado corretamente. Observe o exemplo abaixo:
class User {
private readonly age: number;
constructor(age: number) {
this.age = age;
}
public isMinor(): boolean {
return this.age < 18;
}
}
Aqui podemos ver que a função isMinor
depende da propriedade age
para funcionar corretamente.
"Mas ter dependências é algo ruim?"
Claro que não! Nosso código é repleto de dependências. O que precisamos entender é como controlá-las.
Adicionando complexidade:
class Coupon {
private readonly expirationDate: Date;
constructor(expirationDate: Date) {
this.expirationDate = expirationDate;
}
public isExpired(): boolean {
const today = new Date();
return this.expirationDate.getTime() <= today.getTime();
}
}
Nesse exemplo temos uma classe Coupon
, e temos um método isExpired
que verifica se o cupom está expirado. Para isso, ele compara a data de hoje com a data de expiração do cupom. Se a data de hoje for maior ou igual à data de expiração, o cupom está expirado. (A função getTime
retorna o número de milissegundos).
Vamos testar essa classe:
describe("Coupon", () => {
it("should be expired if current date is after expiration date", () => {
const coupon = new Coupon(new Date("01/01/2023"));
const isExpired = coupon.isExpired();
expect(isExpired).toBe(true);
});
it("should not be expired if current date is before expiration date", () => {
const coupon = new Coupon(new Date("01/12/2023"));
const isExpired = coupon.isExpired();
expect(isExpired).toBe(false);
});
});
Rodamos os testes e eles passam! Viva os testes automatizados! :partying_face:
Mas logo que pensamos um pouco mais sobre nossa implementação, podemos ver que ela é um pouco frágil. O mês de publicação desse artigo é março de 2023, e a data de expiração em nosso segundo teste é dia 01 de dezembro de 2023. O que irá acontecer com ele quando chegarmos no dia 02 de dezembro de 2023? Ele irá falhar, pois a data atual será maior que a data de expiração.
E o que isso quer dizer? Que o nosso teste tem prazo de validade. Pode não parecer, mas por causa de uma simples dependência, nosso teste vai falhar no futuro. Isso é um problema, pois isso mostra que estamos dependendo de algo que muda com o tempo, e isso é potencialmente perigoso.
Mas do que estamos dependendo? Da data atual. Em nossa função isExpired
, quando instanciamos a classe Date
estamos acoplando nosso código à ela. E isso é um problema, pois se a classe Date
mudar, nosso código também vai mudar. Como irá acontecer no dia 02 de dezembro de 2023.
E como podemos resolver isso? Com Inversão de Dependência. Veja-mos:
class Coupon {
private readonly expirationDate: Date;
constructor(expirationDate: Date) {
this.expirationDate = expirationDate;
}
public isExpired(today: Date): boolean {
return this.expirationDate.getTime() <= today.getTime();
}
}
Pelo simples fato de estarmos recebendo a data atual como parâmetro, estamos jogando a responsabilidade de pegar essa informação para fora do método. Ou seja: quem chamar esse método que deve fornecer essa informação.
Como ficariam nossos testes nesse cenário:
describe("Coupon", () => {
it("should be expired if current date is after expiration date", () => {
const expirationDate = new Date("01/01/2023");
const today = new Date("01/02/2023");
const coupon = new Coupon(expirationDate);
const isExpired = coupon.isExpired(today);
expect(isExpired).toBe(true);
});
it("should not be expired if current date is before expiration date", () => {
const expirationDate = new Date("01/12/2023");
const today = new Date("01/11/2023");
const coupon = new Coupon(expirationDate);
const isExpired = coupon.isExpired(today);
expect(isExpired).toBe(false);
});
});
Agora, o nosso método ainda depende da data atual, mas o teste tem o poder de fornecer isso. E isso é muito bom, pois agora nosso teste não tem mais prazo de validade. Ele vai funcionar para sempre, independente de quando for executado.
É isso que a inversão de dependência prega: Não dependa de implementações, dependa de abstrações. Na primeira versão de nossa classe Coupon
, estávamos dependendo diretamente da classe Date
(uma implementação new Date()
). Depois de refatorada, estamos dependendo de um parâmetro do tipo Date
(uma abstração, pois não estamos instanciando nada, apenas recebendo algo de fora). Na maioria dos casos, depender de uma implementação significa instanciar uma classe, ou chamar um método de uma biblioteca diretamente dentro do seu código. O nosso objetivo é, de alguma forma, jogar a responsabilidade de instanciar ou chamar esses códigos de terceiros para fora do nosso, recebendo uma implementação de uma abstração.
Ainda mais complexidade:
Agora, vamos supor que estamos fazendo uma integração da nossa aplicação com uma API externa, onde vamos consultar nossos cupons.
class Coupon {
private readonly id: string;
private readonly expirationDate: Date;
constructor(id: string, expirationDate: Date) {
this.id = id;
this.expirationDate = expirationDate;
}
public getId(): string {
return this.id;
}
public isExpired(today: Date): boolean {
return this.expirationDate.getTime() <= today.getTime();
}
}
Vamos criar um classe de serviço que consulta se o cupom está expirado pelo ID:
class CouponService {
public async isExpiredById(id: string): Promise<boolean> {
const response = await axios.get(`http://localhost:3000/coupons/${id}`);
const coupon = new Coupon(
response.data.id,
new Date(response.data.expirationDate)
);
return coupon.isExpired(new Date());
}
}
Mais uma vez, vamos testar essa classe:
describe("CouponService", () => {
it("should be expired if current date is after expiration date", async () => {
const sut = new CouponService();
const isExpired = await sut.isExpiredById("1");
expect(isExpired).toBe(true);
});
});
E mais uma vez caímos no mesmo problema, nosso código depende de duas implementações concretas: axios
e Date
. Mas dessa vez, vamos resolver de uma forma um pouco diferente.
Primeiramente, vamos criar uma abstração (interface) que irá servir como ferramenta para que possamos pegar um cupom pelo ID:
interface CouponGateway {
getById(id: string): Promise<Coupon>;
}
Um gateway é uma nomenclatura usada para isolar uma parte de nosso código que se comunica com um sistema externo. Nesse caso, nosso gateway será responsável por fazer uma comunicação com uma API externa, através do axios
.
Agora, vamos criar uma implementação concreta dessa interface:
class CouponHttpGateway implements CouponGateway {
public async getById(id: string): Promise<Coupon> {
const response = await axios.get(`http://localhost:3000/coupons/${id}`);
return new Coupon(
response.data.id,
new Date(response.data.expirationDate)
);
}
}
Perceba a nomenclatura que usamos: Para uma abstração usamos CouponGateway
, propositalmente um nome mais genérico que não diz muito sobre como esse gateway faz o que precisa. Já a implementação é mais especifica: CouponHttpGateway
, pois ela faz uma requisição HTTP para pegar o cupom.
Refatorando nossa classe de serviço:
class CouponService {
private readonly couponGateway: CouponGateway;
constructor(couponGateway: CouponGateway) {
this.couponGateway = couponGateway;
}
public async isExpiredById(id: string): Promise<boolean> {
const coupon = await this.couponGateway.getById(id);
return coupon.isExpired(new Date());
}
}
Refatorando nosso teste:
class CouponGatewayStub implements CouponGateway {
public async getById(id: string): Promise<Coupon> {
return new Coupon("1", new Date("01/01/2023"));
}
}
describe("CouponService", () => {
it("should be expired if current date is after expiration date", async () => {
const couponGateway = new CouponGatewayStub();
const sut = new CouponService(couponGateway);
const isExpired = await sut.isExpiredById("1");
expect(isExpired).toBe(true);
});
});
O que fizemos aqui:
- Criamos uma abstração para o nosso gateway, que é a interface
CouponGateway
; - Em nossa classe de serviço, onde o gateway será usado, recebemos uma instância de
CouponGateway
como parâmetro via construtor; - Jogamos a responsabilidade de fazer a requisição HTTP para fora do nosso código de serviço;
- Isolamos a parte que faz a requisição HTTP em outra classe que implementa a interface
CouponGateway
; - Usamos mais uma instância da interface
CouponGateway
para fazer um teste unitário;
Perceba também que criamos duas classes que implementam a interface CouponGateway
: CouponHttpGateway
e CouponGatewayStub
. As duas são implementações reais, a diferença é que uma faz uma requisição HTTP, e a outra não. Essa nossa capacidade de dar múltiplas formas para a interface CouponGateway
ser implementada é o que chamamos de polimorfismo, um dos pilares da programação orientada a objetos.
Mas ainda não acabamos. Vamos retirar a dependência da classe Date
também:
interface Clock {
now(): Date;
}
Como estamos lidando com datas, talvez o nome Relógio
possa ser apropriado.
Implementando a interface Clock
:
class DateClock implements Clock {
public now(): Date {
return new Date();
}
}
Refatorando mais uma vez nossa classe de serviço:
class CouponService {
private readonly couponGateway: CouponGateway;
private readonly clock: Clock;
constructor(couponGateway: CouponGateway, clock: Clock) {
this.couponGateway = couponGateway;
this.clock = clock;
}
public async isExpiredById(id: string): Promise<boolean> {
const coupon = await this.couponGateway.getById(id);
const today = this.clock.now();
return coupon.isExpired(today);
}
}
E mais uma vez, refatorando nosso teste:
class ClockStub implements Clock {
public now(): Date {
return new Date("02/01/2023");
}
}
class CouponGatewayStub implements CouponGateway {
public async getById(id: string): Promise<Coupon> {
return new Coupon("1", new Date("01/01/2023"));
}
}
describe("CouponService", () => {
it("should be expired if current date is after expiration date", async () => {
const couponGateway = new CouponGatewayStub();
const clock = new ClockStub();
const sut = new CouponService(couponGateway, clock);
const isExpired = await sut.isExpiredById("1");
expect(isExpired).toBe(true);
});
});
Também estamos utilizando o polimorfismo para criar duas implementações diferentes para a interface Clock
: DateClock
e ClockStub
. Na segunda, estamos sempre retornando a mesma data, o que nos tira daquele problema dos testes com prazo de validade que já falamos.
E olha que massa: o que estamos testando é apenas a classe CouponService
, e não precisamos nos preocupar com a implementação de CouponGateway
e Clock
. O que está totalmente certo, pois não deveria ser responsabilidade do serviço de coupons bater em uma API externa, ou saber como pegar a data atual.
Mas você pode me perguntar:
"Mas e se eu quiser criar um teste que realmente bata na API e pegue a data atual? Como eu faço?"
Mais simples impossível, basta usar passar as instâncias que criamos: CouponHttpGateway
e DateClock
:
describe("CouponService", () => {
it("should be expired if current date is after expiration date", async () => {
const couponGateway = new CouponHttpGateway();
const clock = new DateClock();
const sut = new CouponService(couponGateway, clock);
const isExpired = await sut.isExpiredById("1");
expect(isExpired).toBe(true);
});
});
Mais uma vez reforço: Quando criamos a instância da classe CouponService
, estamos passando duas instâncias de classes concretas no construtor desse serviço. Essa atitude de usar interfaces para criar abstrações e implementar classes concretas, passando-as como parâmetros para outras classes, chamamos de composição. E como discutimos antes, podemos ter várias implementações diferentes para as interfaces que criamos (polimorfismo).
OBS: Também é nesse caso que temos na prática um exemplo de Liskov Substitution Principle
(Princípio da substituição de Liskov). Mas isso é papo para outro artigo.
Aqui deixo um vídeo bem legal do professor Otavio Lemos sobre o assunto:
Mas aí vem outra pergunta bastante interessante:
"Por que no primeiro exemplo, da classe
Coupon
, recebemos a data de expiração como parâmetro da função, e no segundo exemplo, da classeCouponService
, recebemos oCouponGateway
e oClock
como parâmetros de construtor?"
A resposta para essa pergunta tem duas partes, vamos lá:
- Lidando com tipos de dados diferentes:
No primeiro exemplo, estávamos querendo inverter a dependência de um tipo de dado, que era a classe Date
. No segundo exemplo, estávamos querendo isolar uma chamada HTTP e o tipo de dado da classe Date, por isso usamos interfaces e classes, era um caso mais complexo.
- Lidando com camadas diferentes:
Seguindo o framework da Clean Architecture
, a nossa classe Coupon
faz parte da camada de Entidades (camada amarela no centro), lá lidamos apenas com regras de negócio. Nem fazemos referências à gateways
ou serviços externos, apenas recebemos dados e lidamos com eles.
Por outro lado, quando estamos na classe CouponService
, estamos em outra camada de nossa aplicação, a camada de Casos de Uso (camada vermelha). Nela, adicionamos as ferramentas necessárias para criar e rodar as regras de negócio que existem em nosso domínio. É só reparar que em nenhum momento na classe CouponService
temos um código que define se o cupom está expirado ou não, apenas criamos o cupom e damos os dados necessários para que ele execute suas próprias regras. Essa separação de responsabilidades (Single Responsibility Principle) é extremamente importante para que possamos aplicar o DIP de maneira correta.
Na verdade, todos os 5 princípios do SOLID estão intrinsecamente ligados, é muito difícil ter um e não ter os outros.
Conclusão:
Ter dependências é algo essencial em nossos programas, e ter a capacidades de controlá-los é mais ainda. Para ajudá-lo no entendimento desses assuntos, aqui estão alguns vídeos bem legais sobre isso:
Espero que esse artigo tenha sigo útil para você, e que tenha aprendido algo novo. Se você acha que algo ficou faltando, ou mal explicado, ou se você tem alguma dúvida, não deixe de comentar.
Para me alcançar, você pode me encontrar no linkedIn.
Valeu!
Muito bom
Fera demais
Boa postagem.
Pode interessar uma resposta no Stack Overflow à uma pergunta que eu fiz. E mais um conteúdo que pode ajudar. E exemplos específicos para C#. Mais uma fonte complementar. Outras perguntas intressantes foram feitas, mas sem resposta qualitativa para postar aqui.
Farei algo que muitos pedem para aprender programar corretamente, gratuitamente. Para saber quando, me segue nas suas plataformas preferidas. Quase não as uso, não terá infindas notificações (links aqui).