O Padrão Decorator e o Padrão Observer: Composição Dinâmica e Desacoplamento de Eventos

Aula 13 — Programação Orientada a Objetos

Marcos M. Raimundo — Instituto de Computação, UNICAMP

2026-09-18

Proposta da Aula

Aula 12 fechou o tríptico interno (dado, gênese, fluxo) e abriu a primeira fronteira externa com o Adapter.

Hoje, duas peças finais compartilham um mesmo espírito: trocar uma decisão de compilação por uma decisão de execução — mas para dois problemas diferentes.

Problema 1 — um único objeto: como acumular responsabilidades opcionais (leite, chocolate, chantilly) sem repetir a explosão combinatória da herança (Aula 10)? Resposta: o Decorator.

Problema 2 — vários objetos: como um Pedido avisa e-mail, log e estoque de que foi faturado, sem conhecer cada um deles por nome? Resposta: o Observer.

Roteiro: a explosão combinatória → a estrutura do Decorator → o mundo real (java.io) → o perigo do acoplamento de notificação → a estrutura do Observer → Push/Pull → do local ao distribuído.

O Limite da Herança Estática e a Explosão Combinatória

O Problema da Herança Sazonal

Uma cafeteria vende Cafe e quer oferecer adicionais independentes: Leite, Chocolate, Chantilly.

Resolver por herança exige uma subclasse por combinação, não por adicional.

public class CafeComLeite extends Cafe { /* ... */ }
public class CafeComChocolate extends Cafe { /* ... */ }
public class CafeComLeiteEChocolate extends Cafe { /* ... */ }
// ... e assim por diante, para cada combinacao possivel

Para \(N\) adicionais independentes, a herança exige \(2^N\) subclasses — com 10 adicionais, mais de mil classes.

Por Que Isso é o Mesmo Colapso da Aula 10

Mesma assinatura de problema: comportamentos opcionais, cumulativos e decididos em tempo de execução — herança falha porque fixa a combinação em tempo de compilação.

Diferença em relação ao Strategy (Aula 10): lá, o Contexto trocava um algoritmo por outro. Aqui, o cliente quer acumular várias responsabilidades sobre o mesmo objeto, em quantidade e ordem decididas em runtime.

Se a cafeteria decidir oferecer 5 adicionais independentes (Leite, Chocolate, Chantilly, Canela, Mel), quantas classes distintas a estratégia de herança estática exigiria para representar todas as combinações possíveis — e o que esse número revela sobre por que “adicionar mais uma subclasse quando precisar” nunca é uma resposta sustentável nesse cenário?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ Se a cafeteria removesse a opção de combinar adicionais entre si (isto é, o cliente só pudesse escolher exatamente um adicional ou nenhum), o número de subclasses necessárias para representar todas as combinações cresceria linearmente com o número de adicionais, em vez de exponencialmente.
  • □ No limite em que o número de adicionais independentes tende a infinito, a razão entre o número de subclasses exigidas pela herança estática e o número de adicionais oferecidos também tende a infinito.
  • □ Um sistema de seguros que precisa combinar de forma independente e cumulativa cobertura contra roubo, incêndio e desastres naturais em uma única apólice sofreria da mesma explosão combinatória se cada combinação de coberturas fosse modelada como uma subclasse de Apolice.
  • □ Para 5 adicionais independentes, a estratégia de herança estática exigiria exatamente 5 subclasses novas, uma para cada adicional individual, sem necessidade de subclasses para as combinações entre eles.

Explosão Combinatória — Resposta

Quantas classes, e o que isso revela — Resposta

  • ✔ Se a cafeteria removesse a opção de combinar adicionais entre si (isto é, o cliente só pudesse escolher exatamente um adicional ou nenhum), o número de subclasses necessárias para representar todas as combinações cresceria linearmente com o número de adicionais, em vez de exponencialmente — sem combinação permitida, bastam \(N+1\) classes (uma por adicional, mais a base); é precisamente a possibilidade de combinar que gera o crescimento exponencial.
  • ✔ No limite em que o número de adicionais independentes tende a infinito, a razão entre o número de subclasses exigidas pela herança estática e o número de adicionais oferecidos também tende a infinito — \(2^N/N \to \infty\) quando \(N \to \infty\): o crescimento exponencial supera qualquer crescimento linear de referência.
  • ✔ Um sistema de seguros que precisa combinar de forma independente e cumulativa cobertura contra roubo, incêndio e desastres naturais em uma única apólice sofreria da mesma explosão combinatória se cada combinação de coberturas fosse modelada como uma subclasse de Apolice — mesma assinatura estrutural do problema, domínio diferente.
  • ✗ Para 5 adicionais independentes, a estratégia de herança estática exigiria exatamente 5 subclasses novas, uma para cada adicional individual, sem necessidade de subclasses para as combinações entre eles — falso: são \(2^5=32\) combinações possíveis (incluindo a ausência de qualquer adicional), não 5; é exatamente essa confusão entre “número de adicionais” e “número de combinações” que subestima o tamanho real do problema.

Voltando à pergunta: \(2^5 = 32\) classes seriam necessárias — e esse número, muito maior do que os 5 adicionais em si, revela que o problema não é “faltam subclasses”, é que modelar cada combinação como uma classe é, por construção, insustentável assim que o número de eixos independentes cresce. O bloco seguinte mostra a saída: parar de modelar combinações como classes.

O Padrão Decorator: Estrutura, Wrapping e Transparência

O Conceito de Envelopamento

Tratar adicionais não como subclasses, mas como envelopes (wrappers) em torno do objeto original.

Dualidade de tipos: o Decorator É-UM (implementa a mesma interface) e TEM-UM (contém uma instância dessa interface) — estrutura de “cebola”.

Como núcleo e envelopes assinam a mesma interface, o cliente não sabe quantas camadas foram empilhadas — chama getCusto() na camada mais externa e a execução acumula os valores recursivamente até o centro.

Definição do GoF e a Estrutura em Código

“Anexar responsabilidades adicionais a um objeto de maneira dinâmica. O Decorator fornece uma alternativa flexível à herança para extensão de funcionalidades.”

public interface Bebida {
    double getCusto();
    String getDescricao();
}

public class Cafe implements Bebida {
    public double getCusto() { return 5.0; }
    public String getDescricao() { return "Cafe"; }
}

public abstract class BebidaDecorator implements Bebida {
    protected Bebida bebidaDecorada;

    public BebidaDecorator(Bebida b) { this.bebidaDecorada = b; }
    public double getCusto() { return bebidaDecorada.getCusto(); }
    public String getDescricao() { return bebidaDecorada.getDescricao(); }
}

BebidaDecorator obriga a passar uma Bebida no construtor e faz repasse puro por padrão — o coração técnico do padrão.

O Decorador Concreto: Leite e Chocolate

public class Leite extends BebidaDecorator {
    public Leite(Bebida b) { super(b); }
    @Override
    public double getCusto() { return super.getCusto() + 1.50; }
    @Override
    public String getDescricao() { return super.getDescricao() + ", Leite"; }
}

public class Chocolate extends BebidaDecorator {
    public Chocolate(Bebida b) { super(b); }
    @Override
    public double getCusto() { return super.getCusto() + 2.00; }
}

getCusto() chama super.getCusto() — delega para quem estiver dentro (Cafe ou outro decorador) e soma seu próprio acréscimo. Estratégia puramente recursiva.

A Recursão Visualizada: new Chocolate(new Leite(new Cafe()))

A chamada desce (tracejado) até Cafe; o valor sobe (colorido) acumulando cada acréscimo — nenhuma camada precisa saber quantas outras existem abaixo ou acima dela.

Análise da Recursão, o Mundo Real e a Cadeia de Notificadores do Pedido

Uso Prático: o Ecossistema Java I/O

// O exemplo mais famoso do mundo real: Java IOStreams
InputStream stream = new BufferedInputStream(
                        new FileInputStream("dados.txt")
                     );

FileInputStream é o componente concreto (leitura crua, lenta); BufferedInputStream é o decorador (buffer de memória, mesma interface InputStream).

Em vez de uma classe rígida BufferedFileInputStream, a Sun criou um decorador genérico — funciona sobre arquivos, sockets, ou qualquer InputStream futuro, de forma intercambiável.

Replicando no Domínio do Curso: NotificadorComLog e NotificadorComRetry

public interface Notificador {
    void enviar(String mensagem);
}

public abstract class NotificadorDecorator implements Notificador {
    protected Notificador notificadorDecorado;

    public NotificadorDecorator(Notificador n) { this.notificadorDecorado = n; }

    @Override
    public void enviar(String mensagem) { notificadorDecorado.enviar(mensagem); }
}

public class NotificadorComLog extends NotificadorDecorator {
    public NotificadorComLog(Notificador n) { super(n); }
    @Override
    public void enviar(String mensagem) {
        System.out.println("[LOG] Tentando enviar: " + mensagem);
        super.enviar(mensagem);
    }
}

Mesma estrutura de Bebida/BebidaDecorator — mas evoluir os requisitos de notificação (log, depois retry) por herança geraria a mesma explosão da cafeteria.

NotificadorComRetry e a Montagem da Cadeia

public class NotificadorComRetry extends NotificadorDecorator {
    private int maxTentativas;

    public NotificadorComRetry(Notificador n, int maxTentativas) {
        super(n);
        this.maxTentativas = maxTentativas;
    }

    @Override
    public void enviar(String mensagem) {
        for (int t = 1; t <= maxTentativas; t++) {
            try { super.enviar(mensagem); return; }
            catch (RuntimeException e) { System.out.println("Falha na tentativa " + t); }
        }
    }
}

Notificador notificador = new NotificadorComRetry(
                              new NotificadorComLog(
                                  new NotificadorEmail()), 3);

Como NotificadorComRetry está na camada mais externa, cada tentativa reexecuta super.enviar() — o NotificadorComLog interno — então o log dispara a cada tentativa, não uma vez só.

Ao contrário do custo aditivo da bebida (comutativo), aqui a ordem das camadas muda o comportamento observável: log fora do retry registraria uma única vez, antes de qualquer tentativa.

Se, no exemplo do NotificadorComRetry envolvendo um NotificadorComLog que por sua vez envolve um NotificadorEmail, o método enviar() for chamado na camada mais externa, em que ordem exatamente a lógica de cada camada é executada — e o que essa ordem revela sobre por que o cliente nunca precisa saber quantas camadas existem?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ Se BebidaDecorator não implementasse a interface Bebida, mas apenas contivesse uma referência interna a um objeto Bebida, ainda seria possível empilhar Leite sobre Chocolate da mesma forma transparente que o padrão Decorator permite hoje.
  • □ Se um Cafe puro (sem nenhum decorador aplicado) tiver seu getCusto() chamado diretamente, o resultado é idêntico ao que se obteria encadeando zero decoradores em torno dele — o caso de zero camadas de wrapping é apenas o caso degenerado da mesma recursão.
  • □ Um sistema de processamento de texto que permite aplicar, de forma independente e em qualquer ordem, negrito, itálico e sublinhado sobre um texto-base poderia usar a mesma estrutura de Decorator vista para Bebida, com cada estilo implementado como uma classe que envolve o texto anterior.
  • □ Como o BufferedInputStream decora qualquer InputStream, incluindo outro BufferedInputStream já aplicado, envolver um fluxo já bufferizado com um segundo BufferedInputStream automaticamente dobra a velocidade de leitura, já que dois buffers armazenam mais dados que um.

A Recursão e a Transparência — Resposta

Por que a ordem de execução importa aqui — Resposta

  • ✗ Se BebidaDecorator não implementasse a interface Bebida, mas apenas contivesse uma referência interna a um objeto Bebida, ainda seria possível empilhar Leite sobre Chocolate da mesma forma transparente que o padrão Decorator permite hoje — falso: sem implementar Bebida (o lado É-UM), o decorador não poderia ser passado como argumento para outro decorador nem tratado polimorficamente pelo cliente; a transparência exige as duas relações ao mesmo tempo.
  • ✔ Se um Cafe puro (sem nenhum decorador aplicado) tiver seu getCusto() chamado diretamente, o resultado é idêntico ao que se obteria encadeando zero decoradores em torno dele — o caso de zero camadas de wrapping é apenas o caso degenerado da mesma recursão — a recursão sempre termina no objeto núcleo; com zero decoradores, a “recursão” é trivialmente a própria chamada direta.
  • ✔ Um sistema de processamento de texto que permite aplicar, de forma independente e em qualquer ordem, negrito, itálico e sublinhado sobre um texto-base poderia usar a mesma estrutura de Decorator vista para Bebida, com cada estilo implementado como uma classe que envolve o texto anterior — exemplo clássico de transferência do mesmo mecanismo de composição em camadas.
  • ✗ Como o BufferedInputStream decora qualquer InputStream, incluindo outro BufferedInputStream já aplicado, envolver um fluxo já bufferizado com um segundo BufferedInputStream automaticamente dobra a velocidade de leitura, já que dois buffers armazenam mais dados que um — falso: a flexibilidade estrutural do Decorator (poder envolver qualquer InputStream, inclusive outro decorador) não implica que toda composição traga benefício real; dois buffers redundantes tendem a só adicionar overhead de cópia, sem ganho de desempenho proporcional.

Voltando à pergunta: a chamada desce do mais externo (NotificadorComRetry) para o mais interno (NotificadorEmail), e cada camada só conhece a próxima camada imediatamente interna — nunca o topo nem o fundo completo da pilha. É exatamente essa “cegueira” de cada camada sobre o resto da cadeia que permite ao cliente empilhar (ou reordenar) decoradores livremente sem que nenhuma classe precise saber quantas outras existem.

O Perigo do Acoplamento de Notificação em Cascata

O Perigo do Acoplamento de Notificação

public class Pedido {
    private EmailService email;
    private LogService log;
    private InventarioService inventario; // Dependencias rigidas

    public void fecharPedido() {
        System.out.println("Pedido faturado.");

        // EFEITO CASCATA: acoplamento com subsistemas perifericos
        email.enviarConfirmacao();
        log.registrarEvento();
        inventario.baixarEstoque();
    }
}

Se o marketing exigir um SMS após o pedido, a classe Pedido precisa ser modificada — violação direta do OCP (Aula 10).

Dependência Inversa e a Diferença para o Decorator

O núcleo de negócio (Pedido) conhece detalhes de infraestrutura (e-mail, log, estoque) que não deveriam dizer respeito a ele.

Diferença para a cadeia de notificadores: ali era um objeto acumulando responsabilidades (Decorator). Aqui são múltiplos objetos independentes que o Pedido precisa conhecer e chamar diretamente — problema que o Decorator não resolve.

O padrão que resolve esse acoplamento é o Observer.

O Padrão Observer: Publicador, Assinantes e Desacoplamento

O Padrão Observer: Relação Um-Para-Muitos

“Definir uma dependência um-para-muitos entre objetos, de modo que quando um objeto muda de estado, todos os seus dependentes são notificados e atualizados automaticamente.” (GoF)

Sujeito (Subject): detém o estado de interesse, mantém uma lista abstrata de interessados.

Observadores (Observers): assinam uma interface comum, sem que o Sujeito conheça sua classe concreta.

Inversão de controle: o Sujeito não manda os outros trabalharem — ele avisa que um evento ocorreu.

O Contrato de Eventos e o Sujeito

public interface PedidoObserver {
    void aoFaturar(String pedidoId);
}

public class EmailService implements PedidoObserver {
    public void aoFaturar(String id) { System.out.println("E-mail enviado para " + id); }
}

public class PedidoSubject {
    private List<PedidoObserver> observadores = new ArrayList<>();

    public void registrar(PedidoObserver o) { observadores.add(o); }

    public void faturar(String id) {
        System.out.println("Processando regras de negocio do pedido...");
        for (PedidoObserver o : observadores) {
            o.aoFaturar(id); // Notificacao abstrata em broadcast
        }
    }
}

observadores guarda referências à interface PedidoObserver, nunca à classe concreta — faturar() cuida só da regra de negócio, o broadcast vem depois.

A Topologia Publicador-Assinante

Remover o envio de e-mails exige só deixar de registrar EmailService na inicialização — PedidoSubject permanece intacto: pleno respeito ao OCP.

Estratégias de Dados (Push vs. Pull) e do Observer Local à Arquitetura Distribuída

Estratégias de Dados: Push vs. Pull

Push: o Sujeito empacota os dados nos parâmetros do método (aoFaturar(id, valor)). Simples para o observador, mas rígido — novos dados exigem mudar a assinatura e quebrar observadores existentes.

Pull: o Sujeito envia só sua própria referência (update(pedido)); o observador consulta o que precisa via getters. Flexível a longo prazo, mas cria acoplamento bidirecional — o observador precisa conhecer a API pública do sujeito.

Do Observer Local à Arquitetura Orientada a Eventos

O Observer opera dentro da memória de um único processo (JVM). Ao mover o conceito para a rede, ele se torna a base das Arquiteturas Orientadas a Eventos (EDA).

Observer local Arquitetura Orientada a Eventos
Sujeito local Produtor de Eventos
Interface de callback Tópicos / Filas
Observador local Consumidor de Eventos / Microsserviço

“O Observer é o átomo fundamental que viabiliza sistemas reativos distribuídos.”

A Topologia é a Mesma, a Escala Muda

Mesmo princípio arquitetural — emissor, canal de anúncio, assinantes — só as ferramentas mudam de escala.

Se um novo tipo de observador precisar, no futuro, de um dado que hoje não está entre os parâmetros enviados pelo modelo Push atual (por exemplo, o valor total do pedido), o que precisa mudar no sistema — e por que essa mesma pergunta, respondida de outro jeito, é exatamente o argumento a favor do modelo Pull?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ Se o PedidoSubject usasse o modelo Pull (passando apenas a própria referência para update(PedidoSubject pedido)) em vez do modelo Push, adicionar um observador que precisa do valor total do pedido não exigiria alterar a assinatura do método de notificação já usado pelos observadores existentes.
  • □ Se o número de observadores registrados em um PedidoSubject crescer para um valor muito grande, o Pedido (o Sujeito) continua sem precisar conhecer o tipo concreto de nenhum deles, porque o for de broadcast opera apenas sobre a interface PedidoObserver.
  • □ Um sistema de sensores IoT que publica leituras de temperatura para múltiplos painéis de monitoramento, sem que o sensor conheça quantos ou quais painéis existem, está aplicando a mesma inversão de controle que o Observer aplica entre PedidoSubject e seus observadores.
  • □ Migrar de um Observer local (dentro da mesma JVM) para uma Arquitetura Orientada a Eventos distribuída (com Kafka ou RabbitMQ) exige abandonar completamente o princípio de desacoplamento do Observer, já que agora os componentes rodam em processos e máquinas diferentes.

Push vs. Pull e a Escala Distribuída — Resposta

O que muda ao adicionar um novo observador — Resposta

  • ✔ Se o PedidoSubject usasse o modelo Pull (passando apenas a própria referência para update(PedidoSubject pedido)) em vez do modelo Push, adicionar um observador que precisa do valor total do pedido não exigiria alterar a assinatura do método de notificação já usado pelos observadores existentes — exatamente a vantagem do Pull: o observador extrai o que precisa via getters, sem forçar mudança na assinatura do callback.
  • ✔ Se o número de observadores registrados em um PedidoSubject crescer para um valor muito grande, o Pedido (o Sujeito) continua sem precisar conhecer o tipo concreto de nenhum deles, porque o for de broadcast opera apenas sobre a interface PedidoObserver — o desacoplamento não depende da quantidade de observadores, só da abstração usada na lista.
  • ✔ Um sistema de sensores IoT que publica leituras de temperatura para múltiplos painéis de monitoramento, sem que o sensor conheça quantos ou quais painéis existem, está aplicando a mesma inversão de controle que o Observer aplica entre PedidoSubject e seus observadores — mesma estrutura publicador-assinante, domínio diferente.
  • ✗ Migrar de um Observer local (dentro da mesma JVM) para uma Arquitetura Orientada a Eventos distribuída (com Kafka ou RabbitMQ) exige abandonar completamente o princípio de desacoplamento do Observer, já que agora os componentes rodam em processos e máquinas diferentes — falso: a topologia permanece conceitualmente idêntica; a EDA não abandona o desacoplamento do Observer, ela o escala — produtores e consumidores continuam desconhecendo a identidade física uns dos outros.

Voltando à pergunta: com o modelo Push atual, adicionar um observador que precisa de um dado novo exigiria mudar a assinatura de aoFaturar() e, com isso, quebrar todos os observadores existentes que não esperam esse parâmetro extra. É exatamente esse custo que o modelo Pull evita — trocando simplicidade imediata por resiliência a mudanças futuras nos dados exigidos pelos observadores.

Conclusão

O que Aprendemos

  1. Responsabilidades opcionais e cumulativas explodem combinatoriamente sob herança estática (\(2^N\)) — o mesmo colapso da Aula 10, agora sobre um único objeto
  2. O Decorator resolve isso com uma dualidade É-UM/TEM-UM: cada camada compartilha a interface do objeto que envolve, e o cliente nunca sabe quantas camadas existem
  3. Amarrar um Pedido diretamente a EmailService/LogService/InventarioService viola o OCP a cada canal de notificação novo
  4. O Observer inverte esse controle via uma interface abstrata de observadores e um broadcast genérico — e a mesma topologia escala, sem alterar o princípio, para uma Arquitetura Orientada a Eventos distribuída

Próxima aula: a síntese arquitetural do curso — fronteiras entre núcleo estável e periferia volátil, e uma matriz de decisão comparando Strategy, Adapter, Decorator e Observer.

Exercícios Soluções