Artigo original: 4 Design Patterns You Should Know for Web Development: Observer, Singleton, Strategy, and Decorator
Você já esteve em uma equipe em que precisou começar um projeto do zero? Este é frequentemente o caso em muitas start-ups e outras pequenas empresas.
Existem tantas linguagens de programação, arquiteturas e outras preocupações diferentes que pode ser difícil descobrir por onde começar. É aí que entram os padrões de projeto.
Um padrão de projeto (do inglês, design pattern) é como um modelo para o seu projeto. Ele usa certas convenções e você pode esperar um tipo específico de comportamento dele. Esses padrões foram compostos de experiências de muitos desenvolvedores. Então, eles são realmente como diferentes conjuntos de melhores práticas.
Você e sua equipe decidem qual conjunto de melhores práticas é o mais útil para o seu projeto. Com base no padrão de projeto escolhido, todos vocês começarão a ter expectativas sobre o que o código deve fazer e sobre qual vocabulário usarão.
Os padrões de projeto de programação podem ser usados em todas as linguagens de programação e para se adequar a qualquer projeto, pois eles fornecem apenas um esboço geral de uma solução.
Existem 23 padrões oficiais no livro Design Patterns - Elements of Reusable Object-Oriented Software, que é considerado um dos livros mais influentes de teoria sobre a programação orientada a objetos e sobre desenvolvimento de software.
Neste artigo, abordarei quatro desses padrões de projeto para dar uma ideia do que são alguns dos padrões e quando você os usaria.
O padrão de projeto Singleton
O padrão singleton permite que uma classe ou objeto tenha apenas uma instância e utilize uma variável global para armazenar essa instância. Você pode usar o carregamento lento (do inglês, lazy loading) para garantir que haja apenas uma instância da classe, pois ela só será criada quando você precisar.
Isso impede que múltiplas instâncias estejam ativas ao mesmo tempo, o que poderia causar bugs estranhos. Na maioria das vezes, isso é implementado no construtor. O objetivo do padrão singleton é, geralmente, regular o estado global de uma aplicação.
Um exemplo de singleton que você provavelmente usa o tempo todo é o seu log. Se você trabalha com alguns dos frameworks de front-end, como React ou Angular, sabe tudo sobre como pode ser difícil lidar com logs vindos de múltiplos componentes. Este é um ótimo exemplo de singletons em ação, pois você nunca quer mais de uma instância de um objeto de log, especialmente se estiver usando algum tipo de ferramenta de rastreamento de erros.
class FoodLogger {
constructor() {
this.foodLog = []
}
log(order) {
this.foodLog.push(order.foodItem)
// faça um código elegante para enviar esse log para algum lugar
}
}
// este é o singleton
class FoodLoggerSingleton {
constructor() {
if (!FoodLoggerSingleton.instance) {
FoodLoggerSingleton.instance = new FoodLogger()
}
}
getFoodLoggerInstance() {
return FoodLoggerSingleton.instance
}
}
module.exports = FoodLoggerSingleton
Agora, você não precisa se preocupar em perder logs de múltiplas instâncias, pois você só tem uma em seu projeto. Então, quando você quiser registrar o alimento que foi encomendado, pode usar a mesma instância de FoodLogger em múltiplos arquivos ou componentes.
const FoodLogger = require('./FoodLogger')
const foodLogger = new FoodLogger().getFoodLoggerInstance()
class Customer {
constructor(order) {
this.price = order.price
this.food = order.foodItem
foodLogger.log(order)
}
// outras coisas legais acontecendo para o cliente
}
module.exports = Customer
const FoodLogger = require('./FoodLogger')
const foodLogger = new FoodLogger().getFoodLoggerInstance()
class Restaurant {
constructor(inventory) {
this.quantity = inventory.count
this.food = inventory.foodItem
foodLogger.log(inventory)
}
// outras coisas legais acontecendo no restaurante
module.exports = Restaurant
Com esse padrão singleton instalado, você não precisa se preocupar apenas em obter os logs do arquivo principal da aplicação. Você pode obtê-los de qualquer lugar em sua base de código e todos eles vão exatamente para a mesma instância do logger, o que significa que nenhum de seus logs deve ser perdido devido a novas instâncias.
O padrão de projeto Strategy
O padrão Strategy é como uma versão avançada de uma instrução "if else". É basicamente onde você cria uma interface para um método que você tem em sua classe base. Essa interface é usada para encontrar a implementação correta desse método que deve ser usado em uma classe derivada. A implementação, nesse caso, será decidida em tempo de execução com base no client.
O padrão Strategy é incrivelmente útil em situações em que você tem métodos obrigatórios e opcionais para uma classe. Algumas instâncias dessa classe não precisarão dos métodos opcionais e isso causa um problema para soluções de herança. Você poderia usar interfaces para os métodos opcionais, mas então teria que escrever a implementação toda vez que usasse essa classe, já que não haveria uma implementação padrão.
É aí que o padrão Strategy nos salva. Em vez do client procurar por uma implementação, ele delega para uma interface de Strategy e esta encontra a implementação correta. Um uso comum para isso é com sistemas de processamento de pagamentos.
Você poderia ter um carrinho de compras que só permite que os clientes finalizem a compra com cartões de crédito, mas perderia clientes que querem usar outros métodos de pagamento.
O padrão de projeto strategy nos permite desacoplar os métodos de pagamento do processo de finalização da compra, o que significa que podemos adicionar ou atualizar estratégias sem alterar nenhum código no carrinho de compras ou no processo de finalização da compra.
Aqui está um exemplo de uma implementação do padrão Strategy usando o exemplo de método de pagamento.
class PaymentMethodStrategy {
const customerInfoType = {
country: string
emailAddress: string
name: string
accountNumber?: number
address?: string
cardNumber?: number
city?: string
routingNumber?: number
state?: string
}
static BankAccount(customerInfo: customerInfoType) {
const { name, accountNumber, routingNumber } = customerInfo
// faz coisas para receber o pagamento
}
static BitCoin(customerInfo: customerInfoType) {
const { emailAddress, accountNumber } = customerInfo
// faz coisas para receber o pagamento
}
static CreditCard(customerInfo: customerInfoType) {
const { name, cardNumber, emailAddress } = customerInfo
// faz coisas para receber o pagamento
}
static MailIn(customerInfo: customerInfoType) {
const { name, address, city, state, country } = customerInfo
// faz coisas para receber o pagamento
}
static PayPal(customerInfo: customerInfoType) {
const { emailAddress } = customerInfo
// faz coisas para receber o pagamento
}
}
Para implementar nosso método de pagamento de Strategy, criamos uma única classe com vários métodos estáticos. Cada método usa o mesmo parâmetro, customerInfo, e esse parâmetro tem um tipo definido de customerInfoType (viram só, vocês, desenvolvedores que usam TypeScript?). Observe que cada método tem sua própria implementação e usa valores diferentes de customerInfo.
Com o padrão Strategy, você também pode alterar dinamicamente a estratégia que está sendo usada no tempo de execução. Isso significa que você poderá alterar a estratégia ou implementação do método, sendo usado com base na entrada do usuário ou no ambiente em que a aplicação está sendo executada.
Você também pode definir uma implementação padrão em um arquivo config.json simples, como este:
{
"paymentMethod": {
"strategy": "PayPal"
}
}
Sempre que um cliente começar a fazer o check-out em seu site, o método de pagamento padrão que ele encontra é a implementação do PayPal que vem do config.json. Isso pode ser facilmente atualizado se o cliente selecionar um método de pagamento diferente.
Agora, vamos criar um arquivo para o nosso processo de check-out.
const PaymentMethodStrategy = require('./PaymentMethodStrategy')
const config = require('./config')
class Checkout {
constructor(strategy='CreditCard') {
this.strategy = PaymentMethodStrategy[strategy]
}
// faça algum código sofisticado aqui e obtenha a entrada do usuário e o método de pagamento
changeStrategy(newStrategy) {
this.strategy = PaymentMethodStrategy[newStrategy]
}
const userInput = {
name: 'Malcolm',
cardNumber: 3910000034581941,
emailAddress: 'mac@gmailer.com',
country: 'US'
}
const selectedStrategy = 'Bitcoin'
changeStrategy(selectedStrategy)
postPayment(userInput) {
this.strategy(userInput)
}
}
module.exports = new Checkout(config.paymentMethod.strategy)
Essa classe Checkout é onde o padrão Strategy pode ser exibido. Importamos alguns arquivos para termos as estratégias de método de pagamento disponíveis e o padrão Strategy do config.
Em seguida, criamos a classe com o construtor e um valor de fallback para o padrão Strategy caso não haja um definido na configuração. Em seguida, atribuímos o valor de Strategy a uma variável de estado local.
Um método importante que precisamos implementar em nossa classe Checkout é a capacidade de alterar a estratégia de pagamento. Um cliente pode alterar o método de pagamento que deseja usar e você precisa ser capaz de lidar com isso. É para isso que serve o método changeStrategy.
Depois de fazer uma codificação sofisticada e obter todas as entradas de um cliente, você pode atualizar a estratégia de pagamento imediatamente com base na entrada e definir dinamicamente a variável strategy antes que o pagamento seja enviado para processamento.
Em algum momento, você pode precisar adicionar mais métodos de pagamento ao seu carrinho de compras e tudo o que você precisa fazer é adicioná-lo à classe PaymentMethodStrategy. Ele estará disponível instantaneamente em qualquer lugar em que a classe for usada.
O padrão de projeto Strategy é poderoso quando você está lidando com métodos que possuem múltiplas implementações. Pode parecer que você está usando uma interface, mas não precisa escrever uma implementação para o método toda vez que o chama em uma classe diferente. Ele oferece mais flexibilidade do que as interfaces.
O padrão de projeto Observer
Se você já usou o padrão MVC, você já usou o padrão de projeto observer. A parte do Modelo é semelhante ao "observado" e a parte da View é como um observador (do inglês, observer) do modelo. Seu modelo mantém todos os dados e o estado desses dados. Então, você tem os observers, como diferentes componentes, que vão pegar esses dados do observado quando os dados forem atualizados.
O objetivo do padrão de projeto Observer é criar essa relação de um para muitos entre o observado e todos os observers esperando por dados para que possam ser atualizados. Então, sempre que o estado do observado mudar, todos os observers serão notificados e atualizados instantaneamente.
Alguns exemplos de quando você usaria esse padrão incluem: envio de notificações de usuário, atualização, filtros e tratamento de assinantes.
Digamos que você tenha uma aplicação de página única com três listas suspensas de recursos, que dependem da seleção de uma categoria em uma lista suspensa de nível superior. Isso é comum em muitos sites de compras, como o Home Depot. Você tem vários filtros na página que dependem do valor de um filtro de nível superior.
O código para o menu suspenso de nível superior pode ser mais ou menos assim:
class CategoryDropdown {
constructor() {
this.categories = ['appliances', 'doors', 'tools']
this.subscriber = []
}
// finja que há algum código sofisticado aqui
subscribe(observer) {
this.subscriber.push(observer)
}
onChange(selectedCategory) {
this.subscriber.forEach(observer => observer.update(selectedCategory))
}
}
Este arquivo CategoryDropdown é uma classe simples com um construtor que inicializa as opções de categoria disponíveis no menu suspenso. Este é o arquivo que você usaria para recuperar uma lista do back-end ou qualquer tipo de classificação que deseja fazer antes que o usuário veja as opções.
O método subscribe é como cada filtro criado com essa classe receberá atualizações sobre o estado do Observer.
O método onChange é como enviamos uma notificação a todos os assinantes de que uma mudança de estado ocorreu no Observer que eles estão olhando. Apenas percorremos todos os assinantes e chamamos seu método de atualização com a categoria selectedCategory.
O código para os outros filtros pode parecer algo assim:
class FilterDropdown {
constructor(filterType) {
this.filterType = filterType
this.items = []
}
// mais código elegante aqui; talvez faça a chamada da API para obter a lista de itens baseada no filterType
update(category) {
fetch('https://example.com')
.then(res => this.items(res))
}
}
Esse arquivo FilterDropdown é outra classe simples que representa todos os possíveis menus suspensos que podemos usar em uma página. Quando uma nova instância dessa classe é criada, ela precisa receber um filterType. Isso pode ser usado para fazer chamadas de API específicas para obter a lista de itens.
O método update
é uma implementação do que você pode fazer com a nova categoria depois que ela foi enviada pelo Observer.
Agora, veremos o que significa usar esses arquivos com o padrão Observer:
const CategoryDropdown = require('./CategoryDropdown')
const FilterDropdown = require('./FilterDropdown')
const categoryDropdown = new CategoryDropdown()
const colorsDropdown = new FilterDropdown('colors')
const priceDropdown = new FilterDropdown('price')
const brandDropdown = new FilterDropdown('brand')
categoryDropdown.subscribe(colorsDropdown)
categoryDropdown.subscribe(priceDropdown)
categoryDropdown.subscribe(brandDropdown)
O que este arquivo nos mostra é que temos 3 menus suspensos que são assinantes da categoria drop-down observável. Em seguida, assinamos cada um desses drop-downs para o Observer. Sempre que a categoria do Observer for atualizada, ele enviará o valor para cada assinante, o que atualizará as listas de drop-down individuais instantaneamente.
O padrão de projeto Decorator
Usar o padrão de projeto Decorator é bastante simples. Você pode ter uma classe base com métodos e propriedades que estão presentes quando você cria um objeto com a classe. Agora, digamos que você tenha algumas instâncias da classe que precisam de métodos ou propriedades que não vieram da classe base.
Você pode adicionar esses métodos e propriedades extras à classe base, mas isso poderia estragar suas outras instâncias. Você poderia até criar subclasses para conter métodos e propriedades específicos que você precisa e que não pode colocar na sua classe base.
Qualquer uma dessas abordagens resolverá o seu problema, mas são desajeitadas e ineficientes. É aí que entra o padrão Decorator. Em vez de deixar o seu código base feio só para adicionar algumas coisas a uma instância de objeto, você pode colar essas coisas específicas diretamente na instância.
Então, se você precisar adicionar uma nova propriedade que contenha o preço de um objeto, você pode usar o padrão decorator para adicioná-lo diretamente a essa instância de objeto particular e isso não afetará qualquer outra instância desse objeto de classe.
Você já comprou comida on-line? Então, provavelmente, já encontrou o padrão Decorator. Se você estiver pegando um sanduíche e quiser adicionar coberturas especiais, o site não está adicionando essas coberturas a todas as instâncias de sanduíche que os usuários estão tentando pedir.
Aqui está um exemplo de uma classe de cliente:
class Customer {
constructor(balance=20) {
this.balance = balance
this.foodItems = []
}
buy(food) {
if (food.price) < this.balance {
console.log('you should get it')
this.balance -= food.price
this.foodItems.push(food)
}
else {
console.log('maybe you should get something else')
}
}
}
module.exports = Customer
Aqui está um exemplo de uma classe de sanduíche:
class Sandwich {
constructor(type, price) {
this.type = type
this.price = price
}
order() {
console.log(`Você pediu um sanduíche de ${this.type} no valor de $ ${this.price}.`)
}
}
class DeluxeSandwich {
constructor(baseSandwich) {
this.type = `${baseSandwich.type} deluxe`
this.price = baseSandwich.price + 1.75
}
}
class ExquisiteSandwich {
constructor(baseSandwich) {
this.type = `${baseSandwich.type} delicioso`
this.price = baseSandwich.price + 10.75
}
order() {
console.log(`Você pediu um sanduíche ${this.type}. Ele tem o que você precisa para ficar feliz por dias.`)
}
}
module.exports = { Sandwich, DeluxeSandwich, ExquisiteSandwich }
A classe de sanduíche é onde o padrão Decorator é usado. Temos uma classe base Sandwich que estabelece as regras para o que acontece quando um sanduíche regular é encomendado. Os clientes podem querer atualizar os sanduíches e isso significa apenas uma mudança de ingredientes e preço.
Você só queria adicionar a funcionalidade de aumentar o preço e atualizar o tipo de sanduíche para o DeluxeSandwich sem mudar como é encomendado. Embora possa ser necessário um método de pedido diferente para o ExquisiteSandwich devido à mudança drástica na qualidade dos ingredientes.
O padrão decorator permite que você altere dinamicamente a classe base sem afetá-la ou a qualquer outra classe. Você não precisa se preocupar em implementar funções que não sabe, como com interfaces, e não precisa incluir propriedades que não usará em todas as classes.
Agora, vamos passar por um exemplo onde essa classe é instanciada como se um cliente estivesse fazendo um pedido de sanduíche.
const { Sandwich, DeluxeSandwich, ExquisiteSandwich } = require('./Sandwich')
const Customer = require('./Customer')
const cust1 = new Customer(57)
const turkeySandwich = new Sandwich('Peru', 6.49)
const bltSandwich = new Sandwich('Bacon, alface e tomate', 7.55)
const deluxeBltSandwich = new DeluxeSandwich(bltSandwich)
const exquisiteTurkeySandwich = new ExquisiteSandwich(turkeySandwich)
cust1.buy(turkeySandwich)
cust1.buy(bltSandwich)
Considerações finais
Eu costumava achar que os padrões de projeto eram essas orientações de desenvolvimento de software loucas e distantes. Mais tarde, descobri que os utilizo o tempo todo!
Alguns dos padrões que abordei são usados em tantas aplicações que fariam sua cabeça girar. Eles são apenas teoria no fim das contas. Cabe a nós, desenvolvedores, usar essa teoria de maneiras que tornem nossas aplicações fáceis de implementar e de manter.
Você já usou algum dos outros padrões de projeto em seus projetos? A maioria dos lugares geralmente escolhe um padrão de projeto e adere a ele. Então, gostaria de ouvir de vocês sobre os padrões que vocês usam.
Obrigado pela leitura.
Siga a autora no Twitter, onde ela publica sobre coisas úteis e/ou divertidas.