Padrões de projeto: quando e como usar

1. Fundamentos dos Padrões de Projeto

Padrões de projeto são soluções reutilizáveis para problemas recorrentes no desenvolvimento de software. O conceito foi popularizado pelo livro "Design Patterns: Elements of Reusable Object-Oriented Software" (GoF — Gang of Four), que catalogou 23 padrões divididos em três categorias: criacionais, estruturais e comportamentais.

A ideia central é capturar a experiência de desenvolvedores experientes em soluções testadas e documentadas. No entanto, a aplicação inadequada pode gerar o que chamamos de "overengineering" — quando a complexidade da solução supera o problema original. O segredo está em identificar o momento certo para cada padrão.

2. Critérios para Escolher um Padrão

Antes de aplicar um padrão, é necessário analisar o problema sob três aspectos:

  • Code smell: que sintoma o código apresenta? Duplicação, condicionais complexas, acoplamento excessivo?
  • Contexto: qual o nível de acoplamento atual? A coesão está baixa? Precisamos de mais flexibilidade?
  • Trade-offs: o padrão trará mais manutenibilidade ou apenas complexidade desnecessária?

A regra de ouro é: um padrão deve ser aplicado quando o benefício de longo prazo supera o custo imediato de implementação.

3. Padrões Criacionais — Quando e Como Usar

Singleton

O Singleton garante que uma classe tenha apenas uma instância e fornece um ponto global de acesso a ela.

// Exemplo: Logger global
class Logger {
    private static instance: Logger;
    private logFile: string;

    private constructor() {
        this.logFile = "app.log";
    }

    public static getInstance(): Logger {
        if (!Logger.instance) {
            Logger.instance = new Logger();
        }
        return Logger.instance;
    }

    public log(message: string): void {
        console.log(`[${new Date().toISOString()}] ${message}`);
    }
}

// Uso
const logger = Logger.getInstance();
logger.log("Aplicação iniciada");

Quando usar: gerenciamento de pools de conexão, caches, configurações globais. Cuidado: estado global compartilhado dificulta testes e pode causar acoplamento oculto.

Factory Method

Encapsula a criação de objetos, permitindo que subclasses decidam qual classe instanciar.

// Exemplo: Fábrica de documentos
interface Document {
    open(): void;
    save(): void;
}

class PDFDocument implements Document {
    open(): void { console.log("Abrindo PDF"); }
    save(): void { console.log("Salvando PDF"); }
}

class WordDocument implements Document {
    open(): void { console.log("Abrindo Word"); }
    save(): void { console.log("Salvando Word"); }
}

abstract class DocumentFactory {
    abstract createDocument(): Document;

    public processDocument(): void {
        const doc = this.createDocument();
        doc.open();
        doc.save();
    }
}

class PDFFactory extends DocumentFactory {
    createDocument(): Document {
        return new PDFDocument();
    }
}

Quando usar: quando o sistema precisa lidar com famílias de objetos relacionados ou quando a criação de objetos é complexa.

Builder

Separa a construção de um objeto complexo de sua representação, permitindo diferentes representações a partir do mesmo processo.

// Exemplo: Construção de pedido
class Order {
    public items: string[] = [];
    public discount: number = 0;
    public shipping: string = "standard";
}

class OrderBuilder {
    private order: Order;

    constructor() {
        this.order = new Order();
    }

    addItem(item: string): OrderBuilder {
        this.order.items.push(item);
        return this;
    }

    setDiscount(discount: number): OrderBuilder {
        this.order.discount = discount;
        return this;
    }

    setShipping(type: string): OrderBuilder {
        this.order.shipping = type;
        return this;
    }

    build(): Order {
        return this.order;
    }
}

// Uso
const order = new OrderBuilder()
    .addItem("Notebook")
    .addItem("Mouse")
    .setDiscount(10)
    .setShipping("express")
    .build();

Quando usar: objetos com muitos parâmetros opcionais ou construções que exigem validação entre etapas.

4. Padrões Estruturais — Organizando Relações entre Classes

Adapter

Permite que interfaces incompatíveis trabalhem juntas.

// Exemplo: Integração de sistema legado
class LegacyPaymentSystem {
    processPayment(amount: number): string {
        return `Pagamento de R$${amount} processado (legado)`;
    }
}

interface ModernPaymentGateway {
    pay(amount: number): boolean;
}

class PaymentAdapter implements ModernPaymentGateway {
    private legacySystem: LegacyPaymentSystem;

    constructor() {
        this.legacySystem = new LegacyPaymentSystem();
    }

    pay(amount: number): boolean {
        const result = this.legacySystem.processPayment(amount);
        console.log(result);
        return true;
    }
}

Quando usar: integração com bibliotecas de terceiros, sistemas legados ou APIs com interfaces diferentes.

Decorator

Adiciona responsabilidades a objetos dinamicamente, sem modificar sua estrutura.

// Exemplo: Notificações com recursos extras
interface Notification {
    send(message: string): void;
}

class BaseNotification implements Notification {
    send(message: string): void {
        console.log(`Enviando: ${message}`);
    }
}

class SMSDecorator implements Notification {
    private wrappee: Notification;

    constructor(notification: Notification) {
        this.wrappee = notification;
    }

    send(message: string): void {
        this.wrappee.send(message);
        console.log("Enviando SMS com a mesma mensagem");
    }
}

class EmailDecorator implements Notification {
    private wrappee: Notification;

    constructor(notification: Notification) {
        this.wrappee = notification;
    }

    send(message: string): void {
        this.wrappee.send(message);
        console.log("Enviando e-mail com a mesma mensagem");
    }
}

// Uso
let notification = new BaseNotification();
notification = new SMSDecorator(notification);
notification = new EmailDecorator(notification);
notification.send("Promoção especial!");

Quando usar: quando você precisa adicionar funcionalidades a objetos sem criar uma explosão de subclasses.

Facade

Fornece uma interface simplificada para um conjunto complexo de subsistemas.

// Exemplo: Sistema de e-commerce
class InventorySystem {
    checkStock(productId: string): boolean {
        console.log(`Verificando estoque do produto ${productId}`);
        return true;
    }
}

class PaymentSystem {
    processPayment(amount: number): boolean {
        console.log(`Processando pagamento de R$${amount}`);
        return true;
    }
}

class ShippingSystem {
    scheduleDelivery(address: string): string {
        console.log(`Agendando entrega para ${address}`);
        return "Código de rastreio: BR123";
    }
}

class EcommerceFacade {
    private inventory: InventorySystem;
    private payment: PaymentSystem;
    private shipping: ShippingSystem;

    constructor() {
        this.inventory = new InventorySystem();
        this.payment = new PaymentSystem();
        this.shipping = new ShippingSystem();
    }

    placeOrder(productId: string, amount: number, address: string): string {
        if (!this.inventory.checkStock(productId)) {
            return "Produto fora de estoque";
        }
        if (!this.payment.processPayment(amount)) {
            return "Falha no pagamento";
        }
        return this.shipping.scheduleDelivery(address);
    }
}

Quando usar: para simplificar a interação com subsistemas complexos, reduzindo o acoplamento entre cliente e subsistemas.

5. Padrões Comportamentais — Gerenciando Algoritmos e Comunicação

Strategy

Permite definir uma família de algoritmos, encapsulá-los e torná-los intercambiáveis.

// Exemplo: Cálculo de frete
interface ShippingStrategy {
    calculate(weight: number, distance: number): number;
}

class ExpressShipping implements ShippingStrategy {
    calculate(weight: number, distance: number): number {
        return weight * 0.5 + distance * 0.3;
    }
}

class StandardShipping implements ShippingStrategy {
    calculate(weight: number, distance: number): number {
        return weight * 0.2 + distance * 0.1;
    }
}

class OrderProcessor {
    private strategy: ShippingStrategy;

    constructor(strategy: ShippingStrategy) {
        this.strategy = strategy;
    }

    setStrategy(strategy: ShippingStrategy): void {
        this.strategy = strategy;
    }

    processOrder(weight: number, distance: number): number {
        return this.strategy.calculate(weight, distance);
    }
}

// Uso
const order = new OrderProcessor(new StandardShipping());
console.log(order.processOrder(10, 100)); // Frete padrão
order.setStrategy(new ExpressShipping());
console.log(order.processOrder(10, 100)); // Frete expresso

Quando usar: substituir condicionais complexas (if/else ou switch) por algoritmos intercambiáveis.

Observer

Define uma dependência um-para-muitos entre objetos, para que quando um objeto mude de estado, todos os dependentes sejam notificados.

// Exemplo: Sistema de eventos
interface Observer {
    update(event: string, data: any): void;
}

class EventManager {
    private observers: Observer[] = [];

    subscribe(observer: Observer): void {
        this.observers.push(observer);
    }

    unsubscribe(observer: Observer): void {
        this.observers = this.observers.filter(o => o !== observer);
    }

    notify(event: string, data: any): void {
        this.observers.forEach(o => o.update(event, data));
    }
}

class EmailNotifier implements Observer {
    update(event: string, data: any): void {
        console.log(`Enviando e-mail sobre ${event}: ${JSON.stringify(data)}`);
    }
}

class SMSNotifier implements Observer {
    update(event: string, data: any): void {
        console.log(`Enviando SMS sobre ${event}: ${JSON.stringify(data)}`);
    }
}

// Uso
const manager = new EventManager();
const emailNotifier = new EmailNotifier();
const smsNotifier = new SMSNotifier();

manager.subscribe(emailNotifier);
manager.subscribe(smsNotifier);

manager.notify("order_created", { id: 123, total: 250 });

Quando usar: sistemas de eventos, notificações, MVC (Model-View-Controller) e comunicação entre componentes desacoplados.

6. Armadilhas e Antipadrões Comuns

  • Padrão "porque sim": aplicar padrões sem justificativa real no domínio do problema.
  • Implementação prematura: usar padrões como "solução em busca de um problema".
  • Complexidade desnecessária: às vezes, um simples if/else é mais adequado que um Strategy completo.
  • Singleton abusivo: estado global compartilhado dificulta testes e manutenção.

7. Metodologia Prática para Aplicação

Passo a passo prático:

  1. Isole o problema: identifique o code smell específico.
  2. Liste padrões candidatos: quais padrões resolvem esse tipo de problema?
  3. Prototipe: implemente uma versão simples do padrão escolhido.
  4. Refatore: compare com a versão original e avalie se houve melhoria.

Exemplo prático: evolução de um sistema de pagamentos.

// Antes: switch gigante
function processPayment(type: string, amount: number) {
    if (type === "credit") { /* lógica */ }
    else if (type === "debit") { /* lógica */ }
    else if (type === "pix") { /* lógica */ }
}

// Depois: Strategy + Factory
interface PaymentStrategy {
    pay(amount: number): boolean;
}

class CreditPayment implements PaymentStrategy {
    pay(amount: number): boolean {
        console.log(`Crédito: R$${amount}`);
        return true;
    }
}

class PaymentFactory {
    static createPayment(type: string): PaymentStrategy {
        switch (type) {
            case "credit": return new CreditPayment();
            case "debit": return new DebitPayment();
            default: throw new Error("Tipo inválido");
        }
    }
}

Checklist de validação:
- O padrão resolve o problema real?
- Reduz o acoplamento entre componentes?
- Facilita a escrita de testes unitários?
- A complexidade adicionada é justificada?

8. Padrões e Arquitetura Moderna

Os padrões de projeto se alinham diretamente com princípios como SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) e GRASP (General Responsibility Assignment Software Patterns).

Em microsserviços, padrões como Saga (para transações distribuídas) e Circuit Breaker (para resiliência) são adaptações dos padrões GoF para sistemas distribuídos.

A regra final: simplicidade é a meta. Um padrão bem aplicado reduz complexidade; um mal aplicado aumenta. Se o código ficou mais difícil de entender, repense a decisão.


Referências