Design Patterns em Python

Design Patterns em Python

Singleton, Factory, Builder, Adapter, Facade, Strategy, Observer, Command, Chain of Responsibility e Template Method em Python, com código conferido — e os defeitos que um padrão esconde, como a cadeia que nunca chama o próximo elo e o int() que some com um centavo.
Python

• • 25 min de leitura

Design Patterns — padrões de projeto — são soluções reutilizáveis para problemas recorrentes no desenvolvimento de software. Foram catalogados pelo famoso livro do "Gang of Four" em 1994 e continuam relevantes. Em Python, muitos padrões são mais simples de implementar do que em Java ou C++ graças à tipagem dinâmica, funções de primeira classe e recursos como decoradores e metaclasses. Neste artigo cobrimos os padrões mais úteis no dia a dia, com implementações pythônicas.

Categorias de Padrões

Criacionais          Estruturais          Comportamentais
─────────────────    ─────────────────    ─────────────────
Singleton            Adapter              Strategy
Factory Method       Decorator            Observer
Abstract Factory     Facade               Command
Builder              Composite            Template Method
Prototype            Proxy                Iterator
                                          Chain of Responsibility
                                          State

Padrões Criacionais

Singleton

Garante que uma classe tenha apenas uma instância:

from threading import Lock
from typing import ClassVar


class Singleton:
    """Singleton thread-safe."""
    _instancia: ClassVar["Singleton | None"] = None
    _lock: ClassVar[Lock] = Lock()

    def __new__(cls):
        if cls._instancia is None:
            with cls._lock:
                if cls._instancia is None:   # double-checked locking
                    cls._instancia = super().__new__(cls)
        return cls._instancia


# Versão com decorator — curta, mas a classe vira uma função
def singleton(cls):
    instancias = {}
    def obter_instancia(*args, **kwargs):
        if cls not in instancias:
            instancias[cls] = cls(*args, **kwargs)
        return instancias[cls]
    return obter_instancia


@singleton
class ConfiguracaoApp:
    def __init__(self):
        self.debug    = False
        self.versao   = "1.0.0"
        self.database = "postgresql://localhost/escola"


c1 = ConfiguracaoApp()
c2 = ConfiguracaoApp()
print(c1 is c2)   # True — mesma instância

O decorador tem um custo que costuma passar despercebido: depois dele, o nome ConfiguracaoApp aponta para a função obter_instancia, não mais para a classe. Medido: isinstance(c1, ConfiguracaoApp) levanta TypeError: isinstance() arg 2 must be a type, e herdar de ConfiguracaoApp deixa de ser possível. Em Python, o singleton mais simples é o próprio módulo: ele é importado uma vez só, e uma instância criada no nível do módulo (config = ConfiguracaoApp()) é compartilhada por quem fizer from app.config import config. Em testes, qualquer singleton é estado global que passa de um teste para o outro, e por isso vale oferecer um jeito de recriá-lo.

Factory Method

Define uma interface para criar objetos, deixando subclasses decidirem qual classe instanciar:

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class Relatorio(ABC):
    titulo:   str
    conteudo: str

    def exibir(self):
        print(f"[{self.__class__.__name__}] {self.titulo}")

    @abstractmethod
    def exportar(self) -> bytes:
        """Todo formato exporta bytes, prontos para gravar ou enviar."""


class RelatorioPDF(Relatorio):
    def exportar(self) -> bytes:
        return f"PDF: {self.conteudo}".encode()


class RelatorioExcel(Relatorio):
    def exportar(self) -> bytes:
        return f"XLSX: {self.conteudo}".encode()


class RelatorioHTML(Relatorio):
    def exportar(self) -> bytes:
        return f"<html><body>{self.conteudo}</body></html>".encode()


class FabricaRelatorios:
    """Factory simples baseada em dicionário."""

    _tipos = {
        "pdf":   RelatorioPDF,
        "excel": RelatorioExcel,
        "html":  RelatorioHTML,
    }

    @classmethod
    def criar(cls, tipo: str, titulo: str, conteudo: str) -> Relatorio:
        classe = cls._tipos.get(tipo.lower())
        if not classe:
            raise ValueError(f"Tipo desconhecido: {tipo}. "
                             f"Opções: {list(cls._tipos)}")
        return classe(titulo=titulo, conteudo=conteudo)

    @classmethod
    def registrar(cls, tipo: str, classe):
        """Extensível sem modificar a factory."""
        cls._tipos[tipo] = classe


# Uso
rel = FabricaRelatorios.criar("pdf", "Boletim Março", "Conteúdo...")
rel.exibir()

# Registrando novo tipo sem modificar a classe
class RelatorioCSV(Relatorio):
    def exportar(self) -> bytes:
        return self.conteudo.encode()

FabricaRelatorios.registrar("csv", RelatorioCSV)
rel_csv = FabricaRelatorios.criar("csv", "Dados", "a,b,c")
print(rel_csv.exportar())   # b'a,b,c'

A primeira versão deste exemplo não declarava exportar na classe base, e dois defeitos passavam sem aviso: o relatório HTML devolvia str enquanto os outros devolviam bytes, e o RelatorioCSV registrado não tinha exportar nenhum. Medido: a fábrica o criava normalmente, e o erro só aparecia no uso, com AttributeError: 'RelatorioCSV' object has no attribute 'exportar'. Com o método abstrato, uma subclasse incompleta falha já na criação, com uma mensagem que diz o que falta. É o contrato que torna a fábrica extensível de verdade: quem registra um tipo novo sabe o que precisa implementar.

Builder

Constrói objetos complexos passo a passo:

from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Consulta:
    tabela:    str
    campos:    list[str]    = field(default_factory=lambda: ["*"])
    condicoes: list[str]    = field(default_factory=list)
    ordenacao: Optional[str] = None
    limite:    Optional[int] = None
    offset:    int           = 0

    def to_sql(self) -> str:
        campos_str = ", ".join(self.campos)
        sql        = f"SELECT {campos_str} FROM {self.tabela}"
        if self.condicoes:
            sql += " WHERE " + " AND ".join(self.condicoes)
        if self.ordenacao:
            sql += f" ORDER BY {self.ordenacao}"
        if self.limite:
            sql += f" LIMIT {self.limite}"
        if self.offset:
            sql += f" OFFSET {self.offset}"
        return sql


class ConstrutorConsulta:
    """Builder fluente para consultas SQL."""

    def __init__(self, tabela: str):
        self._consulta = Consulta(tabela=tabela)

    def selecionar(self, *campos: str) -> "ConstrutorConsulta":
        self._consulta.campos = list(campos)
        return self

    def onde(self, condicao: str) -> "ConstrutorConsulta":
        self._consulta.condicoes.append(condicao)
        return self

    def ordenar_por(self, campo: str, direcao: str = "ASC") -> "ConstrutorConsulta":
        self._consulta.ordenacao = f"{campo} {direcao}"
        return self

    def limitar(self, n: int) -> "ConstrutorConsulta":
        self._consulta.limite = n
        return self

    def paginar(self, pagina: int, tamanho: int = 10) -> "ConstrutorConsulta":
        self._consulta.limite  = tamanho
        self._consulta.offset  = (pagina - 1) * tamanho
        return self

    def construir(self) -> Consulta:
        return self._consulta


# Interface fluente — method chaining
sql = (
    ConstrutorConsulta("alunos")
    .selecionar("id", "nome", "nota")
    .onde("ativo = true")
    .onde("nota >= 6.0")
    .ordenar_por("nota", "DESC")
    .paginar(pagina=2, tamanho=10)
    .construir()
    .to_sql()
)
print(sql)
# SELECT id, nome, nota FROM alunos WHERE ativo = true
# AND nota >= 6.0 ORDER BY nota DESC LIMIT 10 OFFSET 10

Padrões Estruturais

Adapter

Converte a interface de uma classe em outra que o cliente espera:

from abc import ABC, abstractmethod


class ProcessadorPagamento(ABC):
    @abstractmethod
    def processar(self, valor: float, descricao: str) -> dict:
        ...


# API do Stripe — interface que não controlamos
class StripeAPI:
    def charge(self, amount_cents: int, currency: str, description: str) -> dict:
        return {
            "id":      "ch_123",
            "status":  "succeeded",
            "amount":  amount_cents,
        }


# API do PayPal — outra interface
class PayPalAPI:
    def create_payment(self, total: float, currency: str, note: str) -> dict:
        return {
            "paymentId": "PAY-456",
            "state":     "approved",
            "total":     total
        }


# Adapters — fazem as APIs externas conformarem à nossa interface
class AdapterStripe(ProcessadorPagamento):
    def __init__(self, api: StripeAPI):
        self._api = api

    def processar(self, valor: float, descricao: str) -> dict:
        centavos = round(valor * 100)   # int() trunca: 19.99 * 100 = 1998.999…
        resultado = self._api.charge(centavos, "BRL", descricao)
        return {
            "sucesso":    resultado["status"] == "succeeded",
            "id":         resultado["id"],
            "valor":      resultado["amount"] / 100,
            "gateway":    "stripe"
        }


class AdapterPayPal(ProcessadorPagamento):
    def __init__(self, api: PayPalAPI):
        self._api = api

    def processar(self, valor: float, descricao: str) -> dict:
        resultado = self._api.create_payment(valor, "BRL", descricao)
        return {
            "sucesso":    resultado["state"] == "approved",
            "id":         resultado["paymentId"],
            "valor":      resultado["total"],
            "gateway":    "paypal"
        }


def cobrar(processador: ProcessadorPagamento, valor: float, desc: str):
    """Funciona com qualquer gateway via Adapter."""
    resultado = processador.processar(valor, desc)
    status = "✓" if resultado["sucesso"] else "✗"
    print(f"{status} [{resultado['gateway']}] R${resultado['valor']:.2f} — {desc}")


cobrar(AdapterStripe(StripeAPI()),   150.00, "Mensalidade Março")
cobrar(AdapterPayPal(PayPalAPI()),   150.00, "Mensalidade Março")

A conversão para centavos merece atenção, porque é o tipo de linha que ninguém revisa. A primeira versão fazia int(valor * 100), e o int() trunca em vez de arredondar. Em ponto flutuante, 19.99 * 100 vale 1998.9999999999998. Medido: int() dá 1998 centavos para R$ 19,99, 28 para R$ 0,29 e 114 para R$ 1,15. Com R$ 150,00, o valor do exemplo, o erro não aparece. round() dá o valor certo nos três casos, e o melhor, num sistema de pagamentos, é nem passar por float: guardar valores como Decimal ou como inteiro de centavos desde a entrada.

Facade

Fornece interface simplificada para um subsistema complexo:

class SubsistemaEmail:
    def conectar(self, host: str): ...
    def autenticar(self, user: str, senha: str): ...
    def enviar(self, para: str, assunto: str, corpo: str): ...
    def desconectar(self): ...


class SubsistemaTemplate:
    def carregar(self, nome: str) -> str: ...
    def renderizar(self, template: str, contexto: dict) -> str: ...


class SubsistemaFila:
    def enfileirar(self, tarefa: dict): ...
    def processar(self): ...


class FacadeNotificacao:
    """
    Facade — simplifica envio de e-mails com template e fila.
    O cliente não precisa conhecer os subsistemas internos.
    """

    def __init__(self):
        self._email    = SubsistemaEmail()
        self._template = SubsistemaTemplate()
        self._fila     = SubsistemaFila()

    def enviar_boas_vindas(self, email: str, nome: str):
        self._fila.enfileirar({
            "tipo":     "email",
            "template": "boas_vindas",
            "para":     email,
            "contexto": {"nome": nome}
        })

    def enviar_boletim(self, email: str, aluno: dict):
        template = self._template.carregar("boletim")
        corpo    = self._template.renderizar(template, aluno)
        self._email.conectar("smtp.gmail.com")
        self._email.autenticar("escola@email.com", "senha")
        self._email.enviar(email, "Seu Boletim", corpo)
        self._email.desconectar()


# Interface simples para o cliente
notif = FacadeNotificacao()
notif.enviar_boas_vindas("ana@email.com", "Ana")

Padrões Comportamentais

Strategy

Define uma família de algoritmos intercambiáveis:

from abc import ABC, abstractmethod
from typing import Protocol


class EstrategiaOrdenacao(Protocol):
    def ordenar(self, alunos: list) -> list: ...


class OrdenarPorNome:
    def ordenar(self, alunos: list) -> list:
        return sorted(alunos, key=lambda a: a["nome"])


class OrdenarPorNota:
    def ordenar(self, alunos: list) -> list:
        return sorted(alunos, key=lambda a: a["nota"], reverse=True)


class OrdenarPorFaltas:
    def ordenar(self, alunos: list) -> list:
        return sorted(alunos, key=lambda a: a["faltas"])


class GerenciadorTurma:
    def __init__(self, estrategia: EstrategiaOrdenacao):
        self._estrategia = estrategia

    def definir_estrategia(self, estrategia: EstrategiaOrdenacao):
        self._estrategia = estrategia

    def listar(self, alunos: list) -> list:
        return self._estrategia.ordenar(alunos)


alunos = [
    {"nome": "Carla", "nota": 8.5, "faltas": 2},
    {"nome": "Ana",   "nota": 9.5, "faltas": 0},
    {"nome": "Bruno", "nota": 7.0, "faltas": 5},
]

gerenciador = GerenciadorTurma(OrdenarPorNota())
print("Por nota:", [a["nome"] for a in gerenciador.listar(alunos)])

gerenciador.definir_estrategia(OrdenarPorNome())
print("Por nome:", [a["nome"] for a in gerenciador.listar(alunos)])

# Versão funcional com Python — Strategy como função
def listar_com_criterio(alunos: list, criterio) -> list:
    return sorted(alunos, key=criterio)

print("Lambda:", [a["nome"] for a in
      listar_com_criterio(alunos, lambda a: -a["nota"])])

Observer

Define dependência um-para-muitos — quando um objeto muda, todos os dependentes são notificados:

from abc import ABC, abstractmethod
from typing import Any
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime, timezone


@dataclass
class Evento:
    tipo:      str
    dados:     dict
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class Observador(ABC):
    @abstractmethod
    def atualizar(self, evento: Evento) -> None: ...


class SistemaEventos:
    """Event bus simples — publisher/subscriber."""

    def __init__(self):
        self._ouvintes: dict[str, list[Observador]] = {}

    def inscrever(self, tipo_evento: str, observador: Observador):
        self._ouvintes.setdefault(tipo_evento, []).append(observador)

    def desinscrever(self, tipo_evento: str, observador: Observador):
        if tipo_evento in self._ouvintes:
            # lista não tem discard (isso é de set): remove() e ignora a ausência
            try:
                self._ouvintes[tipo_evento].remove(observador)
            except ValueError:
                pass

    def publicar(self, evento: Evento):
        for obs in self._ouvintes.get(evento.tipo, []):
            try:
                obs.atualizar(evento)
            except Exception as e:
                print(f"Erro no observador {obs}: {e}")

        # Ouvintes de todos os eventos — com a mesma proteção
        for obs in self._ouvintes.get("*", []):
            try:
                obs.atualizar(evento)
            except Exception as e:
                print(f"Erro no observador {obs}: {e}")


# Observadores concretos
class NotificadorEmail(Observador):
    def atualizar(self, evento: Evento):
        if evento.tipo == "aluno.aprovado":
            print(f"[Email] Parabéns {evento.dados['nome']}! Você foi aprovado.")

class AuditoriaLogger(Observador):
    def atualizar(self, evento: Evento):
        print(f"[Auditoria] {evento.timestamp} | {evento.tipo} | {evento.dados}")

class AtualizadorPainel(Observador):
    def atualizar(self, evento: Evento):
        if evento.tipo in ["aluno.aprovado", "aluno.reprovado"]:
            print(f"[Painel] Atualizando dashboard...")


# Uso
bus = SistemaEventos()
bus.inscrever("aluno.aprovado",  NotificadorEmail())
bus.inscrever("aluno.aprovado",  AuditoriaLogger())
bus.inscrever("aluno.reprovado", AuditoriaLogger())
bus.inscrever("*",               AtualizadorPainel())

bus.publicar(Evento(
    tipo="aluno.aprovado",
    dados={"id": 1, "nome": "Ana Silva", "nota": 9.5}
))
bus.publicar(Evento(
    tipo="aluno.reprovado",
    dados={"id": 2, "nome": "Bruno Costa", "nota": 4.0}
))

Command

Encapsula uma requisição como objeto — permite desfazer, enfileirar e logar:

from abc import ABC, abstractmethod
from typing import Optional
from collections import deque


class Comando(ABC):
    @abstractmethod
    def executar(self) -> None: ...

    @abstractmethod
    def desfazer(self) -> None: ...


class ComandoAdicionarNota(Comando):
    def __init__(self, aluno: dict, nota: float):
        self.aluno          = aluno
        self.nota           = nota
        self._nota_anterior: Optional[float] = None

    def executar(self) -> None:
        self._nota_anterior = self.aluno.get("nota")
        self.aluno["nota"]  = self.nota
        print(f"Nota {self.nota} adicionada para {self.aluno['nome']}")

    def desfazer(self) -> None:
        self.aluno["nota"] = self._nota_anterior
        print(f"Nota desfeita para {self.aluno['nome']}: {self._nota_anterior}")


class ComandoDesativarAluno(Comando):
    def __init__(self, aluno: dict):
        self.aluno = aluno

    def executar(self) -> None:
        self.aluno["ativo"] = False
        print(f"Aluno {self.aluno['nome']} desativado")

    def desfazer(self) -> None:
        self.aluno["ativo"] = True
        print(f"Aluno {self.aluno['nome']} reativado")


class GerenciadorComandos:
    """Controla execução e histórico de comandos — suporta undo/redo."""

    def __init__(self):
        self._historico:  deque[Comando] = deque(maxlen=50)
        self._desfeitos:  deque[Comando] = deque(maxlen=50)

    def executar(self, comando: Comando) -> None:
        comando.executar()
        self._historico.append(comando)
        self._desfeitos.clear()

    def desfazer(self) -> None:
        if not self._historico:
            print("Nada para desfazer.")
            return
        comando = self._historico.pop()
        comando.desfazer()
        self._desfeitos.append(comando)

    def refazer(self) -> None:
        if not self._desfeitos:
            print("Nada para refazer.")
            return
        comando = self._desfeitos.pop()
        comando.executar()
        self._historico.append(comando)


aluno = {"nome": "Ana", "nota": 7.0, "ativo": True}
gerenciador = GerenciadorComandos()

gerenciador.executar(ComandoAdicionarNota(aluno, 9.5))
gerenciador.executar(ComandoDesativarAluno(aluno))
print(f"Estado: {aluno}")

gerenciador.desfazer()
gerenciador.desfazer()
print(f"Após desfazer: {aluno}")

gerenciador.refazer()
print(f"Após refazer: {aluno}")

Chain of Responsibility

Passa uma requisição por uma cadeia de handlers:

from abc import ABC, abstractmethod
from typing import Optional
from dataclasses import dataclass


@dataclass
class Pedido:
    valor:       float
    tipo:        str
    usuario_id:  int
    aprovado:    bool  = False
    motivo:      str   = ""


class Handler(ABC):
    def __init__(self):
        self._proximo: Optional["Handler"] = None

    def definir_proximo(self, handler: "Handler") -> "Handler":
        self._proximo = handler
        return handler

    def processar(self, pedido: Pedido) -> Pedido:
        if self._proximo:
            return self._proximo.handle(pedido)   # o PRÓXIMO elo decide
        return pedido

    @abstractmethod
    def handle(self, pedido: Pedido) -> Optional[Pedido]: ...


class ValidacaoHandler(Handler):
    def handle(self, pedido: Pedido) -> Optional[Pedido]:
        if pedido.valor <= 0:
            pedido.motivo   = "Valor inválido"
            pedido.aprovado = False
            return pedido
        return self.processar(pedido)


class LimiteHandler(Handler):
    def __init__(self, limite: float):
        super().__init__()
        self.limite = limite

    def handle(self, pedido: Pedido) -> Optional[Pedido]:
        if pedido.valor > self.limite:
            pedido.motivo   = f"Valor acima do limite R${self.limite:.2f}"
            pedido.aprovado = False
            return pedido
        return self.processar(pedido)


class FraudeHandler(Handler):
    def handle(self, pedido: Pedido) -> Optional[Pedido]:
        if pedido.usuario_id in [666, 999]:
            pedido.motivo   = "Usuário bloqueado por suspeita de fraude"
            pedido.aprovado = False
            return pedido
        return self.processar(pedido)


class AprovacaoFinalHandler(Handler):
    def handle(self, pedido: Pedido) -> Optional[Pedido]:
        pedido.aprovado = True
        pedido.motivo   = "Aprovado"
        return pedido


# Montando a cadeia
validacao = ValidacaoHandler()
limite    = LimiteHandler(5000.0)
fraude    = FraudeHandler()
aprovacao = AprovacaoFinalHandler()

validacao.definir_proximo(limite).definir_proximo(fraude).definir_proximo(aprovacao)

pedidos = [
    Pedido(valor=250.00,  tipo="compra",    usuario_id=1),
    Pedido(valor=-10.00,  tipo="compra",    usuario_id=2),
    Pedido(valor=8000.00, tipo="saque",     usuario_id=3),
    Pedido(valor=100.00,  tipo="compra",    usuario_id=666),
]

for p in pedidos:
    resultado = validacao.handle(p)
    status = "✓" if resultado.aprovado else "✗"
    print(f"{status} R${p.valor:>8.2f} | {resultado.motivo}")

Uma linha fazia toda a diferença nesse exemplo. Na primeira versão, o processar da classe base repassava o pedido chamando self._proximo.processar(pedido). Assim, o pedido pulava de elo em elo pelo método de repasse e nunca entrava no handle de nenhum deles. Medido: os quatro pedidos saíam recusados — o válido de R$ 250 inclusive —, e três deles com motivo vazio; o limite, a checagem de fraude e a aprovação final nunca rodavam. Com self._proximo.handle(pedido), cada elo decide e passa adiante. O defeito passava porque a saída "tudo recusado" parece o comportamento de um sistema rigoroso. Numa cadeia, vale um teste por elo: um pedido que só aquele elo deveria barrar.

Template Method

Define o esqueleto de um algoritmo, deixando subclasses preencherem os detalhes:

from abc import ABC, abstractmethod


class ProcessadorRelatorio(ABC):
    """Template Method — define o fluxo, subclasses definem os passos."""

    def gerar(self, dados: list) -> str:
        """Método template — não deve ser sobrescrito."""
        dados_limpos  = self.limpar_dados(dados)
        dados_proc    = self.processar(dados_limpos)
        conteudo      = self.formatar(dados_proc)
        cabecalho     = self.cabecalho()
        rodape        = self.rodape(dados_proc)
        return f"{cabecalho}\n{conteudo}\n{rodape}"

    def limpar_dados(self, dados: list) -> list:
        """Hook — comportamento padrão pode ser sobrescrito."""
        return [d for d in dados if d is not None]

    @abstractmethod
    def processar(self, dados: list) -> list: ...

    @abstractmethod
    def formatar(self, dados: list) -> str: ...

    def cabecalho(self) -> str:
        return "=== RELATÓRIO ==="

    def rodape(self, dados: list) -> str:
        return f"Total: {len(dados)} registros"


class RelatorioAprovados(ProcessadorRelatorio):
    def processar(self, dados: list) -> list:
        return [d for d in dados if d.get("nota", 0) >= 6.0]

    def formatar(self, dados: list) -> str:
        linhas = [f"  ✓ {d['nome']:20} — {d['nota']:.1f}" for d in dados]
        return "\n".join(linhas)

    def rodape(self, dados: list) -> str:
        return f"{len(dados)} aprovados listados acima."


class RelatorioRanking(ProcessadorRelatorio):
    def processar(self, dados: list) -> list:
        return sorted(dados, key=lambda d: d.get("nota", 0), reverse=True)

    def formatar(self, dados: list) -> str:
        linhas = [
            f"  {i:2}. {d['nome']:20} — {d['nota']:.1f}"
            for i, d in enumerate(dados, 1)
        ]
        return "\n".join(linhas)

    def cabecalho(self) -> str:
        return "=== RANKING DE NOTAS ==="


alunos = [
    {"nome": "Ana",   "nota": 9.5},
    {"nome": "Bruno", "nota": 4.0},
    {"nome": "Carla", "nota": 8.5},
    {"nome": "Diego", "nota": 6.0},
]

print(RelatorioAprovados().gerar(alunos))
print()
print(RelatorioRanking().gerar(alunos))

Exemplo Completo: Sistema de Processamento de Pagamentos

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, Protocol
from datetime import datetime, timezone
from collections import deque
import uuid


# ── Domínio ───────────────────────────────────────────
@dataclass
class Pagamento:
    id:          str          = field(default_factory=lambda: str(uuid.uuid4())[:8])
    valor:       float        = 0.0
    descricao:   str          = ""
    gateway:     str          = ""
    status:      str          = "pendente"
    timestamp:   datetime     = field(
        default_factory=lambda: datetime.now(timezone.utc)
    )
    tentativas:  int          = 0
    erro:        Optional[str] = None


# ── Observer: eventos do pagamento ────────────────────
class ObservadorPagamento(Protocol):
    def on_pagamento(self, pagamento: Pagamento) -> None: ...


class LogPagamento:
    def on_pagamento(self, p: Pagamento):
        print(f"  [Log] {p.status.upper()} | {p.id} | R${p.valor:.2f}")


class NotificadorPagamento:
    def on_pagamento(self, p: Pagamento):
        if p.status == "aprovado":
            print(f"  [Email] Pagamento de R${p.valor:.2f} aprovado!")
        elif p.status == "recusado":
            print(f"  [Email] Pagamento recusado: {p.erro}")


# ── Strategy: gateways ────────────────────────────────
class GatewayPagamento(Protocol):
    def cobrar(self, valor: float, descricao: str) -> dict: ...


class GatewaySimulado:
    def __init__(self, nome: str, taxa_sucesso: float = 0.9):
        self.nome        = nome
        self.taxa_sucesso = taxa_sucesso
        import random
        self._rand = random.Random(42)

    def cobrar(self, valor: float, descricao: str) -> dict:
        sucesso = self._rand.random() < self.taxa_sucesso
        return {
            "sucesso":     sucesso,
            "id_externo":  f"{self.nome[:3].upper()}-{uuid.uuid4().hex[:6]}",
            "erro":        None if sucesso else "Cartão recusado"
        }


# ── Chain: validações ─────────────────────────────────
class ValidadorPagamento(ABC):
    def __init__(self):
        self._proximo: Optional["ValidadorPagamento"] = None

    def proximo(self, v: "ValidadorPagamento") -> "ValidadorPagamento":
        self._proximo = v
        return v

    def validar(self, p: Pagamento) -> bool:
        resultado = self._checar(p)
        if resultado and self._proximo:
            return self._proximo.validar(p)
        return resultado

    @abstractmethod
    def _checar(self, p: Pagamento) -> bool: ...


class ValidarValor(ValidadorPagamento):
    def _checar(self, p: Pagamento) -> bool:
        if p.valor <= 0:
            p.status = "recusado"
            p.erro   = "Valor inválido"
            return False
        return True


class ValidarLimite(ValidadorPagamento):
    def __init__(self, limite: float):
        super().__init__()
        self.limite = limite

    def _checar(self, p: Pagamento) -> bool:
        if p.valor > self.limite:
            p.status = "recusado"
            p.erro   = f"Acima do limite R${self.limite:.2f}"
            return False
        return True


# ── Facade: orquestra tudo ────────────────────────────
class ProcessadorPagamentos:
    def __init__(self, gateway: GatewayPagamento):
        self._gateway     = gateway
        self._observadores: list = [LogPagamento(), NotificadorPagamento()]
        self._historico:  deque[Pagamento] = deque(maxlen=100)

        # Cadeia de validação
        v1 = ValidarValor()
        v2 = ValidarLimite(10_000.0)
        v1.proximo(v2)
        self._validador = v1

    def processar(self, valor: float, descricao: str) -> Pagamento:
        p = Pagamento(valor=valor, descricao=descricao)
        print(f"\nProcessando: R${valor:.2f} — {descricao}")

        # Validação (Chain of Responsibility)
        if not self._validador.validar(p):
            self._notificar(p)
            self._historico.append(p)
            return p

        # Cobrança (Strategy)
        p.tentativas += 1
        resultado     = self._gateway.cobrar(valor, descricao)

        if resultado["sucesso"]:
            p.status  = "aprovado"
            p.gateway = resultado["id_externo"]
        else:
            p.status  = "recusado"
            p.erro    = resultado["erro"]

        # Notificação (Observer)
        self._notificar(p)
        self._historico.append(p)
        return p

    def _notificar(self, p: Pagamento):
        for obs in self._observadores:
            obs.on_pagamento(p)

    def relatorio(self) -> dict:
        aprovados  = [p for p in self._historico if p.status == "aprovado"]
        recusados  = [p for p in self._historico if p.status == "recusado"]
        return {
            "total":        len(self._historico),
            "aprovados":    len(aprovados),
            "recusados":    len(recusados),
            "valor_total":  sum(p.valor for p in aprovados),
        }


# ── Executando ────────────────────────────────────────
gateway    = GatewaySimulado("Stripe", taxa_sucesso=0.8)
processador = ProcessadorPagamentos(gateway)

pagamentos = [
    (250.00,   "Mensalidade Março"),
    (-10.00,   "Valor inválido"),
    (15000.00, "Acima do limite"),
    (89.90,    "Material didático"),
    (500.00,   "Matrícula"),
]

for valor, desc in pagamentos:
    processador.processar(valor, desc)

print("\n=== Relatório ===")
rel = processador.relatorio()
for k, v in rel.items():
    print(f"  {k:15}: {v}")

Os padrões de projeto dão nome a soluções que se repetem, e em Python várias delas encolhem. O Singleton pode ser simplesmente um módulo; a Factory, um dicionário de classes; a Strategy, uma função passada como argumento. Outros continuam valendo a estrutura inteira: o Adapter isola APIs externas atrás de uma interface própria, o Observer desacopla quem publica de quem reage, o Command transforma operações em objetos que se podem desfazer, a Chain of Responsibility encadeia validações, e o Template Method fixa o esqueleto de um algoritmo e deixa os detalhes para as subclasses.

O que os defeitos desta página ensinam é que um padrão bem desenhado no diagrama pode estar quebrado no código, e ninguém percebe. A cadeia que recusava todo pedido parecia rigorosa; o rodapé que contava sempre zero parecia um detalhe; o desinscrever nunca tinha sido chamado. Anotações de tipo conferidas pelo mypy, como em Qualidade de Código: linting, type hints e mypy, e um teste por comportamento, como em Testes com pytest, pegam esse tipo de erro. O padrão organiza o código; quem garante que ele funciona são as verificações.

Fontes e leituras recomendadas

Exercícios

Exercício 1

A cadeia de aprovação de pedidos está em produção, e o time comemora: nenhuma fraude passou em um mês. Um analista percebe outra coisa: nenhum pedido foi aprovado nesse mês, nem os de R$ 50. Na primeira versão do artigo, o processar da classe base fazia return self._proximo.processar(pedido). Por que isso bloqueava tudo, e por que o bloqueio passou por rigor?

Ver resposta

✓ Resposta: Porque o repasse chamava o método de repasse do próximo elo, e não o de decisão. O pedido entrava no handle do primeiro elo, a validação, que chamava self.processar. Dali ele pulava para o processar do segundo, que pulava para o do terceiro, e assim até o último, que não tinha próximo e devolvia o pedido do jeito que chegou: aprovado=False e motivo vazio. Nenhum handle depois do primeiro rodava. Medido com os quatro pedidos do artigo: todos recusados, três com motivo em branco, incluindo o de R$ 250 que deveria passar. A correção é self._proximo.handle(pedido). Passou por rigor porque "recusado" é o resultado seguro de um sistema de aprovação, e ninguém desconfia de um filtro que filtra demais. Duas práticas teriam pegado o erro: um teste do caminho feliz, em que um pedido válido precisa sair aprovado, e o motivo vazio tratado como erro, porque toda decisão de uma cadeia deveria dizer quem a tomou.

Exercício 2

O adapter do Stripe converte reais em centavos com int(valor * 100). Os testes, todos com R$ 150,00, passam. Em produção, uma cobrança de R$ 19,99 sai como 1998 centavos. Explique o centavo perdido e diga que outros valores sofrem do mesmo problema.

Ver resposta

✓ Resposta: O float não representa 19,99 exatamente; o valor guardado é um pouco menor, e 19.99 * 100 dá 1998.9999999999998. O int() não arredonda, ele trunca em direção a zero, e 1998,999… vira 1998. Medido: o mesmo acontece com R$ 0,29, que vira 28 centavos, e R$ 1,15, que vira 114. Com R$ 150,00 a multiplicação é exata, e o teste não tinha como pegar o erro. round(valor * 100) corrige os três casos. A correção de fundo é não usar float para dinheiro: receber o valor como texto e convertê-lo com Decimal("19.99"), ou trabalhar com inteiros de centavos desde a entrada. A lição de teste vale igualmente: valores "redondos" escondem erros de arredondamento. Um teste com uma lista de preços reais, como 19,99, 0,29 e 1,15, pega o problema na primeira execução.

Exercício 3

No Template Method dos relatórios, a classe base define rodape() retornando f"Total: {0} registros", e o ranking de quatro alunos termina com "Total: 0 registros". Por que o rodapé não tinha como acertar, e o que isso diz sobre o desenho de ganchos num Template Method?

Ver resposta

✓ Resposta: O {0} dentro da f-string é a expressão 0, uma constante, e não uma referência a algum dado. Mesmo que alguém quisesse contar, o método não tinha acesso aos registros: a assinatura era rodape(self), sem os dados processados. Num Template Method, os ganchos só podem usar o que o método-template lhes passa ou o que está guardado no objeto. Aqui, o gerar calculava dados_proc e não o repassava. A correção muda a assinatura para rodape(self, dados) e faz o gerar chamar self.rodape(dados_proc). Medido: o ranking passa a terminar com "Total: 4 registros", e o relatório de aprovados pode dizer "3 aprovados listados acima". A regra de desenho é pensar nos ganchos a partir do que uma subclasse razoável precisaria saber, e passar isso explicitamente. Mudar a assinatura de um gancho depois obriga a mudar todas as subclasses.

Exercício 4

Um colega usa o singleton por decorador do artigo e escreve, num teste, assert isinstance(config, ConfiguracaoApp). O teste falha com TypeError: isinstance() arg 2 must be a type. Depois, ao tentar criar class ConfiguracaoTeste(ConfiguracaoApp), recebe outro TypeError. O que o decorador fez com a classe?

Ver resposta

✓ Resposta: Substituiu a classe por uma função. @singleton equivale a ConfiguracaoApp = singleton(ConfiguracaoApp), e o singleton devolve obter_instancia, uma função que guarda a classe original numa closure. A partir daí, o nome ConfiguracaoApp é essa função: medido, type(ConfiguracaoApp).__name__ é "function". Por isso o isinstance recusa o segundo argumento e o class ... (ConfiguracaoApp) falha. Um decorador de classe que devolve outra coisa muda o que o nome significa para todo o resto do programa, incluindo anotações de tipo e ferramentas como o mypy. Para um singleton em Python, a saída mais simples é uma instância no nível do módulo. Se for preciso controlar a criação dentro da classe, o __new__ com trava, como na classe Singleton do começo do exemplo, mantém a classe como classe.

Exercício 5

O barramento de eventos funciona há meses. Um dia, uma tela precisa parar de receber notificações ao ser fechada, e a chamada bus.desinscrever("aluno.aprovado", painel) falha com AttributeError: 'list' object has no attribute 'discard'. O método existia desde o começo. Como o erro sobreviveu tanto tempo, e que ferramenta o teria pegado sem executar nada?

Ver resposta

✓ Resposta: Sobreviveu porque nunca foi executado: nenhum teste e nenhum caminho do sistema chamava desinscrever até aquele dia, e o Python só resolve .discard na hora em que a linha roda. O discard é de set; a lista tem remove, que levanta ValueError quando o item não está lá, daí o try na versão corrigida. O mypy teria pegado o erro sem executar nada. Os ouvintes estão anotados como dict[str, list[Observador]], e rodando o mypy no bloco original, sai error: "list[Observador]" has no attribute "discard" [attr-defined], junto com outro aviso real: o campo timestamp: datetime = None, que não aceita None pelo tipo declarado. É o argumento mais forte para anotar tipos: eles verificam também o código que os testes esquecem. Trocar a lista por um set também seria possível, mas mudaria o comportamento: os observadores deixariam de ser chamados na ordem de inscrição.

Comentários

Mais em Python

Machine Learning com scikit-learn
Machine Learning com scikit-learn

O pipeline completo de Machine Learning com scikit-learn: pré-processamento…

Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real
Projeto 4: Dashboard Analítico com Dash e Banco de Dados Real

Dashboard analítico com Dash, Plotly, SQLAlchemy e PostgreSQL, conferido num…

Dicionários: chave, valor e as estruturas do mundo real
Dicionários: chave, valor e as estruturas do mundo real

Dicionários trocam posição por significado e são a estrutura mais usada do…