Resumo teórico
Imagine que você constrói coisas com LEGO. Depois de um tempo, percebe que resolve sempre os mesmos pequenos problemas: como fazer uma parede forte, como montar uma porta que abre, como juntar dois conjuntos que não encaixam. Construtores espertos deram nome às boas respostas, e assim dizem "use o truque da dobradiça" sem explicar peça por peça. Um padrão de projeto (design pattern) é exatamente isso: um nome para uma boa resposta a um problema que volta toda hora quando escrevemos programas.
Antes dos padrões existem cinco regras de bolso chamadas SOLID. Elas não são leis. São conselhos de como escrever classes (uma classeuma planta que diz quais dados um objeto guarda e o que ele sabe fazer é como uma forma de biscoito, e cada biscoito é um objeto) para que o programa seja fácil de mudar depois. Programas sempre mudam: uma regra nova do chefe, uma nova empresa de pagamento, um novo tipo de cliente. Código fácil de mudar economiza dias de trabalho e evita bugs novos.
Você vai encontrar tudo isso na vida real: nos aplicativos que usa, nos frameworks que as pessoas programadoras baixam e em toda entrevista de emprego sobre software. No fim desta página você vai conhecer os cinco princípios SOLID, dez padrões famosos (Factory, Builder, Singleton, Adapter, Decorator, Facade, Strategy, Observer, Command e State), o Repository e a injeção de dependência usados em sistemas back-end, e os sinais de alerta de um projeto ruim. Vai saber também quando não usar um padrão, o que é tão importante quanto.
O quadro geral e o vocabulário
Software nunca está terminado. Sempre alguém pede mais uma coisa. Por isso, um bom projeto não é sobre deixar o código bonito. É sobre fazer a próxima mudança custar pouco: a mudança deve tocar poucos lugares e não pode quebrar o que já funciona.
Para falar de projeto (design) precisamos de algumas palavras. Aprenda agora, porque todas as seções as usam.
| Palavra | O que significa | Imagem do dia a dia |
|---|---|---|
| Coesão | Quanto as partes dentro de uma classe combinam entre si. Coesão alta é bom. | Um estojo que guarda só lápis e canetas |
| Acoplamento | Quanto uma classe depende de outras classes. Acoplamento baixo é bom. | Brinquedos ligados por um barbante fino, não soldados |
| Interface | A lista do que uma classe promete fazer, sem dizer como. | Os botões de um controle remoto |
| Contrato | A promessa completa de um método: o que quem chama deve dar, o que o método devolve e quais erros pode lançar. | Um pedido de pizza: você dá o endereço, recebe a pizza |
| Abstração | Uma visão simples que esconde os detalhes. | Você dirige um carro sem conhecer o motor |
| Padrão | Uma resposta com nome e reutilizável para um problema que aparece sempre. | Uma receita com nome |
Existem três famílias de padrões. Os criacionais tratam da criação de objetos. Os estruturais tratam de como os objetos se encaixam. Os comportamentais tratam de como os objetos conversam e dividem o trabalho. O livro famoso que listou 23 deles foi escrito por quatro autores, apelidados de "Gang of Four" (a gangue dos quatro).
Assista ao vídeo: Design Patterns in Object Oriented Programming (playlist de Christopher Okhravi, em inglês)↑ Voltar ao sumárioPrincípio da responsabilidade única (SRP)
Pense numa mochila que também é lancheira, travesseiro e capacete de bicicleta. Quando uma parte quebra, você não consegue consertar sem mexer nas outras. O princípio da responsabilidade única diz que uma classe deve ter só um motivo para mudar.
"Motivo para mudar" é uma pessoa ou um grupo que pode pedir uma alteração. Veja a classe abaixo. O financeiro pode mudar a regra do imposto, o design pode mudar o texto do recibo e o time de banco de dados pode mudar como os dados são salvos. São três motivos para mudar num só lugar.
class Invoice {
calculateTax(): number { /* regras de imposto */ }
saveToDatabase(): void { /* SQL */ }
sendByEmail(): void { /* SMTP */ }
}A cura é separar: uma classe para o cálculo do imposto, uma para salvar, uma para o e-mail. Agora cada mudança toca uma classe pequena, e os testes da regra de imposto não precisam de banco de dados.
Coesão e dois cheiros que se espelham
Uma classe coesatodas as suas partes pertencem a uma só ideia tem métodos e campos que tratam de um único conceito. Uma dica para achar uma divisão que falta: se alguns métodos usam só alguns campos e outros métodos usam só os outros campos, provavelmente há duas classes escondidas numa só. Uma classe que ganha um bloco novo a cada caso novo (um cargo novo, um tipo de cliente novo) também é um sinal.
| Cheiro (smell) | O que você vê | O que ele diz |
|---|---|---|
| Divergent Change | Uma classe é editada por muitos motivos diferentes (regra de imposto e layout de relatório) | Falta de coesão: a classe tem várias responsabilidades |
| Shotgun Surgery | Uma mudança obriga a fazer pequenas edições em muitas classes | Uma responsabilidade foi quebrada em pedaços demais |
Princípio aberto-fechado (OCP)
Um console de videogame tem entradas para novos controles. Você não abre o aparelho e solda fios para ligar mais um. O console é fechado para modificação (ninguém mexe por dentro) e aberto para extensão (controles novos só se encaixam). O princípio aberto-fechado pede o mesmo do código: um comportamento novo deve entrar como código novo, sem editar o código que já existe e já foi testado.
O jeito mais comum é uma abstraçãouma interface que esconde os detalhes. A classe conversa com uma interface, e cada comportamento novo é uma classe nova que a implementa. Isso se chama polimorfismouma chamada, vários comportamentos possíveis, dependendo do objeto real. O lugar onde a classe chama algo que não conhece em detalhe é o seu ponto de extensão.
interface DiscountRule {
apply(total: number): number;
}
class PriceCalculator {
constructor(private readonly rules: DiscountRule[]) {}
total(base: number): number {
return this.rules.reduce((value, rule) => rule.apply(value), base);
}
}ANTES: area() tem um if por forma
area(forma) --> if circulo ... if quadrado ... (edite aqui!)
DEPOIS: cada forma sabe a própria área
area(forma) --> forma.area()
|-- Circulo.area()
|-- Quadrado.area()
'-- (novo) Triangulo.area()Onde a mudança acontece
Nenhum programa é fechado contra toda mudança. Em algum lugar a classe nova precisa ser criada. Uma factory (uma função que decide qual classe criar) pode ter um switch para isso. Isso é aceitável: a variação fica confinada a um ponto, e o resto do código só conhece a abstração. O princípio não elimina a mudança. Ele escolhe onde ela acontece.
A mesma cadeia if (x instanceof ...) copiada num auxiliar, num relatório e numa tela é o verdadeiro sinal de alerta. Quando um tipo novo aparece, cada cópia precisa ser encontrada e editada, e uma cópia esquecida falha em silêncio.
Princípio da substituição de Liskov (LSP)
Suponha que você peça "um bichinho de estimação" e receba um peixe dourado. Aí tenta levá-lo para passear. Não dá. Um peixe dourado é um bichinho, mas não um bichinho que anda. O princípio da substituição de Liskov diz que um subtipo (uma classe filha, ou qualquer classe que implementa uma interface) deve funcionar em qualquer lugar onde o tipo base é esperado. Se o código funciona com o pai, deve funcionar com o filho, sem saber qual filho recebeu.
class Bird {
fly(): string { return 'flying'; }
}
class Penguin extends Bird {
fly(): string { throw new Error('penguins cannot fly'); }
}
function release(birds: Bird[]): string[] {
return birds.map((bird) => bird.fly());
}Na biologia, o pinguim é uma ave. No código, o que decide a herança é o contrato dos métodos, não a frase "é um". A solução é mudar a hierarquia, por exemplo deixando fly só num tipo para aves que voam.
O contrato tem dois lados
Um método tem uma pré-condiçãoo que quem chama deve garantir antes da chamada e uma pós-condiçãoo que o método garante depois da chamada. Tem também um jeito de avisar uma falha, como devolver null ou lançar um erro. O compilador só confere os tipos da assinatura. Ele não confere essas promessas, então os testes de contrato é que precisam conferir.
| Parte do contrato | O que um subtipo pode fazer |
|---|---|
| Pré-condição (o que ele exige) | Manter ou enfraquecer (exigir menos). Nunca deixar mais rígida |
| Pós-condição (o que ele entrega) | Manter ou fortalecer (entregar mais). Nunca enfraquecer |
| Como a falha é avisada | Usar o mesmo jeito. Se a base devolve null para um usuário que não existe, o subtipo não deve lançar erro |
O exemplo clássico é Square extends Rectangle. Um Rectangle promete que mudar a largura não muda a altura. Um Square não consegue cumprir essa promessa, porque mudar um lado muda o outro. Outro caso: uma Account base promete que depois de um saque o saldo nunca fica negativo, e uma OverdraftAccount permite saldo negativo. Ela enfraqueceu uma pós-condição.
O sintoma mais forte de uma substituição quebrada é o instanceof no código cliente (if (account instanceof StudentAccount) ...). O cliente teve de aprender os subtipos, então cada subtipo novo exige mais um ramo. Aí uma violação de Liskov vira também uma violação do aberto-fechado.
Princípio da segregação de interfaces (ISP)
Você pega emprestado um canivete suíço só para abrir uma carta. Ganha 30 ferramentas que não precisa, e se uma delas estiver quebrada você ainda se preocupa com ela. O princípio da segregação de interfaces diz que clientes não devem ser forçados a depender de métodos que não usam. Uma interface é um contrato visto do lado de quem usa. Se ela mistura métodos para usuários diferentes, todo mundo fica preso a tudo.
interface Machine {
print(doc: string): void;
scan(): string;
fax(number: string): void;
}
class SimplePrinter implements Machine {
print(doc: string): void { /* imprime */ }
scan(): string { throw new Error('not supported'); }
fax(number: string): void { throw new Error('not supported'); }
}Um método escrito como "não suportado" é um sinal alto: a interface pede mais do que a classe tem. A solução é dividir por capacidade: Printer, Scanner, Fax. Agora o código que precisa escanear pede um Scanner, e uma impressora simples nunca chega lá. O sistema de tipos trabalha a seu favor.
O que uma interface gorda custa
- Quem implementa precisa escrever métodos que não consegue cumprir, e eles lançam erros.
- Os clientes dependem de métodos que nunca chamam, então uma mudança num método que não usam ainda pode obrigá-los a mudar.
- Quando um décimo terceiro método, usado por um só cliente, entra numa interface com doze métodos e oito implementações, as oito classes precisam mudar, mesmo aquelas cujos clientes nunca o chamam.
- Os test doubles (objetos falsos usados em testes) precisam ter todos os métodos. Um relatório que só lê precisa de um falso com um método, não quatro.
Um bom exemplo é um repositório de usuários. Um relatório só precisa ler. Dê a ele um UserReader com findAll e deixe save e delete em outra interface. A classe concreta implementa as duas, então continua igual. O relatório não consegue gravar nem apagar por engano, e o seu falso precisa de um único método.
ISP e SRP são duas visões da coesão. O SRP pergunta por que o próprio tipo muda. O ISP pergunta o que cada cliente precisa de um tipo. Um repositório pode ter uma responsabilidade (guardar usuários) e ainda assim precisar de interfaces separadas de leitura e de escrita.
↑ Voltar ao sumárioPrincípio da inversão de dependência (DIP)
Uma luminária tem um plugue. Ela não tem um fio fixo na parede de uma casa só. A luminária (a coisa importante) define o formato do plugue, e qualquer tomada que encaixe serve. O princípio da inversão de dependência diz: módulos de alto nível e de baixo nível devem depender de abstrações. O código de alto nívelas regras de negócio: para que o programa serve não deve importar os detalhes de baixo nívelos detalhes: banco de dados, e-mail, empresa de pagamento. Em vez disso, a regra diz o que precisa por meio de uma interface, e o detalhe a implementa.
class OrderService {
private readonly database = new MySqlDatabase();
place(order: Order): void {
if (order.total() <= 0) throw new Error('empty order');
this.database.insert('orders', order);
}
}O new dentro da classe é uma decisão dupla: a classe escolhe o que precisa e qual implementação recebe. A solução é declarar uma interface na linguagem do negócio (OrderStore) e recebê-la no construtor. Assim a regra pode ser testada com um falso em memória.
NORMAL INVERTIDO
OrderService OrderService --> OrderStore
| ^
v |
MySqlDatabase MySqlOrderStore -----'
seta: regra -> detalhe detalhe -> interfaceNote a diferença entre o fluxo das chamadas e a dependência no código-fonte. Em tempo de execução, OrderService ainda chama o banco. No código, os dois conhecem só a interface. Note também quem é dono da interface: ela fica com o módulo de alto nível, e o adaptador do banco a implementa. Essa posse é o que "inversão" quer dizer. Colocar a interface ao lado do banco não inverteria nada.
DIP não é o mesmo que DI
| Tópico | DIP (inversão de dependência) | DI (injeção de dependência) |
|---|---|---|
| O que é | Uma regra de projeto: depender de abstrações | Uma técnica: alguém de fora entrega a dependência |
| Pergunta que responde | Do que devo depender? | Quem a cria e entrega para mim? |
| Pode existir sozinho? | Sim | Sim |
Interfaces são menos arriscadas que classes concretas porque mudam menos: não carregam detalhes e muitas classes dependem delas. Mas a regra não é "ponha uma interface em tudo". O critério é a estabilidade. A classe de texto da biblioteca padrão (String) quase nunca muda, não faz entrada nem saída e se comporta igual toda vez, então pode ser usada direto. O que muda, conversa com o mundo de fora ou dá uma resposta diferente a cada chamada (banco, relógio, rede, números aleatórios) vai atrás de uma interface.
Criacionais: Factory, Builder, Singleton
Criar um objeto envolve escolhas: qual classe, com quais partes, em que estado inicial. Se cada usuário do objeto repete essas escolhas, uma mudança precisa ser repetida em todo lugar. Os padrões criacionais tiram as escolhas de criação do código que usa o objeto, para que quem usa dependa só do tipo abstrato.
| Padrão | O problema | A ideia |
|---|---|---|
| Factory | Há várias classes para o mesmo papel, e quem decide qual é usada está copiado por aí | Uma função ou classe decide qual classe criar |
| Builder | Uma classe com muitos dados, alguns opcionais, numa lista longa de parâmetros | Passos com nome e um build final que confere tudo |
| Singleton | Queremos exatamente uma instância de uma classe | Uma instância mais um jeito global de alcançá-la |
Factory
function createNotifier(channel: string): Notifier {
if (channel === 'email') return new EmailNotifier();
if (channel === 'sms') return new SmsNotifier();
throw new Error(`unknown channel: ${channel}`);
}O valor de uma factory está no que ela esconde. O cliente deve depender da interface do produto, não da classe concreta. Se o cliente faz um cast do resultado para a classe concreta, o benefício se perde.
Builder
// difícil de ler: o que significam null, 30, true e false?
new HttpRequest('POST', '/orders', null, null, 30, true, false, body);
// com um builder, cada valor tem nome
new RequestBuilder().method('POST').path('/orders').timeout(30).body(body).build();O método build é o lugar natural para regras que cruzam campos, como "um POST precisa ter corpo". O objeto só nasce se o conjunto todo fizer sentido. É também por isso que um construtor rico é melhor que um construtor vazio seguido de setters: com setters, entre o new e o último set o objeto existe num estado inválido. Outro cuidado: o build deve entregar ao produto uma cópia dos dados mutáveis, como uma lista de cabeçalhos. Se ele entrega o array do próprio builder, uma chamada posterior ao builder altera um produto que já foi entregue.
Singleton
O Singleton junta duas decisões: existe só uma instância e ela é alcançável de qualquer lugar. A primeira pode ser uma necessidade legítima. A segunda é uma variável global disfarçada, e é por isso que hoje se olha o padrão com desconfiança.
class InvoiceService {
issue(order: Order): Invoice {
const now = Clock.getInstance().now();
const rate = TaxTable.getInstance().rateFor(order.state);
return new Invoice(order, rate, now);
}
}A assinatura de um construtor é a documentação mais honesta do que uma classe precisa. Um acesso global escondido faz essa documentação mentir. Pior: um Singleton com estado mutável liga todo o código que o usa. Nos testes isso aparece como dependência de ordem: o primeiro teste incrementa um contador, o segundo começa com o valor que sobrou e falha, embora cada um passe sozinho. Uma constante exportada de um módulo (export const config = loadConfig()) é a mesma coisa em JavaScript: quem importa fica preso àquela instância.
Estruturais: Adapter, Decorator, Facade
Os padrões estruturais tratam de como juntar classes. Três deles se parecem, porque cada um põe uma classe na frente de outro código. O que muda é a intenção.
| Padrão | Interface que mostra | Intenção |
|---|---|---|
| Adapter | Uma diferente: converte uma classe para a interface que o cliente já espera | Fazer um desencontro encaixar |
| Decorator | A mesma interface do objeto que ele envolve | Acrescentar comportamento (log, cache, retry) |
| Facade | Uma nova e mais simples, para várias classes | Uma porta de entrada fácil para um subsistema complexo |
Adapter
interface PaymentGateway {
charge(cents: number): Receipt;
}
class AcmePayGateway implements PaymentGateway {
constructor(private readonly sdk: AcmePaySdk) {}
charge(cents: number): Receipt {
const result = this.sdk.createTransaction({ amount: cents / 100, currency: 'BRL' });
return { id: result.transactionId, approved: result.status === 'OK' };
}
}O sistema é dono do contrato estável (PaymentGateway). Os tipos do fornecedor nunca saem do adaptador. Quando o fornecedor é trocado, só o adaptador muda e as regras de negócio ficam como estão. O custo de trocar de fornecedor vira o custo de uma classe.
Decorator
Um decorator implementa uma interface e recebe, no construtor, outro objeto da mesma interface. Como o formato é o mesmo, os decorators podem ser empilhados em qualquer combinação, como camadas de roupa numa boneca.
// Logged escreve cada chamada, Cached lembra as respostas
const a: Users = new Logged(new Cached(new SqlUsers()));
const b: Users = new Cached(new Logged(new SqlUsers()));A ordem faz parte do comportamento. Em a, o Logged está por fora, então toda chamada é registrada, até as respondidas pelo cache. Em b, o Cached está por fora, então o segundo find('42') é respondido pelo cache e nunca chega ao Logged: não é escrito no log. As duas são válidas e respondem perguntas diferentes.
Por que não herança? Com herança, cada combinação de recursos é uma subclasse. Com n recursos independentes são 2ⁿ − 1 classes (contando cada combinação com pelo menos um recurso, ignorando a ordem). Com decorators você escreve n classes e as combina quando o programa é montado.
- Herança, 3 recursos
- 7 classes
- Decorators, 3 recursos
- 3 classes
- Herança, 6 recursos
- 63 classes
- Decorators, 6 recursos
- 6 classes
Um decorator é um subtipo do que ele envolve, então o princípio de Liskov se aplica. Um decorator de cache cujo save não limpa o produto guardado faz o find devolver dados velhos depois de uma escrita. Ele quebrou o contrato da interface. Um teste de contrato, executado contra o repositório simples e contra o decorado, pega o problema.
Facade
Um subsistema costuma ter muitas classes que precisam ser chamadas numa certa ordem. A facade guarda esse conhecimento num só lugar, e os clientes dependem de uma classe e não de dez. O subsistema pode mudar por dentro sem machucá-los. O limite é claro: a facade conhece a ordem das chamadas, não as regras.
↑ Voltar ao sumárioComportamentais: Strategy, Observer, Command, State
Os padrões comportamentais tratam de como os objetos dividem o trabalho e conversam. Em cada um deles, uma classe deixa de decidir algo sozinha e passa a decisão para outro objeto que pode ser trocado.
| Padrão | Intenção | Sinal no código |
|---|---|---|
| Strategy | Pôr algoritmos intercambiáveis atrás de uma interface | Um if por tipo, cada ramo um cálculo independente |
| Observer | Um objeto avisa vários outros quando algo acontece | Um par de chamadas: subscribe e publish |
| Command | Guardar um pedido como objeto, com seus dados | Fila, histórico, desfazer e refazer |
| State | Mudar o comportamento quando o estado interno muda | O mesmo campo status testado em todo método |
Strategy
Imagine ir à escola: de ônibus, de bicicleta ou a pé. O objetivo é o mesmo e o caminho muda. O Strategy separa o que varia (como calcular) do que fica (quem precisa do resultado). Cada tipo de frete ganha uma classe que pode ser testada sozinha, e um tipo novo não edita os outros.
type Pricing = (base: number) => number;
const regular: Pricing = (base) => base;
const blackFriday: Pricing = (base) => base * 0.7;
function checkout(base: number, pricing: Pricing): number {
return pricing(base);
}Os padrões descrevem papéis e relações, não sintaxe. Quando a estratégia tem um método e nenhum estado, um tipo função faz o papel da interface. Use uma classe quando ela precisar de dados ou de mais de uma operação.
Observer
Um canal do YouTube não sabe quem são seus inscritos. Ele só publica, e todos que se inscreveram são avisados. No Observer, quem publica não sabe quem escuta, então uma reação nova (somar pontos de fidelidade quando um pedido é pago) é só mais um subscribe na inicialização, e a classe do pedido não é editada. É o princípio aberto-fechado aplicado a eventos.
publish(event: string): void {
for (const listener of [...this.listeners]) {
try { listener(event); } catch (error) { this.errors.push(error); }
}
}- Percorra uma cópia. Se o ouvinte A se cancela durante a entrega, um laço comum move o B para o lugar dele e o pula. Publicar sobre um instantâneo (snapshot) significa: quem estava inscrito quando o evento começou o recebe.
- Isole cada ouvinte. Se o B lança um erro e ninguém o captura, o C nunca é chamado e o erro chega a quem publicou, justamente o que o padrão queria desacoplar. Capture e registre o erro.
- Cancele ao sair. O assunto (subject) guarda uma referência a cada observador. Uma tela que fecha sem cancelar continua na memória e continua sendo chamada. Esse vazamento é o "lapsed listener" (ouvinte esquecido). Um bom
subscribedevolve a função que cancela.
Command
Uma chamada de método comum acontece e some. Um comando é a mesma intenção guardada como dado: qual ação, sobre quem, com quais argumentos. Isso permite colocá-lo numa fila, repeti-lo e desfazê-lo. Se cada comando sabe se reverter, um histórico com duas pilhas dá desfazer e refazer.
run(a) run(b) run(c) histórico = [a, b, c]
undo() tira c -> histórico = [a, b]
undo() tira b -> histórico = [a]
Efeitos ainda aplicados: só o a
Executar um comando novo limpa a pilha de refazerState
Um pedido vai de pendente para pago e para enviado. Cada fase responde de um jeito à mesma operação: não se envia um pedido pendente. Com condicionais, todo método começa com um teste sobre status. Com o padrão State, cada fase é uma classe, e as transições permitidas ficam explícitas. A regra que recusa ship para um pedido pendente mora na classe do estado pendente.
Conte o que cresce. Com 4 estados e 5 operações escritas como métodos com condicionais, um quinto estado obriga a revisar os 5 métodos. Com State, é uma classe nova mais as transições que levam a ela. O contrário também vale: se as operações crescem mais que os estados, as condicionais podem ser a melhor escolha.
| Tópico | Strategy | State |
|---|---|---|
| O que são as alternativas | Jeitos independentes de fazer a mesma tarefa | Fases de um ciclo de vida |
| Quem as troca | Escolhidas de fora | Trocadas pelas próprias operações, como consequência |
Repository e injeção de dependência
Quando você pede um livro à bibliotecária, não entra no depósito para procurar nas prateleiras. Você diz o título e ela traz. Um Repository é essa bibliotecária para os seus dados. Ele dá às regras de negócio uma interface parecida com uma coleção (add, find, save) e esconde como os dados são guardados: SQL, arquivos ou memória.
interface OrderRepository {
find(id: string): Order | null;
save(order: Order): void;
}
class InMemoryOrders implements OrderRepository {
private readonly items = new Map<string, Order>();
find(id: string) { return this.items.get(id) ?? null; }
save(order: Order) { this.items.set(order.id, order); }
}Como a regra só conhece a interface, ela pode ser testada com um repositório em memória: rápido e igual toda vez. Um segundo grupo de testes, mais lento, confere o repositório real do banco. Um teste de contrato executado contra as duas implementações garante que elas se comportam do mesmo jeito.
Injeção de dependência
Injeção de dependência quer dizer que a classe deixa de criar ou buscar o que precisa e recebe de fora. A forma mais comum é a injeção pelo construtor: as dependências ficam visíveis na assinatura, são obrigatórias e podem ser somente leitura. Olhar o construtor mostra tudo o que a classe precisa.
class RegisterUser {
constructor(
private readonly users: UserRepository,
private readonly mailer: Mailer,
) {}
execute(email: string): void {
this.users.add(new User(email));
this.mailer.send(email, 'Welcome');
}
}main() <-- raiz de composição: o único lugar com 'new'
| cria a conexão
| cria SqlOrderRepository(conexão)
| cria RegisterUser(repo, mailer)
v
o resto do programa só conhece interfacesO lugar onde tudo é criado e ligado se chama raiz de composição (composition root), perto do ponto de entrada do programa. Escrever new SqlOrderRepository(connection) ali mantém o resto do sistema dependente só de abstrações.
Duas armadilhas
- Limite da transação. Para salvar um pedido e baixar o estoque de forma atômica (tudo ou nada), a transação pertence ao caso de uso, que abre uma unidade de trabalho (unit of work) cobrindo os dois repositórios. Um repositório cuida de um tipo de objeto e não sabe de qual operação maior faz parte.
- Dependência cativa (captive dependency). Um serviço criado uma vez na inicialização que recebe no construtor um objeto que deveria ser criado a cada requisição fica com o objeto da primeira requisição para sempre. Um objeto só pode depender de objetos que vivem tanto quanto ele ou mais. Dados por requisição chegam como argumento do método, ou por um provedor consultado a cada uso.
Antipadrões e quando não usar um padrão
Um code smell (cheiro de código) é uma pista de que o projeto pode ter um problema, como um cheiro estranho na geladeira. Um antipadrão é uma resposta ruim que ficou popular. Os padrões também têm um preço: mais tipos, mais arquivos e mais um salto para entender o fluxo. Só compensam se o problema que resolvem realmente existe.
| Cheiro | Como reconhecer | Por que machuca |
|---|---|---|
| God Class | Uma classe enorme que concentra boa parte da lógica e conhece quase todas as outras | Baixa coesão, difícil de testar, todo mundo edita e gera conflito |
| Shotgun Surgery | Uma mudança obriga a fazer pequenas edições em muitas classes | O que muda junto não está junto |
| Feature Envy | Um método lê vários getters de outro objeto para calcular algo sobre esse objeto | O cálculo pertence ao outro objeto |
| Refused Bequest | Uma subclasse sobrescreve métodos herdados para que não façam nada ou lancem erro | Quebra o Liskov: ela queria o código, não o contrato |
| Modelo anêmico | Classes só com campos, getters e setters, e todas as regras nos serviços | Código procedural disfarçado: qualquer serviço pode deixar um objeto inválido |
| Generalidade especulativa | Interface, factory e builder, cada um com uma implementação, "só por garantia" | Todos pagam por uma flexibilidade que ninguém usou |
Tell, Don't Ask e a Lei de Demeter
// pede tudo, depois decide
const city = order.getCustomer().getAddress().getCity().getName();
// diz ao pedido o que se quer
const city = order.shippingCity();A Lei de Demeter diz que um método deve falar só com os vizinhos imediatos. Uma cadeia de getters atravessa vários objetos, então se o endereço muda de forma, toda cadeia espalhada pelo sistema quebra. Pedir o resultado ao primeiro objeto mantém o conhecimento da estrutura num só lugar. A mesma ideia, Tell, Don't Ask (diga, não pergunte), cura a Feature Envy: leve o comportamento para perto dos dados que ele usa.
Sobre o modelo anêmico: um projeto orientado a objetos mantém cada regra perto do dado que ela protege (encapsulamento). Se as classes de domínio são só sacos de campos, qualquer serviço pode deixar um pedido num estado inválido, e a mesma regra é reescrita em vários lugares.
Quando não aplicar um padrão
- Com um só algoritmo e nenhum sinal de um segundo, o Strategy não se justifica.
- Uma tela de cadastro com dois campos feita com uma interface, uma abstract factory e um Builder, cada um com uma implementação, é generalidade especulativa. Espere o segundo caso real: refatorar para um padrão quando a necessidade surge custa menos do que carregar a estrutura por anos.
- Aplicar um padrão por precaução costuma produzir a abstração errada, e a flexibilidade errada atrapalha a mudança que realmente chega.
Juntando tudo
Os princípios e os padrões estão ligados. Os padrões costumam ser o caminho para seguir um princípio. Quando vir um problema, pergunte qual dor você sente e escolha o princípio ou o padrão que a remove.
| Dor que você sente | Princípio que a explica | Padrão ou técnica que ajuda |
|---|---|---|
| Uma classe muda por muitos motivos | SRP | Extrair cada assunto para sua própria classe |
Todo caso novo edita uma cadeia de if antiga | OCP | Strategy, Factory, polimorfismo |
| Um subtipo lança erro ou surpreende seus clientes | LSP | Mudar a hierarquia ou usar composição |
| Quem implementa escreve métodos "não suportado" | ISP | Dividir a interface por papel |
| Regras de negócio importam banco ou e-mail | DIP | Interface que pertence à regra, DI, Adapter, Repository |
| Um construtor com muitos parâmetros | (criação) | Builder, construtor rico |
| Um global escondido que quebra testes | DIP | Injetar a instância única em vez de um Singleton |
| Comportamentos que se empilham em qualquer ordem | OCP, composição | Decorator |
| Muitas classes para chamar em ordem | (simplicidade) | Facade |
| Reações a um evento | OCP | Observer |
| Desfazer, fila, repetir | (pedido como dado) | Command |
| Status testado em todo método | OCP | State |
Uma nota justa sobre custo: o código refatorado tem mais tipos e mais linhas que a primeira versão. Esse é o preço. Ele compensa onde mudanças assim realmente chegam, e é desperdício onde nunca chegam. A mudança não desaparece. Os projetos só escolhem onde ela cai.
- Escreva um código simples que funciona, com testes.
- Observe a dor: uma classe que muda por muitos motivos, uma cadeia de
ifque cresce, um teste que precisa de banco de dados. - Dê nome à dor com um princípio ou um cheiro.
- Aplique o menor padrão que a remove, e nada além disso.
- Mantenha os testes verdes durante a refatoração.