Design Patterns: Factory - Primeiros passos com Typescript
O que são Design Patterns?
Olá devs. Hoje iremos começar a falar sobre design patterns, ou no português claro, padrões de projeto para desenvolvimento de software. No dia-a-dia da pessoa desenvolvedora, enfrentamos diversos problemas de refatoração, necessidade de reaproveitar partes do nosso código, escrever um código limpo e legível ou aumentar a performance do código para que ele consiga ser escalável.
Todos esses problemas foram compartilhados pela comunidade por muito tempo e o resultado para solucioná-los proposto pela comunidade de grandes engenheiros de software foi uma série de paradigmas para arquitetar um código.
O resultado de trabalhos assim que geraram a orientação a objetos, os 04 pilares da orientação a objetos, os paradigmas do SOLID e o design patterns.
O design patterns, ou padrões de projeto, são um conjunto de padrões de desenvolvimento de software que visa resolver um problema e melhorar a qualidade do seu código. Eles miram a criação de uma arquitetura de software que garanta resolver da melhor maneira problemas de escalabilidade, manutenibilidade e clareza do seu software.
Lembre-se, por ser padrões, eles não são blocos de código para se colocar no seu projeto mas formas de melhor escrever e planejar a arquitetura do seu código. Portanto, não se limitam a uma linguagem apenas mas a varias. Iremos fazer a prática com Typescript mas você pode ficar a vontade para implementar na linguagem de sua escolha.
Padrão Factory
O padrão Factory é um dos padrões criacionais (Factory, Abstract Factory, Builder, Prototype e Singleton), ou seja, um padrão de projeto relacionado à criação de objetos que propõe o desenvolvimento de um método fábrica responsável pela instância dos objetos de uma classe.
Com a implementação do padrão factory, seu código não irá instanciar mais nenhum objeto de classe relacionadas a domínios ou regras de negócio. O seu código irá chamar esse método fábrica que irá instanciar o objeto e devolvê-lo para você, ou seja, objetos de classes não serão criados mais com um new
. Esses objetos criados e devolvidos pelos métodos fábrica são chamados de produtos.
Mas qual a vantagem de aplicar o padrão factory?
Primeiramente, você desacopla a instância do objeto do resto do seu código. Isso quer dizer que a regra de negócio que precisa de um objeto (produto) não precisa saber das regras do mesmo.
A segunda vantagem é que você, ao criar um método fábrica, tem acesso a todos os novos objetos criados. Se você precisa adicionar uma funcionalidade toda vez que um objeto é instanciado, você precisa apenas modificar o seu método fábrica e em todos os lugares que necessitam de um produto novo terão a funcionalidade aplicada. Sem esse padrão, você teria que procurar no seu código toda vez que o new de uma classe é chamado.
Nesse momento, você pode pensar: mas era só adicionar a funcionalidade que eu quero no construtor da classe. O problema é que você pode ferir 03 paradigmas do SOLID de uma só vez (S, O e D). Vamos a um exemplo para ilustrar melhor:
Digamos que você tenha um software responsável por uma loja de automóveis e nele existe uma classe responsável pela entidade Car
. Essa classe está cheia de regras de negócio relacionadas aos cálculos dos impostos e documentos de um carro.
Então temos a seguinte modificação: 'Toda vez que um carro novo for instanciado, devemos registrar em um log a adição deste carro'. Aqui temos que criar uma classe Log
responsável por gravar esses registros, sejam eles em arquivo ou utilizando um banco de dados.
Se chamarmos uma instância da classe Log
dentro do construtor da classe Car
, estamos ferindo o princípio de responsabilidade única, já que a classe Car
tem que ser responsável apenas pelos carros.
Deixamos de aplicar o princípio aberto/fechado também já que uma classe está aberta a adição de regras mas fechada para modificações, ainda mais uma modificação em um construtor.
Por último, não aplicamos o princípio de inversão de dependência, já que a classe Car
não pode ser reutilizada devido ao alto acoplamento com a classe Log
. Imagine que você deseja reaproveitar todas as regras de negócios dos carros em outro sistema. Além da classe Car
, você teria de levar também a classe Log
.
A terceira vantagem é que você pode não criar um objeto novo com o new
no método fábrica. Dependendo da aplicação, podemos retornar um objeto previamente criado ao invés de criar um novo. Neste caso, usamos um outro padrão de projeto criacional chamado Singleton
Iniciando a Prática
Para nossa prática, vamos criar um software de uma empresa aérea e a nossa primeira classe é a classe Airplane
. Por enquanto, não teremos métodos complexos nem muitos atributos. Apenas os atributos prefix
, manufacturer
e aircraft
para guardar de modo privado o prefixo, fabricante e modelo de uma aeronave.
Nos métodos, teremos os getters
apenas e todos os atributos serão atribuídos no construtor da classe Airplane
. Nossa classe Airplane
implementará uma interface com o nome IAirplane
.
interface IAirplane {
prefix: string;
manufacturer: string;
aircraft: string;
}
class Airplane implements IAirplane {
constructor(private _prefix: string,
private _manufacturer: string,
private _aircraft: string) {}
get prefix(): string {
return this._prefix
}
get manufacturer(): string {
return this._manufacturer;
}
get aircraft(): string {
return this._aircraft;
}
}
A partir desse momento, toda vez que precisássemos de uma instância da classe Airplane
iríamos simplesmente chamar o método new
com o código:
const embraerE195 = new Airplane('PR-ABC','Embraer','E195');
Entretanto iremos aplicar o padrão de projeto Factory. Sua implementação será através de uma nova classe chamada AirplaneFactory
. Essa classe terá o método fábrica chamado create
que irá gerar um novo produto do tipo Airplane
. Portanto, no nosso código, a classe Factory será a única que poderemos instanciar com o método new
.
Existem algumas vertentes que sugerem que tanto a classe Factory quanto o método Factory sejam estáticos. Dessa forma, o método new
seria, de fato, chamado apenas dentro dos métodos factory. Entretanto essa é uma forma equivocada de se construir a factory já que a classe estática impede que a mesma seja estendida. Veremos mais adiante sobre a possibilidade de criar produtos abstratos e concretos.
Com isso, teremos no nosso código a classe e método factory
class AirplaneFactory {
public create (prefix: string, manufacturer: string, aircraft: string): Airplane {
return new Airplane(prefix, manufacturer, aircraft);
}
};
e na utilização, teremos
const airplaneFactory = new AirplaneFactory();
const embraerE195 = airplaneFactory.create('PR-ABC','Embraer','E195');
Note que sempre o método fábrica deve ter um produto do mesmo tipo da classe da regra de negócio, mesmo que seja um produto abstrato.
Aumentando a complexidade: Classes concretas e abstratas.
Iremos aumentar a complexidade do nosso exemplo. Teremos que na nossa regra de negócio, o avião não é uma entidade fechada mas que pode ser estendida para aviões de passageiros e aviões de carga. Também iremos definir que a classe Avião será abstrata, ou seja, não poderemos instanciar um objeto avião, apenas Avião de passageiros ou avião de carga.
No nosso novo diagrama, temos as classes PassengerAirplane
e CargoAirplane
, cada um com um atributo a mais em relação à classe abstrata Airplane
. Também definimos as interfaces estendidas IPassengerAirplane
e ICargoAirplane
.
Iremos então modificar a classe Airplane para que seja abstrata
abstract class Airplane implements IAirplane {
constructor(private _prefix: string,
private _manufacturer: string,
private _aircraft: string) {}
get prefix(): string {
return this._prefix
}
get manufacturer(): string {
return this._manufacturer;
}
get aircraft(): string {
return this._aircraft;
}
}
Então iremos criar as classes concretas
interface IPassengerAirplane extends IAirplane {
passengerCapacity: number;
buyTicket(): void;
}
class PassengerAirplane extends Airplane implements IPassengerAirplane {
constructor(prefix: string, manufacturer: string, aircraft: string, private _passengerCapacity: number) {
super(prefix, manufacturer, aircraft);
}
get prefix(): string {
return super.prefix
}
get manufacturer(): string {
return super.manufacturer;
}
get aircraft(): string {
return super.aircraft;
}
get passengerCapacity(): number {
return this._passengerCapacity;
}
public buyTicket(): void {
console.log(`New ticket emitted to ${this.manufacturer} ${this.aircraft} - Prefix: ${this.prefix}`);
}
}
interface ICargoAirplane extends IAirplane {
payload: number;
loadCargo(weight: number)
}
class CargoAirplane extends Airplane implements ICargoAirplane {
constructor(prefix: string, manufacturer: string, aircraft: string, private _payload: number) {
super(prefix, manufacturer, aircraft);
}
get prefix(): string {
return super.prefix
}
get manufacturer(): string {
return super.manufacturer;
}
get aircraft(): string {
return super.aircraft;
}
get payload(): number {
return this._payload;
}
public loadCargo(weight: number){
console.log(`${weight} loaded to ${this.manufacturer} ${this.aircraft} - Prefix: ${this.prefix}`);
}
}
Por fim, devemos alterar a nossa classe e método fábrica. Nesse momento, como temos uma fábrica produzindo um produto do tipo Airplane
, devemos manter os produtos concretos respeitando esse tipo e se temos dois produtos possíveis (avião de passageiros e avião de carga), teremos duas fábricas concretas respeitando uma fábrica abstrata.
A fábrica abstrata deixa de ter a implementação do método fábrica mas apenas a definição de um método abstrato.
As fábricas concretas estendem a fábrica abstrata e implementam os métodos fábrica concretos de acordo com suas especificidades.
Note pelo diagrama de classe que tanto as classes Airplane
como AirplaneFactory
são classes abstratas. Dessa maneira, as fábricas PassengerAirplaneFactory
e CargoAirplaneFactory
se tornam as fábricas concretas que geram os produtos concretos PassengerAirplane
e CargoAirplane
.
As fábricas concretas implementam o método create
que agora é um método abstrato na AirplaneFactory
. Apesar dos produtos serem diferentes, os produtos respeitam a interface IAirplane
. Isso demonstra o fato de não criarmos o método fábrica como estático.
Vamos às implementações
abstract class AirplaneFactory {
public abstract create (prefix: string,
manufacturer: string,
aircraft: string,
payload: number,
passengerCapacity: number): Airplane
};
class PassengerAirplaneFactory extends AirplaneFactory {
public create (prefix: string,
manufacturer: string,
aircraft: string,
passengerCapacity: number): PassengerAirplane {
return new PassengerAirplane(prefix,
manufacturer,
aircraft,
passengerCapacity);
}
};
class CargoAirplaneFactory extends AirplaneFactory {
public create (prefix: string,
manufacturer: string,
aircraft: string,
payload: number): CargoAirplane {
return new CargoAirplane(prefix,
manufacturer,
aircraft,
payload);
}
};
Por fim, vamos implementar as nossas classes fábricas concretas
const passengerAirplaneFactory = new
PassengerAirplaneFactory();
const cargoAirplaneFactory = new
CargoAirplaneFactory();
const E195 = passengerAirplaneFactory
.create('PR-ABC',
'Embraer',
'E195',
118);
const KC390 = cargoAirplaneFactory
.create('PR-DEF',
'Boeing',
'B747',
137);
E195.buyTicket();
KC390.loadCargo(100);
Note que no exemplo, as únicas instâncias com o método new
são das nossas classes factory. Criamos dois objetos, um chamado E195
criado como um avião de passageiros com a fábrica concreta PassengerAirplaneFactory
e outro objeto KC390
criado como um avião de carga com a fábrica concreta CargoAirplaneFactory
.
Temos um exemplo chamando os métodos buyTicket
e loadCargo
para cada um desses objetos apesar de ambos serem do tipo Airplane
.
Podemos nos questionar sobre o uso da classe AirplaneFactory
. Se não podemos instanciar ela, não seria mais simples implementar apenas as fábricas concretas? A resposta é não, pois assim, perdemos a herança já que os dois produtos são produtos concretos de uma classe abstrata Airplane
. Caso precisássemos adicionar uma classe para controlar aviões comerciais, todos eles devem respeitar a interface IAirplane
e devem ser produtos do tipo Airplane
.
Além disso, se você se questionou se podemos criar uma classe Factory concreta que tenha a capacidade de criar ambos os tipos de produto, a resposta é sim.
Nesse caso, temos o próximo padrão de projeto que é o Abstract Factory ou fábrica abstrata. Não trataremos dele aqui mas nesse padrão, a fábrica abstrata fica responsável por criar famílias de produtos concretos sem a necessidade de especificar a classe concreta. Mas lembre-se, que o AirplaneFactory
do nosso exemplo não é um abstract factory. Desse modo, tornar uma classe fábrica abstrata não se enquadra no padrão de projeto Abstract factory.
Testando o Factory
Podemos fazer testes do nosso Factory para conferir se os produtos estão respeitando as instâncias das classes de domínio, especialmente nos casos de classes concretas e abstratas que devem implementar uma mesma interface.
Vamos realizar um teste em Jest
mas você pode utilizar a biblioteca de testes da sua escolha e de acordo com a linguagem de programação que está utilizando.
Primeiro irei testar o PassengerAirplaneFactory
. Como em todos os it
eu irei usar uma instância nova do factory, irei colocar no beforeEach
.
let passengerAirplaneFactory;
beforeEach(() => {
passengerAirplaneFactory = new
PassengerAirplaneFactory();
});
Depois irei testar cinco pontos:
- Se a
PassengerAirplaneFactory
é uma instância dela mesma. - Se a
PassengerAirplaneFactory
é uma instância daAirplaneFactory
. - Se a
PassengerAirplaneFactory
cria um produto do tipoAirplane
e do tipoPassengerAirplaneFactory
. - E se a
PassengerAirplaneFactory
cria um produto que não é do tipoCargoAirplaneFactory
.
Com todos esses testes, o código para testar a PassengerAirplaneFactory ficará da seguinte maneira:
describe('Passenger airplane factory', () => {
let passengerAirplaneFactory;
beforeEach(() => {
passengerAirplaneFactory = new
PassengerAirplaneFactory();
});
it('is a instance of Airplane factory', () => {
expect(passengerAirplaneFactory)
.toBeInstanceOf(AirplaneFactory);
});
it('is a instance of Passenger airplane factory', () => {
expect(passengerAirplaneFactory)
.toBeInstanceOf(PassengerAirplaneFactory);
});
it('creates a airplane and passenger
airplane product', () => {
const E195 = passengerAirplaneFactory
.create('PR-ABC',
'Embraer',
'E195',
118);
expect(E195).toBeInstanceOf(Airplane);
expect(E195).toBeInstanceOf(PassengerAirplane);
});
it('does not create a cargo airplane product', () => {
const E195 = passengerAirplaneFactory
.create('PR-ABC',
'Embraer',
'E195',
118);
expect(E195).not.toBeInstanceOf(CargoAirplane);
});
});
Para os testes do CargoAirplaneFactory
, temos as mesmas condições do caso do PassengerAirplaneFactory
mas testando se ele gera produtos de CargoAirplane
e Airplane
e não de PassengerAirplane
.
describe('Cargo airplane factory', () => {
let cargoAirplaneFactory;
beforeEach(() => {
cargoAirplaneFactory = new CargoAirplaneFactory();
});
it('is a instance of Airplane factory', () => {
expect(cargoAirplaneFactory)
.toBeInstanceOf(AirplaneFactory);
});
it('is a instance of Cargo airplane factory', () => {
expect(cargoAirplaneFactory)
.toBeInstanceOf(CargoAirplaneFactory);
});
it('creates a airplane and cargo airplane product', () =>
{
const B747 = cargoAirplaneFactory
.create('PR-DEF', 'Boeing', 'B747', 137);
expect(B747).toBeInstanceOf(Airplane);
expect(B747).toBeInstanceOf(CargoAirplane);
});
it('does not create a passenger airplane product', () =>
{
const B747 = cargoAirplaneFactory
.create('PR-DEF', 'Boeing', 'B747', 137);
expect(B747).not.toBeInstanceOf(PassengerAirplane);
});
});
Por último e não menos importante, vamos verificar o resultado do nosso teste.
Finalizando, esse foi o padrão Factory
. Espero que essa breve introdução tenha ajudado na compreensão do início dos Design Patterns e te estimule a continuar aplicando tanto esse padrão quanto os outros. Bons estudos.
Danilo Silva
Desenvolvedor de software experiente em boas práticas, clean code e no desenvolvimento de software embarcado e de integração com hardwares de controle e telecomunicação.