Bases da Engenharia
Livro completo · 30 capítulos · 6 partes
Livro completo · Fundamentos da Engenharia de Software

Tudo o que está
por baixo de uma
arquitetura sólida.

Trinta capítulos cobrindo desde POO aplicada até DDD, mensageria, containers e CI/CD. Com estudos de caso evolutivos, exercícios graduados, exemplos antes-e-depois, contexto histórico e — onde necessário — críticas honestas a cada técnica. Pensado para ser sua referência completa rumo à arquitetura.

30
Capítulos
6
Partes
100+
Exercícios graduados
300+
Exemplos em Python
Prefácio

Não é um manual de receitas.

Este livro não te dá fórmulas para colar. Ele te ensina a raciocinar sobre código, sistemas, dados e equipes — para que, quando o problema real aparecer (e ele nunca é exatamente igual aos exemplos), você tenha o ferramental mental para resolvê-lo.

Cada capítulo segue uma estrutura deliberada: contexto histórico do problema (de onde veio essa ideia), conceito explicado em camadas (intuição, formalização, aplicação), estudo de caso evolutivo (você acompanha um sistema sendo construído ou refatorado), erros comuns, quando não usar a técnica, exercícios graduados (fácil → médio → difícil) e um quiz de verificação. Você não vai apenas ler — você vai praticar.

A linguagem escolhida é Python, mas o que se aprende aqui se traduz para qualquer linguagem que você venha a usar depois: Java, Go, TypeScript, Rust. Os princípios são linguisticamente neutros.

Sobre o caminho até arquitetura: não existe atalho. Você precisa entender profundamente os fundamentos antes de tomar decisões arquiteturais. Este livro é a fundação. Os 36 meses depois dele — com sistemas reais, problemas reais, gente real — são o que vai te transformar em arquiteto.

Sumário completo

Seis partes, trinta capítulos

Organizado em escalada: cada parte se apoia na anterior. Se for a primeira leitura, vá em ordem. Para consulta, salte direto. Cada capítulo é autocontido, mas referencia os anteriores quando necessário.

Parte I · Fundação do código Cinco capítulos · O que toda função sua escreve
CAP. 01
POO aplicada
Encapsulamento, composição, polimorfismo. Para além da sintaxe.
CAP. 02
Estruturas de dados
Big-O sem trauma. Escolher a estrutura certa.
CAP. 03
SOLID profundo
Cada princípio, sua história, suas críticas.
CAP. 04
Tratamento de erros
Exceções, Result, falhas previsíveis.
CAP. 05
Tipagem e contratos
Type hints, Protocol, design by contract.
Parte II · Padrões de design Cinco capítulos · Soluções nominadas que se repetem
CAP. 06
Por que padrões
História, GoF, quando aplicar, quando ignorar.
CAP. 07
Padrões criacionais
Factory, Builder, Singleton (com crítica).
CAP. 08
Padrões estruturais
Adapter, Decorator, Facade, Proxy.
CAP. 09
Padrões comportamentais
Strategy, Observer, Command, State.
CAP. 10
Anti-padrões
Cargo cult, God Object, Anemic Model.
Parte III · Qualidade e evolução Quatro capítulos · Código que sobrevive ao tempo
CAP. 11
Testes profundos
Pirâmide, TDD, doubles, property-based.
CAP. 12
Refatoração disciplinada
Catálogo de Fowler aplicado.
CAP. 13
Code smells avançados
Reconhecer, classificar, tratar.
CAP. 14
Documentação técnica
Diátaxis, ADRs, READMEs, comentários úteis.
Parte IV · Dados e persistência Quatro capítulos · A camada que nunca mente
CAP. 15
Modelagem relacional
Entidades, relacionamentos, normalização.
CAP. 16
SQL e performance
Índices, EXPLAIN, queries pesadas.
CAP. 17
Transações e concorrência
ACID, isolamento, locks, MVCC.
CAP. 18
NoSQL com critério
Documento, chave-valor, coluna, grafo.
Parte V · Arquitetura aplicada Seis capítulos · Estruturando sistemas reais
CAP. 19
Arquitetura em camadas
Layered, separação de preocupações.
CAP. 20
Hexagonal e Clean
Ports & Adapters, inversão de dependência.
CAP. 21
DDD introdutório
Bounded contexts, agregados, linguagem ubíqua.
CAP. 22
APIs REST
Design, versionamento, idempotência.
CAP. 23
GraphQL
Schema, resolvers, N+1, quando vale.
CAP. 24
Mensageria e eventos
Filas, pub/sub, sagas, idempotência.
Parte VI · Operação e equipe Seis capítulos · Software vivo em produção
CAP. 25
Concorrência e paralelismo
async, threads, processos, race conditions.
CAP. 26
Segurança aplicada
OWASP, auth, segredos, criptografia.
CAP. 27
Observabilidade
Logs, métricas, traces, debugging em prod.
CAP. 28
Docker e containers
Imagens, redes, Compose, multistage.
CAP. 29
Git e code review
Grafo de commits, rebase, fluxos.
CAP. 30
CI/CD e deploy
Pipelines, feature flags, blue/green.
Parte I
Fundação do código

Antes de pensar em padrões, antes de arquitetura, antes de testes — você precisa escrever código que outras pessoas, e você mesmo em seis meses, consigam ler, manter e evoluir.

POO aplicada Estruturas de dados SOLID profundo Tratamento de erros Tipagem e contratos
Parte I · Capítulo 01 · Fundação do código

Programação
orientada a objetos,
aplicada.

OOP não é sobre class. É sobre modelar conceitos do mundo real, isolar responsabilidades, proteger invariantes do domínio e permitir que o código cresça sem virar pesadelo.

A maioria dos desenvolvedores aprende a sintaxe de classes na primeira semana de Python e nunca mais revisita o tema com profundidade. O resultado é código que parece orientado a objetos mas se comporta como procedural disfarçado: classes anêmicas que são só sacos de dados, herança aleatória que cria acoplamento forte, e atributos públicos sem nenhuma proteção contra estados inválidos.

Este capítulo vai além da sintaxe. Vamos entender o que cada pilar da OOP resolve de fato, por que existe, quando aplicar — e, igualmente importante, quando deliberadamente não aplicar.

1.1 A história — de onde veio essa ideia

Contexto histórico

Em 1967, Ole-Johan Dahl e Kristen Nygaard publicaram Simula 67, criada para simulação de sistemas complexos (tráfego, processos industriais). Nela, surgiu a ideia de modelar entidades do mundo real como "objetos" que carregam dados e comportamento juntos.

Alan Kay, que estava no Xerox PARC desenvolvendo o Smalltalk nos anos 70, levou essa ideia adiante com uma metáfora biológica: objetos seriam como células vivas, que se comunicam por mensagens e mantêm seu estado interno isolado do mundo externo. Kay viria a dizer, anos depois, que se arrependia de ter chamado isso de "orientação a objetos" — a parte que importava, para ele, era a troca de mensagens, não os "objetos" em si.

A OOP entrou no mainstream nos anos 80 com C++, e explodiu nos 90 com Java. Junto, vieram excessos: hierarquias profundas, padrões aplicados sem critério, classes para tudo. Hoje, com programação funcional ganhando espaço e linguagens modernas misturando paradigmas, vivemos um momento de revisão: usamos OOP onde ela ajuda, e não como religião.

1.2 O conceito em camadas

Camada 1 — intuição

Pense em uma conta bancária. Ela tem um estado (saldo) e comportamentos (depositar, sacar, transferir). Em código procedural, esses dois ficariam separados: uma estrutura de dados para o saldo, funções soltas para as operações. Em OOP, eles ficam juntos, num mesmo objeto, e o objeto protege seu próprio estado.

Camada 2 — formalização

OOP se apoia em quatro pilares:

Camada 3 — aplicação

Na prática, isso significa: modelar conceitos do domínio como objetos, com nomes do negócio (Pedido, Carrinho, Fatura), métodos que descrevem operações reais (cancelar(), aplicar_desconto()), e construtores que não permitem criar objetos inválidos. Isso é diferente de "criar classe para qualquer coisa".

1.3 Encapsulamento — proteja seus invariantes

Encapsular não é colocar um _ antes do nome do atributo. É algo mais forte: garantir que nenhum estado inválido seja sequer representável no seu sistema. Se sua classe permite que código externo coloque ela em estado inválido, ela não está encapsulada de verdade.

✗ Classe anêmica — saco de dados
conta_ruim.py
class Conta:
    def __init__(self):
        self.saldo = 0
        self.status = "ativa"

# Em algum lugar...
conta = Conta()
conta.saldo = -9999       # nada impede
conta.status = "banana"   # pior ainda
conta.saldo = "texto"     # Python não reclama

O construtor não valida nada. O atributo é público. Qualquer função em qualquer parte do código pode colocar a conta em estado inconsistente — e quando o bug aparecer, você não vai saber onde.

✓ Invariantes protegidos
conta_boa.py
from decimal import Decimal
from enum import Enum

class StatusConta(Enum):
    ATIVA = "ativa"
    BLOQUEADA = "bloqueada"
    ENCERRADA = "encerrada"

class Conta:
    def __init__(
        self,
        saldo_inicial: Decimal = Decimal("0"),
    ):
        if saldo_inicial < 0:
            raise ValueError("Saldo negativo")
        self._saldo = saldo_inicial
        self._status = StatusConta.ATIVA

    @property
    def saldo(self) -> Decimal:
        return self._saldo

    def depositar(self, valor: Decimal) -> None:
        if self._status != StatusConta.ATIVA:
            raise ValueError("Conta não está ativa")
        if valor <= 0:
            raise ValueError("Valor deve ser positivo")
        self._saldo += valor

    def sacar(self, valor: Decimal) -> None:
        if self._status != StatusConta.ATIVA:
            raise ValueError("Conta não está ativa")
        if valor <= 0:
            raise ValueError("Valor inválido")
        if valor > self._saldo:
            raise ValueError("Saldo insuficiente")
        self._saldo -= valor
Princípio fundamental
Make illegal states unrepresentable. Essa frase, atribuída a Yaron Minsky, é a essência do encapsulamento bem feito. Use o construtor para validar invariantes na criação. Use métodos com nomes de domínio (sacar, não set_saldo) para operações. Use @property para leitura controlada. Use enums para estados discretos. O resultado: erros são pegos no ponto de origem, não 200 linhas depois.

Por que Decimal e não float?

O tipo float em Python (como em quase toda linguagem) usa representação binária IEEE 754, que não consegue representar exatamente valores como 0.1 ou 0.2. Some 0.1 + 0.2 em Python e veja: 0.30000000000000004. Para dinheiro, datas e qualquer coisa que precise de precisão exata, isso é inaceitável — use Decimal. Esse é o tipo de decisão pequena que separa código profissional de código amador.

"Mas Python não tem private de verdade"

Correto. A convenção é o _ antes do nome, e Python "conta com a maturidade do programador". Isso assusta quem vem de Java, mas funciona bem na prática. O ponto é: o construtor e os métodos definem como o objeto deve ser usado; quem acessa _saldo diretamente sabe que está violando o contrato e assume a responsabilidade. Em times disciplinados isso basta — e elimina muita cerimônia.

1.4 Composição vs herança — a decisão estrutural

Herança é tentadora porque parece reuso. Você cria uma classe pai com comportamento comum, e filhas que herdam. Aparentemente, economia de código. Na prática, herança cria acoplamento forte: mudanças na pai propagam para todas as filhas, e você fica preso a uma hierarquia rígida que raramente representa bem o mundo real.

A regra empírica forjada pela experiência de décadas: prefira composição. Use herança apenas quando há genuinamente uma relação de subtipo — e mesmo aí, considere se uma composição não seria melhor.

✗ Herança forçada
animais_ruim.py
class Animal:
    def voar(self): ...
    def nadar(self): ...
    def correr(self): ...

class Pinguim(Animal):
    def voar(self):
        raise NotImplementedError()
    # pinguim não voa — Liskov violado

class Tubarao(Animal):
    def voar(self):
        raise NotImplementedError()
    def correr(self):
        raise NotImplementedError()
✓ Composição de comportamentos
animais_bom.py
from typing import Protocol

class Nadador(Protocol):
    def nadar(self) -> None: ...

class Voador(Protocol):
    def voar(self) -> None: ...

class Pinguim:
    def nadar(self):
        print("Nadando rápido")

class Tubarao:
    def nadar(self):
        print("Caçando")

class Aguia:
    def voar(self):
        print("Planando")

No exemplo da direita, usamos Protocol (duck typing estrutural do Python). Cada animal implementa apenas o que faz sentido para ele. Sem hierarquia forçada, sem métodos vazios que levantam exceção, sem violar o princípio de substituição de Liskov.

Quando herança ainda vale

Há casos legítimos:

Em outras palavras: herança é uma ferramenta legítima, mas específica. Use composição como padrão; alcance a herança quando ela for genuinamente a resposta.

1.5 Polimorfismo — o motor da extensibilidade

Polimorfismo é o pilar que torna OOP poderosa para sistemas que evoluem. Ele permite que código antigo trabalhe com tipos novos sem modificação. É a base do princípio Open/Closed (que vamos ver em detalhe no capítulo 3).

Em Python, polimorfismo acontece de duas formas: por herança (subclasses sobrescrevem métodos da pai) e por duck typing (qualquer objeto que tenha o método certo funciona). A segunda forma, expressa formalmente com Protocol, é frequentemente a melhor escolha.

pagamentos.py — polimorfismo via abstração
from abc import ABC, abstractmethod
from decimal import Decimal
from dataclasses import dataclass

@dataclass(frozen=True)
class ResultadoPagamento:
    sucesso: bool
    transacao_id: str | None
    mensagem: str

class MetodoPagamento(ABC):
    """Contrato comum para qualquer forma de pagamento."""
    @abstractmethod
    def processar(self, valor: Decimal) -> ResultadoPagamento: ...

class CartaoCredito(MetodoPagamento):
    def __init__(self, numero: str, cvv: str):
        self._numero = numero
        self._cvv = cvv

    def processar(self, valor: Decimal) -> ResultadoPagamento:
        return ResultadoPagamento(True, "tx_123", "Aprovado")

class Pix(MetodoPagamento):
    def __init__(self, chave: str):
        self._chave = chave

    def processar(self, valor: Decimal) -> ResultadoPagamento:
        return ResultadoPagamento(True, "pix_456", "QR Code gerado")

class Boleto(MetodoPagamento):
    def __init__(self, cpf: str):
        self._cpf = cpf

    def processar(self, valor: Decimal) -> ResultadoPagamento:
        return ResultadoPagamento(True, "bol_789", "Boleto gerado")

class Checkout:
    def finalizar(
        self,
        metodo: MetodoPagamento,
        total: Decimal,
    ) -> ResultadoPagamento:
        resultado = metodo.processar(total)
        if resultado.sucesso:
            self._notificar_sucesso(resultado.transacao_id)
        return resultado

    def _notificar_sucesso(self, tx_id: str):
        print(f"Pagamento {tx_id} confirmado")

# Para adicionar PayPal, Cripto, transferência bancária:
# zero alterações em Checkout. Apenas uma classe nova.

Esse é o ganho concreto: o Checkout está fechado para modificação (não muda quando adicionamos PayPal), mas aberto para extensão (basta criar nova classe). Você pode adicionar dezenas de métodos sem nunca tocar no código central de checkout.

1.6 Abstração — o que esconder, o que expor

Abstração é a arte de decidir o que mostrar e o que esconder. Uma boa abstração:

Vazamento de abstração

Sinal clássico de abstração mal feita: um método que às vezes retorna None, às vezes lança exceção, às vezes retorna lista vazia. Padronize o contrato. Se "não encontrar" é caso comum, retorne None ou Optional. Se é erro de verdade, lance exceção específica. Mas seja consistente.

1.7 Estudo de caso — refatorando um sistema de pedidos

Da função procedural ao modelo de domínio rico

Vamos acompanhar a evolução de um sistema de pedidos. Começamos com código procedural típico de quem está começando, e refatoramos passo a passo até um modelo de domínio bem estruturado.

Passo 0 · O ponto de partida

Função de 80 linhas, sem classes. Está tudo "funcionando", mas é frágil:

v0_procedural.py
def processar_pedido(itens, cep, cupom):
    # Validar itens
    if not itens:
        raise ValueError("vazio")
    for i in itens:
        if i["preco"] <= 0:
            raise ValueError("preço inválido")

    # Calcular total
    total = 0
    for i in itens:
        total += i["preco"] * i["qtd"]

    # Frete
    if cep.startswith("0"):
        frete = 10
    elif cep.startswith("9"):
        frete = 20
    else:
        frete = 15

    # Desconto
    if cupom == "PRIMEIRA10" and total >= 50:
        desconto = total * 0.1
    else:
        desconto = 0

    return total + frete - desconto

Problemas: tudo misturado (validação, cálculo, frete, desconto), usa float para dinheiro, item é dict (sem tipo), regras espalhadas, não dá pra testar partes isoladas.

Passo 1 · Modelar Item e Pedido

Primeiro, transformamos os "dicts" em objetos com identidade clara:

v1_modelo.py
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum

class StatusPedido(Enum):
    ABERTO = "aberto"
    FECHADO = "fechado"
    CANCELADO = "cancelado"

@dataclass(frozen=True)
class Item:
    sku: str
    nome: str
    preco: Decimal
    quantidade: int

    def __post_init__(self):
        if self.preco <= 0:
            raise ValueError("preço inválido")
        if self.quantidade <= 0:
            raise ValueError("quantidade inválida")

    @property
    def subtotal(self) -> Decimal:
        return self.preco * self.quantidade

class Pedido:
    def __init__(self):
        self._itens: list[Item] = []
        self._status = StatusPedido.ABERTO

    def adicionar_item(self, item: Item) -> None:
        if self._status != StatusPedido.ABERTO:
            raise ValueError("pedido não está aberto")
        self._itens.append(item)

    @property
    def subtotal(self) -> Decimal:
        return sum(
            (i.subtotal for i in self._itens),
            Decimal("0")
        )

    def fechar(self) -> None:
        if not self._itens:
            raise ValueError("pedido vazio")
        self._status = StatusPedido.FECHADO
Passo 2 · Extrair cálculo de frete (Strategy)

Frete vai variar (transportadora diferente, regras especiais). Não queremos misturar com o pedido:

v2_frete.py
from typing import Protocol

class CalculadoraFrete(Protocol):
    def calcular(self, cep: str, peso_kg: Decimal) -> Decimal: ...

class FreteCorreios:
    def calcular(self, cep: str, peso_kg: Decimal) -> Decimal:
        if cep.startswith("0"):
            return Decimal("10")
        if cep.startswith("9"):
            return Decimal("20")
        return Decimal("15")

class FreteGratis:
    def calcular(self, cep: str, peso_kg: Decimal) -> Decimal:
        return Decimal("0")

class FreteTransportadora:
    def __init__(self, tabela_por_kg: Decimal):
        self._tabela = tabela_por_kg

    def calcular(self, cep: str, peso_kg: Decimal) -> Decimal:
        return peso_kg * self._tabela
Passo 3 · Extrair desconto (também Strategy)

Cupons mudam constantemente — promoções de fim de ano, programa de fidelidade, parceiros. Cada um vira uma estratégia:

v3_desconto.py
class PoliticaDesconto(Protocol):
    def aplicar(self, pedido: Pedido) -> Decimal:
        """Retorna o valor de desconto."""
        ...

class SemDesconto:
    def aplicar(self, pedido: Pedido) -> Decimal:
        return Decimal("0")

class DescontoPercentualMinimo:
    def __init__(self, percentual: Decimal, minimo: Decimal):
        self._percentual = percentual
        self._minimo = minimo

    def aplicar(self, pedido: Pedido) -> Decimal:
        if pedido.subtotal < self._minimo:
            return Decimal("0")
        return pedido.subtotal * self._percentual

class DescontoCombinado:
    def __init__(self, *politicas: PoliticaDesconto):
        self._politicas = politicas

    def aplicar(self, pedido: Pedido) -> Decimal:
        return sum(
            (p.aplicar(pedido) for p in self._politicas),
            Decimal("0")
        )
Passo 4 · Calculadora orquestradora

Finalmente, o orquestrador junta tudo. Cada parte é testável isoladamente:

v4_calculadora.py
@dataclass(frozen=True)
class ResultadoCalculo:
    subtotal: Decimal
    frete: Decimal
    desconto: Decimal
    total: Decimal

class CalculadoraPedido:
    def __init__(
        self,
        calc_frete: CalculadoraFrete,
        politica: PoliticaDesconto,
    ):
        self._calc_frete = calc_frete
        self._politica = politica

    def calcular(
        self,
        pedido: Pedido,
        cep: str,
        peso_kg: Decimal,
    ) -> ResultadoCalculo:
        subtotal = pedido.subtotal
        frete = self._calc_frete.calcular(cep, peso_kg)
        desconto = self._politica.aplicar(pedido)
        total = subtotal + frete - desconto
        return ResultadoCalculo(subtotal, frete, desconto, total)

# Uso:
calc = CalculadoraPedido(
    calc_frete=FreteCorreios(),
    politica=DescontoPercentualMinimo(Decimal("0.10"), Decimal("50")),
)
resultado = calc.calcular(meu_pedido, "01310-000", Decimal("2.5"))

O que ganhamos: cada peça é testável sozinha. Adicionar nova política de desconto não toca em nada que já funciona. Trocar a calculadora de frete por uma que chama API real é uma classe nova — sem mexer no resto.

1.8 Value Objects — quando o valor é a identidade

Nem todo objeto precisa de identidade própria. Quando o que importa é o valor, não qual instância, você tem um Value Object. Exemplos clássicos: dinheiro, endereço, intervalo de datas, coordenada geográfica, CPF.

Value Objects devem ser imutáveis, comparáveis por valor, e operações sobre eles devem retornar novos objetos, nunca modificar o original.

value_objects.py
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Dinheiro:
    valor: Decimal
    moeda: str

    def __post_init__(self):
        if self.valor < 0:
            raise ValueError("Valor negativo")
        if len(self.moeda) != 3:
            raise ValueError("Use ISO de 3 letras")

    def somar(self, outro: "Dinheiro") -> "Dinheiro":
        if self.moeda != outro.moeda:
            raise ValueError("Moedas diferentes")
        return Dinheiro(self.valor + outro.valor, self.moeda)

    def multiplicar(self, fator: Decimal) -> "Dinheiro":
        return Dinheiro(self.valor * fator, self.moeda)

# Vantagens dos Value Objects:
# - Imutáveis: thread-safe automaticamente
# - Hashable: podem ser chave de dict ou estar em set
# - Comparação por valor: == funciona como esperado
# - Operações funcionais: retornam novos objetos
# - Centralizam validação: nunca há um Dinheiro inválido

1.9 Erros comuns de OOP iniciante

Erro 1 · Classe sem comportamento

Criar classes que são só sacos de atributos públicos, com lógica espalhada em funções externas. Isso é struct disfarçado, não OOP. Se sua classe é só getters/setters, ou ela vira dataclass (assumindo Value Object) ou ela precisa de comportamento real.

Erro 2 · Herança como reuso de código

"Quero usar essas funções aqui também — vou herdar". Não. Use composição. Herança é para subtipos genuínos, não para "evitar copiar código".

Erro 3 · God Object

Classe de 2.000 linhas que faz tudo: validação, persistência, formatação, integração. Sinal certo de violação de SRP. Vamos cobrir no capítulo 3 em detalhes — por enquanto, lembre-se: se você precisa rolar muito o mouse, é uma classe ruim.

Erro 4 · Setter para tudo

conta.set_saldo(x) não é encapsulamento. É só um atributo público com cerimônia. Encapsulamento real significa métodos de domínio: conta.depositar(x), conta.transferir_para(outra, x).

1.10 Quando NÃO usar OOP

Reconheça os contextos
Nem tudo precisa ser objeto

Há situações em que OOP atrapalha mais do que ajuda:

  • Scripts curtos: 50 linhas para processar um CSV. Criar classes aqui é overkill.
  • Pipelines de dados: transformações funcionais (map, filter, reduce) sobre listas/streams ficam mais limpas em estilo funcional.
  • Cálculos puros: uma função de cálculo de juros não precisa de classe.
  • Configuração: dict ou dataclass simples basta.
  • Bibliotecas científicas: NumPy, pandas — o estilo aqui é mais funcional/array-oriented.

Regra prática: use OOP quando há estado que precisa ser protegido e comportamento que age sobre esse estado. Para tudo o resto, funções diretas costumam ser melhores.

Verifique seu entendimento
"Sistema de pagamentos precisa suportar cartão, Pix, boleto e, no próximo trimestre, PayPal e cripto. Qual estratégia de design?"

1.11 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Termostato

Modele um Termostato com temperatura entre 16°C e 30°C. Não permita atribuição direta — só métodos aumentar() e diminuir(). Lance ValueError se passar dos limites.

termostato.py
class Termostato:
    MIN = 16
    MAX = 30

    def __init__(self, inicial: int = 22):
        if not self.MIN <= inicial <= self.MAX:
            raise ValueError("fora do range")
        self._temperatura = inicial

    @property
    def temperatura(self) -> int:
        return self._temperatura

    def aumentar(self):
        if self._temperatura >= self.MAX:
            raise ValueError("já no máximo")
        self._temperatura += 1

    def diminuir(self):
        if self._temperatura <= self.MIN:
            raise ValueError("já no mínimo")
        self._temperatura -= 1
Fácil
Exercício 2 · Value Object Endereço

Crie um Value Object Endereco imutável com rua, número, cidade, CEP. CEP deve ter formato 00000-000. Dois endereços com mesmos dados devem ser ==.

endereco.py
import re
from dataclasses import dataclass

CEP_RE = re.compile(r"^\d{5}-\d{3}$")

@dataclass(frozen=True)
class Endereco:
    rua: str
    numero: str
    cidade: str
    cep: str

    def __post_init__(self):
        if not CEP_RE.match(self.cep):
            raise ValueError(f"CEP inválido")
        if not self.rua.strip():
            raise ValueError("rua vazia")
        if not self.cidade.strip():
            raise ValueError("cidade vazia")

# == funciona automaticamente porque é dataclass(frozen)
# hash() também funciona — pode ser chave de dict
Médio
Exercício 3 · Refatorar para polimorfismo

Refatore a função abaixo. Regra: adicionar formato novo (CSV, XML) não deve exigir alterar o código existente.

antes.py
def gerar_relatorio(dados, formato):
    if formato == "json":
        return json.dumps(dados)
    elif formato == "html":
        rows = "".join(f"<tr><td>{d}</td></tr>" for d in dados)
        return f"<table>{rows}</table>"
    elif formato == "texto":
        return "\n".join(str(d) for d in dados)
    else:
        raise ValueError("formato desconhecido")
depois.py
from typing import Protocol
import json

class Formatador(Protocol):
    def formatar(self, dados: list) -> str: ...

class FormatadorJSON:
    def formatar(self, dados: list) -> str:
        return json.dumps(dados)

class FormatadorHTML:
    def formatar(self, dados: list) -> str:
        rows = "".join(f"<tr><td>{d}</td></tr>" for d in dados)
        return f"<table>{rows}</table>"

class FormatadorTexto:
    def formatar(self, dados: list) -> str:
        return "\n".join(str(d) for d in dados)

class GeradorRelatorio:
    def __init__(self, formatador: Formatador):
        self._formatador = formatador

    def gerar(self, dados: list) -> str:
        return self._formatador.formatar(dados)

# Adicionar CSV: nova classe, zero alteração no resto
class FormatadorCSV:
    def formatar(self, dados: list) -> str:
        return "\n".join(",".join(map(str, d)) for d in dados)
Médio
Exercício 4 · Conta bancária com transferência atômica

Modele ContaBancaria com histórico de transações (Value Objects) e transferir_para(outra, valor) que falha atomicamente — se a operação não puder ser concluída, nenhum saldo é alterado.

conta.py
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum

class TipoTransacao(Enum):
    DEPOSITO = "deposito"
    SAQUE = "saque"
    TRANSF_ENVIADA = "transf_env"
    TRANSF_RECEBIDA = "transf_rec"

@dataclass(frozen=True)
class Transacao:
    tipo: TipoTransacao
    valor: Decimal
    quando: datetime

class ContaBancaria:
    def __init__(self, saldo: Decimal = Decimal("0")):
        if saldo < 0:
            raise ValueError("saldo inicial inválido")
        self._saldo = saldo
        self._historico: list[Transacao] = []

    @property
    def saldo(self) -> Decimal:
        return self._saldo

    @property
    def historico(self) -> tuple[Transacao, ...]:
        return tuple(self._historico)

    def transferir_para(
        self,
        destino: "ContaBancaria",
        valor: Decimal,
    ) -> None:
        # Validação completa ANTES de modificar estado
        if valor <= 0:
            raise ValueError("valor inválido")
        if valor > self._saldo:
            raise ValueError("saldo insuficiente")

        agora = datetime.now()
        self._saldo -= valor
        destino._saldo += valor
        self._historico.append(
            Transacao(TipoTransacao.TRANSF_ENVIADA, valor, agora)
        )
        destino._historico.append(
            Transacao(TipoTransacao.TRANSF_RECEBIDA, valor, agora)
        )

Nota: single-thread é suficiente. Em concorrência você precisa de locks — capítulos 17 e 25.

Difícil
Exercício 5 · Carrinho com regras de negócio

Modele um Carrinho com:

  • Máximo 50 unidades totais
  • Mesmo SKU adicionado duas vezes incrementa quantidade
  • Desconto progressivo: 5% acima de R$ 200, 10% acima de R$ 500
  • Não aceita modificação depois de fechado
carrinho.py
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum

class StatusCarrinho(Enum):
    ABERTO = "aberto"
    FECHADO = "fechado"

@dataclass(frozen=True)
class LinhaCarrinho:
    sku: str
    nome: str
    preco_unit: Decimal
    quantidade: int

    @property
    def subtotal(self) -> Decimal:
        return self.preco_unit * self.quantidade

    def com_quantidade(self, qtd: int) -> "LinhaCarrinho":
        return LinhaCarrinho(self.sku, self.nome, self.preco_unit, qtd)

class Carrinho:
    MAX = 50

    def __init__(self):
        self._linhas: dict[str, LinhaCarrinho] = {}
        self._status = StatusCarrinho.ABERTO

    @property
    def linhas(self) -> tuple[LinhaCarrinho, ...]:
        return tuple(self._linhas.values())

    @property
    def total_unidades(self) -> int:
        return sum(l.quantidade for l in self._linhas.values())

    @property
    def subtotal(self) -> Decimal:
        return sum(
            (l.subtotal for l in self._linhas.values()),
            Decimal("0"),
        )

    @property
    def desconto(self) -> Decimal:
        s = self.subtotal
        if s > Decimal("500"):
            return s * Decimal("0.10")
        if s > Decimal("200"):
            return s * Decimal("0.05")
        return Decimal("0")

    @property
    def total(self) -> Decimal:
        return self.subtotal - self.desconto

    def adicionar(self, linha: LinhaCarrinho) -> None:
        self._garantir_aberto()
        if linha.quantidade <= 0:
            raise ValueError("qtd inválida")
        existente = self._linhas.get(linha.sku)
        nova_qtd_item = (existente.quantidade if existente else 0) + linha.quantidade
        nova_qtd_total = self.total_unidades + linha.quantidade
        if nova_qtd_total > self.MAX:
            raise ValueError("excede limite")
        if existente:
            self._linhas[linha.sku] = existente.com_quantidade(nova_qtd_item)
        else:
            self._linhas[linha.sku] = linha

    def fechar(self) -> None:
        if not self._linhas:
            raise ValueError("vazio")
        self._status = StatusCarrinho.FECHADO

    def _garantir_aberto(self):
        if self._status != StatusCarrinho.ABERTO:
            raise ValueError("fechado")
Fim do capítulo 1
Você acabou de cobrir o que separa OOP "de slide" de OOP aplicada. Antes de seguir para o capítulo 2, recomendo escolher um projeto seu antigo e refatorar uma classe usando o que aprendeu — especialmente value objects e composição. A prática consolida muito mais do que releitura.
Parte I · Capítulo 02 · Fundação do código

Estruturas
de dados aplicadas.

Escolher mal a estrutura de dados é o que transforma uma operação que deveria levar 1ms em uma que leva 30 segundos. Não é "otimização prematura" — é fundamentalmente o trabalho.

Você não precisa decorar a tabela de Big-O. Precisa entender por que certas operações são rápidas em certas estruturas e lentas em outras. Esse entendimento te permite escolher a estrutura certa para cada problema — e diagnosticar quando o sistema está lento por uma escolha ruim feita meses atrás.

2.1 A história — por que isso existe

Contexto histórico

Nos anos 50 e 60, a memória de computador era contada em kilobytes e cada acesso ao disco era operação cara. Donald Knuth, em The Art of Computer Programming (volumes 1 e 3, anos 60-70), catalogou de forma sistemática as estruturas de dados conhecidas. Era literatura obrigatória para qualquer engenheiro sério.

Hoje temos gigabytes de RAM e SSDs rapidíssimos. Muita gente conclui, equivocadamente, que "não precisa mais se preocupar com estrutura de dados". A verdade é o oposto: como rodamos sistemas cada vez maiores (milhões de usuários, bilhões de registros), uma escolha ruim de estrutura — que em 1000 itens passa despercebida — em 10 milhões te derruba o sistema.

Linguagens modernas como Python escondem a estrutura. list, dict, set parecem "iguais por dentro". Não são. E saber como funcionam — pelo menos em alto nível — é a diferença entre código que escala e código que vira gargalo.

2.2 Big-O sem trauma

Big-O descreve como o tempo de execução cresce conforme o tamanho do input cresce. Não é uma medida exata; é uma família de comportamento. O ponto não é decorar, é reconhecer.

NotaçãoNomeExemploCrescimento
O(1)ConstanteAcessar lista[5]Independente do tamanho
O(log n)LogarítmicoBusca bináriaCresce devagar
O(n)LinearBuscar item em listaProporcional ao tamanho
O(n log n)LinearítmicoBons algoritmos de ordenaçãoQuase linear
O(n²)QuadráticoLoop dentro de loopCresce rápido
O(2ⁿ)ExponencialForça bruta combinatóriaInviável rápido

Para entender o impacto prático: com input de 1 milhão de itens, O(n) faz 1 milhão de operações; O(n²) faz 1 trilhão. Mesmo em hardware moderno, isso é a diferença entre 1 segundo e horas.

Como medir na prática
Não confie só em raciocínio teórico. Use timeit para medir trechos pequenos, cProfile para perfil de aplicação inteira, e — quando o sistema for grande — ferramentas como py-spy ou scalene. Big-O te dá intuição; medição te dá realidade.

2.3 Listas e arrays

list em Python é internamente um array dinâmico: sequência contígua de ponteiros para objetos, que cresce conforme necessário. Operações típicas:

OperaçãoComplexidadeObservação
lista[i]O(1)Acesso por índice — instantâneo
lista.append(x)O(1)*Amortizado — ocasionalmente realloca
lista.insert(0, x)O(n)Empurra todos os elementos
lista.pop()O(1)Do final, instantâneo
lista.pop(0)O(n)Do início, empurra tudo
x in listaO(n)Varre a lista inteira
lista.sort()O(n log n)Timsort, ótimo na prática
Armadilha clássica

Usar lista.pop(0) ou lista.insert(0, x) em loops sobre listas grandes. Isso transforma um algoritmo O(n) em O(n²) sem você perceber. Para operações no início, use collections.deque, que tem O(1) nas duas pontas.

2.4 Dicionários e hashing

dict em Python é uma tabela hash. As operações fundamentais são O(1) em média: você passa uma chave, a função hash transforma em um índice, e o valor está lá.

dict_vs_list.py — buscar 1 item entre 1 milhão
import timeit

itens = list(range(1_000_000))
itens_dict = {x: True for x in itens}
itens_set = set(itens)

# Buscar 999_999 (pior caso)
t_list = timeit.timeit(lambda: 999_999 in itens, number=100)
t_dict = timeit.timeit(lambda: 999_999 in itens_dict, number=100)
t_set  = timeit.timeit(lambda: 999_999 in itens_set, number=100)

print(f"list: {t_list:.4f}s")   # ~ 1.5s
print(f"dict: {t_dict:.6f}s")   # ~ 0.000005s
print(f"set:  {t_set:.6f}s")    # ~ 0.000005s

# Diferença de ~300.000x. Não é otimização — é escolher certo.

Quando usar dict: sempre que você quiser buscar valor por chave. Mapas de configuração, índices de busca, deduplicação com metadados, caches em memória.

Por baixo: como hash funciona

Cada objeto que serve de chave tem um __hash__() que retorna um inteiro. O dict usa esse número para decidir em qual "bucket" interno guardar o valor. Quando você busca, o mesmo cálculo aponta direto para o bucket. Colisões (duas chaves com mesmo hash) são tratadas internamente. Para que tudo funcione, a chave precisa ser hashable — ou seja, imutável: strings, tuplas, frozensets, ints. list e dict não podem ser chave.

2.5 Sets — quando o que importa é "pertence?"

set também é tabela hash, mas só guarda chaves (sem valores). Use quando precisar de:

sets.py
# Remover duplicatas mantendo ordem (Python 3.7+ dicts preservam ordem)
itens = [1, 2, 3, 2, 1, 4]
unicos_ordenados = list(dict.fromkeys(itens))  # [1,2,3,4]

# Operações de conjunto
inscritos = {"ana", "bruno", "carla"}
ativos = {"ana", "carla", "diego"}

ambos = inscritos & ativos          # {"ana", "carla"}
todos = inscritos | ativos          # {"ana","bruno","carla","diego"}
inativos = inscritos - ativos       # {"bruno"}
exclusivos = inscritos ^ ativos     # {"bruno", "diego"}

# Verificar pertinência em listas grandes
admins = {"alice", "bob", "charlie"}  # em vez de lista
if usuario in admins:                  # O(1), não O(n)
    ...

2.6 Tuplas — imutáveis e leves

Tupla é como lista, mas imutável. Uma vez criada, não muda. Use para:

2.7 Pilha (LIFO) e fila (FIFO)

Pilha (último a entrar, primeiro a sair): use list com append() e pop(). Útil para: navegação de páginas (botão "voltar"), parsing de expressões, undo/redo, execução de funções (o próprio stack do programa é uma pilha).

Fila (primeiro a entrar, primeiro a sair): use collections.deque. Útil para: processamento de tarefas em ordem, busca em largura (BFS) em grafos, buffer de mensagens.

pilha_fila.py
from collections import deque

# PILHA: list serve perfeitamente
pilha = []
pilha.append("home")
pilha.append("produtos")
pilha.append("detalhe")
ultimo = pilha.pop()    # "detalhe", O(1)

# FILA: NÃO use list para isso!
# list.pop(0) é O(n) — em listas grandes vira gargalo
fila = deque()
fila.append("tarefa_1")
fila.append("tarefa_2")
fila.append("tarefa_3")
proxima = fila.popleft()  # "tarefa_1", O(1)

# deque também serve como fila dupla (deque = double-ended queue)
fila.appendleft("urgente")  # adiciona no início, O(1)

2.8 Árvores — quando ordem importa

Árvores são estruturas hierárquicas. Em Python pure não temos uma "árvore de busca" pronta no stdlib, mas vários casos importantes:

heap.py — top 10 maiores em milhões
import heapq

# Você tem 10 milhões de números e quer os 10 maiores
# Estratégia ingênua: sorted(numeros)[-10:] = O(n log n), aloca muito
# Estratégia com heap: O(n log k) onde k=10, muito mais eficiente

top_10 = heapq.nlargest(10, numeros)    # O(n log 10)
top_10_menores = heapq.nsmallest(10, numeros)

# Para uso contínuo, mantenha o heap explicitamente:
h = []
for n in stream_de_numeros:
    heapq.heappush(h, n)
    if len(h) > 10:
        heapq.heappop(h)  # mantém só os 10 maiores

2.9 Estudo de caso — sistema de top trending

Calculando os 100 produtos mais vendidos da última hora

Você tem um stream de eventos de venda chegando em tempo real (milhares por segundo). A cada minuto, precisa entregar o top 100 da última hora. Vamos resolver isso em três versões, cada uma melhor que a anterior.

Versão 1 · Ingênua (vai derrubar o sistema)
v1_ingenuo.py
vendas_hora = []  # lista de tuples (timestamp, sku)

def registrar_venda(timestamp, sku):
    vendas_hora.append((timestamp, sku))
    # Limpar tudo mais antigo que 1h
    limite = timestamp - 3600
    vendas_hora[:] = [v for v in vendas_hora if v[0] >= limite]

def top_100():
    contador = {}
    for _, sku in vendas_hora:
        contador[sku] = contador.get(sku, 0) + 1
    return sorted(contador.items(), key=lambda x: -x[1])[:100]

Problemas: a cada venda, varre a lista inteira (O(n) para limpar). Reordena tudo na hora de pedir o top — O(n log n) onde n pode ser milhões.

Versão 2 · Deque + dict de contagem
v2_deque.py
from collections import deque, defaultdict
import time

vendas = deque()  # (timestamp, sku) ordenadas por chegada
contagem = defaultdict(int)

def registrar_venda(sku):
    agora = time.time()
    vendas.append((agora, sku))
    contagem[sku] += 1
    # Limpa só o que expirou — não varre a lista inteira
    limite = agora - 3600
    while vendas and vendas[0][0] < limite:
        _, sku_antigo = vendas.popleft()
        contagem[sku_antigo] -= 1
        if contagem[sku_antigo] == 0:
            del contagem[sku_antigo]

def top_100():
    return sorted(contagem.items(), key=lambda x: -x[1])[:100]

Melhor: registrar é O(1) amortizado. Mas top_100 ainda é O(k log k) onde k é número de SKUs distintos — se houver 100k SKUs, isso é lento.

Versão 3 · Heap para o top
v3_heap.py
import heapq

def top_100():
    # nlargest é O(n log k) — varre n itens mantendo heap de tamanho k
    return heapq.nlargest(100, contagem.items(), key=lambda x: x[1])

# Em vez de ordenar 100.000 SKUs para pegar 100,
# mantém um heap pequeno e descarta o resto.
# Para n=100k itens com k=100, fica ~ 100k * log(100) = 600k operações
# vs 100k * log(100k) = 1.6M no sorted. ~3x mais rápido.

Lição central: a estrutura certa muda completamente a complexidade. Trocar list por deque resolveu uma ineficiência. Trocar sorted() por heapq.nlargest() resolveu outra. Nenhuma delas é "otimização prematura" — são escolhas adequadas ao problema.

2.10 Erros comuns

Erro 1 · Buscar em lista grande

if x in lista_de_50000_strings dentro de um loop. Se a lista não muda, transforme em set uma vez: conjunto = set(lista_de_50000_strings). De O(n) para O(1).

Erro 2 · Concatenar strings em loop

resultado = "" e depois resultado += linha em milhares de iterações. Strings em Python são imutáveis: cada += aloca nova string. Use lista.append(linha) e "".join(lista) no final.

Erro 3 · Mutar lista enquanto itera

for x in lista: if cond: lista.remove(x) — comportamento imprevisível. Crie nova lista com list comprehension, ou itere sobre cópia.

Erro 4 · Usar list quando set resolve

"Lista de IDs únicos" — se a unicidade importa e ordem não, é set. Se ordem importa, é dict.fromkeys() ou list(dict.fromkeys(items)).

2.11 Quando NÃO se preocupar com isso

Reconheça o contexto
Escala pequena, Big-O irrelevante

Se você está processando 10 itens, 100 itens, ou até 1000 itens em código que roda uma vez por dia, a estrutura "errada" não vai te incomodar. Não vire o desenvolvedor que reescreve um loop simples para usar numpy porque "é mais rápido".

Quando importar de verdade:

  • Loops quentes (chamados milhares de vezes por segundo)
  • Coleções grandes (10k+ itens) em operações repetidas
  • Código que já mediu ser lento e está num caminho crítico

Para o resto, código claro > código teoricamente ótimo. Primeiro perfil, depois otimização.

Verifique seu entendimento
"Preciso verificar repetidamente se um e-mail aparece em uma lista de 500.000 e-mails bloqueados. Qual estrutura?"

2.12 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Encontrar duplicatas

Escreva função que, dada uma lista, retorna apenas os itens que aparecem mais de uma vez (cada um aparecendo só uma vez no resultado). Não use loops aninhados — busque solução O(n).

duplicatas.py
from collections import Counter

def duplicatas(itens: list) -> list:
    contagem = Counter(itens)
    return [item for item, n in contagem.items() if n > 1]

# Alternativa "manual"
def duplicatas_v2(itens: list) -> list:
    visto = set()
    duplos = set()
    for x in itens:
        if x in visto:
            duplos.add(x)
        else:
            visto.add(x)
    return list(duplos)
Fácil
Exercício 2 · Anagrama

Função sao_anagramas(s1, s2) que retorna True se as duas strings têm exatamente as mesmas letras (ignorando ordem). Pense na estrutura ideal.

anagrama.py
from collections import Counter

def sao_anagramas(s1: str, s2: str) -> bool:
    return Counter(s1) == Counter(s2)

# Counter é dict de contagens — comparação por valor funciona.
# Alternativa: sorted(s1) == sorted(s2)
# Mas Counter é O(n), sorted é O(n log n).
Médio
Exercício 3 · Cache LRU manual

Implemente um CacheLRU com capacidade máxima. Métodos: get(chave) e put(chave, valor), ambos O(1). Quando lotado, descarte o item menos recentemente usado.

cache_lru.py
from collections import OrderedDict

class CacheLRU:
    def __init__(self, capacidade: int):
        if capacidade <= 0:
            raise ValueError("capacidade inválida")
        self._capacidade = capacidade
        self._dados: OrderedDict = OrderedDict()

    def get(self, chave):
        if chave not in self._dados:
            return None
        # move pro final = mais recente
        self._dados.move_to_end(chave)
        return self._dados[chave]

    def put(self, chave, valor):
        if chave in self._dados:
            self._dados.move_to_end(chave)
            self._dados[chave] = valor
            return
        if len(self._dados) >= self._capacidade:
            self._dados.popitem(last=False)  # remove o LRU
        self._dados[chave] = valor

    def __len__(self):
        return len(self._dados)
Médio
Exercício 4 · Detector de janela deslizante

Função contar_no_periodo(eventos, agora, segundos). Eventos é lista de timestamps (em segundos). Retorne quantos ocorreram no último segundos antes de agora. Eventos chegam ordenados. Otimize para reuso eficiente (state preservado).

janela.py
from collections import deque

class JanelaDeslizante:
    def __init__(self, segundos: int):
        self._segundos = segundos
        self._eventos: deque = deque()

    def registrar(self, timestamp: float):
        self._eventos.append(timestamp)
        self._expurgar(timestamp)

    def contar(self, agora: float) -> int:
        self._expurgar(agora)
        return len(self._eventos)

    def _expurgar(self, agora: float):
        limite = agora - self._segundos
        while self._eventos and self._eventos[0] < limite:
            self._eventos.popleft()

# Cada registrar/contar é O(1) amortizado:
# cada evento entra uma vez e sai uma vez ao longo da vida da janela.
Difícil
Exercício 5 · Top-K por categoria com merge

Você recebe lista de tuples (categoria, score) em ordem aleatória. Retorne, para cada categoria, os K maiores scores. Otimize para minimizar memória e tempo quando há muitas categorias e muitos scores por categoria.

topk_categoria.py
import heapq
from collections import defaultdict

def top_k_por_categoria(
    eventos: list[tuple[str, float]],
    k: int,
) -> dict[str, list[float]]:
    # min-heap de tamanho k por categoria.
    # Mantemos só os k maiores; o topo é o menor entre eles.
    heaps: dict[str, list] = defaultdict(list)

    for categoria, score in eventos:
        h = heaps[categoria]
        if len(h) < k:
            heapq.heappush(h, score)
        elif score > h[0]:
            heapq.heapreplace(h, score)
        # Senão, score é menor que todos do top-k atual; descarta

    return {
        cat: sorted(h, reverse=True)
        for cat, h in heaps.items()
    }

# Complexidade total: O(n log k), memória O(c * k) onde c=categorias
# Muito melhor que agrupar tudo e ordenar: O(n log n) e O(n).
Fim do capítulo 2
A próxima vez que você sentir que algo está "estranhamente lento", lembre-se: muitas vezes não é o algoritmo — é a estrutura. Antes de otimizar, pergunte: "esta operação está sendo feita em uma estrutura otimizada para ela?" Próximo capítulo: SOLID — os cinco princípios que organizam código que cresce.
Parte I · Capítulo 03 · Fundação do código

SOLID:
cinco princípios,
aplicados.

SOLID não é dogma. É um vocabulário compartilhado sobre o que separa código que sobrevive ao tempo do código que vira pesadelo em seis meses.

A indústria tem uma relação complicada com SOLID. Por um lado, repete os princípios como mantra. Por outro, raramente entende o que cada um realmente significa — ou onde aplicá-los faz mais mal do que bem. Este capítulo te dá os dois lados: como aplicar bem, e quando deliberadamente quebrar.

3.1 A história — quem criou e por quê

Contexto histórico

Os princípios SOLID foram cunhados por Robert C. Martin (Uncle Bob) em uma série de artigos entre 1995 e 2000. O acrônimo "SOLID" em si foi popularizado por Michael Feathers depois. A motivação era prática: nos anos 90, sistemas OOP grandes começavam a se tornar difíceis de manter, e Martin queria nomear os padrões que ele observava em código que envelhecia bem.

Importante: SOLID não foi inventado num laboratório. É destilação de práticas que engenheiros experientes já usavam. SRP estava em Tom DeMarco (1979), OCP em Bertrand Meyer (1988), LSP em Barbara Liskov (1987). Martin organizou e nomeou.

Hoje SOLID é simultaneamente reverenciado e criticado. Há razão para os dois lados — e vamos cobrir as duas no fim do capítulo.

S
Single Responsibility
Uma classe, um motivo para mudar.
O
Open / Closed
Aberto à extensão, fechado à modificação.
L
Liskov Substitution
Subtipos devem honrar o contrato dos pais.
I
Interface Segregation
Interfaces específicas > uma genérica.
D
Dependency Inversion
Dependa de abstrações, não de detalhes.

3.2 S — Single Responsibility Principle

"Uma classe deve ter apenas um motivo para mudar."

A formulação original de Martin é frequentemente mal entendida. SRP não diz "uma classe faz uma coisa só". Diz "uma classe responde a um stakeholder". Se mudar o cálculo de imposto afeta sua classe, e mudar o formato do relatório também afeta sua classe — você tem duas responsabilidades, porque tem dois "motivos para mudar".

✗ Múltiplas responsabilidades
relatorio_ruim.py
class RelatorioVendas:
    def __init__(self, vendas):
        self.vendas = vendas

    def calcular_total(self):
        return sum(v.valor for v in self.vendas)

    def calcular_imposto(self):
        return self.calcular_total() * 0.18

    def formatar_html(self):
        return f"<h1>Total: {self.calcular_total()}</h1>"

    def salvar_pdf(self, path):
        # gera PDF...
        ...

    def enviar_por_email(self, destinatario):
        # conecta SMTP...
        ...

Cálculo, formatação, persistência e envio de e-mail — quatro motivos diferentes para essa classe mudar.

✓ Cada um na sua função
relatorio_bom.py
class CalculadorVendas:
    def total(self, vendas):
        return sum(v.valor for v in vendas)

    def imposto(self, vendas, aliquota):
        return self.total(vendas) * aliquota

class FormatadorRelatorio:
    def html(self, dados):
        return f"<h1>Total: {dados.total}</h1>"

class GeradorPDF:
    def gerar(self, conteudo: str, path: str):
        ...

class ServicoEmail:
    def enviar(self, destinatario, anexo):
        ...

# Cada classe muda por seu próprio motivo:
# - aliquota muda → CalculadorVendas
# - layout muda → FormatadorRelatorio
# - biblioteca PDF muda → GeradorPDF
# - servidor SMTP muda → ServicoEmail
Refinamento importante
SRP não significa "uma função pública por classe". Uma classe Pedido pode ter adicionar_item(), remover_item(), fechar(), cancelar() — todas são "ações sobre um pedido", responsabilidade única. SRP é sobre razões de mudança, não sobre número de métodos.

3.3 O — Open/Closed Principle

"Aberto para extensão, fechado para modificação."

Suas classes devem permitir que novos comportamentos sejam adicionados sem alterar o código existente. Você adiciona uma classe nova; não toca nas que já funcionam.

Já vimos isso aplicado no capítulo 1, com os métodos de pagamento. Vamos formalizar agora. Considere este código que viola OCP:

calculo_frete_ruim.py — viola OCP
class CalculadorFrete:
    def calcular(self, tipo: str, peso: float, distancia: float):
        if tipo == "correios":
            return peso * 2.5 + distancia * 0.1
        elif tipo == "transportadora_a":
            return peso * 3.0 + distancia * 0.15
        elif tipo == "transportadora_b":
            return peso * 2.8 + distancia * 0.08 + 5
        elif tipo == "motoboy":
            return 25 if distancia < 15 else 40
        else:
            raise ValueError("tipo desconhecido")

# Cada nova transportadora exige editar esta classe.
# Risco de quebrar regras existentes a cada mudança.
# Testes precisam rodar tudo de novo.
calculo_frete_bom.py — segue OCP
from typing import Protocol
from decimal import Decimal

class TransportadoraFrete(Protocol):
    def calcular(self, peso: Decimal, distancia: Decimal) -> Decimal: ...

class FreteCorreios:
    def calcular(self, peso, distancia):
        return peso * Decimal("2.5") + distancia * Decimal("0.1")

class FreteTransportadoraA:
    def calcular(self, peso, distancia):
        return peso * Decimal("3.0") + distancia * Decimal("0.15")

class FreteMotoboy:
    def calcular(self, peso, distancia):
        return Decimal("25") if distancia < 15 else Decimal("40")

# Adicionar nova transportadora? Crie uma classe. Zero alteração no resto.
class FreteLoggi:
    def calcular(self, peso, distancia):
        return peso * Decimal("1.8") + distancia * Decimal("0.05")

3.4 L — Liskov Substitution Principle

"Subtipos devem ser substituíveis pelos seus pais sem quebrar o programa."

Princípio formulado por Barbara Liskov em 1987. A intuição: se o cliente trabalha com tipo T, deve funcionar com qualquer subtipo de T. Senão, sua hierarquia está errada.

O exemplo clássico — e didaticamente perfeito — é o problema do Quadrado-Retângulo:

quadrado_retangulo.py — LSP violado
class Retangulo:
    def __init__(self, largura, altura):
        self.largura = largura
        self.altura = altura

    def area(self):
        return self.largura * self.altura

class Quadrado(Retangulo):
    # "Quadrado é um retângulo... né?"
    def __init__(self, lado):
        super().__init__(lado, lado)

    @property
    def largura(self):
        return self._lado

    @largura.setter
    def largura(self, valor):
        self._lado = valor
        self.altura = valor  # quadrado é "lado único"

# Cliente esperando Retangulo:
def aumentar_largura(r: Retangulo, nova_largura):
    r.largura = nova_largura
    # Espera: altura permaneceu igual
    assert r.area() == nova_largura * r.altura

# Passa Quadrado → quebra: setar largura também muda altura.
# Liskov violado: Quadrado NÃO é substituível por Retangulo.

A lição: "é um" em linguagem natural não é "é subtipo" em programação. Quadrado e Retângulo têm a mesma matemática, mas comportamentos diferentes. A herança aqui mente sobre o contrato.

Como detectar LSP violado

3.5 I — Interface Segregation Principle

"Nenhum cliente deve ser forçado a depender de métodos que não usa."

Interfaces grandes acoplam clientes a comportamento irrelevante. Quebre em interfaces menores e específicas. Se você tem uma interface Repositorio com vinte métodos, e a maioria dos clientes só usa três, você tem um problema.

✗ Interface "fat"
repo_ruim.py
class RepositorioPedido(ABC):
    @abstractmethod
    def salvar(self, p): ...

    @abstractmethod
    def buscar_por_id(self, id): ...

    @abstractmethod
    def listar_todos(self): ...

    @abstractmethod
    def deletar(self, id): ...

    @abstractmethod
    def exportar_csv(self): ...

    @abstractmethod
    def gerar_relatorio_mensal(self): ...

    @abstractmethod
    def enviar_para_bi(self): ...

    # ... e mais 12 métodos

Cliente que só quer buscar pedido carrega 20 métodos. Mock no teste vira pesadelo. Qualquer alteração na interface afeta todos os implementadores.

✓ Interfaces específicas
repo_bom.py
from typing import Protocol

class LeitorPedido(Protocol):
    def buscar_por_id(self, id) -> Pedido: ...
    def listar_todos(self) -> list[Pedido]: ...

class EscritorPedido(Protocol):
    def salvar(self, p: Pedido) -> None: ...
    def deletar(self, id) -> None: ...

class ExportadorPedido(Protocol):
    def exportar_csv(self) -> str: ...

class RelatorioPedido(Protocol):
    def gerar_relatorio_mensal(self) -> Relatorio: ...

# Cliente só recebe o que precisa.
# Cada interface tem 1-2 métodos.
# Implementações podem compor as que fizerem sentido.

3.6 D — Dependency Inversion Principle

"Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações."

Talvez o princípio com maior impacto arquitetural. Em vez de seu código de negócio depender diretamente de detalhes de infraestrutura (banco específico, biblioteca de e-mail, API externa), faça ambos dependerem de uma abstração intermediária que você controla.

O resultado: você pode trocar implementações livremente. Testar fica trivial (passa um mock). Migrar de PostgreSQL para outro banco vira problema de uma classe, não do sistema inteiro. É a fundação da arquitetura hexagonal (capítulo 20) e do DDD (capítulo 21).

✗ Negócio dependendo de detalhe
pedido_ruim.py
import psycopg2  # biblioteca específica
import smtplib    # detalhe de e-mail

class ServicoPedido:
    def criar(self, dados):
        # conecta direto no banco
        conn = psycopg2.connect("...")
        cur = conn.cursor()
        cur.execute("INSERT INTO pedidos...", dados)

        # envia e-mail direto
        smtp = smtplib.SMTP("smtp.gmail.com", 587)
        smtp.send_message(...)

# Problemas: difícil testar (precisa banco e SMTP reais).
# Migrar de Postgres → trocar tudo. Trocar SMTP → trocar tudo.
✓ Dependência invertida
pedido_bom.py
from typing import Protocol

class RepositorioPedido(Protocol):
    def salvar(self, p: Pedido) -> None: ...

class Notificador(Protocol):
    def enviar(self, destinatario, mensagem) -> None: ...

class ServicoPedido:
    def __init__(
        self,
        repo: RepositorioPedido,
        notificador: Notificador,
    ):
        self._repo = repo
        self._notif = notificador

    def criar(self, dados):
        pedido = Pedido(...)
        self._repo.salvar(pedido)
        self._notif.enviar(pedido.cliente_email, "Pedido criado")

# Implementações concretas ficam em outro módulo:
# PostgresRepositorioPedido, SmtpNotificador, etc.
# No teste, passa MockRepo e MockNotificador.

3.7 Estudo de caso — refatorando para SOLID

Sistema de cadastro de usuário evoluindo

Vamos pegar uma classe "típica" que viola vários princípios e refatorar passo a passo.

Estado inicial · Tudo numa classe
v0_god.py
class GerenciadorUsuario:
    def __init__(self):
        self.conexao_db = None

    def cadastrar(self, nome, email, senha):
        # 1. Validação
        if "@" not in email:
            raise ValueError("email inválido")
        if len(senha) < 8:
            raise ValueError("senha curta")

        # 2. Hash de senha
        import hashlib
        senha_hash = hashlib.sha256(senha.encode()).hexdigest()

        # 3. Salvar no banco
        import psycopg2
        conn = psycopg2.connect("...")
        cur = conn.cursor()
        cur.execute(
            "INSERT INTO usuarios...",
            (nome, email, senha_hash),
        )

        # 4. Enviar e-mail
        import smtplib
        smtp = smtplib.SMTP("smtp.gmail.com", 587)
        smtp.sendmail("sistema@x.com", email, "Bem-vindo!")

        # 5. Registrar no Analytics
        import requests
        requests.post("https://analytics.x.com/event", json={
            "tipo": "cadastro", "email": email,
        })

Problemas: S violado (5 responsabilidades distintas), D violado (depende direto de psycopg2, smtplib, requests), I violado (clientes que só querem o repo são forçados a importar isso tudo).

Refatoração · Aplicar SRP + DIP
v1_decomposto.py
from typing import Protocol
from dataclasses import dataclass

@dataclass(frozen=True)
class Usuario:
    nome: str
    email: str
    senha_hash: str

class ValidadorCadastro:
    def validar(self, nome, email, senha):
        if "@" not in email:
            raise ValueError("email inválido")
        if len(senha) < 8:
            raise ValueError("senha curta")
        if not nome.strip():
            raise ValueError("nome vazio")

class HasherSenha(Protocol):
    def hash(self, senha: str) -> str: ...

class RepositorioUsuario(Protocol):
    def salvar(self, u: Usuario) -> None: ...
    def existe_email(self, email: str) -> bool: ...

class Notificador(Protocol):
    def boas_vindas(self, email: str) -> None: ...

class Analytics(Protocol):
    def registrar_evento(self, tipo: str, dados: dict) -> None: ...

class ServicoCadastro:
    def __init__(
        self,
        validador: ValidadorCadastro,
        hasher: HasherSenha,
        repo: RepositorioUsuario,
        notif: Notificador,
        analytics: Analytics,
    ):
        self._validador = validador
        self._hasher = hasher
        self._repo = repo
        self._notif = notif
        self._analytics = analytics

    def cadastrar(self, nome, email, senha):
        self._validador.validar(nome, email, senha)
        if self._repo.existe_email(email):
            raise ValueError("e-mail já cadastrado")
        usuario = Usuario(nome, email, self._hasher.hash(senha))
        self._repo.salvar(usuario)
        self._notif.boas_vindas(email)
        self._analytics.registrar_evento("cadastro", {"email": email})

# Cada peça testável isolada. Trocar Postgres por outro banco?
# Nova classe que implementa RepositorioUsuario. Resto inalterado.

O que aplicamos:

  • SRP: separamos validação, hashing, persistência, notificação, analytics.
  • OCP: trocar implementação de qualquer peça não afeta o serviço.
  • ISP: cada Protocol tem 1-2 métodos — clientes específicos.
  • DIP: ServicoCadastro depende de abstrações, não de psycopg2/smtplib/requests.
  • LSP: qualquer RepositorioUsuario precisa honrar o contrato dos métodos.

3.8 Críticas honestas a SOLID

SOLID é útil — mas também tem sido usado para justificar overengineering. Vale revisar as críticas razoáveis:

O autor original (Robert Martin) é controverso, e parte da indústria considera o evangelismo SOLID excessivo. Mas os princípios em si seguem válidos — usados com bom senso.

3.9 Erros comuns ao aplicar SOLID

Erro 1 · Abstração especulativa

Criar interfaces para classes que nunca terão segunda implementação. Resultado: código mais difícil de ler sem benefício real. Regra: rule of three — abstraia quando tiver 3 casos similares, não antes.

Erro 2 · Classes "Manager", "Helper", "Util"

Esses nomes são sinal de SRP violado. Se você não conseguiu nomear especificamente o que a classe faz, ela faz coisas demais.

Erro 3 · Interface que muda o tempo todo

Se a interface está sendo alterada constantemente, ela não é uma abstração estável — é só um intermediário. Provavelmente você está aplicando DIP sem ter razão para inverter.

Erro 4 · Lasagna code

Camadas e mais camadas, cada uma só repassando a chamada para a próxima. Indireção sem informação. Aconteceu DIP sem entender por quê.

3.10 Quando NÃO aplicar SOLID estritamente

Reconheça o contexto
Sistemas pequenos, scripts, protótipos

SOLID tem custo: mais classes, mais arquivos, mais cerimônia. Esse custo se paga em sistemas que evoluem por anos. Em código que:

  • Você vai jogar fora em uma semana
  • Tem 200 linhas no total
  • É um script de migração one-off
  • É um experimento para validar uma hipótese

— SOLID atrapalha. Faça simples. Quando o experimento virar produto e começar a evoluir, aí você refatora com SOLID em mente.

A pergunta é: esse código vai sobreviver? Se sim, SOLID compensa. Se não, não.

Verifique seu entendimento
"Você criou uma classe Veiculo com método ligar(). Agora vai criar BicicletaEletrica, que tem motor mas não tem ignição como um carro. Como tratar?"

3.11 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar violações

Para cada item abaixo, identifique qual princípio é violado e por quê:

  • Classe UtilStrings com 30 métodos: upper, split, format_email, parse_json, send_sms, connect_db.
  • Classe Quadrado(Retangulo) que sobrescreve setters para manter lados iguais.
  • Função processar(tipo: str) com if/elif para 8 tipos diferentes.
  • Classe de domínio que importa psycopg2 diretamente.
  • UtilStrings: viola SRP (mistura strings, e-mail, JSON, SMS, DB) e ISP (clientes recebem 30 métodos). Quebre por contexto.
  • Quadrado(Retangulo): viola LSP — cliente que altera largura espera altura intacta, e em Quadrado isso quebra.
  • processar(tipo): viola OCP — adicionar novo tipo exige editar a função. Use polimorfismo.
  • Domínio com psycopg2: viola DIP — alto nível depende de detalhe. Crie interface Repositorio.
Médio
Exercício 2 · Refatorar para OCP

Refatore esta função, que usa if/elif para tipos de notificação, em algo que segue OCP. Adicionar "Telegram" não deve mexer no código existente.

antes.py
def notificar(usuario, mensagem, canal):
    if canal == "email":
        smtp.send(usuario.email, mensagem)
    elif canal == "sms":
        requests.post("sms-api", {"to": usuario.telefone, "msg": mensagem})
    elif canal == "push":
        firebase.send(usuario.device_id, mensagem)
    else:
        raise ValueError("canal desconhecido")
depois.py
from typing import Protocol

class CanalNotificacao(Protocol):
    def enviar(self, usuario, mensagem: str) -> None: ...

class EmailCanal:
    def enviar(self, usuario, mensagem):
        smtp.send(usuario.email, mensagem)

class SmsCanal:
    def enviar(self, usuario, mensagem):
        requests.post("sms-api", {"to": usuario.telefone, "msg": mensagem})

class PushCanal:
    def enviar(self, usuario, mensagem):
        firebase.send(usuario.device_id, mensagem)

class Notificador:
    def __init__(self, canais: dict[str, CanalNotificacao]):
        self._canais = canais

    def notificar(self, usuario, mensagem, canal_id: str):
        canal = self._canais.get(canal_id)
        if not canal:
            raise ValueError(f"canal {canal_id} não registrado")
        canal.enviar(usuario, mensagem)

# Adicionar Telegram:
class TelegramCanal:
    def enviar(self, usuario, mensagem):
        telegram_api.send(usuario.telegram_id, mensagem)

# Registro:
notif = Notificador({
    "email": EmailCanal(),
    "sms": SmsCanal(),
    "push": PushCanal(),
    "telegram": TelegramCanal(),  # nova entrada
})
Médio
Exercício 3 · ISP em prática

Você tem uma interface Funcionario com métodos trabalhar(), almocar(), tirar_ferias(), aprovar_despesa(), contratar(). Surge a classe Estagiario, que não pode aprovar despesa nem contratar. Como segregar?

funcionario_isp.py
from typing import Protocol

class Trabalhador(Protocol):
    def trabalhar(self) -> None: ...
    def almocar(self) -> None: ...

class Empregado(Trabalhador, Protocol):
    def tirar_ferias(self) -> None: ...

class Gestor(Empregado, Protocol):
    def aprovar_despesa(self, d) -> None: ...
    def contratar(self, candidato) -> None: ...

class Estagiario:
    def trabalhar(self): ...
    def almocar(self): ...
    # NÃO implementa Empregado ou Gestor

class DesenvolvedorCLT:
    def trabalhar(self): ...
    def almocar(self): ...
    def tirar_ferias(self): ...

class GerenteEngenharia:
    def trabalhar(self): ...
    def almocar(self): ...
    def tirar_ferias(self): ...
    def aprovar_despesa(self, d): ...
    def contratar(self, candidato): ...

# Função que processa pedido de férias aceita Empregado, não Estagiario.
Difícil
Exercício 4 · Sistema de moderação

Modele um sistema de moderação de conteúdo que aplica filtros em sequência: detecção de palavrão, detecção de spam, validação de tamanho, detecção de imagens NSFW. Cada filtro pode aprovar, rejeitar ou marcar para revisão humana. O sistema deve permitir adicionar novos filtros sem alterar nada existente. Use SOLID — todos os 5.

moderacao.py
from dataclasses import dataclass
from enum import Enum
from typing import Protocol

class Veredito(Enum):
    APROVADO = "aprovado"
    REJEITADO = "rejeitado"
    REVISAO = "revisao"

@dataclass(frozen=True)
class ResultadoFiltro:
    veredito: Veredito
    motivo: str | None = None

@dataclass(frozen=True)
class Conteudo:
    texto: str
    imagens: tuple[str, ...]
    autor_id: str

# I + D: protocolo enxuto, abstração estável
class FiltroModeracao(Protocol):
    def avaliar(self, c: Conteudo) -> ResultadoFiltro: ...

# S: cada filtro tem uma responsabilidade
class FiltroPalavrao:
    def __init__(self, lista: frozenset[str]):
        self._palavroes = lista

    def avaliar(self, c: Conteudo) -> ResultadoFiltro:
        palavras = c.texto.lower().split()
        achadas = [p for p in palavras if p in self._palavroes]
        if len(achadas) >= 3:
            return ResultadoFiltro(Veredito.REJEITADO, "muito palavrão")
        if achadas:
            return ResultadoFiltro(Veredito.REVISAO, "palavrão detectado")
        return ResultadoFiltro(Veredito.APROVADO)

class FiltroTamanho:
    def __init__(self, min_c=10, max_c=5000):
        self._min, self._max = min_c, max_c

    def avaliar(self, c: Conteudo) -> ResultadoFiltro:
        n = len(c.texto)
        if n < self._min or n > self._max:
            return ResultadoFiltro(Veredito.REJEITADO, f"tamanho {n}")
        return ResultadoFiltro(Veredito.APROVADO)

class FiltroSpam:
    def avaliar(self, c: Conteudo) -> ResultadoFiltro:
        if c.texto.count("http") > 5:
            return ResultadoFiltro(Veredito.REJEITADO, "muitos links")
        return ResultadoFiltro(Veredito.APROVADO)

# O: o orquestrador é fechado a modificação — adicionar filtros NSFW
# é apenas mais uma classe + injetar na lista.
class PipelineModeracao:
    def __init__(self, filtros: tuple[FiltroModeracao, ...]):
        self._filtros = filtros

    def avaliar(self, c: Conteudo) -> ResultadoFiltro:
        em_revisao: str | None = None
        for f in self._filtros:
            r = f.avaliar(c)
            if r.veredito == Veredito.REJEITADO:
                return r  # curto-circuita
            if r.veredito == Veredito.REVISAO:
                em_revisao = r.motivo
        if em_revisao:
            return ResultadoFiltro(Veredito.REVISAO, em_revisao)
        return ResultadoFiltro(Veredito.APROVADO)

# L: todo FiltroModeracao retorna ResultadoFiltro. Substituível.
Fim do capítulo 3
Os princípios SOLID são vocabulário compartilhado. Quando você for revisar código em time, vão te dar nomes para sentimentos que antes eram só "está estranho aqui". Próximo: tratamento de erros — outro lugar onde código profissional se separa do amador.
Parte I · Capítulo 04 · Fundação do código

Tratamento de erros:
onde sistemas
vivem ou morrem.

A maioria dos sistemas em produção não falha porque o "feliz path" tem bugs. Falha porque ninguém pensou direito no que acontece quando algo dá errado.

Tratamento de erros não é decorativo. É design. Toda função sua define implicitamente um contrato: "sob essas condições eu funciono, sob essas eu falho, e quando falho, falho desta forma". Sistemas que sobrevivem em produção têm esse contrato explícito; sistemas que entram em pânico em sexta-feira à noite têm esse contrato silenciado.

4.1 A história — de Hoare a Goodenough

Contexto histórico

Em 1965, Tony Hoare inventou a referência null em ALGOL W. Quase 45 anos depois, em palestra famosa de 2009, ele se referiu a isso como "meu erro de um bilhão de dólares" — uma autocrítica honesta sobre quanto dano null pointer exceptions causaram ao software ao longo das décadas.

Em 1975, John Goodenough publicou o paper seminal "Exception Handling: Issues and a Proposed Notation", formalizando exceções como mecanismo de controle de fluxo. Ada, C++, Java e Python herdaram essa tradição.

Em paralelo, surgiu uma outra escola: linguagens funcionais como ML, OCaml e mais recentemente Rust e Haskell evitaram exceções, preferindo tipos como Option/Maybe e Result/Either, que tornam falhas explícitas no tipo. Go pegou outro caminho ainda: retorno de tupla (valor, erro), forçando o programador a tratar erro a cada chamada.

Hoje, em Python, temos os dois mundos disponíveis. Exceções como padrão da linguagem, e padrões funcionais (Result, Either) quando fazem sentido. Saber quando usar cada um é a marca de quem entende o tema de verdade.

4.2 Filosofia: erro é parte do contrato

Existe uma distinção fundamental que muito código mistura: erros esperados vs erros excepcionais.

Regra prática
Erros esperados, modele como dados no retorno (boolean, Optional, Result). Erros excepcionais, lance exceções específicas. Misturar os dois — usar exceções para "usuário não encontrado" — é o equivalente a usar martelo de demolição para pendurar quadro.

4.3 Exceções com critério

Exceções em Python são poderosas, mas quase sempre mal usadas. Cinco regras práticas:

Regra 1 — Nunca capture Exception genérico

✗ Captura cega
cego.py
try:
    resultado = processar_pedido(p)
except Exception as e:
    print(f"deu ruim: {e}")
    return None

# Problema: você engole TUDO.
# KeyboardInterrupt do Ctrl+C? Engolido.
# Bug de typo em variável? Engolido.
# OutOfMemoryError? Engolido.
# Erro silencioso = bug silencioso.
✓ Captura específica
especifico.py
try:
    resultado = processar_pedido(p)
except PedidoInvalidoError as e:
    logger.warning(f"pedido inválido: {e}")
    return ResultadoFalho(str(e))
except ConexaoBancoError as e:
    logger.error(f"banco fora: {e}")
    raise  # re-lança — não somos quem decide

# Capturamos só o que sabemos tratar.
# O resto sobe e é tratado em camadas superiores
# (ou loga e mata o processo, conscientemente).

Regra 2 — Capture o mais perto possível, mas não mais perto que isso

Capture exceção no nível onde você tem informação para decidir o que fazer. Não capture só para suprimir: ou trate, ou re-lance. Capturar e ignorar é o pior dos mundos.

Regra 3 — Use finally e context managers para limpeza

limpeza.py
# Forma antiga, com finally explícito
arquivo = open("dados.txt")
try:
    conteudo = arquivo.read()
    processar(conteudo)
finally:
    arquivo.close()  # sempre executa, mesmo se processar() lançar

# Forma moderna: context manager (with)
with open("dados.txt") as arquivo:
    conteudo = arquivo.read()
    processar(conteudo)
# Fecha automaticamente, exceção ou não.

Regra 4 — Preserve o contexto ao re-lançar

contexto.py
try:
    dados = json.loads(payload)
except json.JSONDecodeError as e:
    # 'from e' preserva a stack trace original — essencial pra debug
    raise PayloadInvalidoError("payload mal formado") from e

Regra 5 — Falhe rápido e alto

Quando a invariante do seu sistema é violada, lance exceção imediatamente. Não tente "corrigir" silenciosamente. Erro percebido cedo é erro fácil de debugar; erro percebido 200ms depois, em outra parte do sistema, é pesadelo.

4.4 Hierarquia customizada de exceções

Crie sua própria hierarquia de exceções de domínio. Isso permite que clientes capturem por categoria (except ErroPagamento pega cartão recusado, saldo insuficiente, etc) ou específico (except CartaoRecusado) conforme precisarem.

excecoes_dominio.py
# Base: tudo do domínio herda dela
class ErroDominio(Exception):
    """Base para erros de negócio do sistema."""

# Categorias
class ErroPedido(ErroDominio): ...
class ErroPagamento(ErroDominio): ...
class ErroEstoque(ErroDominio): ...

# Específicos
class PedidoVazio(ErroPedido): ...
class PedidoJaFechado(ErroPedido): ...

class CartaoRecusado(ErroPagamento):
    def __init__(self, codigo: str, mensagem: str):
        self.codigo = codigo
        super().__init__(f"[{codigo}] {mensagem}")

class SaldoInsuficiente(ErroPagamento):
    def __init__(self, faltou: Decimal):
        self.faltou = faltou
        super().__init__(f"faltam R$ {faltou}")

class ProdutoSemEstoque(ErroEstoque):
    def __init__(self, sku: str, disponivel: int):
        self.sku = sku
        self.disponivel = disponivel
        super().__init__(f"sku {sku}: {disponivel} disponíveis")

# Uso por quem captura:
try:
    finalizar_compra(pedido)
except CartaoRecusado as e:
    if e.codigo == "51":  # sem limite
        oferecer_outra_forma()
    else:
        pedir_revisao_dados()
except ErroPagamento as e:
    # categoria genérica — fallback
    notificar_usuario(str(e))
except ErroEstoque as e:
    sugerir_produtos_similares(e.sku)

4.5 Result/Either — quando exceções não servem

Exceções têm desvantagens: criam fluxo "invisível" (quem lê o código não sabe que aquela função pode falhar), são caras em performance quando lançadas com frequência, e não aparecem na assinatura da função.

Para erros esperados e frequentes — validações, falhas de negócio rotineiras — frequentemente faz mais sentido retornar um Result explícito.

result.py
from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")
E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    valor: T

@dataclass(frozen=True)
class Erro(Generic[E]):
    motivo: E

Result = Ok[T] | Erro[E]

# Função que valida CPF — falha esperada, não excepcional
def validar_cpf(cpf: str) -> Result[str, str]:
    cpf = cpf.replace(".", "").replace("-", "")
    if len(cpf) != 11:
        return Erro("deve ter 11 dígitos")
    if not cpf.isdigit():
        return Erro("só dígitos")
    if cpf == cpf[0] * 11:
        return Erro("CPF inválido")
    return Ok(cpf)

# Quem chama é obrigado a tratar — não há fluxo invisível
resultado = validar_cpf(entrada)
match resultado:
    case Ok(valor):
        prosseguir(valor)
    case Erro(motivo):
        mostrar_mensagem(motivo)

Não é necessário virar fundamentalista de Result. Use onde fizer sentido — tipicamente: validação de input, parsing, operações de negócio que falham frequentemente.

Validação com erros agregados

validador_agregado.py
from dataclasses import dataclass, field

@dataclass
class ResultadoValidacao:
    erros: list[str] = field(default_factory=list)

    def add(self, msg: str):
        self.erros.append(msg)

    @property
    def valido(self) -> bool:
        return not self.erros

def validar_cadastro(d: dict) -> ResultadoValidacao:
    r = ResultadoValidacao()
    if not d.get("nome", "").strip():
        r.add("nome é obrigatório")
    if "@" not in d.get("email", ""):
        r.add("email inválido")
    if len(d.get("senha", "")) < 8:
        r.add("senha curta")
    return r

# Mostra TODOS os erros de uma vez ao usuário.
# Muito melhor UX que "ah, erro 1 corrigido, agora vejo o erro 2".

4.6 Context managers — limpeza garantida

Use with sempre que houver recurso que precisa ser liberado (arquivo, conexão, lock). Crie seus próprios para encapsular operações que precisam de setup/teardown:

context_manager.py
from contextlib import contextmanager
import time

@contextmanager
def cronometrar(rotulo: str):
    inicio = time.perf_counter()
    try:
        yield
    finally:
        decorrido = time.perf_counter() - inicio
        logger.info(f"{rotulo}: {decorrido*1000:.2f}ms")

with cronometrar("consulta_complexa"):
    resultado = db.query("SELECT ...")
# Loga "consulta_complexa: 142.83ms" automaticamente.

# Versão como classe — mais controle
class TransacaoBanco:
    def __init__(self, conn):
        self._conn = conn

    def __enter__(self):
        self._conn.execute("BEGIN")
        return self._conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self._conn.execute("COMMIT")
        else:
            self._conn.execute("ROLLBACK")
        return False  # não suprime a exceção

with TransacaoBanco(conn) as tx:
    tx.execute("INSERT...")
    tx.execute("UPDATE...")
# Commit se chegou ao fim. Rollback se levantou exceção.

4.7 Retry com backoff exponencial

Falhas transientes são realidade em sistemas distribuídos: rede pisca, banco fica lento, API externa engasga. Repetir a operação algumas vezes resolve a maioria. Mas tem regras:

retry.py
import time
import random
from typing import Callable, TypeVar

T = TypeVar("T")

class RetryError(Exception):
    def __init__(self, tentativas: int, ultimo: Exception):
        self.tentativas = tentativas
        self.ultimo = ultimo
        super().__init__(f"falhou após {tentativas} tentativas: {ultimo}")

def retry_com_backoff(
    func: Callable[[], T],
    *,
    tentativas: int = 5,
    base: float = 1.0,
    fator: float = 2.0,
    teto: float = 30.0,
    exceptions: tuple = (Exception,),
) -> T:
    for tentativa in range(1, tentativas + 1):
        try:
            return func()
        except exceptions as e:
            if tentativa == tentativas:
                raise RetryError(tentativa, e) from e
            espera = min(base * (fator ** (tentativa - 1)), teto)
            # jitter: ±25% da espera para evitar sincronia
            espera *= 0.75 + random.random() * 0.5
            logger.warning(
                f"tentativa {tentativa} falhou ({e}), "
                f"aguardando {espera:.2f}s"
            )
            time.sleep(espera)
    raise RuntimeError("inalcançável")

# Uso:
resultado = retry_com_backoff(
    lambda: requests.get(url, timeout=5),
    tentativas=4,
    exceptions=(ConnectionError, TimeoutError),
)

4.8 Circuit breaker — quando parar de tentar

Retry agressivo num serviço externo que está fora do ar piora a situação — você sobrecarrega o serviço quando ele estiver tentando se recuperar, e seus próprios recursos travam esperando. Resposta: circuit breaker.

Funciona como disjuntor elétrico: depois de N falhas consecutivas, "abre" o circuito e recusa imediatamente as próximas chamadas por algum tempo. Periodicamente, deixa uma passar para testar se o serviço voltou.

circuit_breaker.py
from enum import Enum
import time

class EstadoCB(Enum):
    FECHADO = "fechado"    # normal, passa
    ABERTO = "aberto"      # bloqueando
    MEIO_ABERTO = "meio"   # testando recuperação

class CircuitoAberto(Exception): ...

class CircuitBreaker:
    def __init__(
        self,
        max_falhas: int = 5,
        timeout_segundos: float = 30.0,
    ):
        self._max_falhas = max_falhas
        self._timeout = timeout_segundos
        self._falhas = 0
        self._estado = EstadoCB.FECHADO
        self._aberto_desde: float | None = None

    def chamar(self, func: Callable[[], T]) -> T:
        if self._estado == EstadoCB.ABERTO:
            if time.time() - self._aberto_desde > self._timeout:
                self._estado = EstadoCB.MEIO_ABERTO
            else:
                raise CircuitoAberto("circuito aberto")

        try:
            resultado = func()
        except Exception:
            self._registrar_falha()
            raise

        self._registrar_sucesso()
        return resultado

    def _registrar_falha(self):
        self._falhas += 1
        if self._falhas >= self._max_falhas:
            self._estado = EstadoCB.ABERTO
            self._aberto_desde = time.time()

    def _registrar_sucesso(self):
        self._falhas = 0
        self._estado = EstadoCB.FECHADO
        self._aberto_desde = None

4.9 Estudo de caso — serviço de pagamento resiliente

Pagamento com gateway externo: tratando falhas em camadas

Vamos construir, passo a passo, um serviço de pagamento que combina validação local, retry, circuit breaker e hierarquia de exceções.

Passo 1 · Hierarquia de erros do domínio
erros_pagamento.py
class ErroPagamento(Exception): ...

# Erros do cliente — não retry
class DadosCartaoInvalidos(ErroPagamento): ...
class CartaoRecusado(ErroPagamento):
    def __init__(self, codigo: str):
        self.codigo = codigo
        super().__init__(f"recusa código {codigo}")

# Erros transientes — retry vale a pena
class GatewayTransiente(ErroPagamento): ...
class GatewayTimeout(GatewayTransiente): ...
class GatewayIndisponivel(GatewayTransiente): ...
Passo 2 · Adaptador do gateway
gateway.py
import requests
from dataclasses import dataclass

@dataclass(frozen=True)
class ConfirmacaoPagamento:
    transacao_id: str
    autorizacao: str

class GatewayPagamento:
    def __init__(self, url: str, timeout: float = 5.0):
        self._url = url
        self._timeout = timeout

    def cobrar(self, cartao: dict, valor: Decimal) -> ConfirmacaoPagamento:
        try:
            resp = requests.post(
                self._url,
                json={"cartao": cartao, "valor": str(valor)},
                timeout=self._timeout,
            )
        except requests.Timeout as e:
            raise GatewayTimeout("timeout no gateway") from e
        except requests.ConnectionError as e:
            raise GatewayIndisponivel("conexão falhou") from e

        if resp.status_code == 503:
            raise GatewayIndisponivel("gateway 503")
        if resp.status_code == 400:
            raise DadosCartaoInvalidos(resp.json().get("erro"))
        if resp.status_code == 402:
            raise CartaoRecusado(resp.json().get("codigo"))
        resp.raise_for_status()

        d = resp.json()
        return ConfirmacaoPagamento(d["tx_id"], d["auth"])
Passo 3 · Serviço com retry + circuit breaker
servico_pagamento.py
class ServicoPagamento:
    def __init__(
        self,
        gateway: GatewayPagamento,
        cb: CircuitBreaker,
    ):
        self._gateway = gateway
        self._cb = cb

    def processar(self, cartao: dict, valor: Decimal) -> ConfirmacaoPagamento:
        # Validação local primeiro (fail fast — não vai pra rede)
        self._validar_cartao(cartao)

        # Retry só dos erros transientes
        def _tentar():
            return self._cb.chamar(lambda: self._gateway.cobrar(cartao, valor))

        return retry_com_backoff(
            _tentar,
            tentativas=3,
            exceptions=(GatewayTransiente,),
        )
        # DadosCartaoInvalidos e CartaoRecusado sobem direto — sem retry

    def _validar_cartao(self, c: dict):
        if not c.get("numero"):
            raise DadosCartaoInvalidos("número ausente")
        if not c.get("cvv"):
            raise DadosCartaoInvalidos("cvv ausente")

O que ganhamos: falhas categorizadas semanticamente, retry apenas onde faz sentido, circuit breaker protege gateway sobrecarregado, validação local economiza chamada de rede para erros óbvios. Cliente do serviço captura por categoria que faz sentido para ele.

4.10 Erros comuns

Erro 1 · Engolir e logar

except Exception: logger.error(...) e seguir. Pior dos dois mundos: você "perdeu" o erro mas não trata. Ou trata, ou re-lança. Não engole.

Erro 2 · Exceção como controle de fluxo

Usar try/except para checar se uma chave existe em dict, se um arquivo existe, etc. Exceções são caras quando lançadas. Para coisas que esperamos acontecer, use if, in, os.path.exists() — não exceção.

Erro 3 · Mensagens inúteis

raise Exception("erro"). Inclua contexto: qual valor, qual chave, em que estado. "Pedido 4521 está fechado, não pode receber novos itens" >> "estado inválido".

Erro 4 · Retry de tudo

Decorator @retry em cima de qualquer função, sem distinguir transitório de permanente. Cartão recusado vai ser recusado de novo. Não tente.

4.11 Quando NÃO usar exceções

Reconheça o contexto
Casos onde Result/None/valor especial faz mais sentido
  • Funções de busca: "encontrar usuário por id" que pode não encontrar — retorne None ou Optional. Não é exceção.
  • Validações em massa: formulário com 10 campos — quer acumular todos os erros, não parar no primeiro. Use Result agregado.
  • Loops quentes: performance importa — exceção lançada milhares de vezes/s degrada. Use código condicional.
  • API de biblioteca: retornar Result torna o contrato mais explícito que documentar exceções.
Verifique seu entendimento
"Sua função chama API externa que pode retornar 503 (sobrecarga), 400 (dados ruins) ou 200. Qual estratégia de tratamento?"

4.12 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Validador com erros agregados

Crie validar_endereco(dados: dict) que retorna ResultadoValidacao com TODOS os erros encontrados (não para no primeiro). Valide: rua não vazia, número numérico, CEP no formato 00000-000, cidade não vazia.

validar_endereco.py
import re
from dataclasses import dataclass, field

CEP_RE = re.compile(r"^\d{5}-\d{3}$")

@dataclass
class ResultadoValidacao:
    erros: list[str] = field(default_factory=list)
    @property
    def valido(self): return not self.erros

def validar_endereco(d: dict) -> ResultadoValidacao:
    r = ResultadoValidacao()
    if not d.get("rua", "").strip():
        r.erros.append("rua é obrigatória")
    numero = str(d.get("numero", ""))
    if not numero.isdigit():
        r.erros.append("número deve ser numérico")
    if not CEP_RE.match(d.get("cep", "")):
        r.erros.append("CEP deve estar no formato 00000-000")
    if not d.get("cidade", "").strip():
        r.erros.append("cidade é obrigatória")
    return r
Fácil
Exercício 2 · Hierarquia de exceções

Modele exceções para um sistema de biblioteca: erro base ErroBiblioteca, categorias ErroEmprestimo e ErroAcervo, e específicos: LivroIndisponivel, LeitorBloqueado, LivroNaoCatalogado.

biblioteca_erros.py
class ErroBiblioteca(Exception):
    """Base para erros do domínio da biblioteca."""

class ErroEmprestimo(ErroBiblioteca): ...
class ErroAcervo(ErroBiblioteca): ...

class LivroIndisponivel(ErroEmprestimo):
    def __init__(self, isbn: str):
        self.isbn = isbn
        super().__init__(f"livro {isbn} já emprestado")

class LeitorBloqueado(ErroEmprestimo):
    def __init__(self, leitor_id: str, motivo: str):
        self.leitor_id = leitor_id
        self.motivo = motivo
        super().__init__(f"leitor {leitor_id} bloqueado: {motivo}")

class LivroNaoCatalogado(ErroAcervo):
    def __init__(self, isbn: str):
        self.isbn = isbn
        super().__init__(f"ISBN {isbn} não está no acervo")
Médio
Exercício 3 · Tipo Result

Implemente Result[T, E] com método map(func) que aplica função se for Ok e propaga se for Erro. Use pattern matching para extrair valores.

result.py
from dataclasses import dataclass
from typing import Generic, TypeVar, Callable

T = TypeVar("T"); U = TypeVar("U"); E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    valor: T
    def map(self, f: Callable[[T], U]) -> "Ok[U] | Erro":
        try:
            return Ok(f(self.valor))
        except Exception as e:
            return Erro(str(e))

@dataclass(frozen=True)
class Erro(Generic[E]):
    motivo: E
    def map(self, f: Callable) -> "Erro[E]":
        return self  # propaga sem aplicar

# Uso encadeado:
def parse_int(s: str) -> Ok[int] | Erro[str]:
    try: return Ok(int(s))
    except ValueError: return Erro(f"'{s}' não é inteiro")

resultado = parse_int("42").map(lambda x: x * 2).map(lambda x: x + 1)
match resultado:
    case Ok(v): print(f"valor: {v}")  # 85
    case Erro(m): print(f"erro: {m}")
Médio
Exercício 4 · Decorator de retry

Crie decorator @retry(tentativas, exceptions, base=1.0, fator=2.0) com backoff exponencial e jitter. Aplique em uma função que faz HTTP.

decorator_retry.py
import functools, time, random, logging

logger = logging.getLogger(__name__)

def retry(tentativas=3, exceptions=(Exception,), base=1.0, fator=2.0, teto=30.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for tentativa in range(1, tentativas + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if tentativa == tentativas:
                        raise
                    espera = min(base * (fator ** (tentativa - 1)), teto)
                    espera *= 0.75 + random.random() * 0.5
                    logger.warning(f"{func.__name__} tentativa {tentativa}: {e} — aguardando {espera:.2f}s")
                    time.sleep(espera)
        return wrapper
    return decorator

@retry(tentativas=4, exceptions=(ConnectionError, TimeoutError))
def buscar_dados(url):
    return requests.get(url, timeout=5).json()
Difícil
Exercício 5 · Circuit Breaker completo com 3 estados

Implemente CircuitBreaker com transições explícitas FECHADO → ABERTO → MEIO_ABERTO → FECHADO/ABERTO. Em MEIO_ABERTO, permita uma chamada de teste; se passar, volta a FECHADO; se falhar, volta a ABERTO. Inclua método para inspecionar estado e contadores.

circuit_breaker_completo.py
import time
from enum import Enum
from dataclasses import dataclass
from typing import Callable, TypeVar

T = TypeVar("T")

class EstadoCB(Enum):
    FECHADO = "fechado"
    ABERTO = "aberto"
    MEIO_ABERTO = "meio_aberto"

class CircuitoAberto(Exception): ...

@dataclass
class EstatisticasCB:
    estado: EstadoCB
    falhas_consecutivas: int
    sucessos_total: int
    falhas_total: int
    aberto_desde: float | None

class CircuitBreaker:
    def __init__(self, max_falhas=5, timeout=30.0):
        self._max_falhas = max_falhas
        self._timeout = timeout
        self._estado = EstadoCB.FECHADO
        self._falhas_consec = 0
        self._aberto_desde: float | None = None
        self._sucessos = 0
        self._falhas = 0

    def chamar(self, func: Callable[[], T]) -> T:
        self._transicionar_se_preciso()
        if self._estado == EstadoCB.ABERTO:
            raise CircuitoAberto("circuito aberto")
        try:
            r = func()
        except Exception:
            self._registrar_falha()
            raise
        self._registrar_sucesso()
        return r

    def _transicionar_se_preciso(self):
        if self._estado == EstadoCB.ABERTO and self._aberto_desde:
            if time.time() - self._aberto_desde > self._timeout:
                self._estado = EstadoCB.MEIO_ABERTO

    def _registrar_falha(self):
        self._falhas += 1
        self._falhas_consec += 1
        if self._estado == EstadoCB.MEIO_ABERTO:
            self._abrir()
        elif self._falhas_consec >= self._max_falhas:
            self._abrir()

    def _registrar_sucesso(self):
        self._sucessos += 1
        self._falhas_consec = 0
        if self._estado in (EstadoCB.MEIO_ABERTO, EstadoCB.ABERTO):
            self._estado = EstadoCB.FECHADO
            self._aberto_desde = None

    def _abrir(self):
        self._estado = EstadoCB.ABERTO
        self._aberto_desde = time.time()

    def estatisticas(self) -> EstatisticasCB:
        return EstatisticasCB(
            self._estado, self._falhas_consec,
            self._sucessos, self._falhas, self._aberto_desde,
        )
Fim do capítulo 4
Tratamento de erros é uma das marcas mais visíveis de senioridade em código. Antes de seguir, abra um projeto seu e olhe para os try/except — quantos engolem erro? Quantos têm exceção genérica? Esse é o trabalho. Próximo capítulo: tipagem e contratos, fechando a Parte I.
Parte I · Capítulo 05 · Fundação do código

Tipagem
e contratos:
código que documenta a si.

Type hints em Python não são decoração. Bem usados, são documentação executável, prevenção de bugs em refatoração, e a única forma realista de manter sãmente um sistema grande.

Python é dinamicamente tipado e continuará sendo. Mas desde 3.5 temos type hints, e desde 3.10 eles se tornaram tão expressivos quanto os de TypeScript. A diferença entre usar hints superficialmente (def f(x: int) e pronto) e dominá-los (Protocol, Generic, TypedDict, Literal, NewType, assert_never) é a diferença entre código que parece tipado e código que de fato te protege.

5.1 A história — como Python virou parcialmente tipado

Contexto histórico

Python nasceu sem tipos. Era uma das suas vantagens contra C++ e Java nos anos 90: produtividade rápida, sem cerimônia. Mas conforme codebases cresciam — Dropbox em milhões de linhas, Instagram, YouTube — manter sanidade ficou doloroso.

Em 2014, a PEP 484 — escrita por Guido van Rossum com inspiração no mypy de Jukka Lehtosalo — introduziu type hints como opcionais em Python 3.5. A filosofia: gradual typing. Você tipa o que quer, mantém dinâmico o que não. Mesmo código.

Em 2018, a PEP 544 trouxe Protocol — structural typing, ou "duck typing estático". Em 2020, PEP 585 permitiu usar list[int] em vez de List[int]. Em 2021, PEP 604 trouxe X | Y em vez de Union[X, Y]. Hoje, em 2026, type hints são parte do Python moderno — e usá-los bem é diferencial.

Importante: type hints não são executados pelo interpretador. Eles existem para ferramentas: mypy, pyright, IDEs. O Python ignora. Por isso são "hints", não tipos. Mas o ganho prático é enorme.

5.2 Type hints essenciais

hints_basicos.py
from typing import Optional, Any, Callable
from decimal import Decimal
from datetime import datetime

# Tipos primitivos: int, float, str, bool, bytes
def somar(a: int, b: int) -> int:
    return a + b

# Containers (Python 3.9+, sem importar de typing)
def processar(itens: list[str]) -> dict[str, int]:
    return {item: len(item) for item in itens}

# Union (Python 3.10+, com sintaxe |)
def parse(x: str | int) -> float:
    return float(x)

# Optional = X | None — pode ser X ou None
def buscar(id: int) -> Usuario | None:
    ...

# Callable: assinatura de função
def aplicar(f: Callable[[int, int], int], a: int, b: int) -> int:
    return f(a, b)

# Tipos compostos aninhados — descrevem estrutura real
def agregados(
    vendas: list[tuple[str, Decimal, datetime]],
) -> dict[str, Decimal]:
    ...

# Any: desliga o checker para aquele ponto. USE COM PARCIMÔNIA.
# Cada Any é uma promessa não cumprida.
def deserializar(payload: bytes) -> Any:
    ...
Atenção
Any em type hints é o equivalente a except Exception em exceções: você desligou a proteção. Cada Any deveria ser justificado em comentário. Em projetos bem cuidados, contar quantos Any existem é uma métrica de saúde — quanto menos, melhor.

5.3 Protocol vs ABC — duck typing estático

Você já viu Protocol nos capítulos anteriores. Vamos aprofundar. Em Python, há duas formas de declarar "esse parâmetro precisa ter esses métodos":

ABC — herança explícita
abc_exemplo.py
from abc import ABC, abstractmethod

class Notificador(ABC):
    @abstractmethod
    def enviar(self, msg: str) -> None: ...

class EmailNotif(Notificador):  # herda explicitamente
    def enviar(self, msg):
        ...

Use ABC quando: quer instanciação proibida da base, métodos com implementação default + abstratos, quer que isinstance() funcione, hierarquia faz sentido semanticamente.

Protocol — estrutural
protocol_exemplo.py
from typing import Protocol

class Notificador(Protocol):
    def enviar(self, msg: str) -> None: ...

class EmailNotif:  # nada de herança
    def enviar(self, msg):
        ...
# Já satisfaz Notificador automaticamente.

Use Protocol quando: quer aceitar tipos de bibliotecas externas que você não controla, prefere acoplamento baixo, sua interface é "uma forma de comportamento" mais que uma hierarquia, está fazendo dependency injection.

Na maioria dos casos modernos, Protocol é a escolha melhor. ABC é útil em hierarquias estáveis com comportamento compartilhado. Frameworks tendem a usar ABC; código de aplicação tende a usar Protocol.

5.4 Generics — parametrizando tipos

Generics permitem escrever classes e funções que trabalham com qualquer tipo, sem perder informação de tipo. O exemplo canônico: container que guarda T e devolve T.

generics.py
from typing import Generic, TypeVar

T = TypeVar("T")

class Cache(Generic[T]):
    """Cache tipado: guarda T, retorna T."""
    def __init__(self):
        self._dados: dict[str, T] = {}

    def get(self, chave: str) -> T | None:
        return self._dados.get(chave)

    def set(self, chave: str, valor: T) -> None:
        self._dados[chave] = valor

# Uso: o checker rastreia o tipo
cache_usuarios: Cache[Usuario] = Cache()
cache_usuarios.set("alice", Usuario("Alice"))
u = cache_usuarios.get("alice")  # tipo: Usuario | None

cache_inteiros: Cache[int] = Cache()
cache_inteiros.set("contador", 42)
# cache_inteiros.set("x", "string")  ← mypy reclama: esperado int

# Função genérica: T entra, T sai
def primeiro(itens: list[T]) -> T | None:
    return itens[0] if itens else None

# TypeVar com bound: T precisa ser subtipo de Comparable
from typing import SupportsRichComparison
N = TypeVar("N", bound=int)  # N é qualquer subtipo de int

def maior(a: N, b: N) -> N:
    return a if a > b else b

Sintaxe nova (Python 3.12+)

Python 3.12 introduziu sintaxe mais limpa para generics, sem necessidade de declarar TypeVar:

generics_3.12.py
# Python 3.12+
class Cache[T]:
    def __init__(self):
        self._dados: dict[str, T] = {}

    def get(self, chave: str) -> T | None:
        return self._dados.get(chave)

def primeiro[T](itens: list[T]) -> T | None:
    return itens[0] if itens else None

# Muito mais legível. Use sempre que possível.

5.5 TypedDict e Literal — tipar estruturas e valores

Você precisa tipar uma resposta de API que vem como dict? Usar dict[str, Any] perde informação. TypedDict dá a forma exata:

typeddict_literal.py
from typing import TypedDict, Literal, NotRequired

class RespostaAPI(TypedDict):
    # Campos obrigatórios
    id: int
    nome: str
    status: Literal["ativo", "inativo", "pendente"]
    # Campo opcional (Python 3.11+)
    metadata: NotRequired[dict[str, str]]

def processar_resposta(r: RespostaAPI) -> str:
    # r["id"] é int garantido
    # r["status"] só pode ser uma dessas 3 strings — checker valida
    if r["status"] == "ativo":
        return f"ID {r['id']}: {r['nome']} está ativo"
    return "inativo"

# Literal expressa enums "leves" sem criar classe Enum
NivelLog = Literal["debug", "info", "warn", "error"]

def logar(nivel: NivelLog, msg: str):
    # mypy garante que só essas 4 strings podem ser passadas
    ...

Exhaustividade com assert_never

Combinação poderosa: Literal + typing.assert_never para garantir que você tratou todos os casos. Se adicionar um novo valor ao Literal e esquecer de tratar, mypy avisa:

exhaustive.py
from typing import Literal, assert_never

Forma = Literal["circulo", "quadrado", "triangulo"]

def area(forma: Forma, tamanho: float) -> float:
    if forma == "circulo":
        return 3.14159 * tamanho ** 2
    if forma == "quadrado":
        return tamanho ** 2
    if forma == "triangulo":
        return (tamanho ** 2) / 2
    assert_never(forma)  # mypy: erro se algum caso não foi coberto

# Adicionou "hexagono" ao Literal? mypy avisa: case faltando.

5.6 NewType — distinguir tipos primitivos

NewType cria um "apelido com tipo próprio" para um tipo existente. Útil quando você usa o mesmo tipo primitivo para coisas semanticamente diferentes:

newtype.py
from typing import NewType

UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)

def buscar_usuario(uid: UserId) -> Usuario:
    ...

def buscar_produto(pid: ProductId) -> Produto:
    ...

uid = UserId(42)
pid = ProductId(100)

buscar_usuario(uid)   # OK
buscar_usuario(pid)   # mypy: erro — ProductId não é UserId
buscar_usuario(42)    # mypy: erro — int não é UserId

# Em runtime, UserId e ProductId são int.
# Mas o checker te impede de misturar — protege contra
# bugs do tipo "passei o id errado para a função errada".

5.7 Design by contract — pré e pós-condições

Design by contract, formulado por Bertrand Meyer (criador do Eiffel) nos anos 80, propõe que funções tenham contratos explícitos: o que devem receber (pré-condições), o que garantem entregar (pós-condições), e o que nunca é alterado (invariantes).

Python não tem suporte nativo, mas você implementa com asserts (durante desenvolvimento) ou validações explícitas (em produção):

contracts.py
from decimal import Decimal

class Conta:
    def __init__(self, saldo: Decimal):
        # Invariante: saldo nunca negativo
        assert saldo >= 0, "saldo inicial negativo"
        self._saldo = saldo

    def sacar(self, valor: Decimal) -> None:
        # Pré-condições
        assert valor > 0, "valor deve ser positivo"
        assert valor <= self._saldo, "saldo insuficiente"

        saldo_antes = self._saldo
        self._saldo -= valor

        # Pós-condição
        assert self._saldo == saldo_antes - valor, "saldo incorreto"
        # Invariante mantido
        assert self._saldo >= 0, "saldo virou negativo"
Atenção
assert em Python é removido com flag -O. Não use para validação de input externo (use exceções específicas para isso). Use para invariantes que devem sempre ser verdade no seu próprio código — efetivamente, "isso é bug se falhar". Para validação de cliente, lance exceções.

5.8 mypy strict — sua melhor ferramenta

Type hints sem checker é só documentação. mypy (ou pyright/pylance) é quem garante que os hints estão consistentes. Configure no modo strict do seu projeto:

pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true

# strict ativa:
# disallow_untyped_defs       — toda função tem que ser tipada
# disallow_untyped_calls      — não chama função sem tipos
# disallow_any_explicit       — Any explícito é erro
# disallow_any_generics       — list (sem [T]) vira list[Any] = erro
# warn_unused_ignores         — # type: ignore órfão = erro
# warn_redundant_casts        — cast desnecessário = aviso
# no_implicit_optional        — Optional precisa ser explícito
# warn_return_any             — função tipada que retorna Any = aviso

[[tool.mypy.overrides]]
module = "legacy.*"  # exceções pontuais para código antigo
ignore_errors = true

Comece projeto novo já em strict. Em projeto existente, ativar strict de uma vez é doloroso — vá módulo por módulo. Cada erro corrigido é um bug potencial prevenido.

5.9 Estudo de caso — tipando um repositório real

De código solto para repositório completamente tipado

Vamos tipar um repositório genérico, com Protocol, Generics, NewType e exhaustividade.

Antes · Código sem tipos
antes.py
class Repositorio:
    def __init__(self, conn):
        self.conn = conn
        self.cache = {}

    def salvar(self, entidade):
        self.cache[entidade.id] = entidade
        self.conn.execute("INSERT...", entidade)

    def buscar(self, id):
        if id in self.cache:
            return self.cache[id]
        return self.conn.execute("SELECT...", id)
# Qual entidade? Qual id? O que retorna se não achar? Mistério.
Depois · Tipado de ponta a ponta
depois.py
from typing import Protocol, NewType, Generic, TypeVar
from dataclasses import dataclass

EntityId = NewType("EntityId", str)

class Entidade(Protocol):
    id: EntityId

T = TypeVar("T", bound=Entidade)

class ConexaoBanco(Protocol):
    def executar(self, sql: str, params: tuple) -> list[dict]: ...

class Repositorio(Generic[T]):
    def __init__(
        self,
        conn: ConexaoBanco,
        tabela: str,
        construtor: type[T],
    ):
        self._conn = conn
        self._tabela = tabela
        self._construtor = construtor
        self._cache: dict[EntityId, T] = {}

    def salvar(self, entidade: T) -> None:
        self._cache[entidade.id] = entidade
        self._conn.executar(
            f"INSERT INTO {self._tabela} ...",
            (entidade.id,)
        )

    def buscar(self, id: EntityId) -> T | None:
        if id in self._cache:
            return self._cache[id]
        rows = self._conn.executar(
            f"SELECT * FROM {self._tabela} WHERE id = ?",
            (id,),
        )
        if not rows:
            return None
        e = self._construtor(**rows[0])
        self._cache[id] = e
        return e

# Uso:
@dataclass
class Usuario:
    id: EntityId
    nome: str

repo: Repositorio[Usuario] = Repositorio(conn, "usuarios", Usuario)
u = repo.buscar(EntityId("abc"))  # tipo: Usuario | None

Ganhos: impossível passar id errado de outra entidade (NewType), retorno explícito T | None (sem mistério), cache tipado (descobre bug se você guardar tipo errado), ConexaoBanco abstrai DB (testes com fake).

5.10 Erros comuns

Erro 1 · Any escondido

def f(x: list) sem parâmetro vira list[Any]. Sempre escreva list[T]. mypy strict pega.

Erro 2 · Optional implícito

Em Python < 3.6 e em configs antigas, def f(x: int = None) aceitava None silenciosamente. Sempre escreva x: int | None = None explicitamente.

Erro 3 · type: ignore sem motivo

# type: ignore espalhado pelo código. Cada um deveria ter justificativa em comentário: # type: ignore[arg-type] # lib externa sem stubs. Senão vira ruído.

Erro 4 · Hints como decoração

Tipar só assinaturas públicas e deixar o interior "selvagem". A coerência é o ganho — tipe variáveis locais complexas também.

5.11 Quando NÃO investir em tipagem

Reconheça o contexto
Tipagem tem custo — vale onde?
  • Scripts one-off: 50 linhas para automação manual. Tipar não compensa.
  • Notebooks Jupyter: exploratório, rascunho — tipar atrapalha mais que ajuda.
  • Protótipos descartáveis: validar hipótese, sem futuro de manutenção.
  • Glue code muito dinâmico: metaprogramação pesada (decorators, descriptors customizados, factories) — às vezes simplesmente não dá pra tipar bem.

Mas qualquer código que vai pra produção, que outras pessoas vão tocar, que tem teste — vale tipar. O custo é pequeno; o ganho aparece quando o projeto tem 1, 2, 3 anos.

Verifique seu entendimento
"Você quer permitir que sua função aceite qualquer objeto com método read() que retorna bytes — incluindo arquivos, BytesIO, classes customizadas. Como tipar?"

5.12 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Tipar função existente

Adicione type hints completos a esta função:

antes.py
def agrupar_por(itens, chave):
    resultado = {}
    for item in itens:
        k = chave(item)
        if k not in resultado:
            resultado[k] = []
        resultado[k].append(item)
    return resultado
depois.py
from typing import Callable, TypeVar
from collections import defaultdict

T = TypeVar("T")
K = TypeVar("K")

def agrupar_por(
    itens: list[T],
    chave: Callable[[T], K],
) -> dict[K, list[T]]:
    resultado: dict[K, list[T]] = defaultdict(list)
    for item in itens:
        resultado[chave(item)].append(item)
    return dict(resultado)

# Sintaxe Python 3.12+:
def agrupar_por[T, K](
    itens: list[T],
    chave: Callable[[T], K],
) -> dict[K, list[T]]:
    ...
Fácil
Exercício 2 · TypedDict para resposta de API

Crie TypedDict que representa uma resposta como esta:

exemplo.json
{
  "id": 123,
  "tipo": "premium",            // "premium" | "free" | "trial"
  "email": "x@y.com",
  "tags": ["a", "b"],
  "metadata": {"x": "y"}      // opcional
}
resposta.py
from typing import TypedDict, Literal, NotRequired

TipoUsuario = Literal["premium", "free", "trial"]

class RespostaUsuario(TypedDict):
    id: int
    tipo: TipoUsuario
    email: str
    tags: list[str]
    metadata: NotRequired[dict[str, str]]

def processar(r: RespostaUsuario) -> str:
    # r["tipo"] só pode ser uma das 3 strings
    if r["tipo"] == "premium":
        return "VIP"
    return "comum"
Médio
Exercício 3 · NewType para evitar mistura

Você tem funções que recebem int para "ID de usuário", "idade", "código postal". Use NewType para impedir que sejam misturadas acidentalmente.

newtypes.py
from typing import NewType

UserId = NewType("UserId", int)
Idade = NewType("Idade", int)
CodigoPostal = NewType("CodigoPostal", int)

def buscar_usuario(uid: UserId) -> Usuario: ...
def verificar_idade(idade: Idade) -> bool:
    return idade >= 18
def localizar_por_cep(cep: CodigoPostal) -> Endereco: ...

# Uso correto
uid = UserId(42)
buscar_usuario(uid)

# Misturar dispara erro do checker:
# buscar_usuario(Idade(25))      ← erro: Idade não é UserId
# buscar_usuario(42)             ← erro: int não é UserId
# verificar_idade(uid)           ← erro: UserId não é Idade
Médio
Exercício 4 · Protocol para storage

Crie Protocol Storage com métodos get(key) -> bytes | None, put(key, value), delete(key). Implemente StorageMemoria sem herdar do Protocol — só satisfazendo estruturalmente.

storage.py
from typing import Protocol

class Storage(Protocol):
    def get(self, key: str) -> bytes | None: ...
    def put(self, key: str, value: bytes) -> None: ...
    def delete(self, key: str) -> None: ...

class StorageMemoria:  # SEM herdar
    def __init__(self):
        self._dados: dict[str, bytes] = {}

    def get(self, key: str) -> bytes | None:
        return self._dados.get(key)

    def put(self, key: str, value: bytes) -> None:
        self._dados[key] = value

    def delete(self, key: str) -> None:
        self._dados.pop(key, None)

def trabalhar_com(s: Storage):  # aceita qualquer Storage
    s.put("x", b"valor")

trabalhar_com(StorageMemoria())  # funciona — estruturalmente compatível
Difícil
Exercício 5 · State machine tipada com exhaustividade

Modele uma máquina de estados de pedido (RASCUNHO → CONFIRMADO → PAGO → ENVIADO → ENTREGUE, com CANCELADO em qualquer ponto não-final). Use Literal + assert_never para garantir que toda transição válida está coberta no checker.

state_machine.py
from typing import Literal, assert_never

EstadoPedido = Literal[
    "RASCUNHO", "CONFIRMADO", "PAGO",
    "ENVIADO", "ENTREGUE", "CANCELADO",
]

class TransicaoInvalida(Exception): ...

def proximos_estados(atual: EstadoPedido) -> frozenset[EstadoPedido]:
    if atual == "RASCUNHO":
        return frozenset({"CONFIRMADO", "CANCELADO"})
    if atual == "CONFIRMADO":
        return frozenset({"PAGO", "CANCELADO"})
    if atual == "PAGO":
        return frozenset({"ENVIADO", "CANCELADO"})
    if atual == "ENVIADO":
        return frozenset({"ENTREGUE"})  # não pode cancelar mais
    if atual == "ENTREGUE":
        return frozenset()  # terminal
    if atual == "CANCELADO":
        return frozenset()  # terminal
    assert_never(atual)  # mypy força cobertura completa

class Pedido:
    def __init__(self):
        self._estado: EstadoPedido = "RASCUNHO"

    @property
    def estado(self) -> EstadoPedido:
        return self._estado

    def transicionar(self, novo: EstadoPedido) -> None:
        if novo not in proximos_estados(self._estado):
            raise TransicaoInvalida(
                f"{self._estado} → {novo} não permitido"
            )
        self._estado = novo

# Se adicionarmos "DEVOLVIDO" ao Literal mas esquecermos do case,
# mypy --strict avisa: missing return / assert_never alcançável.
Fim do capítulo 5 · Fim da Parte I
Você chegou ao fim da fundação. POO aplicada, estruturas de dados, SOLID, tratamento de erros e tipagem — esse é o terreno sobre o qual tudo o que vem nas próximas partes vai se apoiar. Antes de seguir, peça "continua" para receber a Parte II — Padrões de design.
Parte II
Padrões de design

Soluções nominadas para problemas que se repetem. Aprenda quando aplicar — e, igualmente importante, quando o padrão é cargo cult disfarçado.

Por que padrões Criacionais Estruturais Comportamentais Anti-padrões
Parte II · Capítulo 06 · Padrões de design

Por que padrões:
vocabulário de
quem evoluiu código.

Padrões não são regras. São nomes para soluções recorrentes — e ter nome compartilhado vale tanto quanto a solução em si.

Quando você diz "vamos usar Strategy aqui", e o time todo entende sem precisar desenhar — isso é o ganho real dos padrões. Eles condensam em uma palavra uma estrutura de classes, intenção e trade-offs que demorariam quinze minutos para explicar. Mas há um lado escuro: padrões viraram cargo cult — aplicados como ritual, sem entender o problema que resolvem. Este capítulo prepara o terreno para que isso não aconteça com você.

6.1 A história — do livro vermelho ao mainstream

Contexto histórico

A ideia de "padrões de design" vem do arquiteto Christopher Alexander, que nos anos 70 publicou A Pattern Language sobre arquitetura de edifícios e cidades. Cada padrão era uma resposta nominada para um problema recorrente de design espacial.

Em 1994, quatro engenheiros — Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides — adaptaram a ideia para software no livro "Design Patterns: Elements of Reusable Object-Oriented Software". Ficaram conhecidos como Gang of Four, ou GoF, e o livro tem capa vermelha — daí "livro vermelho". 23 padrões catalogados, divididos em três categorias.

Nos anos 90 e 2000, GoF virou bíblia. Toda entrevista perguntava. Todo curso ensinava. E aí veio a reação: críticos argumentaram que muitos padrões só existem porque linguagens como Java e C++ são limitadas — em Python, Ruby, Smalltalk, vários se tornam triviais ou desnecessários. Outros argumentaram que padrões viraram desculpa para overengineering: aplicar Factory + Builder + Strategy onde uma função de 10 linhas resolveria.

Hoje a posição madura é: conheça os padrões, use quando agregam, esqueça quando não. Eles são vocabulário, não receita.

6.2 O que é (e o que não é) um padrão

Um padrão de design tem quatro elementos essenciais:

O que padrão não é:

Ponto importante
Você usou padrões nos capítulos anteriores, sem nomeá-los. MetodoPagamento com várias implementações? Strategy. FormatadorRelatorio? Strategy. Endereco imutável? Value Object. O nome só formaliza o que você já fazia intuitivamente.

6.3 As três categorias do GoF

C
Criacionais
Como objetos são criados. Resolvem complexidade de construção. Factory, Builder, Singleton, Prototype, Abstract Factory.
E
Estruturais
Como objetos se compõem. Resolvem integração e composição. Adapter, Decorator, Facade, Proxy, Composite, Bridge, Flyweight.
B
Comportamentais
Como objetos interagem. Resolvem comunicação e responsabilidade. Strategy, Observer, Command, State, Iterator, Template Method, Chain, Mediator, Memento, Visitor.

Vamos cobrir os mais relevantes para Python moderno nos próximos três capítulos. Alguns são quase obrigatórios em qualquer codebase grande (Strategy, Observer, Adapter, Decorator). Outros são raríssimos em Python e existem mais por completude (Bridge, Flyweight). Outros têm crítica honesta a se fazer (Singleton).

6.4 Quando aplicar — e a regra de três

Padrões adicionam indireção. Indireção tem custo cognitivo. Aplicar padrão antes da hora é overengineering. Aplicar tarde demais é refatoração dolorida.

A heurística mais útil é a regra de três (atribuída a Don Roberts): quando você duplica código pela primeira vez, copie. Na segunda, comece a desconfiar. Na terceira, abstraia.

Por que esperar? Porque com um único caso você não sabe qual é a variação real. Você acha que sabe — e modela com base em especulação. Quando o segundo caso chega, descobre que sua "abstração" não encaixava. Com três casos, o padrão de variação fica claro.

Padrão prematuro

"Sei que vou precisar de Strategy aqui — então já vou criar". Você criou abstração para variação que ainda não existe. Quando a variação real chegar, ela provavelmente não cabe no que você imaginou — e agora você tem que refatorar e manter a abstração inútil que criou. Espere.

6.5 Como ler um padrão

Padrões são tradicionalmente apresentados num formato:

Para cada padrão nos próximos capítulos, vamos cobrir essas dimensões — mas com foco em código Python real, não em UML abstrato. Diagramas de classe ajudam pouco quando o ponto é entender o problema.

6.6 Evolução pós-GoF

O catálogo do GoF não esgotou o assunto. Várias correntes surgiram depois:

Saber que isso tudo existe te dá mapa mental. Não precisa decorar tudo — precisa reconhecer quando se depara com algo familiar.

6.7 Estudo de caso — reconhecendo padrões em libraries que você usa

Padrões escondidos no stdlib do Python

Você usa padrões sem perceber. Vamos identificar três que estão em código Python que você já chamou.

Caso 1 · contextlib.contextmanager → Template Method
template_method.py
from contextlib import contextmanager

@contextmanager
def cronometrar(rotulo):
    inicio = time.perf_counter()
    try:
        yield           # aqui sua lógica entra
    finally:
        decorrido = time.perf_counter() - inicio
        logger.info(f"{rotulo}: {decorrido:.2f}s")

# O algoritmo geral é: medir antes, executar algo, medir depois.
# Você "preenche o buraco" no meio com seu código próprio.
# Isso é Template Method — só que implementado com generator,
# não com herança como no GoF original.
Caso 2 · sorted(key=...) → Strategy
strategy_sort.py
itens = [("banana", 3), ("abacaxi", 1), ("caju", 2)]

# Estratégia de comparação injetada
sorted(itens, key=lambda x: x[0])  # por nome
sorted(itens, key=lambda x: x[1])  # por quantidade
sorted(itens, key=lambda x: -x[1]) # decrescente

# sorted() não sabe como comparar SEUS itens.
# Você passa a estratégia como argumento. Pura Strategy.
Caso 3 · logging.Handler → Chain of Responsibility + Observer
chain_observer.py
import logging

logger = logging.getLogger("app")
logger.addHandler(logging.FileHandler("app.log"))
logger.addHandler(logging.StreamHandler())
logger.addHandler(SentryHandler())

# Quando você loga uma mensagem, ela passa por TODOS os handlers
# em sequência (Chain) e cada um decide o que fazer (Observer).
# Adicionar destino novo = só registrar mais um handler.

Lição: padrões não são exóticos. Estão em todo lugar onde código bem feito permite extensão sem modificação. Reconhecê-los acelera leitura de código que você nunca viu.

6.8 Erros comuns ao usar padrões

Erro 1 · Padrão pelo padrão

"Vamos usar Factory aqui." "Por quê?" "Porque é boa prática." Não. Padrão sem problema é overengineering. Pergunte sempre: que problema específico isso resolve?

Erro 2 · Confundir padrão com solução exata

GoF mostra padrões em C++/Java. Aplicar literal em Python frequentemente resulta em código pior — porque a linguagem oferece atalhos. Adapte o espírito, não a letra.

Erro 3 · Nomear errado

"Criei uma Factory" e na verdade era um Builder. "É um Singleton" e era global mutável. Use os nomes corretos — caso contrário você confunde quem lê, e perde o ganho do vocabulário compartilhado.

Erro 4 · Empilhar padrões

Strategy dentro de Factory dentro de Builder dentro de Singleton. Em sistemas que precisam, sim. Em sistemas simples, é puro acoplamento ao "porque vi num livro". Cada padrão precisa de justificativa concreta.

6.9 Quando NÃO aplicar padrões

Reconheça o contexto
Padrões custam — vale onde?
  • Caso único: ainda não há repetição. Espere a regra de três.
  • Variabilidade fixa: "tipo A ou tipo B, nada mais" — um if simples ainda compete bem com Strategy.
  • Equipe iniciante: ler padrões exige base. Em time misto, código simples com comentários frequentemente vence padrão "corretíssimo".
  • Linguagem dinâmica + closures: em Python/Ruby/JS, muito do GoF resolve com função de primeira classe + lambdas, sem classe hierárquica nenhuma.

A pergunta certa não é "qual padrão usar". É: "está acontecendo algum problema concreto que esse padrão resolveria?". Se a resposta é "não, mas pode acontecer no futuro" — espere.

Verifique seu entendimento
"Você tem um sistema com uma única regra de cálculo de desconto. Não há previsão concreta de mudança. Como modelar?"

6.10 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identifique o padrão

Para cada cenário, diga qual padrão (mesmo que você ainda não saiba o nome formal — descreva a estrutura):

  1. Uma classe que tem método render(formato) e internamente chama render_html(), render_pdf() ou render_csv() dependendo do argumento.
  2. Sua aplicação tem 3 destinos de notificação. Você quer enviar a mesma mensagem para todos os 3 quando ocorre um evento.
  3. Você quer construir um objeto Pedido com 12 campos opcionais sem ter construtor com 12 argumentos.
  1. É um Strategy mal feito — em vez de injetar a estratégia, ela é selecionada por if/elif interno. A refatoração correta é receber a estratégia (objeto formatador) como parâmetro.
  2. Observer — múltiplos interessados (notificadores) reagem ao mesmo evento. Variantes: pub/sub, mensageria.
  3. Builder — construtor com 12 args é insustentável. Builder constrói passo a passo: Pedido.builder().cliente(x).item(y).cupom(z).build().
Fácil
Exercício 2 · A regra de três

Você está escrevendo a função de cálculo de imposto. Sua empresa cobra IVA. Surge nova regra para clientes do exterior — diferente. Surge regra para SP que é diferente das demais UFs. Em que momento aplicar Strategy?

Primeira regra (IVA): função simples. Sem padrão.
Segunda regra (exterior): agora aparece variação real. Pode ser if exterior else — ainda dá. Comece a observar.
Terceira regra (SP): agora você tem dados. Três regras com estrutura similar (calculam imposto sobre valor). Hora de refatorar para Strategy: CalculadoraImpostoIVA, CalculadoraImpostoExterior, CalculadoraImpostoSP. Função orquestradora seleciona qual usar.

Médio
Exercício 3 · Reconheça padrões em código que você usa

Abra documentação (ou código) de uma library Python que você usa (requests, sqlalchemy, django, fastapi). Identifique pelo menos 2 padrões nominados que ela usa. Descreva onde e por quê.

Em requests:

  • Adapter: requests.Session.mount(adapter) permite plugar HTTPAdapter customizado — adapta protocolos diferentes para a mesma API.
  • Builder: requests.Request() seguido de .prepare() constrói a requisição em etapas antes de enviar.

Em sqlalchemy:

  • Unit of Work: session acumula mudanças e faz commit em bloco.
  • Identity Map: objetos com mesmo PK retornam mesma instância na mesma sessão.
  • Data Mapper: separa modelo de domínio do schema do banco.
Difícil
Exercício 4 · Quando NÃO usar padrão

Escolha um padrão que você já aplicou em algum projeto seu. Reescreva uma versão sem ele, usando recursos diretos de Python (funções, closures, dicts). Em quais condições a versão sem padrão é melhor? Quando o padrão volta a ser justificado?

Exemplo · Strategy com função vs classe:

strategy_compacto.py
# Versão com classes (GoF clássico)
class DescontoPercentual:
    def __init__(self, p): self.p = p
    def aplicar(self, v): return v * self.p

calc.aplicar_desconto(DescontoPercentual(0.1), 100)

# Versão funcional (Python idiomático)
percentual = lambda p: lambda v: v * p
calc.aplicar_desconto(percentual(0.1), 100)

# OU mais limpo:
def desconto_percentual(p):
    def aplicar(v): return v * p
    return aplicar

Função vence quando: estratégia tem só um método, sem estado complexo, sem precisa de igualdade/hash, sem testes pesados de método individual.

Classe volta a ganhar quando: estratégia tem múltiplos métodos relacionados, mantém estado próprio (cache, conexão), precisa ser comparada por igualdade, precisa de docstring rica, precisa ser configurada via dependency injection.

Fim do capítulo 6
Próximos três capítulos vão direto ao código: padrões criacionais, estruturais e comportamentais. Para cada um: intenção, exemplo Python idiomático, quando aplicar, e — onde relevante — críticas honestas. Sigamos.
Parte II · Capítulo 07 · Padrões de design

Padrões
criacionais:
como objetos nascem.

Construtor com 15 argumentos. Lógica condicional de "qual classe instanciar". Objetos meio-construídos. Esses problemas têm soluções catalogadas.

Padrões criacionais isolam como objetos são criados, separando essa preocupação de quem usa o objeto. Em Python, alguns deles são quase invisíveis — a linguagem já oferece atalhos. Outros são absolutamente essenciais em sistemas reais. E um — Singleton — merece crítica honesta antes de você considerar usar.

7.1 Visão geral dos criacionais

PadrãoResolveUse quando
Factory MethodDecidir qual classe instanciar com base em parâmetrosLógica de criação é não-trivial
Abstract FactoryCriar famílias de objetos relacionadosMúltiplas variantes consistentes (tema dark/light, ambiente prod/test)
BuilderConstruir objeto complexo passo a passoMuitos campos opcionais, validação durante construção
PrototypeCriar copiando objeto existenteConstrução cara, configuração reutilizada
SingletonGarantir instância únicaQuase nunca — leia a seção dedicada

7.2 Factory Method

Em vez de espalhar lógica condicional de "qual subtipo instanciar" pelo código, centralize em uma fábrica. O cliente pede um tipo abstrato e recebe a implementação correta.

factory_method.py
from typing import Protocol
from decimal import Decimal

class MetodoPagamento(Protocol):
    def processar(self, valor: Decimal) -> bool: ...

class Cartao:
    def __init__(self, numero: str, cvv: str):
        self._numero = numero
        self._cvv = cvv
    def processar(self, valor): ...

class Pix:
    def __init__(self, chave: str):
        self._chave = chave
    def processar(self, valor): ...

class Boleto:
    def __init__(self, cpf: str):
        self._cpf = cpf
    def processar(self, valor): ...

# A fábrica centraliza criação. Quem chama não conhece os tipos.
class FabricaPagamento:
    def criar(self, dados: dict) -> MetodoPagamento:
        tipo = dados["tipo"]
        if tipo == "cartao":
            return Cartao(dados["numero"], dados["cvv"])
        if tipo == "pix":
            return Pix(dados["chave"])
        if tipo == "boleto":
            return Boleto(dados["cpf"])
        raise ValueError(f"tipo desconhecido: {tipo}")

# Forma Pythônica: registro com dict
class FabricaPagamentoRegistro:
    def __init__(self):
        self._tipos = {
            "cartao": lambda d: Cartao(d["numero"], d["cvv"]),
            "pix":    lambda d: Pix(d["chave"]),
            "boleto": lambda d: Boleto(d["cpf"]),
        }

    def registrar(self, tipo: str, construtor):
        self._tipos[tipo] = construtor

    def criar(self, dados: dict) -> MetodoPagamento:
        tipo = dados["tipo"]
        construtor = self._tipos.get(tipo)
        if not construtor:
            raise ValueError(f"tipo desconhecido: {tipo}")
        return construtor(dados)

# Vantagem da versão com registro: adicionar Cripto não exige
# editar a Fábrica. Apenas: fabrica.registrar("cripto", construtor_cripto)
Em Python
type(nome, bases, dict) é a "fábrica de classes" do próprio Python. dataclass é uma fábrica que gera __init__. Você usa fábricas o tempo todo sem pensar nelas como tal.

7.3 Abstract Factory

Quando você precisa criar famílias de objetos que precisam ser consistentes entre si. Exemplo clássico: tema de UI — todos os componentes têm que combinar (botões, inputs, modais de um mesmo tema).

abstract_factory.py
from typing import Protocol

class Botao(Protocol):
    def render(self) -> str: ...

class Input(Protocol):
    def render(self) -> str: ...

class TemaUI(Protocol):
    def botao(self, texto: str) -> Botao: ...
    def input(self, placeholder: str) -> Input: ...

class TemaDark:
    def botao(self, texto):
        return BotaoDark(texto)
    def input(self, placeholder):
        return InputDark(placeholder)

class TemaLight:
    def botao(self, texto):
        return BotaoLight(texto)
    def input(self, placeholder):
        return InputLight(placeholder)

# Cliente recebe a fábrica e a usa — não conhece variantes
class FormularioContato:
    def __init__(self, tema: TemaUI):
        self._tema = tema

    def render(self) -> str:
        nome = self._tema.input("Seu nome")
        email = self._tema.input("Seu email")
        enviar = self._tema.botao("Enviar")
        return f"{nome.render()}{email.render()}{enviar.render()}"

A garantia: como tudo vem da mesma fábrica, fica consistente. Não tem como acidentalmente misturar botão dark com input light. Para produção/teste/staging com configurações relacionadas (banco, fila, cache do mesmo ambiente), Abstract Factory também encaixa bem.

7.4 Builder

Quando construir um objeto exige muitos passos opcionais, configurações condicionais ou validações entre passos. Builder separa como construir de o que é construído.

builder.py
from dataclasses import dataclass, field
from decimal import Decimal

@dataclass(frozen=True)
class Pedido:
    cliente_id: str
    itens: tuple[tuple[str, int], ...]
    cupom: str | None
    endereco_entrega: str | None
    metodo_pagamento: str
    observacao: str | None
    embalagem_presente: bool
    confirmacao_email: bool

class PedidoBuilder:
    def __init__(self, cliente_id: str):
        self._cliente_id = cliente_id
        self._itens: list = []
        self._cupom: str | None = None
        self._endereco: str | None = None
        self._metodo: str | None = None
        self._observacao: str | None = None
        self._presente = False
        self._email_conf = True

    def item(self, sku: str, qtd: int) -> "PedidoBuilder":
        if qtd <= 0:
            raise ValueError("qtd inválida")
        self._itens.append((sku, qtd))
        return self

    def cupom(self, codigo: str) -> "PedidoBuilder":
        self._cupom = codigo
        return self

    def entregar_em(self, endereco: str) -> "PedidoBuilder":
        self._endereco = endereco
        return self

    def pagar_com(self, metodo: str) -> "PedidoBuilder":
        self._metodo = metodo
        return self

    def como_presente(self) -> "PedidoBuilder":
        self._presente = True
        return self

    def sem_email_confirmacao(self) -> "PedidoBuilder":
        self._email_conf = False
        return self

    def build(self) -> Pedido:
        if not self._itens:
            raise ValueError("pedido sem itens")
        if not self._metodo:
            raise ValueError("método de pagamento obrigatório")
        return Pedido(
            cliente_id=self._cliente_id,
            itens=tuple(self._itens),
            cupom=self._cupom,
            endereco_entrega=self._endereco,
            metodo_pagamento=self._metodo,
            observacao=self._observacao,
            embalagem_presente=self._presente,
            confirmacao_email=self._email_conf,
        )

# Uso fluente — leitura fica clara
pedido = (PedidoBuilder("cli-42")
    .item("sku-1", 2)
    .item("sku-2", 1)
    .cupom("PROMO10")
    .pagar_com("pix")
    .como_presente()
    .build())
Alternativa em Python
Para casos mais simples, @dataclass com campos opcionais e defaults já resolve muito do que Builder oferece em Java. Builder ainda vale quando você tem validação entre passos, transformações na construção, ou DSL fluente legível.

7.5 Prototype

Em vez de construir do zero, copie um objeto pré-configurado e ajuste. Útil quando construção é cara ou quando muitas instâncias compartilham configuração base.

prototype.py
import copy
from dataclasses import dataclass, field, replace

@dataclass(frozen=True)
class ConfiguracaoRelatorio:
    titulo: str
    formato: str = "pdf"
    incluir_grafico: bool = True
    incluir_sumario: bool = True
    paginas_por_capitulo: int = 20
    margem_cm: float = 2.5
    fonte: str = "Inter"
    cor_destaque: str = "#c2410c"

# Template base — configurado uma vez
TEMPLATE_RELATORIO_EXECUTIVO = ConfiguracaoRelatorio(
    titulo="_PREENCHER_",
    incluir_grafico=True,
    paginas_por_capitulo=10,
    margem_cm=3.0,
)

# Variantes vêm de copy modificada
relatorio_jan = replace(
    TEMPLATE_RELATORIO_EXECUTIVO,
    titulo="Relatório Janeiro 2026",
)
relatorio_anual = replace(
    TEMPLATE_RELATORIO_EXECUTIVO,
    titulo="Relatório Anual 2025",
    paginas_por_capitulo=30,  # override pontual
)

# Para mutáveis ou estruturas profundas:
import copy
nova = copy.deepcopy(prototipo_complexo)
nova.atributo = "diferente"

7.6 Singleton — e por que evitá-lo

Singleton garante que uma classe tenha uma única instância globalmente acessível. É o padrão mais conhecido — e o mais criticado.

A crítica é forte: Singleton é variável global disfarçada. Carrega todos os problemas de globais (acoplamento, dificuldade de teste, ordem de inicialização imprevisível) com a aparência respeitável de "padrão de design".

singleton.py
# Forma "clássica" (Java-style)
class Configuracao:
    _instancia = None

    def __new__(cls):
        if cls._instancia is None:
            cls._instancia = super().__new__(cls)
        return cls._instancia

a = Configuracao()
b = Configuracao()
assert a is b  # True

# Os problemas:
# 1. Estado global escondido — qualquer função do mundo pode mexer
# 2. Testes mantêm estado entre execuções (a menos que você limpe)
# 3. Não dá pra ter "configuração A" e "configuração B" pra testes
# 4. Dependências ficam ocultas — funções que usam Configuracao não
#    declaram que precisam dela
# 5. Ordem de inicialização vira problema em apps grandes

Alternativas melhores

alternativas.py
# 1. Módulo Python já é Singleton — use isso
# config.py
DATABASE_URL = os.getenv("DATABASE_URL")
DEBUG = os.getenv("DEBUG") == "true"

# Em outro arquivo:
from config import DATABASE_URL
# Simples, óbvio, não tem mágica.

# 2. Dependency Injection — explícito, testável
class ServicoEmail:
    def __init__(self, config: Configuracao):
        self._config = config

# Em produção:
servico = ServicoEmail(Configuracao.from_env())
# Em testes:
servico = ServicoEmail(Configuracao(smtp_host="localhost"))

# 3. functools.lru_cache para "fábrica que só roda uma vez"
from functools import lru_cache

@lru_cache(maxsize=None)
def obter_conexao_redis() -> Redis:
    return Redis(host=...)
# Primeira chamada constrói. Demais retornam mesmo objeto.
# Em testes, .cache_clear() reseta — útil.
Quando Singleton é aceitável
Há um caso legítimo: recurso fisicamente único e custoso (pool de conexões de banco, logger, cache global). Mesmo aí, prefira injeção de dependência. Use Singleton apenas quando o overhead de DI é maior que o benefício — em utilitários, infraestrutura de baixo nível, ferramentas.

7.7 Estudo de caso — sistema de notificação com fábricas

Construindo notificações com Factory + Builder

Sistema que envia notificações via email, SMS ou push. Cada uma tem campos diferentes; algumas configurações são opcionais. Vamos combinar Factory e Builder.

Passo 1 · Modelo de domínio
modelo.py
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class Notificacao:
    destinatario: str
    assunto: str
    corpo: str
    anexo: str | None = None
    prioridade: str = "normal"

class CanalEnvio(Protocol):
    def enviar(self, n: Notificacao) -> None: ...

class CanalEmail:
    def __init__(self, smtp_host: str):
        self._host = smtp_host
    def enviar(self, n):
        ...

class CanalSMS:
    def __init__(self, gateway_url: str):
        self._url = gateway_url
    def enviar(self, n):
        ...

class CanalPush:
    def __init__(self, firebase_key: str):
        self._key = firebase_key
    def enviar(self, n):
        ...
Passo 2 · Factory com registro extensível
fabrica.py
from typing import Callable

class FabricaCanal:
    def __init__(self):
        self._criadores: dict[str, Callable[[dict], CanalEnvio]] = {}

    def registrar(self, tipo: str, criador: Callable[[dict], CanalEnvio]):
        self._criadores[tipo] = criador

    def criar(self, tipo: str, config: dict) -> CanalEnvio:
        c = self._criadores.get(tipo)
        if not c:
            raise ValueError(f"canal {tipo} não registrado")
        return c(config)

# Bootstrap (uma vez)
fabrica = FabricaCanal()
fabrica.registrar("email", lambda c: CanalEmail(c["smtp_host"]))
fabrica.registrar("sms",   lambda c: CanalSMS(c["gateway"]))
fabrica.registrar("push",  lambda c: CanalPush(c["firebase_key"]))
Passo 3 · Builder para construir notificação
builder.py
class NotificacaoBuilder:
    def __init__(self, destinatario: str):
        self._destinatario = destinatario
        self._assunto = ""
        self._corpo = ""
        self._anexo = None
        self._prioridade = "normal"

    def assunto(self, a: str): self._assunto = a; return self
    def corpo(self, c: str): self._corpo = c; return self
    def anexar(self, path): self._anexo = path; return self
    def urgente(self): self._prioridade = "alta"; return self

    def build(self) -> Notificacao:
        if not self._corpo.strip():
            raise ValueError("corpo vazio")
        return Notificacao(
            destinatario=self._destinatario,
            assunto=self._assunto,
            corpo=self._corpo,
            anexo=self._anexo,
            prioridade=self._prioridade,
        )

# Uso final — limpo e legível
canal = fabrica.criar("email", {"smtp_host": "smtp.x.com"})
notif = (NotificacaoBuilder("cliente@x.com")
    .assunto("Pedido confirmado")
    .corpo("Seu pedido foi enviado...")
    .anexar("nota_fiscal.pdf")
    .urgente()
    .build())
canal.enviar(notif)

O que ganhamos: adicionar canal Telegram = registrar mais um criador. Mudar smtp host = passar config diferente para a fábrica. Construir notificação sem todos os campos opcionais = builder cuida. Testes ficam triviais (mock do canal, builder controlado).

7.8 Erros comuns

Erro 1 · Singleton sem necessidade

Aplicar Singleton em qualquer "serviço". Acoplamento global escondido, testes complicados. Use módulo Python ou DI.

Erro 2 · Factory que vira if/elif gigante

Cada novo tipo exige editar a fábrica. Use registro (dict) com método registrar(). Isso retorna a fábrica ao espírito de OCP.

Erro 3 · Builder sem validação

Builder que constrói qualquer estado, mesmo inválido. O build() precisa validar antes de retornar — é o ponto único para garantir invariantes.

Erro 4 · Prototype com mutável

Copiar com copy.copy() (shallow) e o protótipo ter lista interna. As cópias vão compartilhar a mesma lista — modificar uma altera todas. Use deepcopy ou objetos imutáveis.

7.9 Quando NÃO usar criacionais

Reconheça o contexto
Sinais de que você não precisa
  • Construtor com 2-3 args: não precisa de Builder. __init__ direto.
  • Um único tipo concreto: não precisa de Factory. Instancia direto.
  • Construção barata: não precisa de Prototype. Cria do zero.
  • Não há requisito real de instância única: não use Singleton. Use DI.
  • Família de objetos não relacionada: não precisa de Abstract Factory.
Verifique seu entendimento
"Você precisa de uma classe Conexao que seja única em todo o sistema. Qual abordagem?"

7.10 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Factory de formas geométricas

Crie FabricaForma que constrói Circulo(raio), Quadrado(lado) ou Retangulo(largura, altura) a partir de um dict. Use registro extensível (não if/elif gigante).

fabrica_forma.py
from dataclasses import dataclass
from typing import Protocol, Callable

class Forma(Protocol):
    def area(self) -> float: ...

@dataclass(frozen=True)
class Circulo:
    raio: float
    def area(self) -> float:
        return 3.14159 * self.raio ** 2

@dataclass(frozen=True)
class Quadrado:
    lado: float
    def area(self) -> float:
        return self.lado ** 2

@dataclass(frozen=True)
class Retangulo:
    largura: float
    altura: float
    def area(self) -> float:
        return self.largura * self.altura

class FabricaForma:
    def __init__(self):
        self._criadores: dict[str, Callable[[dict], Forma]] = {
            "circulo":   lambda d: Circulo(d["raio"]),
            "quadrado":  lambda d: Quadrado(d["lado"]),
            "retangulo": lambda d: Retangulo(d["largura"], d["altura"]),
        }

    def registrar(self, tipo, criador):
        self._criadores[tipo] = criador

    def criar(self, dados: dict) -> Forma:
        c = self._criadores.get(dados["tipo"])
        if not c:
            raise ValueError(f"forma {dados['tipo']} não suportada")
        return c(dados)
Fácil
Exercício 2 · Builder para query SQL

Construa QueryBuilder que monta uma SELECT via métodos encadeados: .from_(tabela), .where(condicao), .order_by(coluna), .limit(n). O .build() retorna a string SQL final.

query_builder.py
class QueryBuilder:
    def __init__(self):
        self._tabela: str | None = None
        self._colunas: list[str] = []
        self._where: list[str] = []
        self._order: list[str] = []
        self._limit: int | None = None

    def select(self, *colunas):
        self._colunas.extend(colunas); return self

    def from_(self, tabela: str):
        self._tabela = tabela; return self

    def where(self, condicao: str):
        self._where.append(condicao); return self

    def order_by(self, coluna: str):
        self._order.append(coluna); return self

    def limit(self, n: int):
        self._limit = n; return self

    def build(self) -> str:
        if not self._tabela:
            raise ValueError("tabela obrigatória")
        colunas = ", ".join(self._colunas) or "*"
        sql = f"SELECT {colunas} FROM {self._tabela}"
        if self._where:
            sql += " WHERE " + " AND ".join(self._where)
        if self._order:
            sql += " ORDER BY " + ", ".join(self._order)
        if self._limit:
            sql += f" LIMIT {self._limit}"
        return sql

# sql = (QueryBuilder()
#     .select("id", "nome").from_("usuarios")
#     .where("ativo = true").order_by("nome").limit(50).build())
Médio
Exercício 3 · Abstract Factory de ambientes

Crie AmbienteFactory abstrato com métodos banco(), cache(), fila(). Implementações: AmbienteProducao (Postgres + Redis + RabbitMQ) e AmbienteTeste (SQLite + dict in-memory + lista). Cada um deve manter consistência.

ambiente_factory.py
from typing import Protocol

class Banco(Protocol):
    def query(self, sql: str) -> list: ...

class Cache(Protocol):
    def get(self, k: str) -> bytes | None: ...
    def set(self, k: str, v: bytes) -> None: ...

class Fila(Protocol):
    def enviar(self, msg: bytes) -> None: ...

class AmbienteFactory(Protocol):
    def banco(self) -> Banco: ...
    def cache(self) -> Cache: ...
    def fila(self) -> Fila: ...

class AmbienteProducao:
    def __init__(self, config: dict):
        self._config = config

    def banco(self) -> Banco:
        return PostgresBanco(self._config["postgres_url"])

    def cache(self) -> Cache:
        return RedisCache(self._config["redis_url"])

    def fila(self) -> Fila:
        return RabbitMQFila(self._config["rabbit_url"])

class AmbienteTeste:
    def banco(self) -> Banco:
        return SQLiteBanco(":memory:")

    def cache(self) -> Cache:
        return CacheMemoria()  # dict interno

    def fila(self) -> Fila:
        return FilaListaMemoria()  # list interna

# No bootstrap:
ambiente = AmbienteProducao(config) if ENV == "prod" else AmbienteTeste()
app = Aplicacao(
    banco=ambiente.banco(),
    cache=ambiente.cache(),
    fila=ambiente.fila(),
)
Difícil
Exercício 4 · Builder com validação progressiva

Construa ContratoBuilder que monta um Contrato com regras:

  • Tem que ter pelo menos um signatário
  • Data de início < data de fim
  • Valor obrigatório > 0
  • Tipo "compra" exige campo "produto"; tipo "serviço" exige campo "descrição"

Cada método do builder valida o que pode; build() valida regras cruzadas.

contrato_builder.py
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Literal

TipoContrato = Literal["compra", "servico"]

@dataclass(frozen=True)
class Contrato:
    tipo: TipoContrato
    signatarios: tuple[str, ...]
    inicio: date
    fim: date
    valor: Decimal
    produto: str | None
    descricao: str | None

class ContratoBuilder:
    def __init__(self, tipo: TipoContrato):
        self._tipo = tipo
        self._sigs: list[str] = []
        self._inicio: date | None = None
        self._fim: date | None = None
        self._valor: Decimal | None = None
        self._produto: str | None = None
        self._descricao: str | None = None

    def signatario(self, nome: str):
        if not nome.strip():
            raise ValueError("signatário vazio")
        self._sigs.append(nome); return self

    def periodo(self, inicio: date, fim: date):
        if fim <= inicio:
            raise ValueError("fim deve ser após início")
        self._inicio, self._fim = inicio, fim; return self

    def valor(self, v: Decimal):
        if v <= 0:
            raise ValueError("valor deve ser positivo")
        self._valor = v; return self

    def produto(self, p: str):
        self._produto = p; return self

    def descricao(self, d: str):
        self._descricao = d; return self

    def build(self) -> Contrato:
        erros: list[str] = []
        if not self._sigs:
            erros.append("sem signatários")
        if self._inicio is None:
            erros.append("sem período")
        if self._valor is None:
            erros.append("sem valor")
        if self._tipo == "compra" and not self._produto:
            erros.append("compra exige produto")
        if self._tipo == "servico" and not self._descricao:
            erros.append("serviço exige descrição")
        if erros:
            raise ValueError("; ".join(erros))
        return Contrato(
            tipo=self._tipo,
            signatarios=tuple(self._sigs),
            inicio=self._inicio,
            fim=self._fim,
            valor=self._valor,
            produto=self._produto,
            descricao=self._descricao,
        )
Fim do capítulo 7
Criacionais focam em como objetos nascem. Próximo: estruturais — como objetos se compõem. Adapter, Decorator, Facade e Proxy entram em cena.
Parte II · Capítulo 08 · Padrões de design

Padrões
estruturais:
como objetos se compõem.

Quando o problema é encaixar peças que não foram feitas para se encaixar — ou simplificar interfaces complexas, ou compor estruturas recursivas — os estruturais entram em cena.

Em sistemas reais você raramente cria tudo do zero. Você integra libraries, APIs externas, código legado, modelos de outros times. Padrões estruturais te dão o vocabulário para essa cola — feita com critério, não com gambiarra.

8.1 Visão geral dos estruturais

PadrãoResolveUse quando
AdapterEncaixar interfaces incompatíveisIntegrar código legado ou library externa
DecoratorAdicionar comportamento sem alterar classeLogging, cache, autenticação, retry
FacadeSimplificar interface complexaSubsistema com muitas peças expostas
ProxySubstituto que controla acessoLazy loading, controle, cache, remoto
CompositeTratar parte e todo uniformementeÁrvores: arquivos, organograma, AST

8.2 Adapter — encaixar o que não encaixa

Sua aplicação espera certa interface. Library externa oferece outra. Adapter é a "tomada de transição" que traduz uma interface na outra, sem que cliente nem library percebam.

adapter.py
from typing import Protocol

# O que SUA aplicação quer
class Notificador(Protocol):
    def enviar(self, destinatario: str, mensagem: str) -> None: ...

# Library externa — interface diferente
class SendGridAPI:
    def post_email(
        self,
        to_address: str,
        body: str,
        from_name: str = "system",
        priority: int = 0,
    ) -> dict:
        # retorna {"id": ..., "status": ...}
        ...

# Adapter — adapta SendGrid para parecer Notificador
class NotificadorSendGrid:
    def __init__(self, sg: SendGridAPI):
        self._sg = sg

    def enviar(self, destinatario: str, mensagem: str) -> None:
        result = self._sg.post_email(
            to_address=destinatario,
            body=mensagem,
            from_name="AppCorp",
        )
        if result["status"] != "sent":
            raise NotificacaoError(f"falhou: {result['id']}")

# Outro provider — outra adaptação
class NotificadorTwilio:
    def __init__(self, client):
        self._client = client

    def enviar(self, destinatario: str, mensagem: str) -> None:
        self._client.messages.create(to=destinatario, body=mensagem)

# Cliente continua usando Notificador. Pode trocar provider sem dor.
def enviar_alerta(notif: Notificador, msg: str):
    notif.enviar("admin@x.com", msg)

Adapter é o padrão mais útil para isolar dependências externas. Sempre que você usar lib que pode ser trocada no futuro, considere envolvê-la em adapter — assim, "trocar de SendGrid para Twilio" vira "criar nova classe adapter", não "refatorar 50 lugares do código".

8.3 Decorator — empilhando comportamento

Decorator adiciona comportamento a um objeto envolvendo-o em outro com mesma interface. Diferente de herança, é dinâmico — você decide na hora da composição. Em Python, há duas formas: a "GoF clássica" (classe que envolve outra) e a sintaxe @decorator (mais comum, mas conceitualmente é uma função decorando outra).

Forma de classe

decorator_classe.py
from typing import Protocol
import time, logging

logger = logging.getLogger(__name__)

class RepositorioPedido(Protocol):
    def buscar(self, id: str) -> Pedido | None: ...
    def salvar(self, p: Pedido) -> None: ...

class RepositorioPedidoPostgres:
    def buscar(self, id):
        ...
    def salvar(self, p):
        ...

# Decorator de logging — envolve qualquer Repositorio
class RepositorioComLog:
    def __init__(self, interno: RepositorioPedido):
        self._interno = interno

    def buscar(self, id):
        logger.info(f"buscar({id})")
        inicio = time.perf_counter()
        r = self._interno.buscar(id)
        logger.info(f"buscar({id}) levou {time.perf_counter()-inicio:.3f}s")
        return r

    def salvar(self, p):
        logger.info(f"salvar({p.id})")
        self._interno.salvar(p)

# Decorator de cache
class RepositorioComCache:
    def __init__(self, interno: RepositorioPedido):
        self._interno = interno
        self._cache: dict[str, Pedido] = {}

    def buscar(self, id):
        if id in self._cache:
            return self._cache[id]
        p = self._interno.buscar(id)
        if p:
            self._cache[id] = p
        return p

    def salvar(self, p):
        self._interno.salvar(p)
        self._cache[p.id] = p  # mantém cache consistente

# Composição flexível — empilha quantos quiser
repo = RepositorioComLog(
    RepositorioComCache(
        RepositorioPedidoPostgres(conn)
    )
)
# Log envolve cache, que envolve postgres.
# Cliente ainda enxerga só Repositorio.

Forma funcional — @decorator

decorator_funcao.py
import functools, time, logging

def cronometrar(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        inicio = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            decorrido = time.perf_counter() - inicio
            logger.info(f"{func.__name__}: {decorrido*1000:.2f}ms")
    return wrapper

def cache_resultado(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@cronometrar
@cache_resultado
def calculo_caro(n: int) -> int:
    return sum(i ** 2 for i in range(n))

# Decorators são aplicados de baixo pra cima:
# calculo_caro = cronometrar(cache_resultado(calculo_caro))
Em Python
A sintaxe @ é açúcar para "func = decorator(func)". A library functools fornece @lru_cache (cache automático), @cached_property, @singledispatch (polimorfismo por tipo de argumento). Estude-os — economizam código.

8.4 Facade — uma porta para um subsistema

Quando você tem subsistema com muitas classes/passos, e clientes só precisam de operações de alto nível — crie uma facade: classe simples que esconde a complexidade interna.

facade.py
# Subsistema com várias peças
class CarregadorImagem:
    def carregar(self, path: str): ...

class RedimensionadorImagem:
    def redimensionar(self, img, largura, altura): ...

class FiltroSepia:
    def aplicar(self, img): ...

class CompressorImagem:
    def comprimir(self, img, qualidade): ...

class SalvadorImagem:
    def salvar(self, img, path): ...

# Facade — esconde a sequência de operações
class ProcessadorImagem:
    def __init__(self):
        self._carregador = CarregadorImagem()
        self._redim = RedimensionadorImagem()
        self._sepia = FiltroSepia()
        self._compressor = CompressorImagem()
        self._salvador = SalvadorImagem()

    def criar_miniatura_sepia(self, origem: str, destino: str):
        img = self._carregador.carregar(origem)
        img = self._redim.redimensionar(img, 200, 200)
        img = self._sepia.aplicar(img)
        img = self._compressor.comprimir(img, qualidade=75)
        self._salvador.salvar(img, destino)

# Cliente:
processador = ProcessadorImagem()
processador.criar_miniatura_sepia("foto.jpg", "thumb.jpg")
# Não precisa conhecer 5 classes. Apenas uma operação clara.

Facades são excelentes para criar APIs públicas de módulos. O subsistema fica complexo internamente, mas a porta de entrada é simples. Frameworks fazem isso: requests.get(url) esconde dezenas de classes internas.

8.5 Proxy — substituto controlado

Proxy é um objeto que se passa por outro, controlando acesso a ele. Mesma interface, mas você adiciona controle: lazy loading, autorização, cache, chamada remota.

É similar a Decorator estruturalmente, mas com intenção diferente: Decorator adiciona comportamento; Proxy controla acesso.

proxy.py
from typing import Protocol

class RelatorioPesado(Protocol):
    def gerar(self) -> bytes: ...

class RelatorioReal:
    def __init__(self, dados):
        # Construção cara: carregar milhões de linhas, processar, etc
        self._dados = dados
        self._preparar()  # operação cara

    def _preparar(self):
        ...

    def gerar(self) -> bytes:
        ...

# Proxy de lazy loading — só constrói quando alguém pede
class RelatorioProxy:
    def __init__(self, dados):
        self._dados = dados
        self._real: RelatorioReal | None = None

    def gerar(self) -> bytes:
        if self._real is None:
            self._real = RelatorioReal(self._dados)
        return self._real.gerar()

# Proxy de autorização
class RelatorioProtegido:
    def __init__(self, real: RelatorioPesado, usuario):
        self._real = real
        self._usuario = usuario

    def gerar(self) -> bytes:
        if not self._usuario.pode_ver_relatorios():
            raise PermissionError("sem permissão")
        return self._real.gerar()

# Proxy remoto — pega de outro serviço
class RelatorioRemoto:
    def __init__(self, url: str):
        self._url = url
    def gerar(self) -> bytes:
        return requests.get(self._url).content

8.6 Composite — parte e todo, mesma interface

Quando você modela estrutura de árvore (organograma, sistema de arquivos, expressões matemáticas, AST) e quer tratar folha e nó composto de forma uniforme, use Composite. A interface é a mesma; o comportamento varia.

composite.py
from typing import Protocol

class ItemFS(Protocol):
    nome: str
    def tamanho(self) -> int: ...

class Arquivo:
    def __init__(self, nome: str, bytes_: int):
        self.nome = nome
        self._bytes = bytes_

    def tamanho(self) -> int:
        return self._bytes

class Pasta:
    def __init__(self, nome: str):
        self.nome = nome
        self._itens: list[ItemFS] = []

    def adicionar(self, item: ItemFS):
        self._itens.append(item)

    def tamanho(self) -> int:
        # Recursivo: soma tamanho dos filhos
        return sum(i.tamanho() for i in self._itens)

# Cliente trata tudo como ItemFS:
raiz = Pasta("projeto")
src = Pasta("src")
src.adicionar(Arquivo("main.py", 1200))
src.adicionar(Arquivo("utils.py", 800))
raiz.adicionar(src)
raiz.adicionar(Arquivo("README.md", 2000))

print(raiz.tamanho())  # 4000 — recursivo automático

8.7 Estudo de caso — camada de cache+log+retry sobre serviço externo

Empilhando decorators sobre adapter de API externa

Vamos combinar Adapter + Decorator para construir camada robusta sobre uma API externa flaky.

Passo 1 · Contrato e adapter base
adapter_base.py
from typing import Protocol
from dataclasses import dataclass

@dataclass(frozen=True)
class PrevisaoTempo:
    cidade: str
    temperatura_c: float
    descricao: str

class ServicoClima(Protocol):
    def previsao(self, cidade: str) -> PrevisaoTempo: ...

# Adapter para OpenWeather
class ServicoClimaOpenWeather:
    def __init__(self, api_key: str):
        self._key = api_key

    def previsao(self, cidade: str) -> PrevisaoTempo:
        r = requests.get(f"https://api.openweather.org/weather",
            params={"q": cidade, "appid": self._key})
        r.raise_for_status()
        d = r.json()
        return PrevisaoTempo(
            cidade=cidade,
            temperatura_c=d["main"]["temp"] - 273.15,
            descricao=d["weather"][0]["description"],
        )
Passo 2 · Decorator de cache
com_cache.py
from time import time

class ServicoClimaComCache:
    def __init__(self, interno: ServicoClima, ttl_segundos: int = 600):
        self._interno = interno
        self._ttl = ttl_segundos
        self._cache: dict[str, tuple[float, PrevisaoTempo]] = {}

    def previsao(self, cidade: str) -> PrevisaoTempo:
        agora = time()
        if cidade in self._cache:
            ts, p = self._cache[cidade]
            if agora - ts < self._ttl:
                return p
        p = self._interno.previsao(cidade)
        self._cache[cidade] = (agora, p)
        return p
Passo 3 · Decorator de retry
com_retry.py
class ServicoClimaComRetry:
    def __init__(self, interno: ServicoClima, tentativas: int = 3):
        self._interno = interno
        self._tentativas = tentativas

    def previsao(self, cidade: str) -> PrevisaoTempo:
        ultimo_erro: Exception | None = None
        for tentativa in range(self._tentativas):
            try:
                return self._interno.previsao(cidade)
            except (ConnectionError, TimeoutError) as e:
                ultimo_erro = e
                time.sleep(2 ** tentativa)
        raise ultimo_erro or RuntimeError("inalcançável")
Passo 4 · Composição final
composicao.py
servico_base = ServicoClimaOpenWeather(api_key=os.getenv("OPENWEATHER_KEY"))
servico_com_retry = ServicoClimaComRetry(servico_base, tentativas=3)
servico_final = ServicoClimaComCache(servico_com_retry, ttl_segundos=600)

# Cliente usa interface simples:
clima = servico_final.previsao("São Paulo")

# Ordem importa:
# 1. Cache primeiro — evita até o retry se já tem
# 2. Retry depois — tenta de novo se o adapter falhou
# 3. Adapter por último — chamada real à API
# Se quiser logging em todas as camadas, adicione mais um decorator.

Lição: cada camada tem uma responsabilidade. Trocar a API externa = mudar adapter. Mudar política de retry = mudar decorator. Cache = decorator. Tudo combinável.

8.8 Erros comuns

Erro 1 · Adapter que faz transformação demais

Adapter deve traduzir interface, não transformar dados de forma complexa. Se seu adapter está convertendo unidades, aplicando regras de negócio, ele virou serviço — separe responsabilidades.

Erro 2 · Decorator que muda contrato

Se o método original retorna lista e seu decorator retorna dict, você quebrou a interface. Decorator preserva contrato, adiciona comportamento.

Erro 3 · Facade que vira god class

Facade simplifica interface — não acumula lógica. Se sua facade tem 500 linhas, está virando god object. Separe em facades menores ou redistribua responsabilidades.

Erro 4 · Proxy que esconde latência

RemoteProxy que parece chamada local mas faz HTTP. Clientes assumem ser barata, e isso vira gargalo silencioso. Documente claramente — ou exponha como assíncrono.

8.9 Quando NÃO usar estruturais

Reconheça o contexto
Sinais de exagero
  • Adapter para library você controla: ajuste a library, não envolva. Adapter é para dependências externas.
  • Decorator para 1 ou 2 chamadas: apenas chame a função direto. Decorator vale quando há reuso real.
  • Facade desnecessária: se o subsistema já é simples (3 classes), facade vira indireção sem ganho.
  • Proxy especulativo: "vou criar Proxy caso um dia eu precise de cache". Não. Adicione quando precisar.
  • Composite sem hierarquia real: se sua árvore tem 2 níveis fixos, é só 2 classes — sem precisar do padrão.
Verifique seu entendimento
"Você precisa adicionar logging e cache a um RepositorioPedido existente, sem alterá-lo. Qual padrão?"

8.10 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Adapter de log

Você usa logging padrão de Python. Sua aplicação espera interface Logger com métodos info(msg), warn(msg), error(msg). Crie adapter que envolva logging.Logger nessa interface.

logger_adapter.py
import logging
from typing import Protocol

class Logger(Protocol):
    def info(self, msg: str) -> None: ...
    def warn(self, msg: str) -> None: ...
    def error(self, msg: str) -> None: ...

class LoggerStdlib:
    def __init__(self, nome: str):
        self._log = logging.getLogger(nome)
    def info(self, msg): self._log.info(msg)
    def warn(self, msg): self._log.warning(msg)
    def error(self, msg): self._log.error(msg)
Fácil
Exercício 2 · Decorator de autenticação

Escreva decorator @requer_auth que verifica se há token válido em request.headers antes de chamar a função. Se não tiver, levanta PermissionError.

requer_auth.py
import functools

def requer_auth(func):
    @functools.wraps(func)
    def wrapper(request, *args, **kwargs):
        token = request.headers.get("Authorization", "")
        if not token.startswith("Bearer "):
            raise PermissionError("token ausente")
        token_valor = token[7:]
        if not validar_token(token_valor):
            raise PermissionError("token inválido")
        return func(request, *args, **kwargs)
    return wrapper

@requer_auth
def obter_dados_privados(request):
    return {"info": "sensível"}
Médio
Exercício 3 · Composite de menus

Modele uma estrutura de menu de aplicativo. ItemMenu tem nome e ação. Submenu contém outros ItemMenu ou Submenu. Adicione método renderizar(profundidade=0) que imprime a árvore com indentação.

menu_composite.py
from typing import Protocol, Callable

class NoMenu(Protocol):
    nome: str
    def renderizar(self, profundidade: int = 0) -> None: ...

class ItemMenu:
    def __init__(self, nome: str, acao: Callable[[], None]):
        self.nome = nome
        self._acao = acao

    def executar(self):
        self._acao()

    def renderizar(self, profundidade: int = 0):
        print("  " * profundidade + f"· {self.nome}")

class Submenu:
    def __init__(self, nome: str):
        self.nome = nome
        self._filhos: list[NoMenu] = []

    def adicionar(self, no: NoMenu):
        self._filhos.append(no)

    def renderizar(self, profundidade: int = 0):
        print("  " * profundidade + f"▸ {self.nome}")
        for filho in self._filhos:
            filho.renderizar(profundidade + 1)

# Uso:
raiz = Submenu("Principal")
arquivo = Submenu("Arquivo")
arquivo.adicionar(ItemMenu("Novo", lambda: print("novo")))
arquivo.adicionar(ItemMenu("Abrir", lambda: print("abrir")))
raiz.adicionar(arquivo)
raiz.adicionar(ItemMenu("Sair", lambda: print("sair")))
raiz.renderizar()
Difícil
Exercício 4 · Stack completo de decorators

Empilhe sobre um ServicoUsuario real:

  • Decorator de validação (CPF formato correto, email com @)
  • Decorator de auditoria (registra cada chamada com timestamp e usuário)
  • Decorator de rate limiting (máx 100 req/min por chave)

Cada decorator deve preservar a interface do ServicoUsuario Protocol.

decorators_completos.py
from typing import Protocol
from collections import deque
from dataclasses import dataclass
import time, re

@dataclass(frozen=True)
class Usuario:
    cpf: str
    nome: str
    email: str

class ServicoUsuario(Protocol):
    def criar(self, u: Usuario) -> None: ...
    def buscar(self, cpf: str) -> Usuario | None: ...

class ServicoUsuarioReal:
    def __init__(self):
        self._dados: dict[str, Usuario] = {}
    def criar(self, u: Usuario):
        self._dados[u.cpf] = u
    def buscar(self, cpf: str):
        return self._dados.get(cpf)

CPF_RE = re.compile(r"^\d{11}$")

class ServicoComValidacao:
    def __init__(self, interno: ServicoUsuario):
        self._interno = interno
    def criar(self, u: Usuario):
        if not CPF_RE.match(u.cpf):
            raise ValueError("cpf inválido")
        if "@" not in u.email:
            raise ValueError("email inválido")
        self._interno.criar(u)
    def buscar(self, cpf: str):
        if not CPF_RE.match(cpf):
            raise ValueError("cpf inválido")
        return self._interno.buscar(cpf)

class ServicoComAuditoria:
    def __init__(self, interno: ServicoUsuario, ator: str):
        self._interno = interno
        self._ator = ator
    def _log(self, acao: str, detalhe: str):
        print(f"[AUDIT {time.time():.0f}] {self._ator} {acao} {detalhe}")
    def criar(self, u: Usuario):
        self._log("criar", u.cpf)
        self._interno.criar(u)
    def buscar(self, cpf: str):
        self._log("buscar", cpf)
        return self._interno.buscar(cpf)

class ServicoComRateLimit:
    def __init__(self, interno: ServicoUsuario, max_por_min: int = 100):
        self._interno = interno
        self._max = max_por_min
        self._chamadas: deque = deque()
    def _checar(self):
        agora = time.time()
        while self._chamadas and self._chamadas[0] < agora - 60:
            self._chamadas.popleft()
        if len(self._chamadas) >= self._max:
            raise RuntimeError("rate limit")
        self._chamadas.append(agora)
    def criar(self, u: Usuario):
        self._checar()
        self._interno.criar(u)
    def buscar(self, cpf: str):
        self._checar()
        return self._interno.buscar(cpf)

# Composição:
servico = ServicoComRateLimit(
    ServicoComAuditoria(
        ServicoComValidacao(
            ServicoUsuarioReal()
        ),
        ator="system",
    ),
    max_por_min=100,
)
Fim do capítulo 8
Estruturais te dão poder de compor — sem comprometer interfaces. Próximo capítulo: comportamentais, onde tratamos como objetos se comunicam entre si. Strategy, Observer, Command, State.
Parte II
Padrões de design

Soluções nominadas para problemas que se repetem. Não receitas para copiar — vocabulário para reconhecer estruturas que você já está descobrindo intuitivamente, com a vantagem de meio século de experiência destilada.

Por que padrões Criacionais Estruturais Comportamentais Anti-padrões
Parte II · Capítulo 06 · Padrões de design

Padrões:
vocabulário compartilhado
de soluções.

Padrões de design não são para você aplicar. São para você reconhecer — em código que já existe, em problemas que você está enfrentando, em soluções que você já intuiu mas não tinha nome.

O maior mal-entendido sobre padrões: que são caixas de ferramenta para serem "aplicadas" a problemas. Não são. São nomes que reconhecem estruturas recorrentes. A diferença prática: você não decide "vou usar Strategy"; você percebe que o problema que tem na mão tem a forma de Strategy, e nomeia isso para que sua equipe consiga conversar sobre ele.

6.1 A história — de Alexander aos quatro

Contexto histórico

A ideia de "padrões de design" não nasceu em software. Veio da arquitetura — civil, de construção. Em 1977, o arquiteto Christopher Alexander publicou A Pattern Language, catalogando 253 padrões de design urbano e arquitetônico: como portas devem ser dimensionadas, onde colocar janelas, como organizar pátios. Alexander argumentava que "each pattern describes a problem which occurs over and over again in our environment".

Em 1987, Kent Beck e Ward Cunningham aplicaram a ideia a software pela primeira vez, em workshops sobre Smalltalk. Em 1994, quatro autores — Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, conhecidos como Gang of Four (GoF) — publicaram o livro Design Patterns: Elements of Reusable Object-Oriented Software, catalogando 23 padrões para OOP.

Esse livro definiu uma geração. Por bem e por mal. Por bem: deu vocabulário compartilhado, profissionalizou a discussão de design. Por mal: gerou anos de cargo cult — engenheiros forçando Singleton em qualquer lugar, criando Factory Factory Factory, justificando overengineering com "estou seguindo o padrão".

Hoje, com paradigmas mais funcionais e linguagens com features que tornam alguns padrões desnecessários (lambdas, decorators, generics), boa parte do catálogo GoF é menos relevante. Mas o conceito — e os padrões mais úteis — seguem vivos. É esses que vamos cobrir nos próximos capítulos.

6.2 O que é (e o que não é) um padrão

Um padrão de design tem três características essenciais:

Padrão não é:

Frase de Erich Gamma, anos depois
Em entrevista de 2009, um dos autores do GoF disse: "I think the great mistake we made was that we didn't put much weight on simpler patterns and we got carried away with more complex ones. If I were doing it again, I'd have a lot more of the simpler ones."

6.3 Classificação clássica

O GoF organizou os 23 padrões em três categorias, baseadas em propósito:

Criacionais
Sobre como objetos são criados. Factory, Builder, Singleton, Prototype, Abstract Factory.
Estruturais
Sobre como objetos são compostos. Adapter, Decorator, Facade, Proxy, Bridge, Composite, Flyweight.
Comportamentais
Sobre como objetos colaboram. Strategy, Observer, Command, State, Iterator, Template Method, Chain, Mediator, Memento, Visitor, Interpreter.

Vamos cobrir os mais úteis hoje em dia — não todos. Cada um nos próximos três capítulos.

6.4 Anatomia de um padrão

Todo padrão bem descrito tem partes claras. Quando ler descrição de qualquer padrão, procure por:

  1. Nome: termo curto que dá vocabulário comum. "Vou usar Strategy aqui" deve significar algo concreto para sua equipe.
  2. Problema: qual situação faz o padrão aparecer. Sem o problema certo, o padrão é dispensável.
  3. Forças em conflito: as tensões que o padrão equilibra. Tipicamente: flexibilidade vs simplicidade, performance vs manutenibilidade, etc.
  4. Solução estrutural: quais classes/objetos com quais responsabilidades, e como se relacionam.
  5. Consequências: o que melhora e o que piora ao aplicar. Sempre há trade-off.
  6. Variações: formas comuns de variar a aplicação.

Os próximos três capítulos seguem essa estrutura para cada padrão coberto. Isso ajuda a desenvolver o que importa — reconhecimento — em vez de só copiar código.

6.5 Quando aplicar um padrão

O critério é simples na teoria, difícil na prática: aplique quando o problema que você tem na mão é exatamente o que o padrão resolve. Não aplique para parecer sofisticado. Não aplique por antecipação. Não aplique por hábito.

Sinais de que vale aplicar:

6.6 Quando NÃO aplicar

Reconheça o contexto
Os custos invisíveis dos padrões

Todo padrão tem custo. Você o paga em:

  • Mais classes: Strategy substitui 3 if/elif por 4 classes. Em código pequeno, isso é piora.
  • Indireção: chamadas que antes iam direto agora pulam camadas. Debugar fica mais difícil.
  • Carga cognitiva: quem entra no projeto precisa entender mais conceitos antes de ler o fluxo.
  • Sobrenomes: classe PaymentProcessorFactoryBuilderDecorator existe. Sério.

Não aplique padrão quando:

  • O problema só apareceu uma vez. Rule of three: refatore na terceira ocorrência, não antes.
  • O sistema é pequeno e estável. Padrões pagam em código que cresce; em script estável, são puro custo.
  • Você não consegue explicar o porquê em uma frase. Se a justificativa é "é boa prática", você está em cargo cult.

6.7 Cargo cult e dogma — os anos perdidos

Vale parar aqui e falar de algo desconfortável. Entre 1996 e ~2010, parte da indústria — especialmente o mundo Java enterprise — caiu em cargo cult dos padrões GoF. Sistemas surgiam com:

O resultado: bases de código onde "fluxo de uma requisição HTTP" passa por 17 classes que existem só por causa de padrões. Manutenção pesadelar. Bugs difíceis de rastrear. Onboarding longo.

A lição: padrões são ferramenta, não virtude. Aplicar muitos não te torna senior. Reconhecer quando não aplicar, sim.

Cheiro de cargo cult

Sua equipe discute padrões antes de discutir o problema. Decisões de design começam com "que padrão vamos usar?" em vez de "o que precisa funcionar?". Quando você ouvir isso numa reunião, intervém — comece pelo problema.

6.8 Estudo de caso — quando o padrão "encontra" você

Reconhecendo Strategy emergindo do código

Vamos acompanhar um cenário típico onde um padrão emerge, não é forçado. O sistema é de cálculo de impostos de uma loja.

Dia 1 · Uma regra

Começa simples — só ICMS de São Paulo:

v1.py
def calcular_imposto(valor: Decimal) -> Decimal:
    return valor * Decimal("0.18")

Avaliação: sem necessidade de padrão. Função pura, clara, direta.

Dia 30 · Duas regras

Loja vende para outro estado. Aliquota diferente:

v2.py
def calcular_imposto(valor: Decimal, estado: str) -> Decimal:
    if estado == "SP":
        return valor * Decimal("0.18")
    if estado == "RJ":
        return valor * Decimal("0.20")
    raise ValueError(estado)

Avaliação: ainda OK. Dois cases, if/elif simples. Não corra para criar Strategy aqui — você teria 3 classes onde 5 linhas resolvem.

Dia 90 · Cinco regras com particularidades

Agora SP, RJ, MG têm regras simples. Mas MA tem isenção para alguns produtos. AM tem zona franca. SC tem desconto por valor. O if/elif vira:

v3.py
def calcular_imposto(valor: Decimal, estado: str, sku: str) -> Decimal:
    if estado == "SP":
        return valor * Decimal("0.18")
    if estado == "RJ":
        return valor * Decimal("0.20")
    if estado == "MG":
        return valor * Decimal("0.18")
    if estado == "MA":
        if sku.startswith("CESTA"):
            return Decimal("0")  # isenção
        return valor * Decimal("0.17")
    if estado == "AM":
        if sku.startswith("ELET"):
            return valor * Decimal("0.07")  # zona franca
        return valor * Decimal("0.18")
    if estado == "SC":
        if valor > 1000:
            return valor * Decimal("0.15")
        return valor * Decimal("0.17")
    raise ValueError(estado)

Avaliação: agora sim. Você está reescrevendo a mesma estrutura — "uma forma de calcular imposto por contexto" — em vários ifs aninhados. Diferentes estados têm regras de natureza diferente. Testes desse if/elif único viram pesadelo (testar 5 contextos, cada um com 2-3 ramos).

Aqui Strategy não é antecipação — é resposta natural à forma que o problema tomou. O padrão "encontrou" o código:

Dia 91 · Strategy emerge
v4.py
from typing import Protocol

class RegraImposto(Protocol):
    def calcular(self, valor: Decimal, sku: str) -> Decimal: ...

class AliquotaFixa:
    def __init__(self, aliquota: Decimal):
        self._aliquota = aliquota
    def calcular(self, valor, sku):
        return valor * self._aliquota

class RegraMA:
    def calcular(self, valor, sku):
        if sku.startswith("CESTA"):
            return Decimal("0")
        return valor * Decimal("0.17")

class RegraAM:
    def calcular(self, valor, sku):
        if sku.startswith("ELET"):
            return valor * Decimal("0.07")
        return valor * Decimal("0.18")

class RegraSC:
    def calcular(self, valor, sku):
        if valor > 1000:
            return valor * Decimal("0.15")
        return valor * Decimal("0.17")

REGRAS: dict[str, RegraImposto] = {
    "SP": AliquotaFixa(Decimal("0.18")),
    "RJ": AliquotaFixa(Decimal("0.20")),
    "MG": AliquotaFixa(Decimal("0.18")),
    "MA": RegraMA(),
    "AM": RegraAM(),
    "SC": RegraSC(),
}

def calcular_imposto(valor, estado, sku):
    regra = REGRAS.get(estado)
    if not regra:
        raise ValueError(f"sem regra para {estado}")
    return regra.calcular(valor, sku)

Avaliação: agora cada regra é testável isolada, adicionar novo estado é mais uma entrada no dict, regras com particularidade têm sua própria classe sem poluir as simples. Strategy emergiu porque a forma do problema pediu por ela.

Lição central: aplicar Strategy no dia 1 seria overengineering — você teria 3 classes para 1 linha. Aplicar no dia 30 seria prematuro — 2 ifs estão bem. Aplicar no dia 90 é resposta natural. O padrão não foi forçado; foi reconhecido. Essa é a diferença entre design e cerimônia.

6.9 Erros comuns ao estudar padrões

Erro 1 · Memorização sem contexto

Decorar os 23 do GoF como flashcards. Você acumula nomes, não reconhecimento. Estude um padrão profundamente, com 3-4 exemplos em código real, antes de passar para o próximo.

Erro 2 · Buscar padrão antes do problema

"Tenho que usar algum padrão aqui." Não. Tem que resolver o problema. Se a solução natural já é boa, não force.

Erro 3 · Ignorar features modernas da linguagem

Em Python, padrão Iterator é... usar __iter__. Padrão Strategy frequentemente é "passe uma função". Padrão Command frequentemente é "use uma fila com tuplas". Linguagens modernas absorveram vários padrões. Saiba reconhecer quando seu padrão favorito virou feature.

Erro 4 · Confundir padrão com framework

"Uso Django, então sigo MVC" — isso é uma frase vazia. Você usa Django; o framework te impõe estrutura. Padrão é decisão consciente, não consequência de biblioteca.

Verifique seu entendimento
"Você está começando um projeto e tem 2 jeitos diferentes de calcular frete. Faz sentido aplicar Strategy já no dia 1?"

6.10 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identifique o padrão

Em cada descrição abaixo, qual padrão (mesmo sem saber o catálogo todo) está sendo descrito? Confie na intuição.

  1. "Quero uma única instância global de configuração compartilhada pelo sistema."
  2. "Quero adicionar logging e cache a uma função sem mexer no código dela."
  3. "Tenho um objeto de uma biblioteca antiga com interface diferente da que meu sistema espera."
  4. "Quero notificar várias partes do sistema quando um pedido for criado."
  1. Singleton (vamos ver no cap. 7, com crítica).
  2. Decorator (cap. 8). Em Python, frequentemente já vem como feature da linguagem.
  3. Adapter (cap. 8).
  4. Observer (cap. 9). Ou, em sistemas modernos, eventos/mensageria (cap. 24).
Fácil
Exercício 2 · Quando NÃO aplicar

Para cada cenário, decida se aplicar o padrão sugerido faz sentido ou seria overengineering. Justifique.

  1. Script de 80 linhas que lê CSV uma vez por dia. Sugestão: Strategy para o parser.
  2. Sistema com 4 gateways de pagamento, esperando adicionar mais em 6 meses. Sugestão: Strategy.
  3. Função de soma simples. Sugestão: Command para auditoria.
  4. Sistema de logs que precisa enviar para 5 destinos diferentes (arquivo, syslog, Datadog, ...). Sugestão: Observer.
  1. Não. 80 linhas, uma vez por dia, parser único — Strategy aqui é cerimônia. Função simples basta.
  2. Sim. 4 variantes já existem, mais virão, cada uma com particularidades. Strategy emergiu.
  3. Não. Função simples + Command para auditoria é desproporcional. Logue chamada, não estruture pattern.
  4. Sim. 5 destinos, com regras diferentes (formato, retry, autenticação), e mais virão. Observer/eventos resolve.
Médio
Exercício 3 · Reconheça padrões em código que você já escreveu

Abra um projeto seu (no trabalho ou pessoal). Procure por:

  • If/elif sobre tipo em mais de uma função (Strategy escondido)
  • Decorators que você criou (Decorator estrutural escondido)
  • Classes que adaptam APIs externas para uso interno (Adapter)
  • Callbacks/handlers registrados dinamicamente (Observer)
  • Builders/factories que constroem objetos complexos (Factory/Builder)

Identifique 2-3. Escreva, em uma frase cada, o problema que aquele código resolve.

Esse exercício é deliberadamente sem solução fechada — é exercício de reconhecimento. O importante é o trabalho de olhar para código real e perceber que você já usa padrões, frequentemente sem nomeá-los. O ganho dos próximos capítulos é dar nome ao que você já faz, para conseguir conversar sobre.

Fim do capítulo 6
Vamos para os padrões em si. Próximo capítulo: criacionais — Factory, Builder, Singleton (com toda a crítica que ele merece).
Parte II · Capítulo 07 · Padrões de design

Criacionais:
como objetos
nascem.

Construir um objeto raramente é só chamar __init__. Quando há configuração complexa, decisão sobre subtipo, dependência externa ou montagem em etapas — você está num caso para padrões criacionais.

Estes padrões respondem a uma pergunta: "quem decide qual classe instanciar, e como?". A resposta certa varia: às vezes é o cliente, às vezes uma fábrica, às vezes uma cadeia de construção em etapas. Saber qual escolher economiza muito retrabalho.

7.1 Factory Method

Problema

Cliente precisa criar objetos cuja classe concreta varia conforme contexto, sem ficar amarrado a cada classe possível. Adicionar nova variante não deve quebrar código existente.

Solução

Encapsule a decisão de "qual classe instanciar" em um método dedicado (a fábrica). Clientes chamam o método; ele decide e retorna o objeto pronto. Você varia a fábrica, não os clientes.

factory_method.py
from typing import Protocol
from dataclasses import dataclass

class Documento(Protocol):
    def renderizar(self) -> bytes: ...

class DocumentoPDF:
    def __init__(self, conteudo: str):
        self.conteudo = conteudo
    def renderizar(self): return b"%PDF-1.4 ..."

class DocumentoDOCX:
    def __init__(self, conteudo: str):
        self.conteudo = conteudo
    def renderizar(self): return b"PK..."  # zip header

class DocumentoHTML:
    def __init__(self, conteudo: str):
        self.conteudo = conteudo
    def renderizar(self):
        return f"<html>{self.conteudo}</html>".encode()

# Factory method: cliente passa o tipo, recebe o objeto pronto
def criar_documento(formato: str, conteudo: str) -> Documento:
    if formato == "pdf":
        return DocumentoPDF(conteudo)
    if formato == "docx":
        return DocumentoDOCX(conteudo)
    if formato == "html":
        return DocumentoHTML(conteudo)
    raise ValueError(f"formato {formato} não suportado")

# Cliente:
doc = criar_documento("pdf", "Olá mundo")
bytes_ = doc.renderizar()

Variação registrável

A função acima ainda viola OCP (adicionar formato exige editar a função). Versão melhor: registro:

factory_registrada.py
from typing import Callable

_FABRICAS: dict[str, Callable[[str], Documento]] = {}

def registrar_formato(nome: str, criador: Callable[[str], Documento]):
    _FABRICAS[nome] = criador

def criar_documento(formato: str, conteudo: str) -> Documento:
    fabrica = _FABRICAS.get(formato)
    if not fabrica:
        raise ValueError(f"formato {formato} não registrado")
    return fabrica(conteudo)

# Em algum lugar do bootstrap:
registrar_formato("pdf", DocumentoPDF)
registrar_formato("docx", DocumentoDOCX)
registrar_formato("html", DocumentoHTML)

# Plugin externo pode registrar novos formatos sem editar nada.

Quando usar

Quando NÃO usar

7.2 Abstract Factory

Problema

Você precisa criar famílias de objetos relacionados que devem funcionar juntos. Trocar a família inteira deve ser uma operação atômica — não pode misturar peça de uma com peça de outra.

Solução

Defina uma interface de "fábrica" que cria todos os membros da família. Cada família concreta é uma classe que implementa essa fábrica e retorna a versão consistente de cada peça.

abstract_factory.py
from typing import Protocol

# Família de produtos: Botão + Janela + Menu coerentes
class Botao(Protocol):
    def renderizar(self) -> str: ...

class Janela(Protocol):
    def renderizar(self) -> str: ...

class Menu(Protocol):
    def renderizar(self) -> str: ...

# Implementações concretas — família "claro"
class BotaoClaro:
    def renderizar(self): return "<button class='light'>"
class JanelaClara:
    def renderizar(self): return "<div class='window-light'>"
class MenuClaro:
    def renderizar(self): return "<nav class='menu-light'>"

# Família "escuro"
class BotaoEscuro:
    def renderizar(self): return "<button class='dark'>"
class JanelaEscura:
    def renderizar(self): return "<div class='window-dark'>"
class MenuEscuro:
    def renderizar(self): return "<nav class='menu-dark'>"

# Fábrica abstrata: contrato para criar a família coerente
class FabricaTema(Protocol):
    def botao(self) -> Botao: ...
    def janela(self) -> Janela: ...
    def menu(self) -> Menu: ...

class TemaClaro:
    def botao(self): return BotaoClaro()
    def janela(self): return JanelaClara()
    def menu(self): return MenuClaro()

class TemaEscuro:
    def botao(self): return BotaoEscuro()
    def janela(self): return JanelaEscura()
    def menu(self): return MenuEscuro()

# Cliente trabalha só com FabricaTema. Trocar tema = trocar fábrica.
# Nunca há risco de BotaoClaro com JanelaEscura — a fábrica garante.

Quando usar

Quando NÃO usar

7.3 Builder

Problema

Construir um objeto envolve muitos parâmetros opcionais, validações entre eles, ou montagem em etapas. __init__ com 20 parâmetros é ilegível e propenso a erro.

Solução

Crie um "construtor" auxiliar que recebe um pedaço de cada vez, valida progressivamente, e ao final monta o objeto definitivo. O resultado: API fluente, intenção clara, validação centralizada.

builder.py
from dataclasses import dataclass, field
from typing import Self

@dataclass(frozen=True)
class Consulta:
    # Resultado final: imutável, validado
    tabela: str
    colunas: tuple[str, ...]
    filtros: tuple[tuple[str, str, str], ...]
    ordem: tuple[str, ...]
    limite: int | None = None

    def sql(self) -> str:
        cols = ", ".join(self.colunas) or "*"
        s = f"SELECT {cols} FROM {self.tabela}"
        if self.filtros:
            w = " AND ".join(f"{c} {op} '{v}'" for c, op, v in self.filtros)
            s += f" WHERE {w}"
        if self.ordem:
            s += f" ORDER BY {', '.join(self.ordem)}"
        if self.limite:
            s += f" LIMIT {self.limite}"
        return s

class ConsultaBuilder:
    def __init__(self, tabela: str):
        if not tabela:
            raise ValueError("tabela obrigatória")
        self._tabela = tabela
        self._colunas: list[str] = []
        self._filtros: list[tuple[str, str, str]] = []
        self._ordem: list[str] = []
        self._limite: int | None = None

    def selecionar(self, *colunas: str) -> Self:
        self._colunas.extend(colunas)
        return self

    def onde(self, coluna: str, op: str, valor: str) -> Self:
        if op not in {"=", "!=", "<", ">", "LIKE"}:
            raise ValueError(f"op inválido: {op}")
        self._filtros.append((coluna, op, valor))
        return self

    def ordenar_por(self, *cols: str) -> Self:
        self._ordem.extend(cols)
        return self

    def limitar(self, n: int) -> Self:
        if n <= 0:
            raise ValueError("limite deve ser positivo")
        self._limite = n
        return self

    def build(self) -> Consulta:
        return Consulta(
            tabela=self._tabela,
            colunas=tuple(self._colunas),
            filtros=tuple(self._filtros),
            ordem=tuple(self._ordem),
            limite=self._limite,
        )

# Uso: API fluente, intenção legível
consulta = (
    ConsultaBuilder("pedidos")
    .selecionar("id", "total", "data")
    .onde("status", "=", "PAGO")
    .onde("total", ">", "100")
    .ordenar_por("data DESC")
    .limitar(50)
    .build()
)
print(consulta.sql())

Quando usar

Quando NÃO usar

Alternativa pythônica
Para muitos casos, dataclass com campos opcionais e factory functions resolve sem Builder. Use Builder quando a construção é genuinamente fluente e em etapas — não para qualquer objeto com vários parâmetros.

7.4 Prototype

Problema

Criar objeto novo é caro (envolve I/O, cálculo pesado, configuração complexa) e você precisa de muitas instâncias parecidas, diferindo só em pequenos detalhes.

Solução

Crie uma instância "protótipo" pronta. Para cada nova instância, clone-a e ajuste os campos diferentes. O custo de criação inicial é pago uma vez.

prototype.py
import copy
from dataclasses import dataclass, field

@dataclass
class ConfiguracaoRelatorio:
    titulo: str
    colunas: list[str]
    filtros: dict[str, str]
    estilos: dict[str, str]
    queries: list[str]  # consultas pesadas pré-resolvidas

    def clone(self) -> "ConfiguracaoRelatorio":
        return copy.deepcopy(self)

# Configuração base "cara" — montada uma vez
TEMPLATE_VENDAS = ConfiguracaoRelatorio(
    titulo="Relatório de Vendas",
    colunas=["data", "produto", "qtd", "total"],
    filtros={"ativo": "true"},
    estilos={"fonte": "Inter", "cabec": "bold"},
    queries=[/* SQL pesado pré-gerado */],
)

# Para cada relatório novo, clone e ajuste:
config_sp = TEMPLATE_VENDAS.clone()
config_sp.filtros["estado"] = "SP"
config_sp.titulo = "Relatório de Vendas - SP"

config_rj = TEMPLATE_VENDAS.clone()
config_rj.filtros["estado"] = "RJ"

Cuidado: shallow vs deep copy

copy.copy() (shallow) compartilha referências aninhadas — modificar a lista no clone modifica também o original. copy.deepcopy() (deep) clona tudo recursivamente — mais caro, mas seguro. Use deep por padrão; use shallow apenas com plena consciência.

Quando usar

Quando NÃO usar

7.5 Singleton — com toda a crítica

Talvez o padrão mais polêmico do catálogo. Vamos cobrir o que é, como implementar — e por que quase sempre você deveria evitar.

Problema (alegado)

Um recurso precisa ter uma única instância no sistema (config global, logger, conexão de banco, cache). Quer impedir criação acidental de múltiplas instâncias.

Solução clássica

singleton_classico.py
class ConfigGlobal:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._inicializar()
        return cls._instance

    def _inicializar(self):
        self.timeout = 30
        self.debug = False

c1 = ConfigGlobal()
c2 = ConfigGlobal()
assert c1 is c2  # mesmíssima instância
Por que evitar Singleton

O Singleton ganhou má fama merecida. Os problemas são reais e graves:

  • Estado global disfarçado: é literalmente uma variável global com classe ao redor. Carrega todos os males do estado global — acoplamento implícito, dificuldade de raciocínio sobre fluxo, ordem de inicialização.
  • Testes ficam pesadelo: impossível isolar testes. Um teste muda config → próximo teste herda. Você acaba com setUp/tearDown manipulando estado interno, ou pior, com testes que falham em ordem aleatória.
  • Esconde dependências: sua função "depende" do Singleton mas a assinatura não mostra. Quem lê o código não sabe.
  • Inimigo da concorrência: em ambiente multi-threaded, criação do Singleton precisa de lock — e várias implementações famosas tinham bugs sutis.
  • Quase sempre você queria outra coisa: "uma instância" geralmente significa "uma por aplicação" ou "uma por contexto". DI resolve melhor.

O que usar no lugar

✗ Singleton
com_singleton.py
class ServicoPedido:
    def criar(self, p):
        config = ConfigGlobal()  # dependência oculta
        timeout = config.timeout
        ...

# Teste: precisa hackear ConfigGlobal._instance
✓ Injeção de dependência
com_di.py
class ServicoPedido:
    def __init__(self, config: Config):
        self._config = config

    def criar(self, p):
        timeout = self._config.timeout
        ...

# Teste: passa Config(timeout=1) e pronto.
# Produção: instancia uma vez no bootstrap.

Quando Singleton ainda faz sentido (raramente)

Em sistemas profissionais maiores, prefira injeção de dependência ou container de objetos do framework (Django settings, FastAPI Depends, etc).

7.6 Versões pythônicas

Python oferece atalhos que tornam vários padrões criacionais menos necessários:

classmethod_factory.py
from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class Pedido:
    id: str
    cliente: str
    criado_em: datetime

    @classmethod
    def novo(cls, cliente: str) -> "Pedido":
        return cls(id=gerar_id(), cliente=cliente, criado_em=datetime.now())

    @classmethod
    def do_dict(cls, d: dict) -> "Pedido":
        return cls(
            id=d["id"],
            cliente=d["cliente"],
            criado_em=datetime.fromisoformat(d["criado_em"]),
        )

# Várias "fábricas" sem precisar de classes Factory separadas.
# Esse é o jeito idiomático em Python.

7.7 Estudo de caso — sistema de envio de mensagens

Combinando Factory registrável + Builder em sistema multi-canal

Sistema precisa enviar mensagens por SMS, e-mail, push e Telegram. Cada canal tem configuração própria, e a mensagem em si pode ser simples (texto) ou rica (com anexos, formatação, template).

Passo 1 · Modelar a mensagem com Builder
mensagem.py
from dataclasses import dataclass
from typing import Self

@dataclass(frozen=True)
class Mensagem:
    destinatario: str
    assunto: str | None
    corpo: str
    anexos: tuple[str, ...]
    prioridade: str  # "baixa", "normal", "alta"

class MensagemBuilder:
    def __init__(self, destinatario: str):
        if not destinatario:
            raise ValueError("destinatário obrigatório")
        self._dest = destinatario
        self._assunto: str | None = None
        self._corpo: str = ""
        self._anexos: list[str] = []
        self._prio: str = "normal"

    def assunto(self, a: str) -> Self:
        self._assunto = a
        return self

    def corpo(self, c: str) -> Self:
        self._corpo = c
        return self

    def anexo(self, path: str) -> Self:
        self._anexos.append(path)
        return self

    def prioridade(self, p: str) -> Self:
        if p not in {"baixa", "normal", "alta"}:
            raise ValueError(f"prioridade inválida: {p}")
        self._prio = p
        return self

    def build(self) -> Mensagem:
        if not self._corpo:
            raise ValueError("corpo é obrigatório")
        return Mensagem(
            destinatario=self._dest,
            assunto=self._assunto,
            corpo=self._corpo,
            anexos=tuple(self._anexos),
            prioridade=self._prio,
        )
Passo 2 · Canais (com Factory registrável)
canais.py
from typing import Protocol, Callable

class Canal(Protocol):
    def enviar(self, m: Mensagem) -> None: ...

class CanalEmail:
    def __init__(self, smtp_host: str):
        self._smtp = smtp_host
    def enviar(self, m): print(f"[email→{m.destinatario}] {m.corpo}")

class CanalSMS:
    def __init__(self, api_key: str):
        self._key = api_key
    def enviar(self, m): print(f"[sms→{m.destinatario}] {m.corpo[:160]}")

class CanalPush:
    def __init__(self, firebase_key: str):
        self._key = firebase_key
    def enviar(self, m): print(f"[push→{m.destinatario}] {m.assunto}: {m.corpo}")

class RegistroCanais:
    def __init__(self):
        self._canais: dict[str, Canal] = {}

    def registrar(self, nome: str, canal: Canal) -> None:
        self._canais[nome] = canal

    def obter(self, nome: str) -> Canal:
        if nome not in self._canais:
            raise ValueError(f"canal {nome} não registrado")
        return self._canais[nome]

    def enviar_em(self, nome: str, m: Mensagem) -> None:
        self.obter(nome).enviar(m)
Passo 3 · Bootstrap e uso
main.py
# Bootstrap: configurações vêm do ambiente, sem Singleton
registro = RegistroCanais()
registro.registrar("email", CanalEmail(smtp_host="smtp.gmail.com"))
registro.registrar("sms", CanalSMS(api_key="abc123"))
registro.registrar("push", CanalPush(firebase_key="xyz789"))

# Uso
mensagem = (
    MensagemBuilder("alice@example.com")
    .assunto("Bem-vinda!")
    .corpo("Sua conta foi criada com sucesso.")
    .prioridade("alta")
    .build()
)
registro.enviar_em("email", mensagem)

# Para adicionar Telegram: nova classe + registrar. Zero alteração no resto.
# Para testar: passe MockCanal no lugar.

O que ganhamos: Builder torna mensagens complexas legíveis e validadas; Factory registrável permite plugar canais sem editar nada; nenhum Singleton — tudo é injeção de dependência, testável.

7.8 Erros comuns

Erro 1 · Factory que retorna Factory

Cargo cult clássico: FactoryFactory.createFactory().create(). Se a fábrica precisa de fábrica, provavelmente você está abstraindo o que ainda nem existe.

Erro 2 · Builder para tudo

Objeto com 3 campos não precisa de Builder. dataclass com defaults faz o trabalho. Builder é para construção fluente e validação progressiva, não para "objetos com mais de um parâmetro".

Erro 3 · Singleton "porque é boa prática"

Nunca. Se não há razão concreta — e na maioria absoluta dos casos não há — não use. DI sempre é melhor escolha por default.

Erro 4 · Prototype com shallow copy não percebido

"Clonei o config e modifiquei — por que o original mudou?" Porque copy.copy() não copia dicts/listas aninhados. Use deepcopy ou implemente clone explicitamente.

Verifique seu entendimento
"Você quer ter uma única conexão de banco em toda a aplicação. Qual estratégia faz mais sentido?"

7.9 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Factory simples

Crie função criar_logger(nivel: str) que retorna logger configurado. Suporte níveis "debug", "info", "warn", "error". Lance erro para nível desconhecido. Use Literal para o tipo do parâmetro.

logger_factory.py
import logging
from typing import Literal

NivelLog = Literal["debug", "info", "warn", "error"]

NIVEIS = {
    "debug": logging.DEBUG,
    "info": logging.INFO,
    "warn": logging.WARNING,
    "error": logging.ERROR,
}

def criar_logger(nivel: NivelLog, nome: str = "app") -> logging.Logger:
    if nivel not in NIVEIS:
        raise ValueError(f"nível desconhecido: {nivel}")
    logger = logging.getLogger(nome)
    logger.setLevel(NIVEIS[nivel])
    if not logger.handlers:
        h = logging.StreamHandler()
        h.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
        logger.addHandler(h)
    return logger
Médio
Exercício 2 · Builder de requisição HTTP

Crie RequestBuilder fluente que monte uma requisição HTTP. Permita configurar: URL (obrigatória), método (GET por padrão), headers, query params, body (JSON ou raw), timeout. build() retorna dataclass imutável Request. Valide: URL com http(s), método em conjunto válido, timeout positivo.

request_builder.py
from dataclasses import dataclass
from typing import Self, Any
import json

METODOS = {"GET", "POST", "PUT", "PATCH", "DELETE"}

@dataclass(frozen=True)
class Request:
    url: str
    metodo: str
    headers: tuple[tuple[str, str], ...]
    params: tuple[tuple[str, str], ...]
    body: bytes | None
    timeout: float

class RequestBuilder:
    def __init__(self, url: str):
        if not url.startswith(("http://", "https://")):
            raise ValueError("URL deve começar com http(s)")
        self._url = url
        self._metodo = "GET"
        self._headers: list[tuple[str, str]] = []
        self._params: list[tuple[str, str]] = []
        self._body: bytes | None = None
        self._timeout: float = 30.0

    def metodo(self, m: str) -> Self:
        if m not in METODOS:
            raise ValueError(f"método inválido: {m}")
        self._metodo = m
        return self

    def header(self, k: str, v: str) -> Self:
        self._headers.append((k, v))
        return self

    def param(self, k: str, v: str) -> Self:
        self._params.append((k, v))
        return self

    def json_body(self, data: Any) -> Self:
        self._body = json.dumps(data).encode()
        self.header("Content-Type", "application/json")
        return self

    def raw_body(self, data: bytes) -> Self:
        self._body = data
        return self

    def timeout(self, t: float) -> Self:
        if t <= 0:
            raise ValueError("timeout deve ser positivo")
        self._timeout = t
        return self

    def build(self) -> Request:
        return Request(
            url=self._url,
            metodo=self._metodo,
            headers=tuple(self._headers),
            params=tuple(self._params),
            body=self._body,
            timeout=self._timeout,
        )

req = (
    RequestBuilder("https://api.example.com/users")
    .metodo("POST")
    .header("Authorization", "Bearer xyz")
    .json_body({"name": "Alice"})
    .timeout(10)
    .build()
)
Médio
Exercício 3 · Substituir Singleton por DI

Refatore o código abaixo, que usa Singleton, para usar injeção de dependência. Mostre como o teste fica mais simples.

antes.py
class Config:
    _i = None
    def __new__(cls):
        if cls._i is None:
            cls._i = super().__new__(cls)
            cls._i.timeout = 30
            cls._i.debug = False
        return cls._i

class ApiClient:
    def chamar(self, url):
        if Config().debug:
            print(f"GET {url}")
        timeout = Config().timeout
        return requests.get(url, timeout=timeout)
depois.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    timeout: int = 30
    debug: bool = False

class ApiClient:
    def __init__(self, config: Config):
        self._config = config

    def chamar(self, url):
        if self._config.debug:
            print(f"GET {url}")
        return requests.get(url, timeout=self._config.timeout)

# Bootstrap (uma vez):
config = Config(timeout=15, debug=False)
client = ApiClient(config)

# Teste fica trivial:
def test_debug_loga():
    config = Config(timeout=1, debug=True)
    client = ApiClient(config)
    # ... testa sem estado global de Config
Difícil
Exercício 4 · Sistema de templates com Prototype + Factory

Modele um sistema de templates de e-mail. Existe um catálogo de templates base (boas-vindas, recuperação de senha, cobrança). Cada um pode ser "instanciado" para um destinatário específico, substituindo placeholders. Use Prototype (clonar template) + Factory (criar instâncias específicas por nome). Garanta que clonar não afeta o template base.

templates.py
import copy
from dataclasses import dataclass, field

@dataclass
class TemplateEmail:
    nome: str
    assunto: str
    corpo: str
    variaveis: dict[str, str] = field(default_factory=dict)

    def clone(self) -> "TemplateEmail":
        return copy.deepcopy(self)

    def renderizar(self) -> tuple[str, str]:
        a, c = self.assunto, self.corpo
        for k, v in self.variaveis.items():
            a = a.replace(f"{{{{{k}}}}}", v)
            c = c.replace(f"{{{{{k}}}}}", v)
        return a, c

class CatalogoTemplates:
    def __init__(self):
        self._templates: dict[str, TemplateEmail] = {}

    def registrar(self, t: TemplateEmail) -> None:
        self._templates[t.nome] = t

    def instanciar(self, nome: str, variaveis: dict[str, str]) -> TemplateEmail:
        base = self._templates.get(nome)
        if not base:
            raise ValueError(f"template {nome} não encontrado")
        novo = base.clone()
        novo.variaveis.update(variaveis)
        return novo

# Bootstrap
cat = CatalogoTemplates()
cat.registrar(TemplateEmail(
    nome="boas_vindas",
    assunto="Bem-vindo, {{nome}}!",
    corpo="Olá {{nome}}, sua conta foi criada.",
))
cat.registrar(TemplateEmail(
    nome="recuperar",
    assunto="Recuperação de senha",
    corpo="Clique: {{link}}",
))

# Uso
email_alice = cat.instanciar("boas_vindas", {"nome": "Alice"})
email_bob = cat.instanciar("boas_vindas", {"nome": "Bob"})

assert email_alice.assunto != email_bob.assunto
# Templates base não foram modificados:
assert cat._templates["boas_vindas"].variaveis == {}
Fim do capítulo 7
Próximo capítulo: padrões estruturais — Adapter, Decorator, Facade, Proxy. Os que ajudam a montar e adaptar objetos sem reescrever.
Parte II · Capítulo 08 · Padrões de design

Estruturais:
como objetos
se compõem.

Padrões estruturais respondem a "como conectar objetos que já existem para que trabalhem juntos sem reescrever cada um". São, talvez, os padrões mais úteis no dia-a-dia.

Você vai precisar conectar uma biblioteca externa que tem interface diferente da que seu sistema espera (Adapter). Vai querer adicionar logging, cache, validação a objetos sem mudá-los (Decorator). Vai querer esconder um subsistema complicado atrás de uma interface simples (Facade). Vai querer controlar acesso a algo caro ou remoto (Proxy). Estes são padrões que aparecem todo dia.

8.1 Adapter

Problema

Você tem uma classe ou biblioteca com interface A. Seu sistema espera interface B. Reescrever a classe ou modificar todo o sistema é inviável. Você quer um "tradutor" entre as duas.

Solução

Crie uma classe adaptadora que implementa a interface esperada (B), mas internamente chama a interface real (A) fazendo a tradução necessária.

adapter.py
from typing import Protocol

# O que seu sistema espera
class Cache(Protocol):
    def get(self, chave: str) -> bytes | None: ...
    def set(self, chave: str, valor: bytes, ttl: int) -> None: ...

# Biblioteca externa com API diferente (não posso alterar)
class RedisLib:
    def retrieve(self, k: str) -> str | None:
        ...  # retorna str, não bytes
    def store(self, k: str, v: str, expire_seconds: int):
        ...

# Outra biblioteca, ainda diferente
class MemcachedLib:
    def read(self, key: bytes) -> bytes | None:
        ...
    def write(self, key: bytes, val: bytes, ttl_ms: int):
        ...  # ttl em milissegundos

# Adapters — cada um traduz a API externa para a esperada
class CacheRedis:
    def __init__(self, lib: RedisLib):
        self._lib = lib

    def get(self, chave: str) -> bytes | None:
        s = self._lib.retrieve(chave)
        return s.encode() if s else None

    def set(self, chave: str, valor: bytes, ttl: int) -> None:
        self._lib.store(chave, valor.decode(), ttl)

class CacheMemcached:
    def __init__(self, lib: MemcachedLib):
        self._lib = lib

    def get(self, chave: str) -> bytes | None:
        return self._lib.read(chave.encode())

    def set(self, chave: str, valor: bytes, ttl: int) -> None:
        self._lib.write(chave.encode(), valor, ttl * 1000)  # s → ms

# Resto do sistema só conhece Cache. Trocar de Redis para Memcached
# é trocar um adapter por outro. Nada mais precisa mudar.

Quando usar

Quando NÃO usar

8.2 Decorator

Problema

Você quer adicionar comportamento (logging, cache, métricas, validação) a um objeto sem modificá-lo, e sem precisar de subclasse para cada combinação possível.

Solução

Crie uma classe que envolve o objeto original, implementando a mesma interface, adicionando o comportamento extra antes ou depois de delegar para o original. Decoradores podem empilhar.

decorator.py
from typing import Protocol
import time, logging

logger = logging.getLogger(__name__)

class Repositorio(Protocol):
    def buscar(self, id: str) -> dict | None: ...

class RepoBanco:
    """Implementação base — vai ao banco."""
    def buscar(self, id):
        # consulta cara aqui
        time.sleep(0.1)
        return {"id": id, "dado": "x"}

class RepoComLogging:
    """Decorator: adiciona log antes/depois."""
    def __init__(self, alvo: Repositorio):
        self._alvo = alvo

    def buscar(self, id):
        logger.info(f"buscando {id}")
        r = self._alvo.buscar(id)
        logger.info(f"resultado: {r is not None}")
        return r

class RepoComCache:
    """Decorator: cache em memória."""
    def __init__(self, alvo: Repositorio):
        self._alvo = alvo
        self._cache: dict[str, dict] = {}

    def buscar(self, id):
        if id in self._cache:
            return self._cache[id]
        r = self._alvo.buscar(id)
        if r:
            self._cache[id] = r
        return r

class RepoComMetricas:
    """Decorator: mede tempo."""
    def __init__(self, alvo: Repositorio):
        self._alvo = alvo

    def buscar(self, id):
        inicio = time.perf_counter()
        try:
            return self._alvo.buscar(id)
        finally:
            dt = time.perf_counter() - inicio
            logger.info(f"buscar levou {dt*1000:.1f}ms")

# Empilhando decoradores — ordem importa:
repo: Repositorio = RepoBanco()
repo = RepoComCache(repo)        # mais interno
repo = RepoComLogging(repo)
repo = RepoComMetricas(repo)     # mais externo

# Chamada agora: métricas → logging → cache → banco
# Sem mudar nenhum dos componentes; só compondo.

Decorators do Python (a feature)

Python tem o operador @ para decorators de função, que é o padrão Decorator em sua forma mais leve. Eles cobrem boa parte dos casos de uso do padrão de classe:

decorators_funcao.py
import functools, time

def cronometrar(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        inicio = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__}: {(time.perf_counter()-inicio)*1000:.1f}ms")
    return wrapper

def cachear(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args in cache:
            return cache[args]
        cache[args] = func(*args)
        return cache[args]
    return wrapper

@cronometrar
@cachear
def calcular_caro(x: int) -> int:
    time.sleep(0.5)
    return x * 2

# Decorators de Python = padrão Decorator destilado em sintaxe.
# Use para comportamento ortogonal a funções/métodos.

Quando usar

Quando NÃO usar

8.3 Facade

Problema

Você tem um subsistema complexo, com várias classes que precisam ser orquestradas em ordem para realizar uma operação. Clientes não deveriam precisar conhecer essa complexidade.

Solução

Crie uma classe Facade que expõe uma interface simples, e internamente orquestra as classes complexas. Clientes falam com o Facade; o Facade fala com o resto.

facade.py
# Subsistema complexo — várias classes com responsabilidades específicas
class ValidadorPedido:
    def validar(self, p): ...

class CalculadoraImposto:
    def calcular(self, p): ...

class CalculadoraFrete:
    def calcular(self, p): ...

class ServicoEstoque:
    def reservar(self, p): ...
    def liberar(self, p): ...

class GatewayPagamento:
    def cobrar(self, valor, dados): ...

class RepositorioPedido:
    def salvar(self, p): ...

class ServicoNotificacao:
    def confirmar_pedido(self, p): ...

# Facade: orquestra o fluxo completo
class CheckoutFacade:
    def __init__(
        self,
        validador, imposto, frete, estoque, gateway, repo, notif,
    ):
        self._validador = validador
        self._imposto = imposto
        self._frete = frete
        self._estoque = estoque
        self._gateway = gateway
        self._repo = repo
        self._notif = notif

    def finalizar(self, pedido, dados_pagamento) -> str:
        self._validador.validar(pedido)
        imposto = self._imposto.calcular(pedido)
        frete = self._frete.calcular(pedido)
        pedido.aplicar_taxas(imposto, frete)

        self._estoque.reservar(pedido)
        try:
            tx = self._gateway.cobrar(pedido.total, dados_pagamento)
        except Exception:
            self._estoque.liberar(pedido)
            raise

        pedido.confirmar(tx.id)
        self._repo.salvar(pedido)
        self._notif.confirmar_pedido(pedido)
        return pedido.id

# Cliente:
checkout = CheckoutFacade(...)
pedido_id = checkout.finalizar(pedido, dados)
# Uma única chamada esconde toda a complexidade.

Quando usar

Cuidado

Facade não substitui as classes internas. Elas continuam acessíveis se algum cliente precisar de algo específico. Facade é caminho fácil, não único.

8.4 Proxy

Problema

Você precisa controlar acesso a um objeto: lazy loading (só carrega quando usado), cache, autorização, log de acesso, ou representação remota de algo que está em outro processo/máquina.

Solução

Crie um "stand-in" — um objeto que implementa a mesma interface, mas internamente gerencia o acesso ao objeto real (que pode nem existir ainda).

proxy.py
from typing import Protocol

class DocumentoGrande(Protocol):
    def conteudo(self) -> bytes: ...

class DocumentoDisco:
    """Carrega 50MB do disco — caro."""
    def __init__(self, path: str):
        self._path = path
        with open(path, "rb") as f:
            self._dados = f.read()

    def conteudo(self) -> bytes:
        return self._dados

class ProxyLazy:
    """Não carrega até alguém pedir conteúdo."""
    def __init__(self, path: str):
        self._path = path
        self._real: DocumentoDisco | None = None

    def conteudo(self) -> bytes:
        if self._real is None:
            self._real = DocumentoDisco(self._path)
        return self._real.conteudo()

class ProxyComAutorizacao:
    """Verifica permissão antes de delegar."""
    def __init__(self, alvo: DocumentoGrande, usuario: str):
        self._alvo = alvo
        self._usuario = usuario

    def conteudo(self) -> bytes:
        if not usuario_pode_ler(self._usuario):
            raise PermissionError(self._usuario)
        return self._alvo.conteudo()

# Uso:
doc = ProxyComAutorizacao(ProxyLazy("/dados/relatorio.bin"), "alice")
# Nada foi carregado ainda. Nenhuma verificação ainda.

bytes_ = doc.conteudo()
# Agora: ProxyAutorizacao checa permissão → ProxyLazy carrega → retorna

Quando usar

Diferença para Decorator

Proxy e Decorator parecem similares: ambos envolvem outro objeto. A diferença é intenção:

Estruturalmente são quase idênticos. Conceitualmente diferentes. Em código pythônico, ninguém vai te crucificar por chamar errado.

8.5 Composite

Problema

Você tem objetos individuais e grupos desses objetos, e quer tratar ambos uniformemente. Exemplos: arquivos e diretórios, formulários com campos e grupos de campos, expressões matemáticas com números e operações.

Solução

Defina uma interface comum. Cada folha (objeto individual) implementa diretamente. Cada composto (grupo) implementa delegando para seus filhos.

composite.py
from typing import Protocol

class ItemSistemaArquivos(Protocol):
    nome: str
    def tamanho(self) -> int: ...

class Arquivo:
    """Folha."""
    def __init__(self, nome: str, bytes_: int):
        self.nome = nome
        self._bytes = bytes_

    def tamanho(self) -> int:
        return self._bytes

class Diretorio:
    """Composto."""
    def __init__(self, nome: str):
        self.nome = nome
        self._itens: list[ItemSistemaArquivos] = []

    def adicionar(self, item: ItemSistemaArquivos) -> None:
        self._itens.append(item)

    def tamanho(self) -> int:
        return sum(i.tamanho() for i in self._itens)

# Constrói árvore: dir → dir → arquivos
raiz = Diretorio("projeto")
src = Diretorio("src")
src.adicionar(Arquivo("main.py", 1200))
src.adicionar(Arquivo("utils.py", 800))
raiz.adicionar(src)
raiz.adicionar(Arquivo("README.md", 2400))

# Mesma chamada funciona em arquivo único OU diretório inteiro
print(raiz.tamanho())  # soma recursiva

Quando usar

8.6 Estudo de caso — pipeline de processamento de imagens

Adapter + Decorator + Facade em sistema real

Sistema que recebe imagens, aplica transformações, salva. Cada parte vem de biblioteca diferente, cada operação tem versões "puras" e "com logging/métricas".

Passo 1 · Interface de domínio
img_dominio.py
from typing import Protocol
from dataclasses import dataclass

@dataclass(frozen=True)
class Imagem:
    bytes_: bytes
    largura: int
    altura: int
    formato: str

class Transformacao(Protocol):
    def aplicar(self, img: Imagem) -> Imagem: ...
Passo 2 · Adapter para Pillow (lib externa)
img_pillow.py
from PIL import Image
import io

class PillowAdapter:
    """Traduz Imagem ↔ PIL.Image."""
    @staticmethod
    def para_pil(img: Imagem) -> Image.Image:
        return Image.open(io.BytesIO(img.bytes_))

    @staticmethod
    def de_pil(pil_img: Image.Image, formato: str) -> Imagem:
        buf = io.BytesIO()
        pil_img.save(buf, format=formato.upper())
        return Imagem(
            bytes_=buf.getvalue(),
            largura=pil_img.width,
            altura=pil_img.height,
            formato=formato,
        )

class Redimensionar:
    def __init__(self, largura: int, altura: int):
        self._w, self._h = largura, altura

    def aplicar(self, img):
        pil = PillowAdapter.para_pil(img)
        pil = pil.resize((self._w, self._h))
        return PillowAdapter.de_pil(pil, img.formato)

class EscalaDeCinza:
    def aplicar(self, img):
        pil = PillowAdapter.para_pil(img).convert("L")
        return PillowAdapter.de_pil(pil, img.formato)

class Comprimir:
    def __init__(self, qualidade: int = 85):
        self._q = qualidade

    def aplicar(self, img):
        pil = PillowAdapter.para_pil(img)
        buf = io.BytesIO()
        pil.save(buf, format=img.formato.upper(), quality=self._q)
        return Imagem(
            bytes_=buf.getvalue(),
            largura=img.largura, altura=img.altura, formato=img.formato,
        )
Passo 3 · Decorator para métricas
img_metricas.py
import time
import logging

logger = logging.getLogger(__name__)

class ComMetricas:
    def __init__(self, alvo: Transformacao, nome: str):
        self._alvo = alvo
        self._nome = nome

    def aplicar(self, img):
        inicio = time.perf_counter()
        try:
            r = self._alvo.aplicar(img)
            dt = (time.perf_counter() - inicio) * 1000
            logger.info(
                f"[{self._nome}] {len(img.bytes_)}B → {len(r.bytes_)}B em {dt:.1f}ms"
            )
            return r
        except Exception as e:
            logger.error(f"[{self._nome}] falhou: {e}")
            raise
Passo 4 · Facade do pipeline
img_pipeline.py
class PipelineImagem:
    """Facade: orquestra transformações em sequência."""
    def __init__(self, *transformacoes: Transformacao):
        self._t = transformacoes

    def processar(self, img: Imagem) -> Imagem:
        atual = img
        for t in self._t:
            atual = t.aplicar(atual)
        return atual

# Bootstrap
pipeline = PipelineImagem(
    ComMetricas(Redimensionar(800, 600), "resize"),
    ComMetricas(EscalaDeCinza(), "gray"),
    ComMetricas(Comprimir(qualidade=75), "compress"),
)

# Uso — uma chamada esconde toda a complexidade
resultado = pipeline.processar(imagem_original)

O que combinamos: Adapter isola Pillow do resto; Decorator adiciona métricas sem tocar nas transformações; Facade dá interface única ao pipeline. Cada parte sozinha resolve um problema; juntos formam arquitetura limpa.

8.7 Erros comuns

Erro 1 · Adapter quando não precisa

API externa já tem interface boa? Use direto. Adapter desnecessário é só indireção sem ganho.

Erro 2 · Decorator infinito

Empilhar 8 decorators numa operação simples — debugar fica pesadelo. Cada decorator adicional precisa de justificativa concreta.

Erro 3 · Facade vira God Object

Facade que cresce até virar a "classe principal do sistema" com 50 métodos. Quebre em facades especializadas. Facade não é depósito de funcionalidades.

Erro 4 · Confundir Proxy com Decorator

Tecnicamente são parecidos; conceitualmente diferentes. O nome importa para comunicação: Decorator adiciona comportamento; Proxy controla acesso.

Verifique seu entendimento
"Você tem 3 transformações de imagem (redimensionar, escala de cinza, comprimir). Quer adicionar log + métricas em todas, sem mexer no código delas. Qual padrão?"

8.8 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Adapter para storage

Você tem duas bibliotecas: S3Lib com upload(key, bytes_) / download(key) -> bytes, e LocalFS com write_file(path, data) / read_file(path) -> str. Crie Protocol Storage com put(key, value: bytes) / get(key) -> bytes, e adapters para as duas libs.

storage_adapters.py
from typing import Protocol

class Storage(Protocol):
    def put(self, key: str, value: bytes) -> None: ...
    def get(self, key: str) -> bytes: ...

class S3Adapter:
    def __init__(self, s3lib):
        self._s3 = s3lib
    def put(self, key, value):
        self._s3.upload(key, value)
    def get(self, key):
        return self._s3.download(key)

class LocalAdapter:
    def __init__(self, fs):
        self._fs = fs
    def put(self, key, value):
        self._fs.write_file(key, value.decode("latin-1"))
    def get(self, key):
        return self._fs.read_file(key).encode("latin-1")
Médio
Exercício 2 · Decorators de função

Crie três decorators de função independentes: @retry(n) que tenta n vezes em caso de exceção, @timed que loga duração, @validar_positivos que valida que todos args int/float são positivos. Empilhe-os numa função.

decorators_func.py
import functools, time, logging

logger = logging.getLogger(__name__)

def retry(n: int):
    def deco(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(n):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if i == n - 1:
                        raise
        return wrapper
    return deco

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            logger.info(f"{func.__name__}: {(time.perf_counter()-t0)*1000:.1f}ms")
    return wrapper

def validar_positivos(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for a in args:
            if isinstance(a, (int, float)) and a <= 0:
                raise ValueError(f"arg não-positivo: {a}")
        return func(*args, **kwargs)
    return wrapper

@timed
@retry(3)
@validar_positivos
def consultar(quantidade: int) -> list:
    return [...]  # busca

# Ordem da pilha (de baixo pra cima quando lê o código):
# validar → retry → timed → consultar
# Quando chama, executa de fora pra dentro:
# timed mede → retry tenta → validar valida → consultar roda
Médio
Exercício 3 · Facade para envio multi-canal

Crie NotificacaoFacade que recebe destinatário, mensagem, e lista de canais (email/sms/push). Internamente: valida que o destinatário tem cada canal configurado, envia em paralelo (use ThreadPoolExecutor), retorna lista de resultados por canal.

notif_facade.py
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass

@dataclass(frozen=True)
class ResultadoCanal:
    canal: str
    sucesso: bool
    erro: str | None = None

class NotificacaoFacade:
    def __init__(self, registro_canais, repo_contatos):
        self._canais = registro_canais
        self._contatos = repo_contatos

    def enviar(self, destinatario_id, mensagem, canais: list[str]) -> list[ResultadoCanal]:
        contatos = self._contatos.get(destinatario_id)
        if not contatos:
            raise ValueError(f"destinatário {destinatario_id} não encontrado")

        def _enviar_um(canal_nome: str) -> ResultadoCanal:
            if canal_nome not in contatos:
                return ResultadoCanal(canal_nome, False, "sem contato")
            try:
                self._canais.obter(canal_nome).enviar(contatos[canal_nome], mensagem)
                return ResultadoCanal(canal_nome, True)
            except Exception as e:
                return ResultadoCanal(canal_nome, False, str(e))

        with ThreadPoolExecutor(max_workers=5) as ex:
            return list(ex.map(_enviar_um, canais))
Difícil
Exercício 4 · Composite para filtros de busca

Modele filtros de busca componíveis: FiltroIgual(campo, valor), FiltroMaior(campo, valor), And(filtros), Or(filtros), Nao(filtro). Cada um implementa aplica(registro: dict) -> bool. Permita compor expressões complexas: "(idade > 18 AND cidade == 'SP') OR NOT ativo".

filtros.py
from typing import Protocol, Any

class Filtro(Protocol):
    def aplica(self, registro: dict) -> bool: ...

class Igual:
    def __init__(self, campo: str, valor: Any):
        self._campo, self._valor = campo, valor
    def aplica(self, r):
        return r.get(self._campo) == self._valor

class Maior:
    def __init__(self, campo, valor):
        self._campo, self._valor = campo, valor
    def aplica(self, r):
        v = r.get(self._campo)
        return v is not None and v > self._valor

class And:
    def __init__(self, *filtros: Filtro):
        self._filtros = filtros
    def aplica(self, r):
        return all(f.aplica(r) for f in self._filtros)

class Or:
    def __init__(self, *filtros: Filtro):
        self._filtros = filtros
    def aplica(self, r):
        return any(f.aplica(r) for f in self._filtros)

class Nao:
    def __init__(self, filtro: Filtro):
        self._f = filtro
    def aplica(self, r):
        return not self._f.aplica(r)

# (idade > 18 AND cidade == "SP") OR NOT ativo
filtro = Or(
    And(Maior("idade", 18), Igual("cidade", "SP")),
    Nao(Igual("ativo", True)),
)

assert filtro.aplica({"idade": 25, "cidade": "SP", "ativo": True})
assert filtro.aplica({"idade": 10, "cidade": "RJ", "ativo": False})
assert not filtro.aplica({"idade": 10, "cidade": "RJ", "ativo": True})
Fim do capítulo 8
Próximo capítulo: comportamentais — Strategy, Observer, Command, State. Os padrões sobre como objetos colaboram.
Parte II · Capítulo 09 · Padrões de design

Comportamentais:
como objetos
colaboram.

Padrões comportamentais respondem à pergunta mais sutil: como objetos conversam entre si? Quem chama quem, em que momento, com qual contrato. São os padrões que mais aparecem em código de aplicação.

Strategy permite trocar algoritmos. Observer permite que algo "escute" mudanças. Command transforma chamadas em objetos para fila, log ou undo. State organiza máquinas de estado. Iterator percorre coleções de forma uniforme. São padrões que você provavelmente já usa sem nomear. Dar nome ajuda a discutir com seu time.

9.1 Strategy

Problema

Você tem várias formas de fazer "a mesma coisa", e quer trocar entre elas conforme contexto. Hardcoded em if/elif vira pesadelo conforme as variantes crescem.

Solução

Encapsule cada algoritmo em uma classe. Cliente recebe a estratégia que vai usar; chama o método uniforme. Trocar de algoritmo é trocar de instância.

Já vimos Strategy nos capítulos 1, 6 e em vários estudos de caso. Vamos cobrir variações importantes agora.

strategy_variantes.py
from typing import Protocol, Callable
from dataclasses import dataclass

# Variante 1: Strategy clássica como classe
class Compressor(Protocol):
    def comprimir(self, dados: bytes) -> bytes: ...

class CompressorGzip:
    def comprimir(self, dados):
        import gzip
        return gzip.compress(dados)

class CompressorBrotli:
    def __init__(self, qualidade: int = 11):
        self._q = qualidade
    def comprimir(self, dados):
        import brotli
        return brotli.compress(dados, quality=self._q)

# Variante 2: Strategy como função (em Python, frequentemente melhor)
TipoCompressao = Callable[[bytes], bytes]

def empacotar(dados: bytes, comprimir: TipoCompressao) -> bytes:
    cabecalho = b"PKG1"
    return cabecalho + comprimir(dados)

# Pode passar a classe.comprimir, ou função pura, ou lambda
empacotar(payload, CompressorGzip().comprimir)
empacotar(payload, lambda d: d)  # sem compressão

# Variante 3: Strategy paramétrica via dataclass
@dataclass(frozen=True)
class EstrategiaComp:
    nome: str
    funcao: Callable[[bytes], bytes]
    extensao: str

ESTRATEGIAS = [
    EstrategiaComp("gzip", CompressorGzip().comprimir, ".gz"),
    EstrategiaComp("brotli", CompressorBrotli().comprimir, ".br"),
]

Quando preferir função a classe

Em Python, se a estratégia é só uma operação sem estado, passar uma função é mais limpo que criar classe. Use classe quando a estratégia tem configuração (qualidade, parâmetros) ou múltiplos métodos.

9.2 Observer

Problema

Algo acontece numa parte do sistema e outras partes precisam reagir, mas você não quer acoplamento direto. Adicionar novos "ouvintes" não deve exigir alterar a fonte do evento.

Solução

A fonte mantém lista de assinantes. Quando o evento acontece, notifica todos. Cada assinante decide o que fazer. Adicionar/remover assinantes é operação dinâmica.

observer.py
from typing import Protocol, Callable
from dataclasses import dataclass, field
from datetime import datetime

@dataclass(frozen=True)
class EventoPedido:
    pedido_id: str
    tipo: str          # "criado", "pago", "cancelado", "enviado"
    quando: datetime
    dados: dict

class Ouvinte(Protocol):
    def notificar(self, evento: EventoPedido) -> None: ...

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

    def assinar(self, tipo: str, ouvinte: Ouvinte) -> None:
        self._ouvintes.setdefault(tipo, []).append(ouvinte)

    def desassinar(self, tipo: str, ouvinte: Ouvinte) -> None:
        if tipo in self._ouvintes:
            self._ouvintes[tipo].remove(ouvinte)

    def publicar(self, evento: EventoPedido) -> None:
        for ouvinte in self._ouvintes.get(evento.tipo, []):
            try:
                ouvinte.notificar(evento)
            except Exception as e:
                # Isolamento: um ouvinte com bug não derruba os outros
                logger.exception(f"ouvinte falhou: {e}")

# Ouvintes concretos — cada um com sua responsabilidade
class EnviadorEmailConfirmacao:
    def notificar(self, e):
        if e.tipo == "pago":
            print(f"Email: pedido {e.pedido_id} confirmado")

class RegistroAnalytics:
    def notificar(self, e):
        print(f"Analytics: {e.tipo} → {e.pedido_id}")

class AtualizadorEstoque:
    def notificar(self, e):
        if e.tipo == "pago":
            print(f"Estoque: deduzir itens do pedido {e.pedido_id}")

# Bootstrap
central = CentralEventos()
central.assinar("pago", EnviadorEmailConfirmacao())
central.assinar("pago", AtualizadorEstoque())
central.assinar("pago", RegistroAnalytics())
central.assinar("cancelado", RegistroAnalytics())

# Em algum lugar do fluxo de pagamento
central.publicar(EventoPedido(
    pedido_id="P-001", tipo="pago",
    quando=datetime.now(), dados={"total": 150}
))
# Três ouvintes reagem. Adicionar quarto: assinar, sem mexer no resto.

Cuidados com Observer

9.3 Command

Problema

Você quer tratar "uma ação a ser executada" como objeto: para enfileirar, registrar em log, desfazer, agendar, repetir.

Solução

Encapsule a ação em um objeto com método executar() (e opcionalmente desfazer()). Quem dispara não precisa saber o que vai acontecer; quem executa não precisa saber quem disparou.

command.py
from typing import Protocol
from dataclasses import dataclass
from collections import deque

class Comando(Protocol):
    def executar(self) -> None: ...
    def desfazer(self) -> None: ...

@dataclass
class Documento:
    texto: str = ""

class InserirTexto:
    def __init__(self, doc: Documento, posicao: int, texto: str):
        self._doc = doc
        self._pos = posicao
        self._texto = texto

    def executar(self):
        self._doc.texto = (
            self._doc.texto[:self._pos] +
            self._texto +
            self._doc.texto[self._pos:]
        )

    def desfazer(self):
        self._doc.texto = (
            self._doc.texto[:self._pos] +
            self._doc.texto[self._pos + len(self._texto):]
        )

class RemoverTexto:
    def __init__(self, doc: Documento, inicio: int, fim: int):
        self._doc = doc
        self._inicio = inicio
        self._fim = fim
        self._removido: str = ""

    def executar(self):
        self._removido = self._doc.texto[self._inicio:self._fim]
        self._doc.texto = (
            self._doc.texto[:self._inicio] +
            self._doc.texto[self._fim:]
        )

    def desfazer(self):
        self._doc.texto = (
            self._doc.texto[:self._inicio] +
            self._removido +
            self._doc.texto[self._inicio:]
        )

class HistoricoComandos:
    def __init__(self):
        self._executados: list[Comando] = []
        self._desfeitos: list[Comando] = []

    def executar(self, c: Comando) -> None:
        c.executar()
        self._executados.append(c)
        self._desfeitos.clear()  # nova ação invalida redo

    def desfazer(self) -> bool:
        if not self._executados:
            return False
        c = self._executados.pop()
        c.desfazer()
        self._desfeitos.append(c)
        return True

    def refazer(self) -> bool:
        if not self._desfeitos:
            return False
        c = self._desfeitos.pop()
        c.executar()
        self._executados.append(c)
        return True

# Uso: editor de texto com undo/redo
doc = Documento("Olá")
hist = HistoricoComandos()

hist.executar(InserirTexto(doc, 3, " mundo"))   # "Olá mundo"
hist.executar(InserirTexto(doc, 9, "!"))           # "Olá mundo!"
hist.desfazer()                                       # "Olá mundo"
hist.desfazer()                                       # "Olá"
hist.refazer()                                        # "Olá mundo"

Quando usar

9.4 State

Problema

Um objeto se comporta diferente conforme seu estado interno, e cada estado tem transições válidas distintas. If/elif crescem proporcionalmente ao número de estados × ações.

Solução

Cada estado vira uma classe. O objeto "delega" o comportamento ao estado atual. Mudar de estado é trocar o objeto interno.

Para máquinas pequenas, frequentemente uma Enum + tabela de transições resolve sem precisar do padrão completo. Vimos isso no exercício final do capítulo 5. Para máquinas grandes com comportamento muito diferente por estado, vale a estrutura completa:

state.py
from typing import Protocol

class EstadoConexao(Protocol):
    def conectar(self, ctx: "Conexao") -> None: ...
    def enviar(self, ctx: "Conexao", dados: bytes) -> None: ...
    def desconectar(self, ctx: "Conexao") -> None: ...

class Desconectado:
    def conectar(self, ctx):
        print("Conectando...")
        ctx.transicionar(Conectando())
    def enviar(self, ctx, dados):
        raise RuntimeError("não conectado")
    def desconectar(self, ctx):
        pass  # já está

class Conectando:
    def conectar(self, ctx):
        pass  # já conectando
    def enviar(self, ctx, dados):
        raise RuntimeError("aguarde a conexão")
    def desconectar(self, ctx):
        ctx.transicionar(Desconectado())

class Conectado:
    def conectar(self, ctx):
        pass
    def enviar(self, ctx, dados):
        print(f"Enviando {len(dados)} bytes")
    def desconectar(self, ctx):
        print("Desconectando")
        ctx.transicionar(Desconectado())

class Conexao:
    def __init__(self):
        self._estado: EstadoConexao = Desconectado()

    def transicionar(self, novo: EstadoConexao) -> None:
        self._estado = novo

    def conectar(self): self._estado.conectar(self)
    def enviar(self, d): self._estado.enviar(self, d)
    def desconectar(self): self._estado.desconectar(self)

Quando NÃO usar State complexo

Se sua máquina tem 3-4 estados e cada um tem 1-2 ações distintas, enum + dict de transições é mais simples. State como padrão só vale quando comportamento muda muito por estado e há muitos métodos por estado.

9.5 Iterator — em Python, feature da linguagem

Iterator é um dos padrões que foi absorvido pela linguagem. Em Python você raramente implementa "padrão Iterator" explicitamente — você define __iter__ e __next__ (ou usa yield) e for faz o resto.

iterator.py
from dataclasses import dataclass

@dataclass
class Pagina:
    numero: int
    conteudo: list[str]

class Livro:
    def __init__(self, titulo: str, paginas: list[Pagina]):
        self.titulo = titulo
        self._paginas = paginas

    def __iter__(self):
        # Forma 1: implementar __iter__ retornando iterador próprio
        return iter(self._paginas)

    def linhas(self):
        # Forma 2: generator — muito mais pythônico
        for p in self._paginas:
            for linha in p.conteudo:
                yield linha

livro = Livro("X", [Pagina(1, ["linha A", "linha B"]), Pagina(2, ["linha C"])])

for pagina in livro:                  # usa __iter__
    print(pagina.numero)

for linha in livro.linhas():            # usa generator
    print(linha)

Generators (com yield) são a forma idiomática de implementar Iterator em Python. Lazy, eficientes em memória, e legíveis. Use sempre que possível.

9.6 Template Method

Problema

Você tem um algoritmo cujos passos são fixos em sequência, mas alguns passos variam por subclasse. Quer fixar o "esqueleto" e permitir variação só nos pontos certos.

Solução

Classe base define o método principal com a sequência. Pontos variáveis viram métodos abstratos (ou com implementação padrão). Subclasses sobrescrevem só esses pontos.

template_method.py
from abc import ABC, abstractmethod

class RelatorioBase(ABC):
    def gerar(self) -> str:
        # Template method — esqueleto fixo
        dados = self._buscar_dados()
        dados_filtrados = self._filtrar(dados)
        ordenados = self._ordenar(dados_filtrados)
        return self._formatar(ordenados)

    @abstractmethod
    def _buscar_dados(self) -> list: ...

    def _filtrar(self, dados: list) -> list:
        # Implementação default — subclasse pode sobrescrever se quiser
        return [d for d in dados if d]

    def _ordenar(self, dados: list) -> list:
        return sorted(dados)

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

class RelatorioVendasCSV(RelatorioBase):
    def __init__(self, repo):
        self._repo = repo

    def _buscar_dados(self):
        return self._repo.listar_vendas()

    def _formatar(self, dados):
        return "\n".join(",".join(map(str, d)) for d in dados)

class RelatorioVendasHTML(RelatorioBase):
    def __init__(self, repo):
        self._repo = repo

    def _buscar_dados(self):
        return self._repo.listar_vendas()

    def _formatar(self, dados):
        linhas = "".join(f"<tr><td>{d}</td></tr>" for d in dados)
        return f"<table>{linhas}</table>"

Quando preferir Strategy a Template Method

Template Method usa herança. Strategy usa composição. Em sistemas modernos, prefira Strategy (mais flexível, sem acoplamento por herança). Template Method ainda é útil em frameworks: você herda de uma View de Django e sobrescreve métodos específicos — isso é Template Method em ação.

9.7 Chain of Responsibility

Problema

Você tem uma requisição que pode ser tratada por diferentes handlers, mas não sabe de antemão qual. Quer passar pela cadeia até alguém tratar (ou aplicar transformações sucessivas).

Solução

Cada handler tem referência ao próximo. Recebe requisição, decide se trata, se não passa adiante. É a forma estruturada de middleware.

chain.py
from typing import Protocol, Callable
from dataclasses import dataclass

@dataclass
class Requisicao:
    metodo: str
    path: str
    headers: dict[str, str]
    corpo: bytes
    usuario: str | None = None

class Middleware(Protocol):
    def processar(self, req: Requisicao, proximo: Callable[[Requisicao], str]) -> str: ...

class Autenticacao:
    def processar(self, req, proximo):
        token = req.headers.get("Authorization")
        if not token:
            return "401 Unauthorized"
        req.usuario = self._decodificar(token)
        return proximo(req)

    def _decodificar(self, token):
        return "alice"  # simplificado

class RateLimit:
    def __init__(self, max_por_min: int):
        self._max = max_por_min
        self._contador: dict[str, int] = {}

    def processar(self, req, proximo):
        c = self._contador.get(req.usuario, 0)
        if c >= self._max:
            return "429 Too Many Requests"
        self._contador[req.usuario] = c + 1
        return proximo(req)

class Logging:
    def processar(self, req, proximo):
        print(f">>> {req.metodo} {req.path}")
        resp = proximo(req)
        print(f"<<< {resp}")
        return resp

def criar_cadeia(middlewares: list[Middleware], handler_final: Callable[[Requisicao], str]):
    def cadeia(req):
        atual = handler_final
        for mw in reversed(middlewares):
            anterior = atual
            atual = lambda r, _mw=mw, _next=anterior: _mw.processar(r, _next)
        return atual(req)
    return cadeia

def handler_final(req: Requisicao) -> str:
    return f"200 OK — olá {req.usuario}"

cadeia = criar_cadeia(
    [Logging(), Autenticacao(), RateLimit(100)],
    handler_final,
)

resposta = cadeia(Requisicao(
    metodo="GET", path="/me",
    headers={"Authorization": "Bearer xyz"},
    corpo=b"",
))

Esse é o coração de toda biblioteca de middleware: Django middleware, Express middleware, ASP.NET. Conhecer o padrão te permite entender ferramentas em profundidade.

9.8 Mediator e Visitor — quando você vai precisar

Não vamos cobrir em detalhe, mas vale conhecer pelo nome:

Ambos têm aplicação real, mas em situações específicas. Reconheça quando aparecerem; não force.

9.9 Estudo de caso — sistema de notificações com Observer + Command

Eventos de domínio + processamento assíncrono

Sistema de pedidos onde várias coisas precisam acontecer quando um pedido é pago: notificar cliente, atualizar estoque, registrar em analytics, gerar nota fiscal. Algumas síncronas, outras enfileiradas para processamento posterior.

Passo 1 · Eventos e bus síncrono
eventos.py
from dataclasses import dataclass
from typing import Protocol, Callable
from datetime import datetime
from decimal import Decimal
import logging

logger = logging.getLogger(__name__)

@dataclass(frozen=True)
class EventoDominio:
    pedido_id: str
    quando: datetime

@dataclass(frozen=True)
class PedidoPago(EventoDominio):
    total: Decimal
    forma_pagamento: str

@dataclass(frozen=True)
class PedidoCancelado(EventoDominio):
    motivo: str

Handler = Callable[[EventoDominio], None]

class EventBus:
    def __init__(self):
        self._handlers: dict[type, list[Handler]] = {}

    def assinar(self, tipo: type, handler: Handler) -> None:
        self._handlers.setdefault(tipo, []).append(handler)

    def publicar(self, evento: EventoDominio) -> None:
        for h in self._handlers.get(type(evento), []):
            try:
                h(evento)
            except Exception:
                logger.exception(f"handler {h.__name__} falhou")
Passo 2 · Command queue para processamento diferido
fila_comandos.py
from typing import Protocol
from collections import deque

class Comando(Protocol):
    def executar(self) -> None: ...

class FilaComandos:
    def __init__(self):
        self._fila: deque[Comando] = deque()

    def adicionar(self, c: Comando) -> None:
        self._fila.append(c)

    def processar_proximo(self) -> bool:
        if not self._fila:
            return False
        c = self._fila.popleft()
        try:
            c.executar()
        except Exception:
            logger.exception("comando falhou")
        return True

    def processar_tudo(self) -> None:
        while self.processar_proximo():
            pass

class EnviarEmail:
    def __init(self, destinatario: str, assunto: str, corpo: str):
        self._dest = destinatario
        self._ass = assunto
        self._corpo = corpo
    def executar(self):
        print(f"📧 {self._dest}: {self._ass}")

class GerarNotaFiscal:
    def __init__(self, pedido_id: str):
        self._pid = pedido_id
    def executar(self):
        print(f"📃 Nota fiscal para pedido {self._pid}")
Passo 3 · Composição: Observer dispara Commands
composicao.py
# Bootstrap
bus = EventBus()
fila = FilaComandos()

# Síncrono — precisa acontecer imediatamente
def atualizar_estoque(e: PedidoPago):
    print(f"📦 Estoque atualizado para pedido {e.pedido_id}")

bus.assinar(PedidoPago, atualizar_estoque)

# Assíncrono — vai pra fila pra processar depois
def enfileirar_email(e: PedidoPago):
    fila.adicionar(EnviarEmail(
        destinatario="cliente@x.com",
        assunto=f"Pedido {e.pedido_id} confirmado",
        corpo=f"Total: R$ {e.total}",
    ))

def enfileirar_nota(e: PedidoPago):
    fila.adicionar(GerarNotaFiscal(e.pedido_id))

def registrar_analytics(e: PedidoPago):
    print(f"📊 Analytics: pago R$ {e.total} via {e.forma_pagamento}")

bus.assinar(PedidoPago, enfileirar_email)
bus.assinar(PedidoPago, enfileirar_nota)
bus.assinar(PedidoPago, registrar_analytics)

# Disparado durante o fluxo do pedido
bus.publicar(PedidoPago(
    pedido_id="P-001",
    quando=datetime.now(),
    total=Decimal("150.00"),
    forma_pagamento="cartao",
))

# Processa a fila depois (worker em outra thread, ou no fim do request)
fila.processar_tudo()

O que combinamos: Observer (EventBus) desacopla quem publica de quem reage. Command (na fila) permite processar diferido — em produção real, isso vira mensageria (Kafka, RabbitMQ). A estrutura é a mesma; só muda o transporte.

9.10 Erros comuns

Erro 1 · Observer síncrono em fluxo crítico

Publicar evento e esperar todos os observers retornarem antes de seguir. Um observer lento atrasa todo o fluxo. Em pontos críticos, prefira fila + processamento assíncrono.

Erro 2 · Cadeia de Observer disparando Observer

Observer A dispara evento que dispara Observer B que dispara evento que dispara Observer C... debugar isso é pesadelo. Limite a profundidade; documente.

Erro 3 · Command pesado

Command que guarda referências para objetos grandes ou recursos vivos (conexão, file handle). Comando deveria ser dado serializável idealmente. Guarde IDs, não objetos inteiros.

Erro 4 · State pra qualquer máquina

Máquina com 3 estados e 5 transições não precisa de 4 classes. Enum + dict resolve. Padrão State é para casos onde cada estado tem comportamento muito diferente.

Verifique seu entendimento
"Editor de texto precisa suportar undo/redo de várias operações (inserir, deletar, formatar). Qual padrão é a base natural?"

9.11 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Strategy como função

Crie função aplicar_desconto(carrinho, estrategia) que recebe carrinho e uma estratégia de desconto como função. Implemente três estratégias: sem_desconto, desconto_fixo(valor), desconto_percentual(p).

strategy_func.py
from typing import Callable
from decimal import Decimal

EstrategiaDesconto = Callable[[Decimal], Decimal]

def sem_desconto(total: Decimal) -> Decimal:
    return Decimal("0")

def desconto_fixo(valor: Decimal) -> EstrategiaDesconto:
    def estrategia(total):
        return min(valor, total)
    return estrategia

def desconto_percentual(p: Decimal) -> EstrategiaDesconto:
    def estrategia(total):
        return total * p
    return estrategia

def aplicar_desconto(carrinho_total: Decimal, estrategia: EstrategiaDesconto) -> Decimal:
    return carrinho_total - estrategia(carrinho_total)

# Uso
total = Decimal("200")
aplicar_desconto(total, sem_desconto)                  # 200
aplicar_desconto(total, desconto_fixo(Decimal("50")))   # 150
aplicar_desconto(total, desconto_percentual(Decimal("0.10")))  # 180
Médio
Exercício 2 · EventBus tipado

Implemente EventBus genérico onde handlers são registrados por tipo de evento (não string). Use Generics para que o tipo do evento seja conhecido pelo handler. Bonus: handlers que retornam False param a propagação.

event_bus.py
from typing import Callable, TypeVar, Generic
from dataclasses import dataclass

E = TypeVar("E")

class EventBus:
    def __init__(self):
        self._handlers: dict[type, list[Callable]] = {}

    def assinar(self, tipo: type[E], handler: Callable[[E], bool | None]) -> None:
        self._handlers.setdefault(tipo, []).append(handler)

    def publicar(self, evento: E) -> None:
        for h in self._handlers.get(type(evento), []):
            try:
                resultado = h(evento)
                if resultado is False:
                    return  # para propagação
            except Exception:
                logger.exception(f"handler falhou")

@dataclass(frozen=True)
class UsuarioCriado:
    user_id: str
    email: str

bus = EventBus()

def enviar_boas_vindas(e: UsuarioCriado) -> None:
    print(f"Email para {e.email}")

bus.assinar(UsuarioCriado, enviar_boas_vindas)
bus.publicar(UsuarioCriado("u1", "x@y.com"))
Médio
Exercício 3 · Editor com undo/redo

Implemente Editor com texto, suportando comandos Inserir e Remover. Cada comando tem executar/desfazer. Editor tem aplicar(comando), desfazer(), refazer(). Garanta que após nova ação o histórico de redo é limpo.

editor.py
from typing import Protocol

class ComandoEditor(Protocol):
    def executar(self) -> None: ...
    def desfazer(self) -> None: ...

class Editor:
    def __init__(self):
        self.texto = ""
        self._feitos: list[ComandoEditor] = []
        self._desfeitos: list[ComandoEditor] = []

    def aplicar(self, c: ComandoEditor) -> None:
        c.executar()
        self._feitos.append(c)
        self._desfeitos.clear()

    def desfazer(self) -> bool:
        if not self._feitos:
            return False
        c = self._feitos.pop()
        c.desfazer()
        self._desfeitos.append(c)
        return True

    def refazer(self) -> bool:
        if not self._desfeitos:
            return False
        c = self._desfeitos.pop()
        c.executar()
        self._feitos.append(c)
        return True

class Inserir:
    def __init__(self, editor: Editor, pos: int, texto: str):
        self._ed = editor
        self._pos = pos
        self._texto = texto

    def executar(self):
        t = self._ed.texto
        self._ed.texto = t[:self._pos] + self._texto + t[self._pos:]

    def desfazer(self):
        t = self._ed.texto
        self._ed.texto = t[:self._pos] + t[self._pos + len(self._texto):]

class Remover:
    def __init__(self, editor: Editor, pos: int, n: int):
        self._ed, self._pos, self._n = editor, pos, n
        self._removido = ""

    def executar(self):
        t = self._ed.texto
        self._removido = t[self._pos:self._pos + self._n]
        self._ed.texto = t[:self._pos] + t[self._pos + self._n:]

    def desfazer(self):
        t = self._ed.texto
        self._ed.texto = t[:self._pos] + self._removido + t[self._pos:]
Difícil
Exercício 4 · Pipeline de validação com Chain of Responsibility

Implemente cadeia de validadores para um cadastro: ValidarEmail, ValidarSenha, ValidarCPF, ValidarMaioridade. Cada validador recebe os dados e o próximo validador da cadeia. Coleta TODOS os erros (não para no primeiro). Permite adicionar validadores externamente sem alterar nada.

pipeline_validacao.py
from typing import Protocol, Callable
from dataclasses import dataclass, field
import re

@dataclass
class Contexto:
    dados: dict
    erros: list[str] = field(default_factory=list)

class Validador(Protocol):
    def validar(self, ctx: Contexto, proximo: Callable[[Contexto], None]) -> None: ...

class ValidarEmail:
    def validar(self, ctx, proximo):
        e = ctx.dados.get("email", "")
        if "@" not in e:
            ctx.erros.append("email inválido")
        proximo(ctx)

class ValidarSenha:
    def validar(self, ctx, proximo):
        s = ctx.dados.get("senha", "")
        if len(s) < 8:
            ctx.erros.append("senha curta")
        proximo(ctx)

class ValidarCPF:
    RE = re.compile(r"^\d{11}$")
    def validar(self, ctx, proximo):
        cpf = ctx.dados.get("cpf", "")
        if not self.RE.match(cpf):
            ctx.erros.append("cpf inválido")
        proximo(ctx)

class ValidarMaioridade:
    def validar(self, ctx, proximo):
        idade = ctx.dados.get("idade", 0)
        if idade < 18:
            ctx.erros.append("menor de idade")
        proximo(ctx)

def construir_pipeline(validadores: list[Validador]) -> Callable[[Contexto], None]:
    def fim(ctx):
        pass
    atual = fim
    for v in reversed(validadores):
        anterior = atual
        atual = lambda ctx, _v=v, _next=anterior: _v.validar(ctx, _next)
    return atual

pipeline = construir_pipeline([
    ValidarEmail(), ValidarSenha(), ValidarCPF(), ValidarMaioridade(),
])

ctx = Contexto(dados={"email": "abc", "senha": "123", "cpf": "x", "idade": 15})
pipeline(ctx)
print(ctx.erros)  # todos os erros coletados
Fim do capítulo 9
Próximo capítulo: anti-padrões. Os padrões negativos — estruturas que parecem boas mas degradam o sistema. Fecha a Parte II.
Parte II · Capítulo 10 · Padrões de design

Anti-padrões:
o que não fazer.

Anti-padrões são soluções que parecem boas no momento da escrita, mas degradam o sistema com o tempo. Reconhecê-los é tão importante quanto saber padrões corretos.

Padrões existem porque problemas se repetem; anti-padrões existem porque respostas erradas também se repetem. Conhecê-los pelo nome serve para o mesmo que conhecer padrões: dar à sua equipe vocabulário para apontar problema, sem que cada discussão recomece do zero.

10.1 O que é anti-padrão

Andrew Koenig cunhou o termo "antipattern" em 1995 com definição clara: "uma solução comum para um problema que produz consequências decididamente negativas". Dois elementos cruciais:

Anti-padrões frequentemente são boas práticas levadas ao extremo: "evitar duplicação" vira "abstrair tudo prematuramente"; "ser explícito" vira "expor toda a estrutura interna". A virtude e o vício são gradiente, não opostos.

10.2 God Object (Blob)

O cheiro

Uma classe com 1000+ linhas, 30+ métodos públicos, que sabe sobre quase tudo no sistema. Ela faz validação, persistência, cálculo, formatação, integração externa, envio de e-mail — tudo. Esta classe é o "coração" do sistema, mas é difícil de testar, difícil de modificar, fonte de bugs.

Por que aparece

Geralmente por acúmulo orgânico. Começou pequena. "Vamos só adicionar um método aqui". "Esse cálculo cabe junto". Seis meses depois, é o monstro. Ninguém quer ser o cara que abre o PR que reescreve essa classe.

Por que dói

Como sair

  1. Mapeie as responsabilidades: separe métodos por "motivo de mudança". Crie tabela.
  2. Extraia gradualmente: uma responsabilidade por vez. Mova métodos para classe nova, deixe método-fachada na God Object delegando.
  3. Use testes de caracterização antes de mexer: garantem que comportamento atual não muda.
  4. Apague a fachada quando todos os clientes migrarem.

10.3 Anemic Domain Model

O cheiro

Suas "classes de domínio" são só sacos de dados — apenas atributos e getters/setters. Toda a lógica está em classes "Service" externas que manipulam esses dados. Você tem objetos, mas não tem orientação a objetos.

✗ Anêmico
anemic.py
@dataclass
class Pedido:
    id: str
    itens: list
    status: str
    total: Decimal
    # sem nenhuma operação real

class PedidoService:
    def adicionar_item(self, p, i):
        p.itens.append(i)
        p.total += i.preco
    def fechar(self, p):
        if p.status != "aberto":
            raise ...
        p.status = "fechado"
✓ Modelo rico
rico.py
class Pedido:
    def __init__(self, id: str):
        self._id = id
        self._itens: list[Item] = []
        self._status = "aberto"

    def adicionar_item(self, item: Item):
        self._garantir_aberto()
        self._itens.append(item)

    def fechar(self):
        self._garantir_aberto()
        if not self._itens:
            raise ValueError("vazio")
        self._status = "fechado"

    @property
    def total(self):
        return sum(i.subtotal for i in self._itens)

Quando "anêmico" é OK

DTOs (Data Transfer Objects) — objetos que existem só para transitar dados entre camadas — devem ser anêmicos. dataclass sem método é a forma certa para um payload de API, evento de domínio (que é só fato + dados), ou linha de banco.

O anti-padrão é quando seus agregados de domínio, com regras de negócio, ficam anêmicos. Pedido sem método é o problema; EventoUsuarioCriado sem método não é.

10.4 Cargo Cult Programming

O cheiro

Aplicar padrão, framework, ferramenta ou técnica por imitação, sem entender o que o problema original era. "Vi a Netflix usando" não é razão. "Ouvi em palestra" não é razão. "Está em todo lugar no Stack Overflow" não é razão.

Origem do termo

"Cargo cult" vem de tribos da Melanésia que, após a Segunda Guerra, observaram aviões americanos pousando com mantimentos. Imitaram as ações dos militares — pistas falsas, antenas de bambu, "vestir uniforme" — esperando que aviões aparecessem. As ações funcionavam para os militares por razões que a tribo não viu. Imitar a forma sem entender a função.

Exemplos comuns em software

Como evitar

Antes de adotar qualquer tecnologia ou padrão, pergunte: qual problema específico isso resolve? Eu tenho esse problema? Se a resposta é "não", ou se você não consegue articular o problema, está em risco de cargo cult.

10.5 Magic Numbers e Magic Strings

O cheiro

Valores literais aparecem espalhados pelo código sem explicação. if status == 3. * 1.18. "PENDING_APPROVAL". Quando você lê, não sabe o que significam; quando precisa mudar, tem que caçar todas as ocorrências.

✗ Magia
magic.py
def calcular_preco_final(p):
    if p.status == 3:
        return p.valor * 1.18
    if p.status == 5:
        return p.valor * 0.95
    return p.valor

# O que é 3? E 5? E 1.18 vs 0.95?
# Espalhe isso em 30 arquivos. Boa sorte.
✓ Nomes
nomeado.py
from enum import Enum
from decimal import Decimal

class Status(Enum):
    APROVADO = 3
    DESCONTO_FIDELIDADE = 5

ALIQUOTA_ICMS = Decimal("0.18")
DESCONTO_FIDELIDADE_PERC = Decimal("0.05")

def calcular_preco_final(p):
    if p.status == Status.APROVADO:
        return p.valor * (1 + ALIQUOTA_ICMS)
    if p.status == Status.DESCONTO_FIDELIDADE:
        return p.valor * (1 - DESCONTO_FIDELIDADE_PERC)
    return p.valor

10.6 Shotgun Surgery

O cheiro

Uma mudança de negócio aparentemente simples ("trocar nome de um campo", "adicionar opção a um dropdown") exige editar 12 arquivos espalhados pelo projeto. Cada vez que algo muda em um aspecto do sistema, você sai atirando edições em muitos lugares.

Por que aparece

Falta de coesão. Conceito do domínio que deveria estar concentrado em um lugar está pulverizado por várias classes. Frequentemente sintoma de Anemic Domain Model: a "lógica do pedido" está em DOZE services, não no objeto Pedido.

Como diagnosticar

Métrica simples: olhe os últimos 10 PRs. Quantos arquivos cada um modificou? Se a mediana é alta para mudanças "pequenas", você tem shotgun surgery.

Como sair

Encontre o "conceito" que está espalhado e concentre. Tudo que se relaciona a "imposto" vira um módulo impostos. Tudo que se relaciona a "pedido" vai pro objeto Pedido. Coesão alta > acoplamento baixo, porque coesão te entrega acoplamento baixo "de graça".

10.7 Premature Optimization

"Premature optimization is the root of all evil" — Donald Knuth, 1974.

O cheiro

Código contorcido para "ser rápido", sem que nunca tenha sido medido. Cache em locais aleatórios. Estruturas exóticas onde uma list resolveria. Bit manipulation onde aritmética seria suficiente. Tudo "porque pode ser lento".

O que Knuth realmente disse

A citação completa é importante: "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%."

Em outras palavras: otimize onde mediu importar. Não onde "achou" que importava. 97% do código não precisa ser otimizado; 3% precisa ser muito otimizado. Distinguir os dois exige profile, não intuição.

Como evitar

  1. Escreva código simples e claro primeiro.
  2. Meça performance real do sistema.
  3. Otimize só os pontos quentes identificados.
  4. Meça de novo para confirmar ganho.

Esse processo evita 90% das otimizações ruins. O outro 10% requer experiência em saber, antes de medir, que certas estruturas (estrutura de dados errada, query N+1, alocação em loop quente) vão dar problema em escala. Essa intuição vem com tempo — mas, mesmo com ela, confirme com medição.

10.8 Golden Hammer

"Para quem só tem martelo, todo problema parece prego."

O cheiro

Time aplica a mesma ferramenta/padrão/linguagem a todo problema, mesmo onde outra seria evidentemente melhor. "Vamos usar Spark" para processar arquivo de 1MB. "Vamos usar Kafka" para passar mensagem entre duas funções. "Vamos usar Redux" em um app de uma tela.

Por que aparece

Como evitar

Cultive diversidade de ferramentas. Antes de aplicar a "ferramenta padrão", pergunte: esse problema realmente combina com essa ferramenta? Existe coisa mais simples que resolveria?

10.9 YAGNI violado

"You Aren't Gonna Need It" — XP, anos 90.

O cheiro

Código construído para suportar requisitos que ainda não existem, baseado em "vai precisar". Hierarquias prontas para extensão que nunca aconteceu. Configurabilidade extrema para casos hipotéticos. Abstrações para futuras implementações que nunca foram escritas.

Por que dói

Código "futuro" tem custo real agora: mais complexidade para ler, mais código para manter, mais bugs potenciais. E os futuros requisitos quase sempre são diferentes do que você imaginou, então a estrutura "preparada" não serve.

Regra prática

Rule of three: abstraia quando tiver três casos reais, não dois. Antes disso, código direto. Quando o terceiro caso aparece, você sabe o que abstrair. Antes, é especulação.

10.10 Estudo de caso — saindo de um God Object

Refatoração progressiva de uma classe inchada

Vamos pegar uma God Object real e refatorar passo a passo, sem quebrar o sistema. Inspirada em padrão de refatoração descrito por Martin Fowler.

Estado inicial · 800 linhas
godobject.py (resumo)
class ServicoPedido:  # 800 linhas, 40 métodos
    def criar_pedido(self, ...): ...
    def validar_cliente(self, ...): ...
    def validar_estoque(self, ...): ...
    def calcular_imposto(self, ...): ...
    def calcular_frete_correios(self, ...): ...
    def calcular_frete_transportadora(self, ...): ...
    def aplicar_cupom_primeira_compra(self, ...): ...
    def aplicar_cupom_blackfriday(self, ...): ...
    def enviar_para_gateway(self, ...): ...
    def processar_resposta_gateway(self, ...): ...
    def notificar_email(self, ...): ...
    def notificar_sms(self, ...): ...
    def gerar_nota_fiscal(self, ...): ...
    def salvar_em_banco(self, ...): ...
    def salvar_em_log_auditoria(self, ...): ...
    def registrar_analytics(self, ...): ...
    # ... mais 23 métodos
Passo 1 · Mapear responsabilidades

Agrupe os métodos por "motivo de mudança":

GrupoMétodosMotivo de mudança
Validaçãovalidar_cliente, validar_estoqueRegras de negócio
Fretecalcular_frete_*Mudança em transportadoras
Cuponsaplicar_cupom_*Campanhas de marketing
Gatewayenviar_para_gateway, processar_respostaTroca de gateway
Notificaçãonotificar_*Canais de notificação
Persistênciasalvar_em_*Esquema de banco
Impostocalcular_impostoLegislação fiscal
Passo 2 · Adicionar testes de caracterização

Antes de mexer em qualquer linha, escreva testes que capturam o comportamento atual da classe (mesmo que seja errado). Esses testes te avisam se você quebra algo durante a refatoração.

teste_caracterizacao.py
def test_pedido_normal_calcula_corretamente():
    s = ServicoPedido()
    resultado = s.criar_pedido({...})
    assert resultado.total == Decimal("150.00")
    assert resultado.frete == Decimal("15.00")

def test_pedido_com_cupom_primeira_compra():
    s = ServicoPedido()
    resultado = s.criar_pedido({"cupom": "PRIMEIRA10"})
    assert resultado.desconto == Decimal("15.00")

# 30-40 cenários cobrindo os casos de uso reais.
Passo 3 · Extrair uma classe por vez

Comece pelo grupo mais isolado. Frete parece bom — não depende de muita coisa interna:

extracao_frete.py
# Classe nova
class CalculadoraFrete:
    def correios(self, peso, cep):
        ...  # lógica que estava em ServicoPedido

    def transportadora(self, peso, cep, transp):
        ...

# Na God Object, deixe fachada delegando:
class ServicoPedido:
    def __init__(self):
        self._frete = CalculadoraFrete()

    def calcular_frete_correios(self, peso, cep):
        return self._frete.correios(peso, cep)

# Rode os testes. Tudo passa. Comita. Próximo grupo.
Passo 4 · Migrar clientes

Conforme novas classes ficam estáveis, atualize os clientes da God Object para usarem as classes novas diretamente. A God Object vira casca cada vez mais fina.

cliente_antes_e_depois.py
# Antes
servico = ServicoPedido()
frete = servico.calcular_frete_correios(2.5, "01310-000")

# Depois
frete_calc = CalculadoraFrete()
frete = frete_calc.correios(2.5, "01310-000")
Passo 5 · Apagar a casca

Quando ninguém usa mais o método-fachada da God Object, apague. Repita até a God Object virar uma classe pequena, ou desaparecer.

Lição central: God Object não se mata num PR. Mata-se em iterações, com testes que te protegem, extraindo uma responsabilidade por vez. Cada passo é seguro; cumulativamente a classe encolhe até virar algo manejável.

10.11 Erros comuns ao "consertar" anti-padrões

Erro 1 · Big bang rewrite

Decidir reescrever o sistema inteiro. Quase sempre fracassa: meses de trabalho, sem entregas, e o sistema novo nasce com bugs próprios que ninguém previu. Refatore incrementalmente.

Erro 2 · Sem testes, partir para refatorar

"Vou consertar essa classe rapidinho." Sem testes, você não sabe o que quebrou. Sempre teste antes de mexer.

Erro 3 · Trocar anti-padrão por outro

Substitui God Object por explosão de classes minúsculas. Substitui Magic Numbers por configurabilidade extrema. Cada extremo tem seu próprio anti-padrão.

Erro 4 · Refatorar sem entender o porquê

Mover métodos sem entender as razões pelas quais estão onde estão. Frequentemente há razão histórica (regra de negócio, integração externa). Pergunte ao time antes.

Verifique seu entendimento
"Em um projeto novo (poucos meses), você suspeita que pode precisar trocar de banco PostgreSQL para MongoDB no futuro. Você cria uma camada de abstração agora para 'estar preparado'. Isso é..."

10.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identifique os anti-padrões

Para cada situação, identifique o anti-padrão dominante:

  1. Classe UtilManager com 1.500 linhas que faz validação, cálculo de imposto, envio de e-mail e geração de PDF.
  2. Função tem if codigo == 7: aplicar_taxa(valor * 0.18) espalhada em 12 arquivos.
  3. Time adotou Kubernetes para deploy de uma API com 100 usuários/dia.
  4. Função foi reescrita com bitwise operations para "ser rápida", mas roda 10x por dia em 50 itens.
  5. Mudar nome de um campo do pedido exige editar 15 arquivos.
  6. Classe Cliente com 30 atributos públicos e nenhum método de comportamento; todas as regras estão em ClienteService.
  1. God Object — responsabilidades múltiplas concentradas.
  2. Magic Numbers + Shotgun Surgery — número sem nome espalhado.
  3. Cargo Cult + Golden Hammer — ferramenta de hyperscale em escala pequena.
  4. Premature Optimization — sem perfil que justifique, sem volume real.
  5. Shotgun Surgery — conceito pulverizado.
  6. Anemic Domain Model — saco de dados sem comportamento.
Médio
Exercício 2 · Refatorar Magic Strings

Pegue o código abaixo e elimine Magic Strings/Numbers. Use Enum e constantes nomeadas.

antes.py
def aplicar_regra(usuario, acao):
    if usuario.tipo == "PREMIUM" and acao == "DOWNLOAD":
        if usuario.contador > 100:
            return "BLOCKED"
        return "ALLOW"
    if usuario.tipo == "FREE" and acao == "DOWNLOAD":
        if usuario.contador > 10:
            return "BLOCKED"
        return "ALLOW"
    return "DENIED"
depois.py
from enum import Enum

class TipoUsuario(Enum):
    PREMIUM = "PREMIUM"
    FREE = "FREE"

class Acao(Enum):
    DOWNLOAD = "DOWNLOAD"

class Resultado(Enum):
    ALLOW = "ALLOW"
    BLOCKED = "BLOCKED"
    DENIED = "DENIED"

LIMITE_DOWNLOAD_PREMIUM = 100
LIMITE_DOWNLOAD_FREE = 10

LIMITES = {
    TipoUsuario.PREMIUM: LIMITE_DOWNLOAD_PREMIUM,
    TipoUsuario.FREE: LIMITE_DOWNLOAD_FREE,
}

def aplicar_regra(usuario, acao: Acao) -> Resultado:
    if acao != Acao.DOWNLOAD:
        return Resultado.DENIED
    limite = LIMITES.get(usuario.tipo)
    if limite is None:
        return Resultado.DENIED
    if usuario.contador > limite:
        return Resultado.BLOCKED
    return Resultado.ALLOW
Médio
Exercício 3 · Detectar God Object

Escreva script Python que analisa um arquivo .py e identifica classes com cheiro de God Object. Métricas: classes com mais de N linhas, mais de M métodos públicos, ou mais de K atributos. Use o módulo ast.

detector_god.py
import ast
from dataclasses import dataclass

@dataclass
class RelatorioClasse:
    nome: str
    linhas: int
    metodos_publicos: int
    metodos_privados: int
    atributos: int

    @property
    def suspeita_god(self) -> bool:
        return (
            self.linhas > 300
            or self.metodos_publicos > 15
            or self.atributos > 15
        )

def analisar_arquivo(path: str) -> list[RelatorioClasse]:
    with open(path) as f:
        tree = ast.parse(f.read())

    relatorios = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        metodos_pub = 0
        metodos_priv = 0
        atributos = set()
        for item in node.body:
            if isinstance(item, ast.FunctionDef):
                if item.name.startswith("_"):
                    metodos_priv += 1
                else:
                    metodos_pub += 1
                # procura atribuições self.X
                for sub in ast.walk(item):
                    if (isinstance(sub, ast.Attribute)
                        and isinstance(sub.value, ast.Name)
                        and sub.value.id == "self"):
                        atributos.add(sub.attr)
        relatorios.append(RelatorioClasse(
            nome=node.name,
            linhas=node.end_lineno - node.lineno,
            metodos_publicos=metodos_pub,
            metodos_privados=metodos_priv,
            atributos=len(atributos),
        ))
    return relatorios

# Uso:
for r in analisar_arquivo("meu_modulo.py"):
    if r.suspeita_god:
        print(f"⚠️ {r.nome}: {r.linhas}L, "
              f"{r.metodos_publicos}+{r.metodos_privados} métodos, "
              f"{r.atributos} atributos")
Difícil
Exercício 4 · Refatorar God Object

Refatore esta classe inchada. Identifique pelo menos 3 responsabilidades distintas. Extraia em classes próprias, mantenha fachada delegando para não quebrar clientes. Garanta que a interface pública original ainda funciona.

god.py (entrada)
class GerenciadorRelatorio:
    def buscar_vendas(self, mes):
        conn = psycopg2.connect("...")
        # SQL...
        return dados

    def calcular_totais(self, vendas):
        return {"total": sum(v["valor"] for v in vendas)}

    def formatar_html(self, dados):
        return f"<h1>Total: {dados['total']}</h1>"

    def enviar_email(self, html, destinatario):
        smtp = smtplib.SMTP("...", 587)
        smtp.sendmail("sis@x", destinatario, html)

    def salvar_log(self, mes, dados):
        with open(f"logs/{mes}.json", "w") as f:
            json.dump(dados, f)

    def processar_e_enviar(self, mes, destinatario):
        vendas = self.buscar_vendas(mes)
        totais = self.calcular_totais(vendas)
        html = self.formatar_html(totais)
        self.enviar_email(html, destinatario)
        self.salvar_log(mes, totais)
refatorado.py
from typing import Protocol

# Responsabilidade 1: dados
class RepositorioVendas(Protocol):
    def buscar_por_mes(self, mes) -> list: ...

class RepoVendasPostgres:
    def __init__(self, conn):
        self._conn = conn
    def buscar_por_mes(self, mes):
        # SQL
        return [...]

# Responsabilidade 2: cálculo
class CalculadoraTotais:
    def calcular(self, vendas: list) -> dict:
        return {"total": sum(v["valor"] for v in vendas)}

# Responsabilidade 3: formatação
class FormatadorRelatorio:
    def html(self, dados: dict) -> str:
        return f"<h1>Total: {dados['total']}</h1>"

# Responsabilidade 4: notificação
class ServicoEmail(Protocol):
    def enviar(self, destinatario: str, html: str) -> None: ...

class EmailSMTP:
    def __init__(self, smtp):
        self._smtp = smtp
    def enviar(self, dest, html):
        self._smtp.sendmail("sis@x", dest, html)

# Responsabilidade 5: log
class LogRelatorio:
    def salvar(self, mes: str, dados: dict):
        with open(f"logs/{mes}.json", "w") as f:
            json.dump(dados, f)

# Fachada orquestra
class ServicoRelatorio:
    def __init__(self, repo, calc, fmt, email, log):
        self._repo = repo; self._calc = calc
        self._fmt = fmt; self._email = email
        self._log = log

    def processar_e_enviar(self, mes, destinatario):
        vendas = self._repo.buscar_por_mes(mes)
        totais = self._calc.calcular(vendas)
        html = self._fmt.html(totais)
        self._email.enviar(destinatario, html)
        self._log.salvar(mes, totais)

# Cada parte testável isolada. Trocar canal de e-mail por SMS:
# nova classe, injeta no lugar. Trocar banco: novo repo.
# Antiga interface ainda funciona se você mantiver fachada.
Fim do capítulo 10 · Fim da Parte II
Padrões e anti-padrões cobertos. Próximo é a Parte III — Qualidade e evolução: testes profundos, refatoração disciplinada, code smells avançados, documentação técnica. Peça "continua" para receber.
Parte III
Qualidade e evolução

Código que sobrevive não é código que nasce perfeito — é código que pode ser modificado com segurança. Testes que protegem mudanças, refatoração disciplinada, sensibilidade para cheiros que ainda não viraram bugs, e documentação que continua útil em seis meses.

Testes profundos Refatoração Code smells Documentação
Parte III · Capítulo 11 · Qualidade e evolução

Testes:
como código sobrevive
a mudanças.

Testes não existem para "garantir que o código funciona". Existem para permitir mudança. Sistemas sem testes não são "código que funciona" — são código que ninguém pode mais alterar sem medo.

O propósito dos testes é frequentemente mal entendido. Eles não são certificado de qualidade. Não são prova de ausência de bugs. São rede de segurança para o futuro — o seu, daqui a três meses, ou de quem entrar no time. Sem essa rede, qualquer mudança vira ato de coragem. Com ela, mudança vira operação rotineira.

11.1 A história — de checks ad hoc a TDD

Contexto histórico

Testes automatizados existem desde os anos 60, mas eram raros e ad hoc. Cada engenheiro fazia "seus checks" antes de entregar. Não havia framework, padrão, ou cultura.

Em 1989, Kent Beck, trabalhando em Smalltalk no projeto Tektronix, criou SUnit — primeiro framework de testes unitários reconhecível. A ideia foi portada para Java por Beck e Erich Gamma em 1997 como JUnit. Estava nascido o "xUnit pattern" — replicado em quase toda linguagem (PyTest, RSpec, NUnit, etc).

Em 2002, Beck publicou Test-Driven Development by Example, popularizando TDD: escreva o teste antes do código, faça falhar, escreva o mínimo para passar, refatore. TDD virou religião por uma década, com toda a fricção e crítica que isso implica.

Em paralelo, surgiram outras escolas: BDD (Dan North, 2003) com Gherkin/Cucumber; property-based testing (QuickCheck, Haskell, 1999); snapshot testing (Jest); mutation testing. Hoje, "testes" é um campo amplo, com várias abordagens válidas para problemas diferentes.

Em Python, o framework dominante é pytest, criado por Holger Krekel em 2003, que destronou o unittest nativo por sua simplicidade e poder. É o que vamos usar nos exemplos.

11.2 Para que testes servem (e não servem)

Vamos ser explícitos. Testes servem para:

Testes não servem para:

Frase de Dijkstra (1969)
"Program testing can be used to show the presence of bugs, but never to show their absence." Testes mostram que algo pelo menos funciona — não que tudo funciona. Útil sempre lembrar.

11.3 A pirâmide de testes

Mike Cohn, em Succeeding with Agile (2009), popularizou a metáfora da pirâmide para distribuir tipos de teste:

Por que essa proporção
Testes unitários falham rápido e apontam exatamente onde está o problema. Testes E2E falham lento e dizem "alguma coisa está errada em algum lugar". Você quer a maioria dos bugs sendo pegos pelos primeiros. Inverter a pirâmide (poucos unitários, muitos E2E) é um anti-padrão chamado "ice cream cone" — bugs ficam caros de diagnosticar.

11.4 Estrutura AAA — Arrange, Act, Assert

Todo teste bem escrito tem três fases visíveis:

  1. Arrange: prepara o cenário — cria objetos, configura estado, define inputs.
  2. Act: executa a operação que está sendo testada. Apenas uma.
  3. Assert: verifica o resultado e/ou efeito.
aaa.py
def test_carrinho_calcula_total_com_desconto_progressivo():
    # Arrange — cenário
    carrinho = Carrinho()
    carrinho.adicionar(LinhaCarrinho("SKU1", "Produto", Decimal("100"), 3))

    # Act — ação testada
    total = carrinho.total

    # Assert — resultado esperado
    assert total == Decimal("285.00")  # 300 - 5%

Não force AAA quando não couber — mas a maioria dos testes cabe. Quando estiver difícil de separar as três fases, sinal de que o teste está fazendo coisa demais (ou o código está fazendo coisa demais).

Um assert por teste — quase sempre

Cada teste verifica uma propriedade. Quando você precisa de 5 asserts no mesmo teste, ou:

11.5 Fixtures e setup compartilhado

Em pytest, fixtures são funções decoradas com @pytest.fixture que preparam dependências para testes. Resolvem repetição de setup e tornam testes mais legíveis:

fixtures.py
import pytest
from decimal import Decimal

@pytest.fixture
def carrinho_vazio():
    return Carrinho()

@pytest.fixture
def carrinho_com_itens(carrinho_vazio):  # fixtures compõem
    carrinho_vazio.adicionar(LinhaCarrinho("A", "X", Decimal("10"), 2))
    carrinho_vazio.adicionar(LinhaCarrinho("B", "Y", Decimal("20"), 1))
    return carrinho_vazio

def test_carrinho_vazio_tem_total_zero(carrinho_vazio):
    assert carrinho_vazio.total == Decimal("0")

def test_carrinho_com_itens_calcula_total(carrinho_com_itens):
    assert carrinho_com_itens.total == Decimal("40")

Escopo de fixture

Fixtures podem ter escopos diferentes. Padrão é function (recriada a cada teste). Para coisas caras de criar (conexão de banco, app web), use module ou session:

fixtures_escopo.py
@pytest.fixture(scope="session")
def banco():
    conn = criar_conexao_real()
    yield conn  # entrega para os testes
    conn.close()  # cleanup no final da session

@pytest.fixture
def transacao_isolada(banco):
    tx = banco.begin()
    yield tx
    tx.rollback()  # cada teste roda em transação que é desfeita

11.6 Test doubles — stubs, mocks, fakes, spies

Quando uma unidade testada depende de outra unidade externa (banco, API, e-mail), você precisa substituir a real para isolar o teste. Existem categorias com nomes específicos — cunhadas por Gerard Meszaros em xUnit Test Patterns (2007):

Na prática, a maioria dos times mistura tudo num saco chamado "mock". Saber distinguir ajuda em código mais limpo:

doubles.py
from typing import Protocol
from unittest.mock import MagicMock, call

class RepositorioPedido(Protocol):
    def salvar(self, p) -> None: ...
    def buscar(self, id) -> Pedido | None: ...

# STUB — retorna o que combinamos, sem verificar chamada
class StubRepo:
    def __init__(self, pedido_para_retornar):
        self._p = pedido_para_retornar
    def salvar(self, p): pass
    def buscar(self, id): return self._p

# FAKE — implementação funcional simplificada
class FakeRepo:
    def __init__(self):
        self._dados: dict[str, Pedido] = {}
    def salvar(self, p):
        self._dados[p.id] = p
    def buscar(self, id):
        return self._dados.get(id)

# MOCK — verifica como foi chamado (usando unittest.mock)
def test_servico_salva_pedido_apos_processar():
    repo_mock = MagicMock(spec=RepositorioPedido)
    servico = ServicoPedido(repo=repo_mock)

    pedido = Pedido("P-1")
    servico.processar(pedido)

    repo_mock.salvar.assert_called_once_with(pedido)

# SPY — caseiro
class SpyRepo:
    def __init__(self, real: RepositorioPedido):
        self._real = real
        self.chamadas_salvar: list = []
    def salvar(self, p):
        self.chamadas_salvar.append(p.id)
        return self._real.salvar(p)
    def buscar(self, id):
        return self._real.buscar(id)

Prefira fakes a mocks

Mocks que verificam como a função foi chamada acoplam testes à implementação, não ao comportamento. Refatorar quebra teste mesmo sem mudar o que ele testa. Fakes (implementação real simplificada) testam comportamento — refatoração interna mantém testes verdes. Use mocks quando precisar verificar interação com efeito externo (ex: "API externa foi chamada com argumentos certos"). Para o resto, fakes ganham.

11.7 Parametrização — testes em tabela

Quando você quer testar a mesma lógica com vários inputs, copiar-colar 12 funções é ruim. Pytest tem parametrização:

parametrize.py
import pytest

@pytest.mark.parametrize("entrada,esperado", [
    ("123.456.789-00", True),
    ("12345678900",    True),
    ("111.111.111-11", False),  # todos iguais
    ("123",            False),  # curto demais
    ("abc.def.ghi-jk", False),  # não numérico
    ("",               False),  # vazio
])
def test_validar_cpf(entrada, esperado):
    assert validar_cpf(entrada) == esperado

# Pytest gera 6 testes individuais. Falha individual mostra qual entrada falhou.

# Múltiplas dimensões
@pytest.mark.parametrize("moeda", ["BRL", "USD", "EUR"])
@pytest.mark.parametrize("valor", [0, 1, 100, 9999])
def test_dinheiro_aceita_valores_validos(moeda, valor):
    d = Dinheiro(Decimal(valor), moeda)
    assert d.valor >= 0
# Gera 3 × 4 = 12 testes

Inclua sempre os casos de borda: zero, negativo, vazio, máximo, mínimo, unicode, valores especiais. Bugs vivem nas bordas.

11.8 Property-based testing

Em vez de testar exemplos específicos, descreva propriedades que devem valer para qualquer input válido. A biblioteca gera centenas de inputs aleatórios procurando casos que quebram. Em Python, Hypothesis é o padrão.

hypothesis.py
from hypothesis import given, strategies as st

# Propriedade: ordenar é idempotente
@given(st.lists(st.integers()))
def test_sort_idempotente(lst):
    assert sorted(sorted(lst)) == sorted(lst)

# Propriedade: reverter duas vezes volta ao original
@given(st.lists(st.integers()))
def test_reverse_involucao(lst):
    assert list(reversed(list(reversed(lst)))) == lst

# Propriedade de domínio: soma de itens nunca dá negativo se cada item é positivo
@given(
    st.lists(
        st.builds(
            lambda p, q: LinhaCarrinho("SKU", "N", p, q),
            p=st.decimals(min_value=Decimal("0.01"), max_value=Decimal("1000")),
            q=st.integers(min_value=1, max_value=10),
        ),
        min_size=1, max_size=10,
    )
)
def test_carrinho_total_nunca_negativo(linhas):
    carrinho = Carrinho()
    for l in linhas:
        try:
            carrinho.adicionar(l)
        except ValueError:
            pass  # excede limite — OK
    assert carrinho.subtotal >= 0

Property-based brilha em código de domínio puro (parsers, validadores, cálculos), e em invariantes algébricas. Para testes que envolvem I/O ou efeitos externos, fica complicado.

11.9 TDD — com sobriedade

Test-Driven Development tem ciclo de três passos:

  1. Red: escreva teste que falha (porque o código nem existe).
  2. Green: escreva o mínimo de código para o teste passar.
  3. Refactor: melhore o código mantendo os testes verdes.

Quando TDD funciona bem

Quando TDD atrapalha

TDD foi vendido por anos como solução universal. David Heinemeier Hansson, criador do Rails, publicou em 2014 "TDD is dead. Long live testing", provocando debate que durou anos. A síntese pragmática hoje: TDD é uma técnica, útil em vários cenários, contraproducente em outros. Saber distinguir é mais valioso que dogma.

11.10 Cobertura — uso e abuso

Cobertura mede quanto do código foi executado pelos testes. Útil como diagnóstico — código importante com 0% de cobertura é alerta. Mas tem armadilhas:

Uso saudável
Cobertura como guia: identifique áreas críticas com baixa cobertura, decida se merece teste. Não como meta: "subir para 90%" frequentemente piora qualidade real dos testes. Foque em testar caminhos importantes (felizes e tristes), bordas, e bugs já corrigidos.

11.11 Estudo de caso — testando um sistema de cobrança

Cobertura em camadas com fakes e parametrização

Vamos testar um ServicoCobranca que: valida fatura, aplica desconto, processa pagamento via gateway externo, envia confirmação por e-mail. Cobertura em três níveis: unidades, integração, e2e.

Passo 1 · Código de produção (alvo do teste)
servico_cobranca.py
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol

@dataclass(frozen=True)
class Fatura:
    id: str
    valor: Decimal
    cliente_email: str

class FaturaInvalida(Exception): ...
class PagamentoFalhou(Exception): ...

class Gateway(Protocol):
    def cobrar(self, valor: Decimal) -> str: ...

class EmailService(Protocol):
    def enviar(self, destinatario: str, assunto: str, corpo: str) -> None: ...

class ServicoCobranca:
    def __init__(self, gateway: Gateway, email: EmailService):
        self._gateway = gateway
        self._email = email

    def processar(self, fatura: Fatura, cupom: str | None = None) -> str:
        if fatura.valor <= 0:
            raise FaturaInvalida("valor não-positivo")
        if "@" not in fatura.cliente_email:
            raise FaturaInvalida("email inválido")

        valor_final = self._aplicar_cupom(fatura.valor, cupom)
        try:
            tx_id = self._gateway.cobrar(valor_final)
        except Exception as e:
            raise PagamentoFalhou(str(e)) from e

        self._email.enviar(
            fatura.cliente_email,
            f"Pagamento confirmado #{tx_id}",
            f"Valor cobrado: R$ {valor_final}",
        )
        return tx_id

    def _aplicar_cupom(self, valor: Decimal, cupom: str | None) -> Decimal:
        if cupom == "DESC10":
            return valor * Decimal("0.90")
        if cupom == "DESC20":
            return valor * Decimal("0.80")
        return valor
Passo 2 · Fakes para dependências
fakes.py
from dataclasses import dataclass, field
from decimal import Decimal

@dataclass
class FakeGateway:
    falhar: bool = False
    cobrancas: list[Decimal] = field(default_factory=list)
    proximo_tx_id: str = "tx_001"

    def cobrar(self, valor: Decimal) -> str:
        if self.falhar:
            raise ConnectionError("gateway fora")
        self.cobrancas.append(valor)
        return self.proximo_tx_id

@dataclass
class FakeEmail:
    enviados: list = field(default_factory=list)

    def enviar(self, destinatario, assunto, corpo):
        self.enviados.append({
            "para": destinatario, "assunto": assunto, "corpo": corpo
        })
Passo 3 · Testes de caminho feliz
test_feliz.py
import pytest
from decimal import Decimal

@pytest.fixture
def gateway():
    return FakeGateway()

@pytest.fixture
def email():
    return FakeEmail()

@pytest.fixture
def servico(gateway, email):
    return ServicoCobranca(gateway, email)

@pytest.fixture
def fatura():
    return Fatura("F-1", Decimal("100"), "alice@x.com")

def test_processa_fatura_retorna_tx_id(servico, fatura):
    tx = servico.processar(fatura)
    assert tx == "tx_001"

def test_processa_fatura_cobra_valor_correto(servico, fatura, gateway):
    servico.processar(fatura)
    assert gateway.cobrancas == [Decimal("100")]

def test_processa_fatura_envia_email_confirmacao(servico, fatura, email):
    servico.processar(fatura)
    assert len(email.enviados) == 1
    assert email.enviados[0]["para"] == "alice@x.com"
Passo 4 · Caminhos tristes — validações
test_validacao.py
@pytest.mark.parametrize("valor", [Decimal("0"), Decimal("-1"), Decimal("-100")])
def test_fatura_valor_invalido_lanca_excecao(servico, valor):
    fatura = Fatura("F", valor, "alice@x.com")
    with pytest.raises(FaturaInvalida, match="não-positivo"):
        servico.processar(fatura)

@pytest.mark.parametrize("email_invalido", ["", "sem_arroba", "nada"])
def test_email_invalido_lanca_excecao(servico, email_invalido):
    fatura = Fatura("F", Decimal("100"), email_invalido)
    with pytest.raises(FaturaInvalida, match="email"):
        servico.processar(fatura)

def test_gateway_falha_lanca_pagamento_falhou(servico, fatura, gateway, email):
    gateway.falhar = True
    with pytest.raises(PagamentoFalhou):
        servico.processar(fatura)
    # Não deve ter enviado email se pagamento falhou
    assert email.enviados == []
Passo 5 · Regras de cupom — parametrizadas
test_cupons.py
@pytest.mark.parametrize("cupom,valor_inicial,valor_esperado", [
    (None,     Decimal("100"), Decimal("100")),
    ("DESC10", Decimal("100"), Decimal("90")),
    ("DESC20", Decimal("100"), Decimal("80")),
    ("INEXISTENTE", Decimal("100"), Decimal("100")),
    ("DESC10", Decimal("50"),  Decimal("45")),
])
def test_cupom_aplica_corretamente(
    servico, gateway, cupom, valor_inicial, valor_esperado
):
    fatura = Fatura("F", valor_inicial, "a@b.c")
    servico.processar(fatura, cupom=cupom)
    assert gateway.cobrancas == [valor_esperado]

O que ganhamos: testes pequenos e específicos. Cada um pega uma regra, cada falha aponta exatamente o que quebrou. Fakes em vez de mocks evitam acoplamento de implementação. Parametrização cobre 5 cenários de cupom em uma função. Total: ~15 testes que rodam em milissegundos.

11.12 Erros comuns

Erro 1 · Testar implementação, não comportamento

Mock que verifica "função X foi chamada na ordem Y, com argumento Z, pela função W". Mudou implementação sem mudar comportamento? Teste quebra. Acoplamento ruim entre teste e código.

Erro 2 · Setup gigante e frágil

Cada teste tem 50 linhas de preparação. Quando muda algo, todos quebram. Sintoma de código mal estruturado: muitas dependências para uma unidade pequena. Refatore o código, não duplique o setup.

Erro 3 · Testes não-determinísticos (flakes)

Testes que falham aleatoriamente. Geralmente: dependência de ordem, de tempo (sleep em teste), de relógio do sistema, de aleatoriedade real. Equipe ignora — e perde a confiança no sinal. Investigue e elimine.

Erro 4 · Asserts vazios ou inúteis

assert resultado que só checa truthiness. assert True em catch. Testes que rodam sem realmente verificar nada. Cobertura sobe; valor é zero.

Erro 5 · Esperar 100% sempre

Algumas linhas não pagam o custo do teste (getters triviais, código defensivo para impossíveis). Mais importante: cobrir bem caminhos críticos do que cobrir tudo superficialmente.

11.13 Quando NÃO escrever testes

Reconheça o contexto
Há momentos em que testes têm custo maior que benefício
  • Prototipagem exploratória: você está descobrindo o que o código deve fazer. Teste antes prematuro engessa decisões erradas.
  • Spike de investigação: "será que essa abordagem funciona?". Código será descartado em 2 dias. Não teste.
  • Migrações one-off: script roda uma vez, transforma dados, é deletado. Teste seria 3x o tamanho do código útil.
  • Glue code muito específico: 10 linhas conectando dois sistemas. Frequentemente cobrir por teste de integração geral.
  • UI experimental: componentes que mudam todos os dias. Stabilize antes de testar.

Mas atenção: "código que vai pra produção" raramente cai nessas categorias. A tentação de pular testes é grande; o arrependimento, garantido.

Verifique seu entendimento
"Seu serviço processa pedidos, depende de banco, gateway externo e fila. Você quer testar a lógica de orquestração rapidamente, sem rodar banco real. Qual estratégia?"

11.14 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Teste AAA simples

Escreva um teste pytest para função calcular_subtotal(itens) que soma preco * qtd de cada item (dataclass Item(preco, qtd)). Use estrutura AAA explícita com comentários.

test_subtotal.py
from dataclasses import dataclass
from decimal import Decimal

@dataclass
class Item:
    preco: Decimal
    qtd: int

def calcular_subtotal(itens: list[Item]) -> Decimal:
    return sum((i.preco * i.qtd for i in itens), Decimal("0"))

def test_subtotal_com_multiplos_itens():
    # Arrange
    itens = [
        Item(Decimal("10"), 2),
        Item(Decimal("5"), 3),
    ]

    # Act
    total = calcular_subtotal(itens)

    # Assert
    assert total == Decimal("35")

def test_subtotal_lista_vazia_retorna_zero():
    assert calcular_subtotal([]) == Decimal("0")
Fácil
Exercício 2 · Parametrização

Você tem função classificar_idade(n) que retorna "crianca" (0-12), "adolescente" (13-17), "adulto" (18-59), "idoso" (60+). Escreva teste parametrizado cobrindo casos típicos e bordas.

test_idade.py
import pytest

@pytest.mark.parametrize("idade,esperado", [
    (0,   "crianca"),
    (5,   "crianca"),
    (12,  "crianca"),     # borda superior crianca
    (13,  "adolescente"), # borda inferior adolescente
    (17,  "adolescente"),
    (18,  "adulto"),      # borda
    (30,  "adulto"),
    (59,  "adulto"),
    (60,  "idoso"),       # borda
    (100, "idoso"),
])
def test_classificar_idade(idade, esperado):
    assert classificar_idade(idade) == esperado

@pytest.mark.parametrize("invalida", [-1, -100])
def test_idade_negativa_lanca(invalida):
    with pytest.raises(ValueError):
        classificar_idade(invalida)
Médio
Exercício 3 · Fake para repositório

Implemente FakeRepoUsuario que satisfaz Protocol RepoUsuario com salvar, buscar(id), buscar_por_email, listar_todos. Use dict interno. Teste ServicoUsuario.criar() com esse fake, verificando que usuário foi salvo e que email duplicado lança erro.

fake_repo.py
import pytest
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class Usuario:
    id: str; email: str; nome: str

class EmailJaExiste(Exception): ...

class RepoUsuario(Protocol):
    def salvar(self, u: Usuario) -> None: ...
    def buscar(self, id: str) -> Usuario | None: ...
    def buscar_por_email(self, email: str) -> Usuario | None: ...
    def listar_todos(self) -> list[Usuario]: ...

class FakeRepoUsuario:
    def __init__(self):
        self._dados: dict[str, Usuario] = {}
    def salvar(self, u):
        self._dados[u.id] = u
    def buscar(self, id):
        return self._dados.get(id)
    def buscar_por_email(self, email):
        return next(
            (u for u in self._dados.values() if u.email == email),
            None,
        )
    def listar_todos(self):
        return list(self._dados.values())

class ServicoUsuario:
    def __init__(self, repo: RepoUsuario):
        self._repo = repo
    def criar(self, id, email, nome) -> Usuario:
        if self._repo.buscar_por_email(email):
            raise EmailJaExiste(email)
        u = Usuario(id, email, nome)
        self._repo.salvar(u)
        return u

def test_criar_salva_usuario():
    repo = FakeRepoUsuario()
    servico = ServicoUsuario(repo)
    u = servico.criar("u1", "a@b.c", "Alice")
    assert repo.buscar("u1") == u

def test_criar_email_duplicado_lanca():
    repo = FakeRepoUsuario()
    servico = ServicoUsuario(repo)
    servico.criar("u1", "a@b.c", "Alice")
    with pytest.raises(EmailJaExiste):
        servico.criar("u2", "a@b.c", "Outro")
Médio
Exercício 4 · Property-based simples

Função ordenar(lst) retorna lista ordenada. Use Hypothesis para verificar três propriedades: (1) tamanho da saída igual ao da entrada; (2) saída ordenada não-decrescentemente; (3) saída tem mesmos elementos (Counter igual).

test_ordenar.py
from hypothesis import given, strategies as st
from collections import Counter

def ordenar(lst: list[int]) -> list[int]:
    return sorted(lst)

@given(st.lists(st.integers()))
def test_ordenar_preserva_tamanho(lst):
    assert len(ordenar(lst)) == len(lst)

@given(st.lists(st.integers()))
def test_ordenar_resulta_em_ordem_nao_decrescente(lst):
    resultado = ordenar(lst)
    assert all(resultado[i] <= resultado[i + 1] for i in range(len(resultado) - 1))

@given(st.lists(st.integers()))
def test_ordenar_preserva_elementos(lst):
    assert Counter(ordenar(lst)) == Counter(lst)
Difícil
Exercício 5 · Testando rollback em falha

Sistema processa pedido em 4 etapas: reservar estoque, cobrar cartão, registrar venda, notificar. Se qualquer etapa falha, etapas anteriores devem ser desfeitas. Escreva testes que verificam compensação correta para cada ponto possível de falha.

test_compensacao.py
import pytest
from dataclasses import dataclass, field

@dataclass
class FakeEstoque:
    falhar: bool = False
    reservas: list[str] = field(default_factory=list)
    liberadas: list[str] = field(default_factory=list)
    def reservar(self, pid):
        if self.falhar: raise RuntimeError("estoque fora")
        self.reservas.append(pid)
    def liberar(self, pid):
        self.liberadas.append(pid)

@dataclass
class FakeGateway:
    falhar: bool = False
    cobrancas: list[str] = field(default_factory=list)
    estornadas: list[str] = field(default_factory=list)
    def cobrar(self, pid):
        if self.falhar: raise RuntimeError("gateway fora")
        self.cobrancas.append(pid)
        return f"tx_{pid}"
    def estornar(self, pid):
        self.estornadas.append(pid)

@dataclass
class FakeVendas:
    falhar: bool = False
    registradas: list[str] = field(default_factory=list)
    def registrar(self, pid):
        if self.falhar: raise RuntimeError("db fora")
        self.registradas.append(pid)

class ProcessadorPedido:
    def __init__(self, estoque, gateway, vendas, notif):
        self._estoque, self._gateway = estoque, gateway
        self._vendas, self._notif = vendas, notif

    def processar(self, pid):
        self._estoque.reservar(pid)
        try:
            tx = self._gateway.cobrar(pid)
        except:
            self._estoque.liberar(pid)
            raise
        try:
            self._vendas.registrar(pid)
        except:
            self._gateway.estornar(pid)
            self._estoque.liberar(pid)
            raise
        try:
            self._notif.enviar(pid)
        except:
            # falha de notificação não desfaz — apenas loga
            pass

def test_falha_gateway_libera_estoque():
    estoque, gateway = FakeEstoque(), FakeGateway(falhar=True)
    vendas, notif = FakeVendas(), object()
    p = ProcessadorPedido(estoque, gateway, vendas, notif)
    with pytest.raises(RuntimeError):
        p.processar("P1")
    assert estoque.reservas == ["P1"]
    assert estoque.liberadas == ["P1"]
    assert gateway.cobrancas == []

def test_falha_vendas_estorna_e_libera():
    estoque, gateway = FakeEstoque(), FakeGateway()
    vendas = FakeVendas(falhar=True)
    p = ProcessadorPedido(estoque, gateway, vendas, None)
    with pytest.raises(RuntimeError):
        p.processar("P2")
    assert estoque.liberadas == ["P2"]
    assert gateway.estornadas == ["P2"]
Fim do capítulo 11
Próximo capítulo: refatoração disciplinada. Como modificar código existente sem quebrar comportamento — usando os testes que você acabou de aprender como rede de segurança.
Parte III · Capítulo 12 · Qualidade e evolução

Refatoração:
melhorar código sem
quebrar comportamento.

Refatoração é mudança disciplinada — não improviso, não "limpeza geral". É uma sequência de pequenas transformações verificadas que melhora a estrutura interna preservando o comportamento externo.

A palavra "refatoração" é amplamente mal usada. "Vou refatorar essa parte" frequentemente significa "vou reescrever". São coisas diferentes. Refatoração de verdade tem regra inviolável: comportamento externo não muda. Você melhora a forma sem alterar a função. Com bons testes como rede de segurança, é operação mecânica e segura.

12.1 A história — Opdyke, Fowler e o catálogo

Contexto histórico

O termo "refactoring" surgiu na tese de doutorado de William Opdyke (1992), que formalizou transformações de código preservando comportamento, em Smalltalk. Ele e Ralph Johnson exploraram as bases teóricas — quando uma transformação garantidamente preserva semântica.

Em 1999, Martin Fowler publicou Refactoring: Improving the Design of Existing Code, transformando ideias acadêmicas em prática diária. O livro catalogou ~70 refatorações nomeadas, cada uma com receita passo-a-passo e quando aplicar. Tornou refatoração vocabulário compartilhado da indústria.

A segunda edição (2018), reescrita em JavaScript, atualizou as receitas para linguagens modernas e simplificou o catálogo. Continua sendo a referência canônica.

Em paralelo, IDEs absorveram refatorações automatizadas: "Extract Method" virou atalho de teclado em IntelliJ, PyCharm, VS Code. O que demorava 10 minutos vira 2 segundos — e sem risco de erro mecânico.

12.2 O que é (e o que não é) refatoração

Definição estrita de Fowler: "alteração disciplinada do código existente, melhorando estrutura interna, sem alterar comportamento externo observável". Cada palavra importa:

O que não é refatoração:

Regra das duas mãos
Em um momento você está adicionando feature/corrigindo bug; em outro momento, refatorando. Nunca os dois ao mesmo tempo. Misturar é como dirigir e mexer no GPS: ambas atividades viáveis isoladas, perigosas juntas.

12.3 A rede de segurança — testes

Refatoração sem testes é desejo de fé. Cada transformação pode introduzir bug sutil — variável shadow, ordem de avaliação diferente, side-effect deslocado. Sem teste, você nunca saberá.

A sequência canônica de refatoração:

  1. Garanta que existem testes cobrindo o comportamento da área.
  2. Se não houver, escreva primeiro (testes de caracterização — vamos cobrir).
  3. Aplique uma transformação pequena.
  4. Rode todos os testes. Verde? Comite.
  5. Vermelho? Reverta. Investigue.
  6. Repita.

Cada passo é minúsculo. Cada commit é seguro. Se 30 transformações depois algo der errado, você reverte apenas a última. Comparado à reescrita em PR de 2.000 linhas, é incomparavelmente mais seguro.

12.4 Catálogo essencial

Fowler catalogou ~70 refatorações. Você não precisa decorar. Mas vale dominar bem as 10-15 mais comuns. Vamos cobrir as essenciais.

12.5 Extract Method

Quando aplicar

Você tem trecho de código dentro de uma função maior que pode ser entendido com um nome. Comentário que explica "o que esse bloco faz" é sinal claro — o comentário pode virar o nome da nova função.

Antes
antes.py
def imprimir_dados(invoice):
    # cabecalho
    print("===========================")
    print(f"Nome: {invoice.customer}")
    print(f"Data: {invoice.date}")
    print("===========================")

    # calcular total
    total = 0
    for item in invoice.itens:
        total += item.preco * item.qtd

    # imprimir detalhes
    for item in invoice.itens:
        print(f"  {item.nome}: {item.preco}x{item.qtd}")
    print(f"Total: {total}")
Depois
depois.py
def imprimir_dados(invoice):
    _imprimir_cabecalho(invoice)
    total = _calcular_total(invoice.itens)
    _imprimir_detalhes(invoice.itens, total)

def _imprimir_cabecalho(invoice):
    print("===========================")
    print(f"Nome: {invoice.customer}")
    print(f"Data: {invoice.date}")
    print("===========================")

def _calcular_total(itens):
    return sum(i.preco * i.qtd for i in itens)

def _imprimir_detalhes(itens, total):
    for i in itens:
        print(f"  {i.nome}: {i.preco}x{i.qtd}")
    print(f"Total: {total}")

Receita mecânica

  1. Crie nova função com nome descritivo.
  2. Copie o trecho para dentro dela.
  3. Identifique variáveis locais usadas: viram parâmetros.
  4. Identifique variáveis modificadas no trecho que são usadas depois: viram retornos.
  5. Substitua o trecho original pela chamada.
  6. Rode os testes.

12.6 Rename

Renomear bem é metade do design. Nomes ruins envenenam código por anos. Boas IDEs fazem rename automatizado, garantindo que todos os usos são atualizados.

Nomes que indicam refatoração:

rename.py
# Antes
def calc(d, t):
    r = []
    for x in d:
        if x.t == t:
            r.append(x)
    return r

# Depois
def filtrar_por_tipo(itens, tipo):
    return [item for item in itens if item.tipo == tipo]

Não foi só sintaxe que ficou melhor: o propósito da função agora é claro pela assinatura.

12.7 Extract Class

Quando aplicar

Uma classe está fazendo trabalho de duas. Conjunto de campos e métodos relacionados pode ser tirado para classe própria.

extract_class.py
# Antes — Pessoa carrega informação de telefone
class Pessoa:
    def __init__(self, nome, codigo_pais, ddd, numero):
        self.nome = nome
        self.codigo_pais = codigo_pais
        self.ddd = ddd
        self.numero = numero

    def telefone_formatado(self):
        return f"+{self.codigo_pais} ({self.ddd}) {self.numero}"

# Depois — Telefone vira value object próprio
@dataclass(frozen=True)
class Telefone:
    codigo_pais: str
    ddd: str
    numero: str

    def formatado(self):
        return f"+{self.codigo_pais} ({self.ddd}) {self.numero}"

class Pessoa:
    def __init__(self, nome: str, telefone: Telefone):
        self.nome = nome
        self.telefone = telefone

Receita

  1. Crie nova classe vazia.
  2. Mova campos relacionados para ela.
  3. Mova métodos que operam apenas nesses campos.
  4. Na classe original, mantenha referência à nova como atributo composto.
  5. Use delegation temporariamente: métodos antigos delegam para a nova classe.
  6. Atualize clientes gradualmente; remova delegations quando ninguém mais precisar.

12.8 Move Method / Move Field

Um método "pertence" mais a outra classe que à onde está atualmente. Se ele usa mais dados de B que de A (sua classe atual), provavelmente deveria estar em B (princípio "Feature Envy" — code smell que veremos no próximo capítulo).

move_method.py
# Antes — Pedido tem método que usa só dados de Cliente
class Pedido:
    def __init__(self, cliente, itens):
        self.cliente = cliente
        self.itens = itens

    def desconto_por_fidelidade(self):
        # só usa cliente, não usa itens
        if self.cliente.anos_cadastro > 5:
            return Decimal("0.10")
        if self.cliente.anos_cadastro > 2:
            return Decimal("0.05")
        return Decimal("0")

# Depois — método movido para Cliente
class Cliente:
    def __init__(self, nome, anos_cadastro):
        self.nome = nome
        self.anos_cadastro = anos_cadastro

    def desconto_fidelidade(self):
        if self.anos_cadastro > 5: return Decimal("0.10")
        if self.anos_cadastro > 2: return Decimal("0.05")
        return Decimal("0")

class Pedido:
    def __init__(self, cliente, itens):
        self.cliente = cliente
        self.itens = itens
    # Pedido pede via cliente quando precisar:
    # pedido.cliente.desconto_fidelidade()

12.9 Replace Conditional with Polymorphism

Já vimos essa em ação no capítulo 3 (OCP). Estrutura if/elif baseada em tipo vira hierarquia de classes ou Strategy. Vou repetir aqui porque, vista no contexto de refatoração, mostra como aplicar mecanicamente:

Receita

  1. Identifique a "variável de tipo" (string, enum, etc) que dirige o condicional.
  2. Crie interface (Protocol) com método para o comportamento variável.
  3. Para cada caso do condicional, crie classe que implementa.
  4. Substitua o condicional por um dispatch: dict mapping tipo → instância.
  5. Mova lógica de cada ramo para o método respectivo na classe.
  6. Rode testes a cada passo.

12.10 Inline — o oposto de Extract

Nem toda extração faz sentido. Função com nome aplicar_desconto(valor) que tem corpo return valor * 0.9 talvez seja indireção desnecessária. Inline: traga o corpo de volta para o caller.

inline.py
# Antes — abstração desnecessária
def _eh_aprovado(status):
    return status == "aprovado"

def processar(pedido):
    if _eh_aprovado(pedido.status):
        ...

# Depois
def processar(pedido):
    if pedido.status == "aprovado":
        ...

Refatoração é dinâmica: às vezes você extrai, às vezes faz inline. Não é "decompor sempre". É melhorar a clareza, que depende do contexto.

12.11 Quando refatorar

Há três momentos canônicos:

Refatoração "por refatoração" — sentar 4 horas só para limpar — também tem lugar, mas é mais arriscada. Sem objetivo concreto, fica fácil "melhorar" coisas que não estão atrapalhando, gastando energia que poderia ir para problema real.

12.12 Quando NÃO refatorar

Reconheça o contexto
Há momentos em que tocar no código é piorar a situação
  • Sem testes e área crítica: escreva testes primeiro. Refatorar às cegas é só esperança.
  • Código que será descartado em semanas: migração, MVP que será reescrito. Não invista.
  • Sob pressão de prazo: não inicie refatoração grande na sexta antes do release. Adia.
  • Sem entender o código: primeiro entenda. Refatorar o que você não compreende é roleta russa.
  • Quando o "ruim" já é estável: "feio mas funciona, raramente mexido". Frequentemente melhor deixar em paz.

Regra de Chesterton: antes de remover uma cerca, descubra por que ela foi colocada lá. Códigos estranhos frequentemente têm razões esquecidas.

12.13 Testes de caracterização

Você precisa refatorar área sem testes. Pré-requisito: criar testes que caracterizem o comportamento atual — mesmo que seja errado. Isso te dá rede de segurança.

caracterizacao.py
# Função legada que você precisa refatorar
def calc_preco_legado(itens, cliente_tipo, mes):
    total = 0
    for i in itens:
        p = i["preco"]
        if mes == 12:
            p *= 0.9
        if cliente_tipo == "PREMIUM":
            p *= 0.95
        total += p * i["qtd"]
    return total

# Não tem teste. Antes de mexer, caracterize:
def test_caracterizacao_cliente_normal_mes_qualquer():
    itens = [{"preco": 100, "qtd": 2}, {"preco": 50, "qtd": 1}]
    assert calc_preco_legado(itens, "NORMAL", 6) == 250

def test_caracterizacao_premium_aplica_5_pct():
    itens = [{"preco": 100, "qtd": 1}]
    assert calc_preco_legado(itens, "PREMIUM", 6) == 95

def test_caracterizacao_dezembro_aplica_10_pct():
    itens = [{"preco": 100, "qtd": 1}]
    assert calc_preco_legado(itens, "NORMAL", 12) == 90

def test_caracterizacao_premium_dezembro_compoe_descontos():
    itens = [{"preco": 100, "qtd": 1}]
    assert calc_preco_legado(itens, "PREMIUM", 12) == 85.5
    # 100 * 0.9 * 0.95 = 85.5

# Agora, refatore com segurança — esses testes te avisam se quebrar algo.

Atenção: testes de caracterização capturam o comportamento atual, mesmo bugs. Se durante a refatoração você descobrir que calc_preco_legado tem bug — tipo aplicar desconto a item que não devia — não conserte na refatoração. Conserte separadamente, com seu próprio commit, alterando o teste explicitamente. Isso mantém a regra: refatoração não muda comportamento.

12.14 Estudo de caso — refatorando uma função pesada

De 80 linhas com if aninhado para 5 classes legíveis

Função real, simplificada: cálculo de tarifa de remessa. Cresceu organicamente. Tem testes (vamos garantir que continuem passando).

Estado inicial
tarifa_inicial.py
def calcular_tarifa(peso, distancia, modal, urgencia, cliente_tipo):
    if modal == "AEREO":
        base = 25 + peso * 8
        if distancia > 1000:
            base += (distancia - 1000) * 0.05
    elif modal == "RODOVIARIO":
        base = 10 + peso * 2
        base += distancia * 0.10
    elif modal == "MARITIMO":
        base = 5 + peso * 0.5
    else:
        raise ValueError(f"modal {modal} inválido")

    if urgencia == "EXPRESSO":
        base *= 1.5
    elif urgencia == "SEDEX":
        base *= 1.2

    if cliente_tipo == "PREMIUM":
        base *= 0.95
    elif cliente_tipo == "PARCEIRO":
        base *= 0.85

    return base
Passo 1 · Extract Method — separar fases

Primeiro, três cálculos viram funções:

tarifa_v1.py
def calcular_tarifa(peso, distancia, modal, urgencia, cliente_tipo):
    base = _base_por_modal(peso, distancia, modal)
    base = _ajustar_urgencia(base, urgencia)
    base = _ajustar_cliente(base, cliente_tipo)
    return base

def _base_por_modal(peso, distancia, modal):
    if modal == "AEREO":
        base = 25 + peso * 8
        if distancia > 1000:
            base += (distancia - 1000) * 0.05
        return base
    if modal == "RODOVIARIO":
        return 10 + peso * 2 + distancia * 0.10
    if modal == "MARITIMO":
        return 5 + peso * 0.5
    raise ValueError(f"modal {modal} inválido")

def _ajustar_urgencia(base, urgencia):
    if urgencia == "EXPRESSO": return base * 1.5
    if urgencia == "SEDEX":    return base * 1.2
    return base

def _ajustar_cliente(base, cliente_tipo):
    if cliente_tipo == "PREMIUM":  return base * 0.95
    if cliente_tipo == "PARCEIRO": return base * 0.85
    return base

# Rode os testes. Verde? Comita. Vamos pro próximo passo.
Passo 2 · Replace Conditional with Polymorphism — para modais
tarifa_v2.py
from typing import Protocol
from decimal import Decimal

class CalculoModal(Protocol):
    def calcular(self, peso: Decimal, distancia: Decimal) -> Decimal: ...

class ModalAereo:
    def calcular(self, peso, distancia):
        base = Decimal("25") + peso * Decimal("8")
        if distancia > 1000:
            base += (distancia - 1000) * Decimal("0.05")
        return base

class ModalRodoviario:
    def calcular(self, peso, distancia):
        return Decimal("10") + peso * Decimal("2") + distancia * Decimal("0.10")

class ModalMaritimo:
    def calcular(self, peso, distancia):
        return Decimal("5") + peso * Decimal("0.5")

MODAIS: dict[str, CalculoModal] = {
    "AEREO": ModalAereo(),
    "RODOVIARIO": ModalRodoviario(),
    "MARITIMO": ModalMaritimo(),
}

def _base_por_modal(peso, distancia, modal):
    impl = MODAIS.get(modal)
    if not impl:
        raise ValueError(f"modal {modal} inválido")
    return impl.calcular(peso, distancia)
Passo 3 · Mesma técnica para urgência e cliente

Repete o padrão. Cada conjunto de regras vira polimorfismo. O código original — função de 25 linhas — vira composição de classes claras:

tarifa_final.py
from dataclasses import dataclass

@dataclass(frozen=True)
class FreteParams:
    peso: Decimal
    distancia: Decimal
    modal: str
    urgencia: str
    cliente_tipo: str

class CalculadoraTarifa:
    def __init__(self, modais, urgencias, descontos):
        self._modais = modais
        self._urgencias = urgencias
        self._descontos = descontos

    def calcular(self, params: FreteParams) -> Decimal:
        base = self._modais[params.modal].calcular(params.peso, params.distancia)
        base = self._urgencias.get(params.urgencia, IdentityMod()).aplicar(base)
        base = self._descontos.get(params.cliente_tipo, IdentityMod()).aplicar(base)
        return base

# Adicionar novo modal/urgência/cliente: registrar no dict.
# Cada peça testável isolada.
# Comportamento externo idêntico — todos os testes originais passam.

Lição central: a refatoração foi feita em passos pequenos, cada um verificado pelos testes. Em momento algum o sistema ficou quebrado. Cada commit é seguro de reverter individualmente. Esse é o método.

12.15 Erros comuns

Erro 1 · Refatorar e adicionar feature no mesmo commit

Combinação tóxica. Quando algo quebra, você não sabe se foi a refatoração ou a feature. Mantenha separados — separe inclusive em commits diferentes.

Erro 2 · Refatorar sem testes

Se a área não tem testes, escreva testes de caracterização primeiro. Refatoração sem rede é só sorte.

Erro 3 · Passos grandes demais

"Vou só reorganizar essas 5 classes". 4 horas depois, nada compila. Cada passo deve ser minúsculo e testável. Se demorou mais que 5 minutos sem rodar testes, você foi longe demais.

Erro 4 · Consertar bug durante refatoração

"Vi que tem um bug aqui, já corrijo". Pare. Comite a refatoração até onde estiver. Faça o conserto separado, com seu próprio teste.

Erro 5 · Refatoração especulativa

"Vou abstrair isso porque pode vir requisito X". Não. Refatore para resolver problema real, atual. Especulação cria estrutura para necessidades que nunca chegam.

Verifique seu entendimento
"Você precisa adicionar nova regra de negócio numa função que é confusa e sem testes. Qual é a ordem certa?"

12.16 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Extract Method

Aplique Extract Method na função abaixo, identificando 3 sub-responsabilidades. Cada uma vira função com nome descritivo.

antes.py
def processar_compra(carrinho, cliente):
    # validar carrinho
    if not carrinho.itens:
        raise ValueError("carrinho vazio")
    if sum(i.qtd for i in carrinho.itens) > 100:
        raise ValueError("limite excedido")

    # calcular total com taxas
    subtotal = sum(i.preco * i.qtd for i in carrinho.itens)
    impostos = subtotal * Decimal("0.18")
    frete = Decimal("15") if subtotal < 100 else Decimal("0")
    total = subtotal + impostos + frete

    # notificar cliente
    if cliente.email:
        enviar_email(cliente.email, f"Total: {total}")
    if cliente.telefone:
        enviar_sms(cliente.telefone, f"Pedido R$ {total}")

    return total
depois.py
def processar_compra(carrinho, cliente):
    _validar_carrinho(carrinho)
    total = _calcular_total(carrinho)
    _notificar_cliente(cliente, total)
    return total

def _validar_carrinho(carrinho):
    if not carrinho.itens:
        raise ValueError("carrinho vazio")
    if sum(i.qtd for i in carrinho.itens) > 100:
        raise ValueError("limite excedido")

def _calcular_total(carrinho):
    subtotal = sum(i.preco * i.qtd for i in carrinho.itens)
    impostos = subtotal * Decimal("0.18")
    frete = Decimal("15") if subtotal < 100 else Decimal("0")
    return subtotal + impostos + frete

def _notificar_cliente(cliente, total):
    if cliente.email:
        enviar_email(cliente.email, f"Total: {total}")
    if cliente.telefone:
        enviar_sms(cliente.telefone, f"Pedido R$ {total}")
Médio
Exercício 2 · Replace Conditional with Polymorphism

Função abaixo decide tipo de relatório por string. Refatore para polimorfismo (Strategy). Mantenha mesmo comportamento externo. Demonstre os passos pequenos: cada passo, qual mudança e qual teste valida.

antes.py
def gerar_relatorio(dados, formato):
    if formato == "pdf":
        cabec = b"%PDF-1.4\n"
        corpo = b"".join(str(d).encode() for d in dados)
        return cabec + corpo
    elif formato == "csv":
        return "\n".join(",".join(str(v) for v in d) for d in dados).encode()
    elif formato == "json":
        import json
        return json.dumps(dados).encode()
    raise ValueError(f"formato {formato} desconhecido")
depois.py
from typing import Protocol
import json

class Formato(Protocol):
    def gerar(self, dados: list) -> bytes: ...

class FormatoPDF:
    def gerar(self, dados):
        cabec = b"%PDF-1.4\n"
        corpo = b"".join(str(d).encode() for d in dados)
        return cabec + corpo

class FormatoCSV:
    def gerar(self, dados):
        return "\n".join(",".join(str(v) for v in d) for d in dados).encode()

class FormatoJSON:
    def gerar(self, dados):
        return json.dumps(dados).encode()

FORMATOS: dict[str, Formato] = {
    "pdf": FormatoPDF(),
    "csv": FormatoCSV(),
    "json": FormatoJSON(),
}

def gerar_relatorio(dados, formato):
    impl = FORMATOS.get(formato)
    if not impl:
        raise ValueError(f"formato {formato} desconhecido")
    return impl.gerar(dados)

# Sequência de passos:
# 1. Extract Method para cada caso do if (testes passam)
# 2. Criar Protocol Formato e classe para PDF (testes passam)
# 3. Atualizar caso "pdf" no if para usar a classe (testes passam)
# 4. Repete para CSV e JSON
# 5. Substitui if/elif por lookup no dict
# Comportamento externo idêntico em todos os passos.
Médio
Exercício 3 · Testes de caracterização para função obscura

Você tem a função abaixo, sem testes. Escreva 5+ testes de caracterização que capturem comportamento atual antes de refatorar. Inclua casos de borda.

obscura.py
def formatar_telefone(s):
    s = "".join(c for c in str(s) if c.isdigit())
    if len(s) == 11:
        return f"({s[0:2]}) {s[2:7]}-{s[7:]}"
    if len(s) == 10:
        return f"({s[0:2]}) {s[2:6]}-{s[6:]}"
    if len(s) == 8 or len(s) == 9:
        return s
    return ""
test_obscura.py
import pytest

@pytest.mark.parametrize("entrada,saida", [
    # Casos típicos
    ("11987654321",         "(11) 98765-4321"),  # celular SP
    ("1133334444",          "(11) 3333-4444"),   # fixo SP

    # Com caracteres não-numéricos
    ("(11) 98765-4321",     "(11) 98765-4321"),
    ("11-9876-54321",       "(11) 98765-4321"),
    ("11.3333.4444",        "(11) 3333-4444"),

    # Locais curtos (sem DDD)
    ("33334444",            "33334444"),
    ("987654321",           "987654321"),

    # Inválidos
    ("",                    ""),
    ("abc",                 ""),
    ("123",                 ""),
    ("123456789012",        ""),  # 12 dígitos

    # Borda — número passado como int
    (11987654321,           "(11) 98765-4321"),
])
def test_caracterizacao_formatar_telefone(entrada, saida):
    assert formatar_telefone(entrada) == saida

# Agora pode refatorar com segurança. Por exemplo:
# - Extrair regex para constante
# - Separar "extrair dígitos" de "formatar"
# - Usar match/case do Python 3.10
# Cada passo: testes verdes → comita.
Difícil
Exercício 4 · Refatoração em passos pequenos

Pegue a função abaixo, escreva testes que cubram comportamento, e refatore em ao menos 4 passos pequenos. Documente cada passo. O objetivo: separar 3 responsabilidades distintas e eliminar magic numbers.

antes.py
def processar_arquivo(path, max_bytes):
    # Lê arquivo
    with open(path, "rb") as f:
        dados = f.read()
    if len(dados) > max_bytes:
        raise ValueError(f"arquivo > {max_bytes}")
    if len(dados) < 10:
        raise ValueError("arquivo pequeno demais")

    # Faz checksum
    import hashlib
    h = hashlib.sha256()
    h.update(dados)
    digest = h.hexdigest()

    # Salva metadados
    with open(f"{path}.meta", "w") as f:
        f.write(f"size={len(dados)}\n")
        f.write(f"sha256={digest}\n")

    return digest
passos.py
# Passo 0: testes de caracterização cobrindo:
# - arquivo válido retorna sha256 correto
# - arquivo > max lança erro
# - arquivo < 10 bytes lança erro
# - arquivo .meta é criado com conteúdo correto

# Passo 1: Extract Method — separar leitura, hash, persistência
def _ler_arquivo(path, max_bytes, min_bytes):
    with open(path, "rb") as f:
        dados = f.read()
    if len(dados) > max_bytes:
        raise ValueError(f"arquivo > {max_bytes}")
    if len(dados) < min_bytes:
        raise ValueError("arquivo pequeno demais")
    return dados

def _calcular_sha256(dados):
    import hashlib
    h = hashlib.sha256()
    h.update(dados)
    return h.hexdigest()

def _salvar_metadados(path, tamanho, digest):
    with open(f"{path}.meta", "w") as f:
        f.write(f"size={tamanho}\n")
        f.write(f"sha256={digest}\n")

def processar_arquivo(path, max_bytes):
    dados = _ler_arquivo(path, max_bytes, 10)
    digest = _calcular_sha256(dados)
    _salvar_metadados(path, len(dados), digest)
    return digest
# Rode testes. Verde? Comita.

# Passo 2: Eliminar magic number 10 com constante nomeada
TAMANHO_MIN_ARQUIVO = 10

def processar_arquivo(path, max_bytes):
    dados = _ler_arquivo(path, max_bytes, TAMANHO_MIN_ARQUIVO)
    digest = _calcular_sha256(dados)
    _salvar_metadados(path, len(dados), digest)
    return digest
# Rode testes. Verde? Comita.

# Passo 3: Tornar configurável e tipado
from dataclasses import dataclass

@dataclass(frozen=True)
class LimitesArquivo:
    max_bytes: int
    min_bytes: int = 10

def processar_arquivo(path: str, limites: LimitesArquivo) -> str:
    dados = _ler_arquivo(path, limites.max_bytes, limites.min_bytes)
    digest = _calcular_sha256(dados)
    _salvar_metadados(path, len(dados), digest)
    return digest

# OBS: Esse último passo MUDA assinatura externa — alerta os clientes.
# Em refatoração pura, manteria a antiga e adicionaria nova.
# Aqui o teste de caracterização atualizado avisa quem usa.
Fim do capítulo 12
Próximo capítulo: code smells. Os sinais que indicam onde refatoração vai pagar — sintomas que precedem dor.
Parte III
Qualidade e evolução

O que separa código que sobrevive ao tempo de código que apodrece em meses. Testes de verdade, refatoração disciplinada, leitura de cheiros, documentação que importa. A parte que distingue programador competente de profissional sênior.

Testes profundos Refatoração disciplinada Code smells avançados Documentação técnica
Parte III · Capítulo 11 · Qualidade e evolução

Testes:
do cosmético
ao essencial.

Testes não existem para "garantir que o código funciona". Existem para te permitir mudar o código sem medo. Sem testes, refatoração vira jogo de azar.

A maioria dos times tem testes. Poucos têm bons testes. A diferença entre os dois define se você consegue evoluir um sistema durante anos ou se você fica preso em "qualquer mexida quebra tudo". Este capítulo cobre o que de fato importa: pirâmide certa, AAA, fixtures, mocks usados com critério, property-based, TDD sem dogma, e o que cobertura realmente diz (e não diz).

11.1 A história — de Beck ao xUnit moderno

Contexto histórico

Em 1989, Kent Beck escreveu o primeiro framework de teste automatizado, em Smalltalk, chamado SUnit. A ideia era simples e revolucionária: testes deveriam ser código, executáveis a qualquer momento, e idealmente antes da implementação. SUnit virou JUnit em 1997 (Beck + Erich Gamma), e dali o modelo se espalhou — NUnit, PyUnit, RSpec — formando a família "xUnit".

Em 1999, no livro Extreme Programming Explained, Beck cunhou TDD (Test-Driven Development) como prática estruturada: escreva o teste primeiro, veja falhar, implemente o mínimo para passar, refatore. A indústria abraçou com fervor — e, como sempre, com excessos. Por anos, "100% de cobertura" foi tratado como troféu sem questionamento.

Hoje, com mais maturidade, sabemos: testes são essenciais, mas nem todo teste vale a pena, cobertura alta não garante qualidade, e TDD não é dogma. Vamos entender o que separa testes que ajudam de testes que atrapalham.

11.2 A pirâmide — onde investir energia

Mike Cohn, no livro Succeeding with Agile (2009), popularizou a "test pyramid" — a ideia de que testes devem se distribuir em uma pirâmide:

E2E · Topo
Poucos. Lentos. Caros. Validam fluxo completo. 5-10% do total.
Integração · Meio
Médios. Testam módulos interagindo (DB, fila, API). 15-30%.
Unitários · Base
Muitos. Rápidos. Isolados. Testam classes/funções. 60-80%.

A intuição é clara: testes unitários rodam em milissegundos, podem ser milhares, te dão feedback rápido sobre lógica. Integração valida que peças conversam direito. E2E captura o que só aparece no fluxo completo (UI, rede, sistemas externos).

Inverter a pirâmide — fazer principalmente E2E — é tentação clássica: "meu E2E cobre tudo!". Mas E2E são lentos, frágeis (qualquer detalhe de UI quebra), caros de manter, e quando falham raramente apontam onde está o bug. Você gasta o dobro do tempo debugando.

Sinal de pirâmide invertida

CI demora 25 minutos. Time evita rodar testes localmente. Falhas frequentes em CI sem mudança aparente. Testes E2E geram noise tão alto que time normaliza red builds. Se você reconhece três desses, está com pirâmide invertida.

11.3 AAA e nomes que importam

Todo teste tem três fases. Quando você as separa explicitamente, o teste vira documentação executável:

aaa.py
def test_pedido_fechado_nao_aceita_novo_item():
    # Arrange
    pedido = Pedido()
    pedido.adicionar_item(Item("SKU1", 10, 1))
    pedido.fechar()
    item_novo = Item("SKU2", 5, 1)

    # Act + Assert (operação única, validação inline)
    with pytest.raises(PedidoJaFechado):
        pedido.adicionar_item(item_novo)

O nome do teste é parte do design

Nome de teste deve descrever o que está sendo verificado, não como. Compare:

✗ Nome ruim
test_metodo_1()
test_adicionar_item()
test_pedido()
test_funciona()

Não diz o que verifica. Quando falha, você precisa ler o código do teste para entender.

✓ Nome descritivo
test_pedido_fechado_nao_aceita_novo_item()
test_total_inclui_imposto_e_frete()
test_cupom_expirado_levanta_excecao()
test_busca_com_filtro_vazio_retorna_todos()

Cada nome conta uma regra de negócio. CI verde = regra mantida. CI vermelho = regra violada, e você sabe qual.

Padrão útil em times: test_<sujeito>_<contexto>_<resultado>. Por exemplo: test_carrinho_quando_vazio_lanca_ao_fechar. Verboso, mas a verbosidade é o ponto.

11.4 pytest na prática

O ecossistema Python tem alguns frameworks de teste; pytest é o padrão de facto da indústria há mais de uma década. Sintaxe minimalista, fixtures poderosas, plugin system rico.

test_basico.py
import pytest
from decimal import Decimal
from meu_app import Pedido, Item, PedidoJaFechado

def test_pedido_novo_tem_total_zero():
    p = Pedido()
    assert p.total == Decimal("0")

def test_pedido_com_dois_itens_soma_corretamente():
    p = Pedido()
    p.adicionar_item(Item("A", Decimal("10"), 2))
    p.adicionar_item(Item("B", Decimal("5"), 1))
    assert p.total == Decimal("25")

def test_pedido_fechado_nao_aceita_item():
    p = Pedido()
    p.adicionar_item(Item("A", Decimal("10"), 1))
    p.fechar()
    with pytest.raises(PedidoJaFechado):
        p.adicionar_item(Item("B", Decimal("5"), 1))

def test_comparacao_aproximada_de_float():
    # Use pytest.approx quando comparar floats
    assert 0.1 + 0.2 == pytest.approx(0.3)

Estrutura recomendada de projeto

estrutura_projeto
meu_projeto/
├── src/
│   └── meu_app/
│       ├── __init__.py
│       ├── pedido.py
│       └── repositorio.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py         # fixtures compartilhadas
│   ├── unit/
│   │   ├── test_pedido.py
│   │   └── test_calculadora.py
│   └── integration/
│       └── test_repositorio_postgres.py
├── pyproject.toml
└── README.md

pyproject.toml essencial

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "-ra --strict-markers --tb=short"
markers = [
    "slow: testes que demoram",
    "integration: requerem recursos externos",
]

11.5 Fixtures — preparação reutilizável

Fixtures evitam duplicação de setup. Você declara o "estado preparado" uma vez; testes recebem por parâmetro.

conftest.py
import pytest
from meu_app import Pedido, Item, Cliente

@pytest.fixture
def cliente_padrao():
    return Cliente(nome="Alice", email="a@x.com")

@pytest.fixture
def pedido_vazio(cliente_padrao):  # compõe fixtures
    return Pedido(cliente=cliente_padrao)

@pytest.fixture
def pedido_com_itens(pedido_vazio):
    pedido_vazio.adicionar_item(Item("A", Decimal("10"), 2))
    pedido_vazio.adicionar_item(Item("B", Decimal("5"), 1))
    return pedido_vazio

@pytest.fixture
def conexao_banco():
    # Fixture com setup e teardown via yield
    conn = criar_conexao_teste()
    conn.execute("BEGIN")
    yield conn          # teste roda aqui
    conn.execute("ROLLBACK")  # cleanup garantido
    conn.close()
test_pedido.py — usando fixtures
def test_pedido_vazio_tem_total_zero(pedido_vazio):
    assert pedido_vazio.total == Decimal("0")

def test_pedido_com_itens_calcula_total(pedido_com_itens):
    assert pedido_com_itens.total == Decimal("25")

def test_fechar_pedido_com_itens_funciona(pedido_com_itens):
    pedido_com_itens.fechar()
    assert pedido_com_itens.status == StatusPedido.FECHADO

Escopos de fixture

Por padrão, fixture é recriada para cada teste. Você pode alterar o escopo:

Cuidado com session/module
Fixture compartilhada é mais rápida, mas acopla testes. Se teste A muda estado e teste B depende disso (mesmo sem saber), você tem testes que falham em ordem aleatória. Use escopo amplo só para recursos custosos e que não devem ser modificados.

11.6 Parametrize — um teste, muitos casos

Quando você precisa testar a mesma lógica com inputs diferentes, evite copiar o teste 10 vezes. @pytest.mark.parametrize roda o mesmo teste com cada conjunto de parâmetros:

parametrize.py
import pytest
from meu_app import validar_cpf

@pytest.mark.parametrize("cpf, esperado", [
    ("123.456.789-09", True),
    ("12345678909", True),
    ("111.111.111-11", False),  # todos iguais — inválido
    ("", False),
    ("abc.def.ghi-jk", False),
    ("123", False),
])
def test_validacao_cpf(cpf, esperado):
    assert validar_cpf(cpf) == esperado

# Variante com IDs descritivos:
@pytest.mark.parametrize("valor, faixa, esperado", [
    pytest.param(100, "baixa", Decimal("5"), id="baixa_compra"),
    pytest.param(500, "media", Decimal("50"), id="media_compra"),
    pytest.param(5000, "alta", Decimal("750"), id="alta_compra"),
])
def test_calculo_desconto_por_faixa(valor, faixa, esperado):
    assert calcular_desconto(valor, faixa) == esperado

Resultado: 9 testes pelo preço de 2. Quando falha, pytest diz exatamente qual parâmetro: test_calculo_desconto_por_faixa[alta_compra]. Diagnóstico imediato.

11.7 Test doubles — mocks, stubs, fakes

Test doubles (termo guarda-chuva cunhado por Gerard Meszaros) são objetos que substituem dependências reais durante testes. Eles vêm em sabores diferentes — e confundir os sabores é fonte comum de testes ruins:

TipoPropósitoVerifica?
DummySó preenche parâmetro, não é usado de fatoNão
StubRetorna valores pré-definidos (responde sem lógica real)Não
FakeImplementação funcional simplificada (ex: dict no lugar de DB)Não
MockStub + verifica que foi chamado corretamenteSim
SpyObjeto real + grava chamadas para inspeção posteriorSim
doubles.py
from unittest.mock import Mock, MagicMock, patch
import pytest

# STUB: retorna valor fixo
def test_stub_simples():
    repo = Mock()
    repo.buscar.return_value = Usuario("alice")

    servico = Servico(repo)
    resultado = servico.processar("alice")

    assert resultado.nome == "alice"

# FAKE: implementação alternativa simples
class RepoFake:
    def __init__(self):
        self._dados = {}
    def salvar(self, e):
        self._dados[e.id] = e
    def buscar(self, id):
        return self._dados.get(id)

def test_fake():
    repo = RepoFake()
    servico = Servico(repo)
    servico.criar_usuario("alice")
    assert repo.buscar("alice") is not None

# MOCK: verifica chamadas
def test_mock_verifica_envio_email():
    notif = Mock()
    servico = ServicoCadastro(notif=notif)

    servico.cadastrar("Alice", "a@x.com", "senha123")

    # Mock verifica que método foi chamado, e com que args
    notif.boas_vindas.assert_called_once_with("a@x.com")

# patch: substitui temporariamente algo no módulo testado
@patch("meu_app.servico.datetime")
def test_data_fixa(mock_dt):
    mock_dt.now.return_value = datetime(2026, 1, 15)
    assert calcular_dias_uteis() == 10
Quando mocks pioram seu teste

Mock que verifica cada chamada interna da função testada cria tight coupling com implementação. Qualquer refatoração — mesmo equivalente — quebra o teste. Sinal: você muda um detalhe interno e 30 testes falham mesmo sem mudar comportamento.

Regra: teste deve verificar comportamento observável, não como a função opera por dentro. Mock o necessário para isolar; não micro-instrumente.

Quando usar fake em vez de mock

Para dependências usadas em muitos testes (repositório, cache), fake é frequentemente melhor que mock. Você implementa um RepoFake que funciona com dict em memória; usa em todos os testes. Não precisa ficar configurando .return_value em cada um. Quando o contrato muda, você atualiza o fake — não 50 testes.

11.8 Property-based testing

Testes tradicionais checam exemplos específicos. Property-based testing inverte: você declara propriedades que devem sempre valer, e a biblioteca gera centenas de inputs aleatórios para tentar quebrar.

A biblioteca canônica em Python é Hypothesis, criada por David MacIver. É surpreendentemente boa em achar edge cases que você nunca teria escrito à mão.

property_based.py
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reverse_reverse_eh_identidade(lst):
    # Propriedade: reverter duas vezes volta ao original
    assert list(reversed(list(reversed(lst)))) == lst

@given(st.lists(st.integers(), min_size=1))
def test_max_eh_maior_que_todos(lst):
    m = max(lst)
    assert all(x <= m for x in lst)
    assert m in lst

@given(
    valor=st.decimals(min_value="0", max_value="1000000", places=2),
    aliquota=st.decimals(min_value="0", max_value="1", places=4),
)
def test_imposto_nunca_excede_valor_original(valor, aliquota):
    imposto = valor * aliquota
    assert imposto <= valor

# Hypothesis tenta entradas como: lista vazia, lista de 1 item,
# inteiros enormes, negativos, zero, listas com duplicatas...
# Coisas que você esqueceu de testar.

Property-based não substitui testes baseados em exemplo — complementa. Use para lógica matemática, parsing, serialização/deserialização, qualquer função pura onde você consiga articular invariantes.

11.9 TDD sem dogma

TDD prescreve o ciclo Red → Green → Refactor:

  1. Red: escreva teste que falha (porque o código não existe ainda).
  2. Green: escreva o código mínimo para o teste passar — sem elegância.
  3. Refactor: agora limpe o código, mantendo testes verdes.

Quando TDD funciona muito bem

Quando TDD atrapalha

Visão pragmática

TDD estrito (sempre teste primeiro) funciona em alguns contextos. Test-First (teste antes do código de produção, mas sem o ritual de 30s do TDD) é mais flexível e cobre 80% do benefício.

O que importa: o código precisa estar coberto por testes que validem comportamento. Se você prefere escrever teste antes ou depois, é detalhe de processo pessoal. Não vire fundamentalista.

11.10 Cobertura: o que ela diz (e o que não diz)

Cobertura de código mede que percentual de linhas/branches foi executado durante os testes. Ferramenta padrão em Python: coverage.py, usada via pytest-cov.

rodando_cobertura
$ pytest --cov=src --cov-report=term-missing

src/meu_app/pedido.py        45      3    93%   102, 115-117
src/meu_app/repositorio.py   28     12    57%   31-42
src/meu_app/calculadora.py   18      0   100%
-------------------------------------------------------------
TOTAL                        91     15    84%

O que cobertura te diz

O que cobertura NÃO te diz

Cobertura como meta

"Vamos atingir 95% de cobertura" é objetivo perigoso. Times manipulam: escrevem testes sem asserções, testam getters/setters triviais, capturam exceções para "passar pela linha". Cobertura vira métrica de vaidade. Use cobertura para encontrar o que está sem teste, não como troféu.

Recomendação prática: estabeleça threshold que falha CI se cobertura cair abaixo de X% (típico: 70-85%). Não persiga 100%. Foque qualitativamente nos pontos críticos: lógica de negócio, paths de erro, transições de estado.

11.11 Estudo de caso — testes de serviço de cadastro

Da unidade ao fluxo: testes em camadas

Vamos testar o ServicoCadastro do capítulo 3 — aquele que aplicou SOLID. Vamos cobrir os três níveis: unitário, integração, e um e2e mínimo.

Nível 1 · Unitário com fakes
test_cadastro_unit.py
import pytest
from meu_app.cadastro import ServicoCadastro, ValidadorCadastro

class RepoUsuarioFake:
    def __init__(self):
        self.salvos = []
        self.emails_existentes = set()
    def salvar(self, u): self.salvos.append(u)
    def existe_email(self, e): return e in self.emails_existentes

class HasherFake:
    def hash(self, s): return f"HASH({s})"

class NotifFake:
    def __init__(self):
        self.enviados = []
    def boas_vindas(self, e): self.enviados.append(e)

class AnalyticsFake:
    def __init__(self): self.eventos = []
    def registrar_evento(self, t, d): self.eventos.append((t, d))

@pytest.fixture
def servico():
    repo = RepoUsuarioFake()
    notif = NotifFake()
    analytics = AnalyticsFake()
    s = ServicoCadastro(
        validador=ValidadorCadastro(),
        hasher=HasherFake(),
        repo=repo, notif=notif, analytics=analytics,
    )
    return s, repo, notif, analytics

def test_cadastro_valido_salva_usuario(servico):
    s, repo, _, _ = servico
    s.cadastrar("Alice", "a@x.com", "senhaforte")
    assert len(repo.salvos) == 1
    assert repo.salvos[0].email == "a@x.com"
    assert repo.salvos[0].senha_hash == "HASH(senhaforte)"

def test_cadastro_valido_envia_email_e_registra_evento(servico):
    s, _, notif, analytics = servico
    s.cadastrar("Alice", "a@x.com", "senhaforte")
    assert "a@x.com" in notif.enviados
    assert ("cadastro", {"email": "a@x.com"}) in analytics.eventos

def test_email_duplicado_levanta(servico):
    s, repo, _, _ = servico
    repo.emails_existentes.add("a@x.com")
    with pytest.raises(ValueError, match="já cadastrado"):
        s.cadastrar("Alice", "a@x.com", "senhaforte")

@pytest.mark.parametrize("nome, email, senha, erro", [
    ("", "a@x.com", "senhaforte", "nome"),
    ("Alice", "sem-arroba", "senhaforte", "email"),
    ("Alice", "a@x.com", "123", "senha"),
])
def test_validacao_falha_em_dados_ruins(servico, nome, email, senha, erro):
    s, _, _, _ = servico
    with pytest.raises(ValueError, match=erro):
        s.cadastrar(nome, email, senha)

Cinco testes cobrem caminho feliz, duplicação, e três tipos de validação. Tudo roda em milissegundos. Sem rede, sem banco, sem servidor SMTP.

Nível 2 · Integração com repositório real
test_repo_integracao.py
import pytest
from meu_app.repositorio import RepoUsuarioPostgres

@pytest.fixture
def conn():
    import psycopg2
    c = psycopg2.connect("postgresql://test@localhost/test_db")
    c.execute("BEGIN")
    yield c
    c.execute("ROLLBACK")
    c.close()

@pytest.mark.integration
def test_repo_salva_e_recupera(conn):
    repo = RepoUsuarioPostgres(conn)
    repo.salvar(Usuario(nome="Alice", email="a@x.com", senha_hash="h"))
    assert repo.existe_email("a@x.com")

@pytest.mark.integration
def test_repo_email_duplicado_no_db(conn):
    repo = RepoUsuarioPostgres(conn)
    repo.salvar(Usuario("Alice", "a@x.com", "h"))
    with pytest.raises(IntegrityError):
        repo.salvar(Usuario("Bob", "a@x.com", "h2"))

# Integration tests rodam com flag separada:
# pytest -m integration

Esses testes garantem que SQL está certo, que constraints do banco funcionam — coisas que fake não captura.

Nível 3 · E2E mínimo
test_e2e.py
import pytest
from fastapi.testclient import TestClient
from meu_app.api import app

@pytest.fixture
def client():
    return TestClient(app)

@pytest.mark.slow
def test_fluxo_cadastro_completo(client):
    # Cria usuário via API
    r = client.post("/usuarios", json={
        "nome": "Alice",
        "email": "alice@e2e.test",
        "senha": "senha-forte-aqui",
    })
    assert r.status_code == 201
    user_id = r.json()["id"]

    # Login com a senha
    r = client.post("/login", json={
        "email": "alice@e2e.test",
        "senha": "senha-forte-aqui",
    })
    assert r.status_code == 200
    token = r.json()["token"]

    # Acessa endpoint autenticado
    r = client.get(f"/usuarios/{user_id}", headers={"Authorization": f"Bearer {token}"})
    assert r.json()["email"] == "alice@e2e.test"

Um teste E2E cobre o fluxo crítico. Mais que isso, raramente compensa.

A pirâmide concreta: 20+ testes unitários (rápidos, isolados). 5-8 testes de integração (DB real, mais lentos). 1-2 E2E (fluxo crítico). Total ~30 testes que dão confiança real para refatorar.

11.12 Erros comuns

Erro 1 · Testes que dependem uns dos outros

Teste B passa se A rodar antes. Sintoma: rodar testes em ordem aleatória quebra coisas. Cada teste deve preparar seu próprio estado.

Erro 2 · Mock excessivo

Cada chamada interna é mockada e verificada. Resultado: testes acoplados à implementação. Refatorar fica impossível. Mock só o que cruza fronteira do sistema (rede, banco, fila); o resto, use objetos reais ou fakes.

Erro 3 · Testes lentos sem flag

Suite de testes leva 10 minutos para rodar. Time evita rodar localmente. CI vira lento. Marque testes lentos (@pytest.mark.slow), separe em camadas, rode os rápidos por padrão.

Erro 4 · Falsa segurança de cobertura alta

95% de cobertura, e todo dia tem bug em produção. Provavelmente testes só executam linhas sem verificar comportamento de verdade. Cobertura não substitui pensar sobre o que está sendo verificado.

Verifique seu entendimento
"Seu teste verifica que servico.cadastrar() chama exatamente 3 métodos internos em ordem específica. Você refatora a função para usar um método auxiliar, mantendo o comportamento. Como deveria reagir o teste?"

11.13 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Testes para Conta bancária

Escreva 5 testes pytest para a classe Conta do capítulo 1: depósito válido, saque válido, saque maior que saldo, depósito negativo, sequência depósito/saque com saldo final correto. Use AAA explicitamente, nomes descritivos.

test_conta.py
import pytest
from decimal import Decimal
from meu_app import Conta

def test_deposito_aumenta_saldo():
    c = Conta(saldo_inicial=Decimal("100"))
    c.depositar(Decimal("50"))
    assert c.saldo == Decimal("150")

def test_saque_diminui_saldo():
    c = Conta(saldo_inicial=Decimal("100"))
    c.sacar(Decimal("30"))
    assert c.saldo == Decimal("70")

def test_saque_maior_que_saldo_falha():
    c = Conta(saldo_inicial=Decimal("50"))
    with pytest.raises(ValueError, match="insuficiente"):
        c.sacar(Decimal("100"))

def test_deposito_zero_ou_negativo_falha():
    c = Conta()
    with pytest.raises(ValueError):
        c.depositar(Decimal("0"))
    with pytest.raises(ValueError):
        c.depositar(Decimal("-10"))

def test_sequencia_de_operacoes_resulta_saldo_correto():
    c = Conta()
    c.depositar(Decimal("100"))
    c.depositar(Decimal("50"))
    c.sacar(Decimal("30"))
    assert c.saldo == Decimal("120")
Fácil
Exercício 2 · Parametrize

Use @pytest.mark.parametrize para testar função classificar_idade(n: int) que retorna "criança" (<13), "adolescente" (13-17), "adulto" (18-64), "idoso" (≥65). Cubra fronteiras: -1, 0, 12, 13, 17, 18, 64, 65, 200.

test_idade.py
import pytest

@pytest.mark.parametrize("idade, esperado", [
    pytest.param(0, "criança", id="recem_nascido"),
    pytest.param(12, "criança", id="limite_crianca"),
    pytest.param(13, "adolescente", id="inicio_adolescencia"),
    pytest.param(17, "adolescente", id="limite_adolescente"),
    pytest.param(18, "adulto", id="maioridade"),
    pytest.param(64, "adulto", id="limite_adulto"),
    pytest.param(65, "idoso", id="inicio_idoso"),
    pytest.param(200, "idoso", id="matusalem"),
])
def test_classificacao_idade(idade, esperado):
    assert classificar_idade(idade) == esperado

def test_idade_negativa_lanca():
    with pytest.raises(ValueError):
        classificar_idade(-1)
Médio
Exercício 3 · Fixtures compostas

Crie fixtures em conftest.py para um sistema de carrinho: cliente_padrao, produto_a, produto_b, carrinho_vazio (depende de cliente_padrao), carrinho_com_dois_produtos (compõe outras). Escreva 3 testes usando as fixtures.

conftest.py + test_carrinho.py
# conftest.py
import pytest
from decimal import Decimal
from meu_app import Cliente, Produto, Carrinho

@pytest.fixture
def cliente_padrao():
    return Cliente("Alice", "a@x.com")

@pytest.fixture
def produto_a():
    return Produto("SKU-A", "Caneta", Decimal("5.00"))

@pytest.fixture
def produto_b():
    return Produto("SKU-B", "Caderno", Decimal("15.00"))

@pytest.fixture
def carrinho_vazio(cliente_padrao):
    return Carrinho(cliente=cliente_padrao)

@pytest.fixture
def carrinho_com_dois_produtos(carrinho_vazio, produto_a, produto_b):
    carrinho_vazio.adicionar(produto_a, 2)  # 2x R$5
    carrinho_vazio.adicionar(produto_b, 1)  # 1x R$15
    return carrinho_vazio

# test_carrinho.py
def test_carrinho_novo_esta_vazio(carrinho_vazio):
    assert len(carrinho_vazio.itens) == 0
    assert carrinho_vazio.total == Decimal("0")

def test_total_soma_corretamente(carrinho_com_dois_produtos):
    assert carrinho_com_dois_produtos.total == Decimal("25")

def test_adicionar_mesmo_produto_incrementa_quantidade(carrinho_vazio, produto_a):
    carrinho_vazio.adicionar(produto_a, 2)
    carrinho_vazio.adicionar(produto_a, 3)
    assert carrinho_vazio.itens[0].quantidade == 5
Médio
Exercício 4 · Mock vs Fake

Para testar ServicoNotificacao que envia e-mails via SMTP externo, mostre duas versões do mesmo teste: uma usando Mock com assert_called_with, outra usando FakeSMTP (classe que coleta mensagens em lista). Compare qual fica mais legível e robusto.

mock_vs_fake.py
from unittest.mock import Mock
from dataclasses import dataclass, field

# --- VERSÃO COM MOCK ---
def test_envia_email_com_mock():
    smtp_mock = Mock()
    servico = ServicoNotificacao(smtp=smtp_mock)
    servico.enviar_boas_vindas("a@x.com", "Alice")
    smtp_mock.send.assert_called_once_with(
        para="a@x.com",
        assunto="Bem-vinda, Alice!",
        corpo="...",
    )

# --- VERSÃO COM FAKE ---
@dataclass
class MensagemEnviada:
    para: str
    assunto: str
    corpo: str

class FakeSMTP:
    def __init__(self):
        self.enviadas: list[MensagemEnviada] = []
    def send(self, para, assunto, corpo):
        self.enviadas.append(MensagemEnviada(para, assunto, corpo))

def test_envia_email_com_fake():
    smtp = FakeSMTP()
    servico = ServicoNotificacao(smtp=smtp)
    servico.enviar_boas_vindas("a@x.com", "Alice")

    assert len(smtp.enviadas) == 1
    msg = smtp.enviadas[0]
    assert msg.para == "a@x.com"
    assert "Alice" in msg.assunto

# Comparação: Fake permite múltiplas asserções claras, reutilizar
# entre testes, e descreve o comportamento real. Mock é mais rígido
# e quebra se a ordem dos kwargs muda na implementação.
Difícil
Exercício 5 · Property-based com Hypothesis

Para uma função serializar_pedido(p) -> bytes + deserializar_pedido(b) -> Pedido, escreva property test que verifica: para qualquer pedido válido, serializar + deserializar volta ao original. Use Hypothesis para gerar pedidos aleatórios. Bonus: garanta que serialização sempre produz bytes que começam com header específico.

test_serializacao.py
from hypothesis import given, strategies as st, settings

# Strategy para gerar Item válido
itens_strategy = st.builds(
    Item,
    sku=st.text(min_size=1, max_size=20),
    nome=st.text(min_size=1, max_size=50),
    preco=st.decimals(min_value="0.01", max_value="1000", places=2),
    quantidade=st.integers(min_value=1, max_value=100),
)

# Strategy para gerar Pedido com lista de itens
pedido_strategy = st.builds(
    Pedido,
    cliente_id=st.text(min_size=1, max_size=10),
    itens=st.lists(itens_strategy, min_size=1, max_size=10),
)

@given(pedido=pedido_strategy)
@settings(max_examples=200)
def test_serializacao_eh_reversivel(pedido):
    bytes_ = serializar_pedido(pedido)
    recuperado = deserializar_pedido(bytes_)
    assert recuperado == pedido

@given(pedido=pedido_strategy)
def test_serializacao_tem_header(pedido):
    bytes_ = serializar_pedido(pedido)
    assert bytes_.startswith(b"PEDIDO_V1")

@given(dados=st.binary(min_size=0, max_size=100))
def test_deserializar_lixo_levanta(dados):
    # Hypothesis vai tentar vários "lixo" e garantir que sempre falha graciosamente
    if not dados.startswith(b"PEDIDO_V1"):
        with pytest.raises((ValueError, FormatoInvalido)):
            deserializar_pedido(dados)

Hypothesis vai testar com listas vazias, strings com caracteres especiais, decimais nas bordas — coisas que você nunca lembraria de testar manualmente.

Fim do capítulo 11
Testes bons são investimento, não custo. A próxima vez que você puder refatorar uma classe pesada sem medo, vai entender por quê. Próximo capítulo: refatoração disciplinada — as técnicas para transformar código mantendo comportamento.
Parte III · Capítulo 12 · Qualidade e evolução

Refatoração:
mudar a forma,
preservar a função.

Refatorar não é "reescrever". Refatorar é uma técnica disciplinada: você muda a estrutura interna do código sem alterar comportamento observável, em passos pequenos verificáveis, protegido por testes.

O termo é usado com frouxidão na indústria. "Vou refatorar isso" frequentemente significa "vou reescrever do zero". Não é. Refatoração tem definição estrita, técnicas catalogadas, e protocolo: cada passo pequeno, cada passo verde, commit em commit. Quando feito assim, transforma legado em código limpo sem trauma.

12.1 A história — Opdyke, Fowler, e o catálogo

Contexto histórico

O termo "refactoring" apareceu pela primeira vez na tese de doutorado de William Opdyke em 1992, na Universidade de Illinois. Opdyke trabalhava com a comunidade de Smalltalk e tentava formalizar transformações preservadoras de comportamento em código orientado a objetos.

Mas foi em 1999 que Martin Fowler, com Kent Beck e outros, publicou o livro definitivo: Refactoring: Improving the Design of Existing Code. Catalogou dezenas de transformações nomeadas, com receitas passo a passo. O livro virou referência da indústria. Em 2018, a segunda edição foi reescrita usando JavaScript (após o original em Java), mas o catálogo permaneceu essencialmente o mesmo — e continua válido em qualquer linguagem, incluindo Python.

A grande contribuição da escola de refatoração: transformações têm nome e protocolo. Não é "vou melhorar"; é "vou aplicar Extract Function". A diferença é poder explicar, revisar, ensinar.

12.2 A definição estrita

Refatoração é:

Se você está mudando o que o código faz, isso não é refatoração — é mudança de funcionalidade. Pode ser legítimo, mas é outra coisa. Misturar os dois (refatorar + adicionar feature no mesmo PR) é receita para bugs e diff impossíveis de revisar.

Regra de ouro
Refatoração e mudança de funcionalidade são duas chapéus. Use um por vez. Ou você está refatorando (sem mudar comportamento), ou está adicionando/alterando funcionalidade. Troque conscientemente. Commits separados. PRs separados se possível.

12.3 Extract Function e Extract Class

As duas refatorações mais frequentes da carreira. Você identifica um bloco de código que faz "uma coisa" e o move para uma função/método/classe própria, com nome descritivo.

Extract Function

✗ Antes
antes.py
def processar_pedido(pedido):
    # Imprimir cabeçalho
    print("=" * 50)
    print(f"Pedido #{pedido.id}")
    print(f"Cliente: {pedido.cliente.nome}")
    print("=" * 50)

    # Calcular total
    total = Decimal("0")
    for item in pedido.itens:
        subtotal = item.preco * item.qtd
        total += subtotal
    total += pedido.frete

    # Imprimir detalhes
    for item in pedido.itens:
        print(f"{item.nome}: {item.qtd} x {item.preco}")
    print(f"Frete: {pedido.frete}")
    print(f"TOTAL: {total}")
✓ Depois
depois.py
def processar_pedido(pedido):
    _imprimir_cabecalho(pedido)
    total = _calcular_total(pedido)
    _imprimir_detalhes(pedido, total)

def _imprimir_cabecalho(pedido):
    print("=" * 50)
    print(f"Pedido #{pedido.id}")
    print(f"Cliente: {pedido.cliente.nome}")
    print("=" * 50)

def _calcular_total(pedido) -> Decimal:
    total = sum(
        (item.preco * item.qtd for item in pedido.itens),
        Decimal("0")
    )
    return total + pedido.frete

def _imprimir_detalhes(pedido, total):
    for item in pedido.itens:
        print(f"{item.nome}: {item.qtd} x {item.preco}")
    print(f"Frete: {pedido.frete}")
    print(f"TOTAL: {total}")

Protocolo de Extract Function

  1. Identifique o bloco coeso. Geralmente tem comentário descrevendo o que faz — sinal de que merece nome próprio.
  2. Crie função vazia com nome do que esse bloco faz.
  3. Identifique variáveis usadas: as que entram são parâmetros; as que saem, retorno (ou tuple de retornos).
  4. Cole o bloco na nova função. Ajuste tipos.
  5. Substitua o bloco original pela chamada.
  6. Rode os testes. Se quebrou, reverta e tente de novo.
  7. Commit.

Extract Class

Quando o bloco que você extrai começa a precisar de várias funções relacionadas, ou tem estado próprio, viva uma classe. Você viu Extract Class no estudo de caso do capítulo 10 (refatorando God Object).

12.4 Rename — a refatoração que mais paga

Renomear bem é a refatoração mais subestimada. O código é lido 10 vezes mais do que é escrito. Um nome ruim custa 10x mais que um nome bom — em horas humanas, por toda a vida do projeto.

Sinais de que algo precisa de rename:

✗ Nomes vagos
def process(d):
    return [x for x in d if x["v"] > 0]

manager = DataManager()
result = manager.handle(stuff)
✓ Nomes específicos
def filtrar_lancamentos_positivos(lancamentos):
    return [l for l in lancamentos
            if l["valor"] > 0]

repositorio = RepositorioVendas()
vendas_aprovadas = repositorio.listar_aprovadas(data)

Rename é "barato" com IDE moderna

PyCharm, VS Code (com Pylance), e qualquer IDE séria oferecem rename automático que atualiza todas as referências. Use. Não tenha medo de renomear — é uma das poucas refatorações que IDEs fazem com 100% de precisão.

12.5 Inline — quando abstrair foi cedo demais

Inverso de Extract: às vezes você criou uma função/variável que não está ajudando — está só adicionando indireção. Inline volta o código ao lugar onde é usado.

inline.py
# Antes — função sem ganho
def eh_maior_de_idade(idade):
    return idade >= 18

def processar(usuario):
    if eh_maior_de_idade(usuario.idade):
        ...

# Depois — inline
def processar(usuario):
    if usuario.idade >= 18:
        ...

Quando inline faz sentido:

Quando inline NÃO faz sentido:

12.6 Move/Push down/Pull up

Move Method / Move Field

Método está na classe errada. Sinal: o método usa muito mais dados/métodos de outra classe do que da própria. Sugere que o conceito pertence à outra classe.

move_method.py
# Antes — Pedido sabe demais sobre Cliente
class Pedido:
    def desconto_fidelidade(self):
        if self.cliente.tipo == "VIP":
            return Decimal("0.20")
        if self.cliente.anos_ativo > 5:
            return Decimal("0.10")
        return Decimal("0")

# Depois — pertence ao Cliente
class Cliente:
    def percentual_desconto_fidelidade(self) -> Decimal:
        if self.tipo == "VIP":
            return Decimal("0.20")
        if self.anos_ativo > 5:
            return Decimal("0.10")
        return Decimal("0")

class Pedido:
    @property
    def desconto_fidelidade(self):
        return self.cliente.percentual_desconto_fidelidade()

Push Down e Pull Up

Em hierarquia de classes: Pull Up move método/atributo das filhas para a pai (quando o comportamento é comum). Push Down faz o inverso (quando só uma filha usa). Aplicar pull up por reflexo é tentador; cuidado com violação de LSP — só puxe o que é genuinamente comum.

12.7 Substituir condicional por polimorfismo

Cadeia de if/elif sobre tipo é cheiro clássico. Substitua por polimorfismo (Strategy ou subclasses). Já vimos isso aplicado várias vezes; vamos formalizar a refatoração.

substituir_condicional.py
# Antes
def salario(funcionario):
    if funcionario.tipo == "engineer":
        return funcionario.base + funcionario.bonus_codigo
    if funcionario.tipo == "manager":
        return funcionario.base + funcionario.bonus_lideranca * 2
    if funcionario.tipo == "sales":
        return funcionario.base + funcionario.comissao
    raise ValueError(funcionario.tipo)

# Depois — Strategy
class Funcionario(ABC):
    def __init__(self, base: Decimal):
        self.base = base
    @abstractmethod
    def salario(self) -> Decimal: ...

class Engineer(Funcionario):
    def __init__(self, base, bonus_codigo):
        super().__init__(base); self.bonus_codigo = bonus_codigo
    def salario(self): return self.base + self.bonus_codigo

class Manager(Funcionario):
    def __init__(self, base, bonus_lideranca):
        super().__init__(base); self.bonus_lideranca = bonus_lideranca
    def salario(self): return self.base + self.bonus_lideranca * 2

class Sales(Funcionario):
    def __init__(self, base, comissao):
        super().__init__(base); self.comissao = comissao
    def salario(self): return self.base + self.comissao

# Adicionar novo cargo: nova classe. Zero alteração no resto.

12.8 Introduzir parâmetro e Value Object

Introduce Parameter Object

Quando função tem 5+ parâmetros, especialmente se vários sempre aparecem juntos, agrupe em objeto:

parameter_object.py
# Antes — assinatura difícil de ler
def buscar_voos(
    origem: str, destino: str,
    data_ida: str, data_volta: str | None,
    adultos: int, criancas: int, bebes: int,
    classe: str, somente_diretos: bool,
):
    ...

# Depois — Parameter Object
@dataclass(frozen=True)
class Trecho:
    origem: str
    destino: str
    data: str

@dataclass(frozen=True)
class Passageiros:
    adultos: int
    criancas: int = 0
    bebes: int = 0

@dataclass(frozen=True)
class BuscaVoo:
    ida: Trecho
    volta: Trecho | None
    passageiros: Passageiros
    classe: str = "economica"
    somente_diretos: bool = False

def buscar_voos(busca: BuscaVoo) -> list[Voo]:
    ...

Replace Primitive with Value Object

Quando você usa str para CPF, int para idade, str para e-mail — e essas coisas têm regras de validação ou operações próprias — vire Value Object. Vimos isso no capítulo 1.

12.9 Quando refatorar

Bons momentos:

12.10 Quando NÃO refatorar

Reconheça o contexto
Refatoração tem custo. Avalie.
  • Código sem testes: refatorar sem rede é roleta russa. Primeiro escreva testes de caracterização; depois refatore.
  • Código que vai ser jogado fora em breve: protótipo que será descartado, módulo que será removido na próxima release.
  • Sob pressão de produção: 3h da manhã, sistema fora do ar, não é hora de refatorar. Cole um band-aid; refatore depois com calma.
  • Branch longa: refatorar e adicionar feature na mesma branch que demora 2 meses para mergir = merge conflict pesadelar. Faça refactor antes, mergeie, depois faça a feature.
  • "Vai dar tempo": projeto crítico com deadline iminente. Anote, registre, faça depois.

12.11 Estudo de caso — refatoração disciplinada de função complexa

De função de 50 linhas com aninhamento à organização limpa

Vamos pegar uma função complexa real e refatorar passo a passo, mantendo testes verdes a cada passo.

Estado inicial · Função complexa
v0.py
def processar_relatorio_vendas(transacoes, data_inicio, data_fim, tipo_cliente):
    # Validar datas
    if data_inicio > data_fim:
        raise ValueError("data inválida")

    # Filtrar por data
    filtradas = []
    for t in transacoes:
        if t.data >= data_inicio and t.data <= data_fim:
            filtradas.append(t)

    # Filtrar por tipo de cliente
    if tipo_cliente:
        resultado = []
        for t in filtradas:
            if t.cliente.tipo == tipo_cliente:
                resultado.append(t)
        filtradas = resultado

    # Agrupar por dia
    por_dia = {}
    for t in filtradas:
        chave = t.data.strftime("%Y-%m-%d")
        if chave not in por_dia:
            por_dia[chave] = []
        por_dia[chave].append(t)

    # Calcular totais por dia
    totais = {}
    for dia, txs in por_dia.items():
        total_dia = Decimal("0")
        for t in txs:
            # Aplicar imposto
            if t.cliente.estado == "SP":
                imposto = t.valor * Decimal("0.18")
            elif t.cliente.estado == "RJ":
                imposto = t.valor * Decimal("0.20")
            else:
                imposto = t.valor * Decimal("0.17")
            total_dia += t.valor + imposto
        totais[dia] = total_dia

    # Formatar saída
    linhas = []
    linhas.append(f"Relatório de {data_inicio} a {data_fim}")
    linhas.append("=" * 40)
    for dia in sorted(totais.keys()):
        linhas.append(f"{dia}: R$ {totais[dia]}")
    total_geral = sum(totais.values(), Decimal("0"))
    linhas.append(f"TOTAL: R$ {total_geral}")
    return "\n".join(linhas)
Passo 0 · Testes de caracterização

Antes de mexer, capturamos comportamento atual:

test_relatorio.py
def test_relatorio_basico():
    transacoes = [...]
    saida = processar_relatorio_vendas(transacoes, ini, fim, None)
    assert "TOTAL: R$ 1180.00" in saida

def test_filtra_por_tipo_cliente():
    saida = processar_relatorio_vendas(transacoes, ini, fim, "VIP")
    assert "TOTAL: R$ 590.00" in saida

# Mais 4-5 testes cobrindo diferentes cenários...
Passo 1 · Extract: cálculo de imposto

Trecho de cálculo de imposto vira função:

v1.py
ALIQUOTAS = {"SP": Decimal("0.18"), "RJ": Decimal("0.20")}
ALIQUOTA_PADRAO = Decimal("0.17")

def _calcular_imposto(valor: Decimal, estado: str) -> Decimal:
    aliquota = ALIQUOTAS.get(estado, ALIQUOTA_PADRAO)
    return valor * aliquota

# Em processar_relatorio_vendas: substitui o trecho.
# Rode testes. Verde. Commit.
Passo 2 · Extract: filtros
v2.py
def _filtrar_por_periodo(transacoes, inicio, fim):
    return [t for t in transacoes if inicio <= t.data <= fim]

def _filtrar_por_tipo_cliente(transacoes, tipo):
    if not tipo:
        return transacoes
    return [t for t in transacoes if t.cliente.tipo == tipo]

# Rode testes. Verde. Commit.
Passo 3 · Extract: agrupamento e totalização
v3.py
from collections import defaultdict

def _agrupar_por_dia(transacoes) -> dict[str, list]:
    grupos = defaultdict(list)
    for t in transacoes:
        grupos[t.data.strftime("%Y-%m-%d")].append(t)
    return dict(grupos)

def _total_com_imposto(t) -> Decimal:
    return t.valor + _calcular_imposto(t.valor, t.cliente.estado)

def _calcular_totais_por_dia(por_dia) -> dict[str, Decimal]:
    return {
        dia: sum((_total_com_imposto(t) for t in txs), Decimal("0"))
        for dia, txs in por_dia.items()
    }
Passo 4 · Extract: formatação
v4.py
def _formatar_relatorio(totais: dict, inicio, fim) -> str:
    linhas = [
        f"Relatório de {inicio} a {fim}",
        "=" * 40,
    ]
    for dia in sorted(totais.keys()):
        linhas.append(f"{dia}: R$ {totais[dia]}")
    total_geral = sum(totais.values(), Decimal("0"))
    linhas.append(f"TOTAL: R$ {total_geral}")
    return "\n".join(linhas)
Passo 5 · Resultado final
final.py
def processar_relatorio_vendas(transacoes, data_inicio, data_fim, tipo_cliente):
    if data_inicio > data_fim:
        raise ValueError("data inválida")

    filtradas = _filtrar_por_periodo(transacoes, data_inicio, data_fim)
    filtradas = _filtrar_por_tipo_cliente(filtradas, tipo_cliente)
    por_dia = _agrupar_por_dia(filtradas)
    totais = _calcular_totais_por_dia(por_dia)
    return _formatar_relatorio(totais, data_inicio, data_fim)

# De 50 linhas aninhadas para 7 linhas que se leem como prosa.
# Cada peça testável. Substituir imposto por outra regra: troque _calcular_imposto.
# Adicionar agrupamento por semana: nova função, mesma estrutura.

Total: 5 commits pequenos, cada um verde. Diff de cada PR é revisável em 2 minutos. Risco minimizado. Função final lê como pseudocódigo. Esse é o protocolo da refatoração disciplinada.

12.12 Erros comuns

Erro 1 · Refatoração + feature no mesmo PR

Reviewer não consegue distinguir "isso mudou comportamento" de "isso só foi movido". Bugs passam, ou PR fica preso para sempre. Separe.

Erro 2 · Refatorar sem testes

"Vou só limpar isso aqui". Sem testes você não sabe se quebrou. Hora depois, bug em produção, ninguém liga os pontos. Sempre teste antes.

Erro 3 · Passos grandes demais

Tentar refatorar a função inteira em um commit. Algo dá errado, você não sabe qual mudança causou. Volte passos. Pequenos. Verde a cada um.

Erro 4 · "Refatoração" que é reescrita

Você reescreveu o algoritmo do zero "porque ficou melhor". Isso não é refatoração — é nova implementação. Trate como tal: testes novos, validação cuidadosa.

Verifique seu entendimento
"Você precisa adicionar uma feature em um módulo legado complexo, sem testes. Qual a sequência correta?"

12.13 Exercícios graduados

Pratique antes de seguir adiante
Fácil
Exercício 1 · Extract Function

Refatore esta função em 3 funções menores com nomes descritivos:

antes.py
def processar_pedido(pedido):
    if not pedido.itens:
        raise ValueError("vazio")
    for i in pedido.itens:
        if i.preco <= 0:
            raise ValueError("preço inválido")
    total = sum(i.preco * i.qtd for i in pedido.itens)
    desconto = Decimal("0")
    if total > 500:
        desconto = total * Decimal("0.10")
    elif total > 200:
        desconto = total * Decimal("0.05")
    final = total - desconto
    print(f"Pedido #{pedido.id}: R$ {final}")
    return final
depois.py
def _validar_pedido(pedido):
    if not pedido.itens:
        raise ValueError("vazio")
    if any(i.preco <= 0 for i in pedido.itens):
        raise ValueError("preço inválido")

def _calcular_subtotal(pedido) -> Decimal:
    return sum(
        (i.preco * i.qtd for i in pedido.itens),
        Decimal("0"),
    )

def _calcular_desconto_progressivo(subtotal) -> Decimal:
    if subtotal > 500:
        return subtotal * Decimal("0.10")
    if subtotal > 200:
        return subtotal * Decimal("0.05")
    return Decimal("0")

def processar_pedido(pedido) -> Decimal:
    _validar_pedido(pedido)
    subtotal = _calcular_subtotal(pedido)
    desconto = _calcular_desconto_progressivo(subtotal)
    final = subtotal - desconto
    print(f"Pedido #{pedido.id}: R$ {final}")
    return final
Fácil
Exercício 2 · Rename

Renomeie tudo neste código para nomes que descrevem intenção:

ruim.py
def do_stuff(d, t):
    r = []
    for x in d:
        if x["f"] == t:
            r.append(x["n"])
    return r

users = [{"n": "Alice", "f": "admin"}, {"n": "Bob", "f": "user"}]
res = do_stuff(users, "admin")
renomeado.py
def listar_nomes_por_funcao(usuarios, funcao):
    return [
        u["nome"]
        for u in usuarios
        if u["funcao"] == funcao
    ]

usuarios = [
    {"nome": "Alice", "funcao": "admin"},
    {"nome": "Bob", "funcao": "user"},
]
nomes_admins = listar_nomes_por_funcao(usuarios, "admin")
Médio
Exercício 3 · Substituir condicional por polimorfismo

Refatore este código que usa if/elif sobre tipo, para Strategy via dict de classes:

antes.py
def calcular_envio(tipo, peso, distancia):
    if tipo == "normal":
        return peso * 2 + distancia * 0.1
    elif tipo == "expresso":
        return peso * 3.5 + distancia * 0.2 + 15
    elif tipo == "economico":
        return peso * 1.5 + distancia * 0.05
    else:
        raise ValueError(tipo)
depois.py
from typing import Protocol
from dataclasses import dataclass

class CalculoEnvio(Protocol):
    def calcular(self, peso: float, distancia: float) -> float: ...

@dataclass(frozen=True)
class EnvioPorPesoEDistancia:
    fator_peso: float
    fator_distancia: float
    fixo: float = 0

    def calcular(self, peso, distancia):
        return peso * self.fator_peso + distancia * self.fator_distancia + self.fixo

ESTRATEGIAS: dict[str, CalculoEnvio] = {
    "normal": EnvioPorPesoEDistancia(2, 0.1),
    "expresso": EnvioPorPesoEDistancia(3.5, 0.2, fixo=15),
    "economico": EnvioPorPesoEDistancia(1.5, 0.05),
}

def calcular_envio(tipo: str, peso: float, distancia: float) -> float:
    estrategia = ESTRATEGIAS.get(tipo)
    if estrategia is None:
        raise ValueError(f"tipo desconhecido: {tipo}")
    return estrategia.calcular(peso, distancia)
Médio
Exercício 4 · Introduce Parameter Object

Esta função tem assinatura grande. Crie 2-3 objetos para agrupar parâmetros relacionados.

antes.py
def enviar_email(
    remetente_nome: str,
    remetente_email: str,
    destinatario_nome: str,
    destinatario_email: str,
    assunto: str,
    corpo: str,
    tem_html: bool,
    smtp_host: str,
    smtp_porta: int,
    smtp_user: str,
    smtp_senha: str,
):
    ...
depois.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Contato:
    nome: str
    email: str

@dataclass(frozen=True)
class Mensagem:
    assunto: str
    corpo: str
    tem_html: bool = False

@dataclass(frozen=True)
class ConfigSMTP:
    host: str
    porta: int
    usuario: str
    senha: str

def enviar_email(
    remetente: Contato,
    destinatario: Contato,
    mensagem: Mensagem,
    smtp: ConfigSMTP,
):
    ...
Difícil
Exercício 5 · Refatoração disciplinada completa

Pegue esta função, escreva 3 testes de caracterização (cobrindo o comportamento atual), depois refatore em pelo menos 4 passos pequenos. Documente cada passo com 1 frase explicando a refatoração aplicada.

desafio.py
def aprovar_emprestimo(cliente, valor, prazo_meses):
    if cliente.score < 300:
        return {"aprovado": False, "motivo": "score baixo"}
    if cliente.renda < valor / prazo_meses * 3:
        return {"aprovado": False, "motivo": "renda insuficiente"}
    if cliente.tem_negativacao:
        return {"aprovado": False, "motivo": "negativado"}
    if cliente.idade < 18 or cliente.idade > 75:
        return {"aprovado": False, "motivo": "idade fora do range"}
    if cliente.score > 700:
        taxa = 0.015
    elif cliente.score > 500:
        taxa = 0.025
    else:
        taxa = 0.04
    parcela = valor * (1 + taxa) ** prazo_meses / prazo_meses
    return {
        "aprovado": True,
        "taxa": taxa,
        "parcela": parcela,
        "total": parcela * prazo_meses,
    }

Testes de caracterização:

testes.py
def test_cliente_bom_aprovado():
    c = Cliente(score=750, renda=10000, idade=35, tem_negativacao=False)
    r = aprovar_emprestimo(c, 10000, 12)
    assert r["aprovado"] is True
    assert r["taxa"] == 0.015

def test_score_baixo_recusado():
    c = Cliente(score=200, renda=10000, idade=35, tem_negativacao=False)
    r = aprovar_emprestimo(c, 1000, 12)
    assert r == {"aprovado": False, "motivo": "score baixo"}

def test_negativado_recusado():
    c = Cliente(score=800, renda=50000, idade=35, tem_negativacao=True)
    r = aprovar_emprestimo(c, 1000, 12)
    assert r["aprovado"] is False

Refatoração final:

refatorado.py
from dataclasses import dataclass
from typing import Protocol

# Passo 1: Replace return dict with dataclass (Value Object)
@dataclass(frozen=True)
class Recusa:
    motivo: str

@dataclass(frozen=True)
class Aprovacao:
    taxa: float
    parcela: float
    total: float

ResultadoEmprestimo = Aprovacao | Recusa

# Passo 2: Extract regras de recusa (Chain-like)
class RegraRecusa(Protocol):
    def avaliar(self, c, valor, prazo) -> str | None: ...

class ScoreMinimo:
    def avaliar(self, c, valor, prazo):
        return "score baixo" if c.score < 300 else None

class RendaSuficiente:
    def avaliar(self, c, valor, prazo):
        parcela_min = valor / prazo
        return "renda insuficiente" if c.renda < parcela_min * 3 else None

class SemNegativacao:
    def avaliar(self, c, valor, prazo):
        return "negativado" if c.tem_negativacao else None

class FaixaEtaria:
    def avaliar(self, c, valor, prazo):
        return "idade fora do range" if not (18 <= c.idade <= 75) else None

REGRAS_RECUSA: list[RegraRecusa] = [
    ScoreMinimo(), RendaSuficiente(), SemNegativacao(), FaixaEtaria(),
]

# Passo 3: Extract cálculo de taxa
def _calcular_taxa(score: int) -> float:
    if score > 700: return 0.015
    if score > 500: return 0.025
    return 0.04

def _calcular_parcela(valor, taxa, prazo) -> float:
    return valor * (1 + taxa) ** prazo / prazo

# Passo 4: Orquestrador limpo
def aprovar_emprestimo(cliente, valor, prazo) -> ResultadoEmprestimo:
    for regra in REGRAS_RECUSA:
        motivo = regra.avaliar(cliente, valor, prazo)
        if motivo:
            return Recusa(motivo)
    taxa = _calcular_taxa(cliente.score)
    parcela = _calcular_parcela(valor, taxa, prazo)
    return Aprovacao(taxa=taxa, parcela=parcela, total=parcela * prazo)

Resumo dos 4 passos:

  1. Replace return dict with dataclass — tipos explícitos
  2. Extract regras de recusa em Strategy-like — adicionar regra nova é adicionar classe
  3. Extract cálculo de taxa e parcela — funções puras testáveis
  4. Simplify orquestrador — lê como prosa
Fim do capítulo 12
Refatoração disciplinada é a habilidade que separa quem mantém legado de quem o transforma. Próximo capítulo: code smells avançados — como reconhecer os cheiros que pedem refatoração, antes que o problema fique grande.
Parte III
Qualidade e evolução

Como código sobrevive à passagem do tempo. Testes que protegem mudanças. Refatoração que destrava sem quebrar. Sinais de degradação detectados cedo. Documentação que envelhece bem.

Testes profundos Refatoração disciplinada Code smells avançados Documentação técnica
Parte III · Capítulo 11 · Qualidade e evolução

Testes:
a rede que libera
mudanças.

Testes não servem para "garantir que código funciona". Servem para você poder mudar o código sem medo. Sem testes, todo refactor é apostar. Com testes, é trabalhar.

Existe uma diferença gigante entre código com testes que dão sensação de segurança e código com testes que realmente protegem mudanças. A primeira categoria — testes que cobrem só feliz path, testam implementação em vez de comportamento, mockam tudo — entrega 80% de cobertura e zero proteção real. A segunda — testes que documentam comportamento, falham quando o sistema quebra, passam quando o sistema funciona — é o que separa código maduro do resto.

11.1 A história — de "depurar" a "automatizar"

Contexto histórico

Nos anos 50 e 60, "teste" era sinônimo de debugger. Programador rodava o programa, observava saída, corrigia. Glenford Myers, em The Art of Software Testing (1979), formalizou a ideia de que testar é uma disciplina à parte do desenvolvimento, com objetivo explícito: "o teste é o processo de executar um programa com a intenção de encontrar erros".

A virada para automação aconteceu nos anos 90. Kent Beck, em Smalltalk no início dos 90, criou o que viria a ser o JUnit (em Java, com Erich Gamma, 1997). O framework xUnit, hoje em todas as linguagens, é descendente direto. Em Python, unittest entrou na stdlib em 2001; pytest, mais ergonômico, dominou a indústria depois de 2005.

Test-Driven Development foi formalizado por Beck no livro de 2002. A ideia central: escreva o teste primeiro, veja-o falhar, faça o código mínimo para passar. Famoso, controverso, hoje usado parcial e seletivamente — não como religião.

Hoje, "testes" se tornou um espectro: testes unitários, de integração, end-to-end, de contrato, property-based, mutation testing, fuzzing. Cada categoria responde a uma pergunta diferente sobre o sistema. Saber qual usar em cada momento é a marca de quem domina o tema.

11.2 Para que testes servem (e para que não servem)

Existe uma confusão recorrente sobre o propósito de testes. Vamos ser específicos:

Testes servem para:

Testes NÃO servem para:

Princípio central
O melhor teste é o que falha exatamente quando o comportamento errado acontece, e passa exatamente quando funciona. Falha quando código está bom = teste frágil. Passa quando código está quebrado = teste cego. Os dois extremos prejudicam.

11.3 Pirâmide e diamante — proporções saudáveis

Mike Cohn, em Succeeding with Agile (2009), popularizou a metáfora da pirâmide de testes. A intuição: muitos testes unitários (rápidos, baratos), alguns de integração (médios), poucos end-to-end (lentos, caros).

piramide.txt
            ┌─────────┐
            │   E2E   │   ← poucos, lentos, frágeis
            ├─────────┤
            │  Integ  │   ← alguns, médios
            ├─────────┤
            │ Unitário│   ← muitos, rápidos, isolados
            └─────────┘

Hoje, com frameworks que tornam testes de integração rápidos (containers em segundos, bancos in-memory), muitos times adotam o "diamante de testes": maioria dos testes ficam na camada de integração, com poucos unitários (só para lógica densa) e poucos E2E. A razão: testes de integração capturam interações reais; testes unitários sobre código simples são frequentemente teste tautológico.

Não há resposta universal. O que importa: conhecer o trade-off de cada nível e escolher conscientemente.

11.4 Testes unitários — feitos certo

"Unitário" geralmente significa "testa uma unidade isoladamente". O problema é definir "unidade". Duas escolas:

Em Python moderno, com classes pequenas e dataclasses, frequentemente a escola sociable é mais prática. Mas vamos cobrir os dois.

test_pedido.py — pytest
import pytest
from decimal import Decimal
from meuapp.pedido import Pedido, Item, PedidoVazio, PedidoJaFechado

def test_pedido_recem_criado_esta_vazio():
    p = Pedido()
    assert p.subtotal == Decimal("0")
    assert p.status == "aberto"

def test_adicionar_item_aumenta_subtotal():
    p = Pedido()
    p.adicionar_item(Item("sku1", "Caneta", Decimal("5"), 2))
    assert p.subtotal == Decimal("10")

def test_fechar_pedido_vazio_lanca_excecao():
    p = Pedido()
    with pytest.raises(PedidoVazio):
        p.fechar()

def test_adicionar_item_em_pedido_fechado_lanca():
    p = Pedido()
    p.adicionar_item(Item("sku1", "X", Decimal("5"), 1))
    p.fechar()
    with pytest.raises(PedidoJaFechado):
        p.adicionar_item(Item("sku2", "Y", Decimal("3"), 1))

@pytest.mark.parametrize("qtd,esperado", [
    (1, Decimal("5")),
    (2, Decimal("10")),
    (10, Decimal("50")),
    (100, Decimal("500")),
])
def test_subtotal_proporcional_a_quantidade(qtd, esperado):
    p = Pedido()
    p.adicionar_item(Item("sku", "X", Decimal("5"), qtd))
    assert p.subtotal == esperado

Padrão AAA — Arrange, Act, Assert

Cada teste deve ter três fases visíveis: Arrange (montar o estado), Act (executar a operação testada), Assert (verificar resultado). Quando essas fases ficam misturadas, o teste é difícil de ler.

aaa.py
def test_aplicar_desconto_reduz_total():
    # Arrange
    pedido = Pedido()
    pedido.adicionar_item(Item("x", "X", Decimal("100"), 1))
    politica = DescontoPercentual(Decimal("0.10"))

    # Act
    desconto = politica.aplicar(pedido)

    # Assert
    assert desconto == Decimal("10")

Nomeação de testes

Bons nomes: test_ + condição + resultado esperado. Quando o teste falha, o nome já te diz o que quebrou:

11.5 Testes de integração

Testes de integração verificam que partes do sistema conversam corretamente: seu código + banco real, seu código + API externa, seu código + fila de mensagens. Use containers efêmeros (Docker via testcontainers) ou ambientes in-memory (SQLite no lugar de Postgres em alguns casos) para manter rápidos.

test_repo_integracao.py
import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()

@pytest.fixture
def conn(postgres):
    c = psycopg2.connect(postgres)
    with c.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS pedidos (
                id TEXT PRIMARY KEY, total NUMERIC
            )
        """)
    c.commit()
    yield c
    with c.cursor() as cur:
        cur.execute("TRUNCATE pedidos")
    c.commit()
    c.close()

def test_repo_persiste_e_recupera(conn):
    repo = RepoPedidosPostgres(conn)
    p = Pedido("P-001", Decimal("150"))
    repo.salvar(p)
    recuperado = repo.buscar("P-001")
    assert recuperado.total == p.total

Container sobe uma vez por sessão (rápido). Banco é limpo entre testes. Você testa contra Postgres real — pega bugs que SQLite mascararia (tipos diferentes, dialetos SQL diferentes).

11.6 Testes end-to-end

E2E testam o sistema inteiro como um usuário externo veria: dispara requisição HTTP, verifica resposta, talvez inspeciona estado em banco. Lentos, frágeis, caros — mas pegam bugs que nenhum outro tipo pega: bugs de configuração, de roteamento, de integração de subsistemas.

test_e2e.py
import pytest
from fastapi.testclient import TestClient
from meuapp.main import app

@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c

def test_fluxo_completo_criar_pedido(client):
    # Cria pedido
    r = client.post("/pedidos", json={"cliente_id": "c1"})
    assert r.status_code == 201
    pedido_id = r.json()["id"]

    # Adiciona item
    r = client.post(f"/pedidos/{pedido_id}/itens",
                    json={"sku": "X", "qtd": 2})
    assert r.status_code == 200

    # Fecha
    r = client.post(f"/pedidos/{pedido_id}/fechar")
    assert r.status_code == 200

    # Consulta — deve estar fechado
    r = client.get(f"/pedidos/{pedido_id}")
    assert r.json()["status"] == "fechado"

Use E2E com parcimônia. Cada teste E2E é caro: lento, sensível a flakiness (timeouts, ordem, estado compartilhado), difícil de debugar quando falha. Mantenha um conjunto pequeno cobrindo os fluxos críticos do produto, não cada feature.

11.7 Fixtures, mocks, stubs, fakes

Terminologia confusa. Vamos esclarecer:

fakes_vs_mocks.py
from unittest.mock import Mock

# Fake — implementação real, leve
class RepoMemoria:
    def __init__(self):
        self._dados: dict = {}
    def salvar(self, p):
        self._dados[p.id] = p
    def buscar(self, id):
        return self._dados.get(id)

def test_servico_com_fake():
    repo = RepoMemoria()
    servico = ServicoPedido(repo)
    servico.criar("P-001")
    assert repo.buscar("P-001") is not None

# Mock — verifica interação
def test_servico_com_mock():
    repo = Mock()
    repo.buscar.return_value = None  # stub

    servico = ServicoPedido(repo)
    servico.criar("P-001")

    repo.salvar.assert_called_once()  # verificação
    args = repo.salvar.call_args.args
    assert args[0].id == "P-001"

Quando usar fake, quando usar mock

Cuidado com mock excessivo
Código que precisa de 8 mocks por teste tem cheiro de design ruim — muitas dependências, baixa coesão. Tente reorganizar antes de criar mais mocks. Fakes frequentemente são alternativa melhor: testam comportamento real, não trama de chamadas.

11.8 TDD — honestamente

O ciclo Red-Green-Refactor: (1) escreva teste que falha, (2) escreva código mínimo para passar, (3) refatore mantendo testes verdes.

TDD funciona muito bem em certos contextos: lógica densa com regras bem definidas, refatoração de código legado (escreva teste para o comportamento atual, refatore com proteção), APIs com contrato claro.

TDD funciona mal em outros: exploração de domínio que você ainda não entende (você escreveria os testes errados), UIs onde "como ficou" é a parte difícil, integração com sistemas externos cujo comportamento você está descobrindo.

A verdade honesta da indústria: poucos times praticam TDD estrito. Muitos praticam "test-after" — escrevem código, depois testes. Algumas pessoas alternam — TDD para lógica densa, test-after para resto. O importante é que testes existam, não o momento exato em que foram escritos.

O que dura do TDD
Mesmo quem não pratica TDD ortodoxo absorveu lições centrais: pensar em comportamento antes de implementação, escrever código testável, manter ciclo curto. Esses hábitos sobreviveram à modinha; o ritual literal nem sempre.

11.9 Property-based testing

Teste tradicional: você fornece exemplos específicos. Property-based: você descreve propriedades que devem valer para qualquer input, e a biblioteca gera centenas de exemplos automaticamente, incluindo casos extremos que você esqueceria.

Em Python, hypothesis é o padrão.

property_based.py
from hypothesis import given, strategies as st

# Em vez de testar exemplos específicos:
def test_inverter_string_duas_vezes_retorna_original_exemplos():
    assert reverse(reverse("abc")) == "abc"
    assert reverse(reverse("")) == ""
    assert reverse(reverse("a")) == "a"

# Descreve a propriedade:
@given(st.text())
def test_inverter_duas_vezes_retorna_original(s):
    assert reverse(reverse(s)) == s
    # Hypothesis vai gerar centenas de strings, incluindo:
    # vazia, unicode, com null bytes, com newlines, gigantes...

@given(st.lists(st.integers()))
def test_ordenar_idempotente(xs):
    # Ordenar duas vezes é igual a ordenar uma
    assert sorted(sorted(xs)) == sorted(xs)

@given(st.lists(st.integers()))
def test_ordenar_preserva_tamanho(xs):
    assert len(sorted(xs)) == len(xs)

@given(
    valor=st.decimals(min_value=0, max_value=10000, places=2),
    aliquota=st.decimals(min_value=0, max_value=1, places=4),
)
def test_imposto_nao_excede_valor(valor, aliquota):
    imposto = calcular_imposto(valor, aliquota)
    assert 0 <= imposto <= valor

Property-based brilha em código que processa estruturas de dados, parsers, codificadores/decodificadores, qualquer função pura com domínio amplo. Não é substituto de testes baseados em exemplos — é complemento.

11.10 Cobertura — o que medir importa

Métrica clássica: porcentagem de linhas executadas pelos testes. Útil como sinal de alerta — se uma área tem 0% de cobertura, há lacuna. Mas é fácil de falsificar: testes que executam código sem verificar comportamento sobem o número e não pegam nada.

Métricas mais informativas:

Anti-padrão: cobertura como meta

"Vamos ficar acima de 80%" vira pressão para escrever testes triviais que executam código sem assert significativo. Resultado: número alto, proteção real baixa. Use cobertura para encontrar lacunas, não como métrica de sucesso.

11.11 Estudo de caso — montando suíte de testes do zero

Sistema de carrinho: testes em três níveis

Vamos montar suíte completa para um sistema de carrinho — unitários para o domínio, integração para persistência, E2E para o fluxo HTTP.

Passo 1 · Unitários do agregado
test_carrinho.py
import pytest
from decimal import Decimal
from meuapp.carrinho import Carrinho, LinhaCarrinho, CarrinhoFechado

class TestCarrinhoNovo:
    def test_inicia_vazio_e_aberto(self):
        c = Carrinho()
        assert c.subtotal == Decimal("0")
        assert len(c.linhas) == 0

class TestAdicionarItem:
    def test_adicionar_aumenta_subtotal(self):
        c = Carrinho()
        c.adicionar(LinhaCarrinho("X", "X", Decimal("10"), 2))
        assert c.subtotal == Decimal("20")

    def test_mesmo_sku_incrementa_quantidade(self):
        c = Carrinho()
        c.adicionar(LinhaCarrinho("X", "X", Decimal("10"), 1))
        c.adicionar(LinhaCarrinho("X", "X", Decimal("10"), 2))
        assert len(c.linhas) == 1
        assert c.linhas[0].quantidade == 3

    @pytest.mark.parametrize("qtd", [0, -1, -100])
    def test_quantidade_invalida_lanca(self, qtd):
        c = Carrinho()
        with pytest.raises(ValueError):
            c.adicionar(LinhaCarrinho("X", "X", Decimal("10"), qtd))

class TestFecharCarrinho:
    def test_fechado_nao_aceita_modificacao(self):
        c = Carrinho()
        c.adicionar(LinhaCarrinho("X", "X", Decimal("5"), 1))
        c.fechar()
        with pytest.raises(CarrinhoFechado):
            c.adicionar(LinhaCarrinho("Y", "Y", Decimal("3"), 1))
Passo 2 · Integração do repositório
test_repo_carrinho.py
def test_carrinho_persistido_volta_igual(repo):
    c = Carrinho()
    c.adicionar(LinhaCarrinho("X", "X", Decimal("10"), 2))
    repo.salvar(c)
    recuperado = repo.buscar(c.id)
    assert recuperado.subtotal == c.subtotal
    assert len(recuperado.linhas) == 1

def test_carrinho_inexistente_retorna_none(repo):
    assert repo.buscar("nao_existe") is None
Passo 3 · E2E do fluxo HTTP
test_api.py
def test_fluxo_completo(client):
    # Criar
    r = client.post("/carrinhos")
    cid = r.json()["id"]

    # Adicionar
    r = client.post(f"/carrinhos/{cid}/itens",
                    json={"sku": "X", "qtd": 2, "preco": "10.00"})
    assert r.status_code == 200

    # Conferir subtotal
    r = client.get(f"/carrinhos/{cid}")
    assert Decimal(r.json()["subtotal"]) == Decimal("20")

    # Fechar
    r = client.post(f"/carrinhos/{cid}/fechar")
    assert r.status_code == 200

    # Não pode mais adicionar
    r = client.post(f"/carrinhos/{cid}/itens",
                    json={"sku": "Y", "qtd": 1, "preco": "5.00"})
    assert r.status_code == 409

Pirâmide saudável: dezenas de unitários cobrindo regras do agregado, alguns de integração validando persistência, dois ou três E2E cobrindo o fluxo principal. Quando algum quebra, você sabe em que camada está o problema.

11.12 Erros comuns

Erro 1 · Testes acoplados à implementação

Teste que verifica "o método interno _validar() foi chamado" em vez de "o resultado é o esperado". Refatoração troca método interno e teste quebra sem o sistema ter quebrado. Teste comportamento, não implementação.

Erro 2 · Setup gigante

Teste com 50 linhas de setup para 3 linhas de verificação. Sinal de design ruim do sistema testado, ou de teste no nível errado (devia ser de integração, não unitário, ou usa fake em vez de mock).

Erro 3 · Testes flaky

Teste que às vezes passa, às vezes falha. Causas comuns: dependência de tempo (sleeps), ordem entre testes, estado compartilhado, concorrência. Flaky é pior que vermelho — você aprende a ignorar.

Erro 4 · Testar getters/setters

Teste tautológico: obj.x = 5; assert obj.x == 5. Não pega bug, só sobe cobertura. Teste comportamento, não estrutura interna.

11.13 Quando NÃO escrever testes

Reconheça o contexto
Onde testes têm pouco valor
  • Scripts one-off: migração que você roda uma vez. Validar o resultado manualmente é mais rápido.
  • Protótipos descartáveis: código para experimentar uma hipótese. Quando virar produto, aí escreve.
  • Glue code trivial: função que só repassa para outra. Teste seria tautológico.
  • Geração de HTML/template: "ficou bonito" não é testável; é trabalho de revisão visual.
  • Código que depende fortemente de UI/UX: testes E2E ajudam pouco; teste de usabilidade vale mais.

Para o resto — código com lógica, com integração, com regras de negócio, com persistência — testes pagam. Faça.

Verifique seu entendimento
"Sua suíte tem 95% de cobertura, mas regressões críticas escapam regularmente para produção. Qual diagnóstico provável?"

11.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Testes parametrizados

Escreva, com @pytest.mark.parametrize, testes para função eh_par(n) cobrindo: positivo par, positivo ímpar, zero, negativo par, negativo ímpar.

test_eh_par.py
import pytest
from meuapp import eh_par

@pytest.mark.parametrize("n,esperado", [
    (2, True),
    (3, False),
    (0, True),
    (-4, True),
    (-7, False),
])
def test_eh_par(n, esperado):
    assert eh_par(n) == esperado
Fácil
Exercício 2 · Fake repositório

Implemente RepoUsuariosMemoria que satisfaz Protocol RepoUsuarios com métodos salvar(u), buscar(id), existe_email(email). Use para testar ServicoCadastro sem tocar em banco.

fake_repo.py
class RepoUsuariosMemoria:
    def __init__(self):
        self._por_id: dict = {}
        self._por_email: dict = {}

    def salvar(self, u):
        self._por_id[u.id] = u
        self._por_email[u.email] = u

    def buscar(self, id):
        return self._por_id.get(id)

    def existe_email(self, email):
        return email in self._por_email

def test_cadastro_com_email_duplicado_falha():
    repo = RepoUsuariosMemoria()
    repo.salvar(Usuario("u1", "a@b.com"))
    servico = ServicoCadastro(repo, ...)
    with pytest.raises(ValueError):
        servico.cadastrar("Bob", "a@b.com", "senha123")
Médio
Exercício 3 · Property-based para soma de Decimal

Use hypothesis para escrever propriedades de somar_valores(lista): (a) somar lista vazia retorna 0; (b) somar lista com um item retorna o item; (c) ordem dos elementos não muda o resultado (comutatividade); (d) somar duas sublistas separadamente e depois somar = somar a lista toda (associatividade).

test_soma.py
from hypothesis import given, strategies as st
from decimal import Decimal

decimais = st.decimals(min_value=-10000, max_value=10000, places=2)

def test_vazio_retorna_zero():
    assert somar_valores([]) == Decimal("0")

@given(decimais)
def test_um_item_retorna_o_item(x):
    assert somar_valores([x]) == x

@given(st.lists(decimais))
def test_comutativa(xs):
    import random
    embaralhada = xs[:]
    random.shuffle(embaralhada)
    assert somar_valores(xs) == somar_valores(embaralhada)

@given(st.lists(decimais), st.lists(decimais))
def test_associativa(a, b):
    assert somar_valores(a + b) == somar_valores(a) + somar_valores(b)
Difícil
Exercício 4 · Suíte para circuit breaker

Escreva suíte completa para o CircuitBreaker do capítulo 4: estado inicial é FECHADO; abre após N falhas consecutivas; depois de timeout vai para MEIO_ABERTO; em MEIO_ABERTO, sucesso volta a FECHADO e falha volta a ABERTO; sucesso intercalado reseta contador de falhas. Use monkeypatch para controlar tempo.

test_cb.py
import pytest
from meuapp.cb import CircuitBreaker, EstadoCB, CircuitoAberto

class TestCircuitBreaker:
    def test_estado_inicial_fechado(self):
        cb = CircuitBreaker(max_falhas=3, timeout=10)
        assert cb.estatisticas().estado == EstadoCB.FECHADO

    def test_abre_apos_n_falhas_consecutivas(self):
        cb = CircuitBreaker(max_falhas=3, timeout=10)
        def falha():
            raise RuntimeError()
        for _ in range(3):
            with pytest.raises(RuntimeError):
                cb.chamar(falha)
        assert cb.estatisticas().estado == EstadoCB.ABERTO

    def test_aberto_recusa_chamadas(self):
        cb = CircuitBreaker(max_falhas=1, timeout=10)
        with pytest.raises(RuntimeError):
            cb.chamar(lambda: (_ for _ in ()).throw(RuntimeError()))
        with pytest.raises(CircuitoAberto):
            cb.chamar(lambda: "qualquer coisa")

    def test_volta_para_meio_aberto_apos_timeout(self, monkeypatch):
        import time
        agora = [100.0]
        monkeypatch.setattr(time, "time", lambda: agora[0])

        cb = CircuitBreaker(max_falhas=1, timeout=5.0)
        with pytest.raises(RuntimeError):
            cb.chamar(lambda: (_ for _ in ()).throw(RuntimeError()))
        assert cb.estatisticas().estado == EstadoCB.ABERTO

        agora[0] += 6  # passa o timeout
        resultado = cb.chamar(lambda: "ok")
        assert resultado == "ok"
        assert cb.estatisticas().estado == EstadoCB.FECHADO

    def test_sucesso_reseta_contador(self):
        cb = CircuitBreaker(max_falhas=3, timeout=10)
        def falha():
            raise RuntimeError()
        with pytest.raises(RuntimeError):
            cb.chamar(falha)
        with pytest.raises(RuntimeError):
            cb.chamar(falha)
        cb.chamar(lambda: "ok")  # sucesso reseta
        with pytest.raises(RuntimeError):
            cb.chamar(falha)
        assert cb.estatisticas().estado == EstadoCB.FECHADO
Fim do capítulo 11
Próximo capítulo: refatoração disciplinada — como melhorar código sem quebrar, com testes te protegendo.
Parte III · Capítulo 12 · Qualidade e evolução

Refatoração
disciplinada:
melhorar sem quebrar.

Refatorar é mudar a forma do código sem mudar o que ele faz. Quando feito com método e testes, é a operação mais segura de engenharia de software. Quando feito por impulso, é a forma mais comum de quebrar produção.

"Refatorar" virou palavra abusada. Frequentemente as pessoas chamam de refatoração qualquer mudança que envolva mexer em código existente — incluindo reescrever pedaços inteiros, alterar comportamento, ou "limpar enquanto" implementam feature nova. Refatoração de verdade é mais estrita, e justamente por isso mais útil: você sabe que o sistema continua se comportando igual, porque nada do que ele faz mudou.

12.1 A história — de "limpar" a "catálogo"

Contexto histórico

O termo "refactoring" apareceu em uso técnico nos anos 80, na comunidade Smalltalk. Mas foi Martin Fowler, em Refactoring: Improving the Design of Existing Code (1999), que sistematizou: refatoração é uma técnica disciplinada, com passos pequenos, cada um preservando comportamento, com testes verificando.

Fowler catalogou dezenas de refatorações com nomes (Extract Method, Inline Variable, Move Field, Replace Conditional with Polymorphism, etc), cada uma com receita: motivo, mecânica passo a passo, exemplo. O nome dá vocabulário; a receita dá método.

Em paralelo, IDEs começaram a automatizar refatorações: rename simbólico, extract method, move method. PyCharm, VS Code com Pylance, IntelliJ — todas oferecem isso. O que era hesitação manual ("será que mover isso quebra outro lugar?") virou clique seguro.

Em 2018, Fowler publicou a segunda edição com exemplos em JavaScript e refinamentos. A mensagem central segue intacta: refatoração não é projeto pré-feito; é a forma como design emerge do código existente.

12.2 Disciplina vs. reescrita

Há duas coisas que se confundem e não deveriam:

Joel Spolsky, em texto famoso de 2000, alertou contra "the single worst strategic mistake that any software company can make": jogar fora código que funciona para reescrever. A razão: o código antigo, por mais feio, acumulou anos de correção de bugs específicos, condições estranhas, regras de negócio invisíveis. Reescrever cria sistema novo que vai redescobrir tudo isso na dor.

Refatoração é a alternativa. Você muda gradualmente, preservando todo o conhecimento acumulado, testando cada passo. Em meses, o sistema fica irreconhecivelmente melhor — sem nunca ter ficado pior.

Regra fundamental
Não refatore e adicione feature ao mesmo tempo. São dois "chapéus": chapéu de refatoração (não muda comportamento, testes passam continuamente) e chapéu de feature (muda comportamento). Misturar é receita para confusão: quando teste quebrar, você não sabe se foi a feature ou a refatoração. Faça um, depois o outro.

12.3 Os passos seguros

Qualquer refatoração disciplinada segue mais ou menos esse fluxo:

  1. Garanta testes que cobrem o comportamento atual. Se não existem, escreva primeiro (testes de caracterização — veremos adiante).
  2. Identifique o cheiro que justifica a refatoração. "Vou refatorar porque está feio" não basta; precisa ter um problema concreto que isso resolve.
  3. Escolha uma refatoração nomeada do catálogo. Cada uma tem mecânica conhecida.
  4. Execute em passos pequenos, rodando testes a cada passo. Se quebrou, você sabe o que foi.
  5. Comite cada passo. Reverter um commit pequeno é trivial; reverter "refatorei a tarde toda" é pesadelo.
  6. Pare quando satisfeito. Refatoração tem custo de tempo e atenção. Não precisa "deixar perfeito" — precisa deixar melhor que estava.

12.4 Extract Method / Extract Class

A refatoração mais comum. Bloco de código com identidade própria — um cálculo, uma validação, uma transformação — vira função (ou classe, se carregar estado também).

✗ Função inchada
antes.py
def imprimir_fatura(pedido):
    # cabeçalho
    print(f"Pedido: {pedido.id}")
    print(f"Cliente: {pedido.cliente}")
    print("-" * 40)

    # cálculo do subtotal
    subtotal = Decimal("0")
    for item in pedido.itens:
        subtotal += item.preco * item.qtd

    # aplicação de desconto
    desconto = Decimal("0")
    if subtotal > Decimal("500"):
        desconto = subtotal * Decimal("0.10")
    elif subtotal > Decimal("200"):
        desconto = subtotal * Decimal("0.05")

    # cálculo de frete
    if pedido.cep.startswith("0"):
        frete = Decimal("10")
    else:
        frete = Decimal("20")

    # total e impressão
    total = subtotal - desconto + frete
    print(f"Subtotal: R$ {subtotal}")
    print(f"Desconto: R$ {desconto}")
    print(f"Frete: R$ {frete}")
    print(f"Total: R$ {total}")
✓ Extraído
depois.py
def imprimir_fatura(pedido):
    _imprimir_cabecalho(pedido)
    subtotal = _calcular_subtotal(pedido)
    desconto = _calcular_desconto(subtotal)
    frete = _calcular_frete(pedido.cep)
    total = subtotal - desconto + frete
    _imprimir_totais(subtotal, desconto, frete, total)

def _imprimir_cabecalho(pedido):
    print(f"Pedido: {pedido.id}")
    print(f"Cliente: {pedido.cliente}")
    print("-" * 40)

def _calcular_subtotal(pedido):
    return sum(
        (i.preco * i.qtd for i in pedido.itens),
        Decimal("0"),
    )

def _calcular_desconto(subtotal):
    if subtotal > Decimal("500"):
        return subtotal * Decimal("0.10")
    if subtotal > Decimal("200"):
        return subtotal * Decimal("0.05")
    return Decimal("0")

def _calcular_frete(cep):
    return Decimal("10") if cep.startswith("0") else Decimal("20")

def _imprimir_totais(subtotal, desconto, frete, total):
    print(f"Subtotal: R$ {subtotal}")
    print(f"Desconto: R$ {desconto}")
    print(f"Frete: R$ {frete}")
    print(f"Total: R$ {total}")

Mecânica passo a passo

  1. Identifique o bloco com identidade clara (geralmente precedido por comentário).
  2. Crie função com nome que descreve o que faz, não como.
  3. Mova o bloco; identifique variáveis usadas (viram parâmetros) e variável produzida (vira retorno).
  4. Rode os testes. Verde? Comita. Vermelho? Reverte e tenta de novo menor.
  5. Próximo bloco.

Extract Class é o equivalente para quando o bloco extraído precisa de estado próprio. Cinco campos e três métodos relacionados saindo de uma classe maior viram nova classe.

12.5 Inline — o inverso de Extract

Função ou variável que existe sem ganhar você nada — só adiciona indireção. Inline traz o conteúdo de volta ao ponto de uso.

inline.py
# Antes — indireção desnecessária
def _eh_negativo(x):
    return x < 0

def processar(valor):
    if _eh_negativo(valor):
        return -valor
    return valor

# Depois — inline
def processar(valor):
    if valor < 0:
        return -valor
    return valor

Inline e Extract são forças opostas. Você usa Extract quando bloco merece nome próprio, Inline quando o nome existente não está agregando valor. Equilíbrio depende do contexto.

12.6 Rename — barato e poderoso

Mudar nome de variável, função, classe, módulo. Parece trivial, é uma das refatorações mais impactantes — código bem nomeado é metade do entendimento.

IDEs fazem rename simbólico (renomeia todos os usos automaticamente). Use isso, não search-and-replace textual: o IDE entende escopo, evita confundir variáveis com mesmo nome em contextos diferentes.

rename.py
# Antes — nome sem significado
def calc(x, y):
    z = x * 0.18 + y
    return z

# Depois — nomes que descrevem domínio
def calcular_total_com_icms(subtotal, frete):
    icms = subtotal * ALIQUOTA_ICMS
    return icms + frete

Sinais de que um nome precisa de rename: você precisa abrir a implementação para entender o que faz; o nome usa abreviações que ninguém mais usa; o nome não bate com o que o código realmente faz hoje (provavelmente o comportamento mudou e o nome ficou para trás).

12.7 Move Method / Move Field

Método ou campo está numa classe, mas se relaciona mais com outra. Move-se para onde pertence. Sinal clássico: classe A acessa pesadamente atributos de classe B.

move.py
# Antes — Pedido decide se cliente é fiel
class Pedido:
    def cliente_eh_fiel(self):
        return (
            self.cliente.compras_total > 10
            and self.cliente.cadastro_anos > 2
        )
# Método mexe só em atributos de cliente, nada de pedido.

# Depois — método mora em Cliente
class Cliente:
    def eh_fiel(self):
        return self.compras_total > 10 and self.cadastro_anos > 2

class Pedido:
    # pedido fica mais magro; cliente sabe das coisas dele
    ...

# Uso: pedido.cliente.eh_fiel()

12.8 Replace Conditional with Polymorphism

Você tem if/elif baseado em tipo ou estado discreto, e a mesma estrutura aparece em vários lugares. Refatoração para polimorfismo (frequentemente Strategy ou State).

✗ Conditional espalhado
antes.py
def calcular_salario(funcionario):
    if funcionario.tipo == "CLT":
        return funcionario.base + funcionario.base * 0.08
    if funcionario.tipo == "PJ":
        return funcionario.base
    if funcionario.tipo == "ESTAGIO":
        return funcionario.base * 0.5

def ferias(funcionario):
    if funcionario.tipo == "CLT": return 30
    if funcionario.tipo == "PJ": return 0
    if funcionario.tipo == "ESTAGIO": return 15
✓ Polimorfismo
depois.py
class RegimeContratacao(Protocol):
    def salario(self, base): ...
    def dias_ferias(self): ...

class CLT:
    def salario(self, base):
        return base * Decimal("1.08")
    def dias_ferias(self):
        return 30

class PJ:
    def salario(self, base):
        return base
    def dias_ferias(self):
        return 0

class Estagio:
    def salario(self, base):
        return base * Decimal("0.5")
    def dias_ferias(self):
        return 15

def calcular_salario(f):
    return f.regime.salario(f.base)

def ferias(f):
    return f.regime.dias_ferias()

Atenção: só faça essa refatoração quando o mesmo if/elif aparece em vários lugares. Um único if/elif sobre tipo é OK; a refatoração só ganha quando elimina duplicação.

12.9 Introduce Parameter Object

Função recebendo 5+ parâmetros, vários sempre passados juntos. Esses parâmetros formam um conceito que merece nome próprio.

parameter_object.py
# Antes
def criar_evento(titulo, data_inicio, hora_inicio, data_fim, hora_fim, tz):
    ...

criar_evento("Reunião", "2026-05-20", "14:00", "2026-05-20", "15:00", "America/Sao_Paulo")

# Depois
@dataclass(frozen=True)
class Periodo:
    inicio: datetime
    fim: datetime

    def __post_init__(self):
        if self.fim <= self.inicio:
            raise ValueError("fim antes do início")

    @property
    def duracao(self):
        return self.fim - self.inicio

def criar_evento(titulo: str, periodo: Periodo):
    ...

# Ganho extra: Periodo agora é reutilizável,
# carrega validação (fim > início), e métodos úteis (duração).

12.10 Refatorando legado sem testes

Cenário real: você herda código sem testes, com bugs documentados, e precisa mexer. Como refatorar com segurança?

Michael Feathers, em Working Effectively with Legacy Code (2004), deu a definição clássica: "código legado é código sem testes". A estratégia é construir rede de teste antes de qualquer mudança.

Testes de caracterização

Em vez de testar "o que deveria fazer", você testa "o que faz hoje" — mesmo que seja errado. O objetivo não é validar correção, é congelar comportamento para você poder refatorar sem mudar nada acidentalmente.

caracterizacao.py
# Função misteriosa que você herdou
def calcular_taxa_estranha(valor, codigo):
    # Você não entende. Rode com casos e veja o que sai.
    ...

# Teste de caracterização — não julga, só captura
@pytest.mark.parametrize("valor,codigo,esperado", [
    (Decimal("100"), "A1", Decimal("118")),
    (Decimal("100"), "A2", Decimal("110")),
    (Decimal("100"), "X9", Decimal("127.05")),  # estranho mas é o que sai
    (Decimal("0"), "A1", Decimal("0")),
    (Decimal("50"), "B0", Decimal("50")),
])
def test_caracterizacao(valor, codigo, esperado):
    assert calcular_taxa_estranha(valor, codigo) == esperado

Esses testes parecem inúteis — só repetem o output atual. Mas servem para uma coisa: enquanto você refatora, eles falham se você acidentalmente mudar comportamento. Quando ficar entendendo o código, alguns dos "esperados" podem ser de fato bugs antigos; aí você corrige conscientemente, atualizando o teste com nota.

Seams — pontos de costura para inserir testes

Código legado frequentemente é difícil de testar por causa de dependências hardcoded (conexão de banco criada dentro da função, chamada HTTP direta, etc). Feathers propõe identificar seams — pontos onde dá pra interceptar comportamento sem reescrever:

seams.py
# Original — difícil de testar
def processar_pedido(pedido_id):
    conn = psycopg2.connect("prod-db")
    # ...

# Refatoração mínima — introduz object seam SEM reescrever
def processar_pedido(pedido_id, conn=None):
    if conn is None:
        conn = psycopg2.connect("prod-db")
    # ...

# Agora dá para testar passando fake
def test_processar_pedido():
    processar_pedido("P-1", conn=ConnFake())

Essa "refatoração mínima para testabilidade" é o primeiro passo. Depois que tem teste, faça refatoração de verdade com tranquilidade.

12.11 Estudo de caso — refatoração de uma função real

Função de processamento de pedido em passos sucessivos

Vamos refatorar uma função realmente ruim, passo a passo, comitando cada um. O objetivo é mostrar a disciplina, não só o resultado final.

Estado inicial · Função de 80 linhas
v0.py
def processar(p):
    # Valida
    if not p["itens"]:
        raise ValueError("vazio")
    for i in p["itens"]:
        if i["preco"] <= 0:
            raise ValueError("preço inválido")
        if i["qtd"] <= 0:
            raise ValueError("quantidade inválida")

    # Calcula subtotal
    sub = 0
    for i in p["itens"]:
        sub += i["preco"] * i["qtd"]

    # Aplica cupom
    desc = 0
    if p.get("cupom") == "PRIMEIRA10" and sub >= 50:
        desc = sub * 0.10
    elif p.get("cupom") == "BLACK20" and sub >= 100:
        desc = sub * 0.20

    # Calcula frete
    cep = p["cep"]
    if cep.startswith("0"):
        frete = 10
    elif cep.startswith("9"):
        frete = 20
    else:
        frete = 15

    # Salva no banco
    conn = psycopg2.connect("...")
    cur = conn.cursor()
    cur.execute("INSERT INTO pedidos...", (p["id"], sub - desc + frete))
    conn.commit()

    # Envia email
    smtp = smtplib.SMTP("...", 587)
    smtp.sendmail("sis@", p["email"], f"Total: {sub - desc + frete}")

    return sub - desc + frete
Passo 1 · Testes de caracterização
test_caracterizacao.py
def test_pedido_normal(fake_conn, fake_smtp):
    p = {"id": "X", "itens": [{"preco": 50, "qtd": 2}],
         "cep": "01000-000", "email": "x@y"}
    total = processar(p, conn=fake_conn, smtp=fake_smtp)
    assert total == 110  # 100 + 10 frete

def test_pedido_com_cupom(fake_conn, fake_smtp):
    p = {"id": "X", "itens": [{"preco": 50, "qtd": 2}],
         "cep": "01000-000", "email": "x@y", "cupom": "PRIMEIRA10"}
    assert processar(p, conn=fake_conn, smtp=fake_smtp) == 100

# ... 8-10 cenários cobrindo cupons, CEPs, falhas

Antes disso, refatoração mínima: aceitar conn e smtp como parâmetros opcionais (seams).

Passo 2 · Extract Method para validação
v1.py
def processar(p, conn, smtp):
    _validar(p)
    # resto igual...

def _validar(p):
    if not p["itens"]:
        raise ValueError("vazio")
    for i in p["itens"]:
        if i["preco"] <= 0:
            raise ValueError("preço inválido")
        if i["qtd"] <= 0:
            raise ValueError("quantidade inválida")

Rode os testes. Verde. Comita.

Passo 3 · Extract Method para cálculos

Mesmo padrão: _calcular_subtotal, _calcular_desconto, _calcular_frete. Cada uma vira função pura, recebendo o necessário, retornando o resultado. Comita cada uma.

Passo 4 · Substituir dicts por dataclass
v2.py
@dataclass(frozen=True)
class Item:
    preco: Decimal
    qtd: int

@dataclass(frozen=True)
class DadosPedido:
    id: str
    itens: tuple[Item, ...]
    cep: str
    email: str
    cupom: str | None = None

def processar(p: DadosPedido, conn, smtp) -> Decimal:
    _validar(p)
    sub = _calcular_subtotal(p.itens)
    desc = _calcular_desconto(sub, p.cupom)
    frete = _calcular_frete(p.cep)
    total = sub - desc + frete
    _persistir(conn, p.id, total)
    _notificar(smtp, p.email, total)
    return total

Cliente que chama precisa adaptar de dict para DadosPedido. Conversão acontece nos endpoints (camada de borda).

Passo 5 · Strategy para cupons

Agora os cupons estão isolados em _calcular_desconto. Quando o terceiro cupom aparecer ("BLACK_FRIDAY30"), refatoramos para Strategy:

v3.py
class Cupom(Protocol):
    def aplicar(self, subtotal: Decimal) -> Decimal: ...

class CupomPercentualMin:
    def __init__(self, percentual, minimo):
        self._p, self._min = percentual, minimo
    def aplicar(self, sub):
        if sub < self._min:
            return Decimal("0")
        return sub * self._p

CUPONS: dict[str, Cupom] = {
    "PRIMEIRA10": CupomPercentualMin(Decimal("0.10"), Decimal("50")),
    "BLACK20": CupomPercentualMin(Decimal("0.20"), Decimal("100")),
}

def _calcular_desconto(sub, cupom_codigo):
    if not cupom_codigo:
        return Decimal("0")
    cupom = CUPONS.get(cupom_codigo)
    return cupom.aplicar(sub) if cupom else Decimal("0")

Adicionar cupom novo: nova linha no dict. Zero alteração em processar.

Resultado cumulativo: de função de 80 linhas com 6 responsabilidades para função de 8 linhas orquestrando 6 funções/classes nomeadas. Cada passo foi commitado individualmente; cada um passou nos testes; se algum tivesse quebrado, revert seria trivial. Em nenhum momento o sistema deixou de funcionar.

12.12 Erros comuns

Erro 1 · Refatorar sem testes

"É mudança pequena, vai dar certo." Não vai. Antes de qualquer refatoração não trivial, garanta cobertura. Sem testes, não é refatoração — é aposta.

Erro 2 · Refatorar e adicionar feature ao mesmo tempo

Mistura dois "chapéus". Quando teste quebra, você não sabe se foi a refatoração ou a feature. Faça uma, comita; depois a outra.

Erro 3 · Passos grandes demais

"Vou refatorar a tarde toda e comitar no fim." Quando algo quebra perto do fim, você não sabe quando. Comite a cada 10-20 minutos. Pequeno, pequeno, pequeno.

Erro 4 · Querer deixar perfeito

"Vou refatorar até ficar bonito." Bonito é subjetivo, sem fim. Refatore com objetivo: "vou extrair essas três funções, depois paro". Quando atinge, pare. Volta outro dia.

12.13 Quando NÃO refatorar

Reconheça o contexto
Refatoração tem custo — vale onde?
  • Código estável e estável: não muda há anos, ninguém mexe nele, funciona. Não compensa o risco.
  • Código que vai morrer: sistema sendo desativado em 6 meses. Use o tempo para outra coisa.
  • Falta de testes E falta de tempo para escrever: sem rede, refatoração é arriscada. Se a urgência manda, faça mudança mínima.
  • Pressão de prazo crítico: sexta-feira à noite com cliente esperando não é hora de "limpar primeiro". Faça o necessário; refatore depois.
  • Time discorda do "melhor": antes de refatorar, alinhe direção. Refatorar para um lado e o time refatorar para outro vira churn.
Verifique seu entendimento
"Você precisa adicionar uma feature em código sem testes, com vários métodos de 200+ linhas. Qual a primeira coisa a fazer?"

12.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Extract Method

Refatore a função abaixo extraindo métodos com nomes que descrevem intenção. Não mude comportamento.

antes.py
def imprimir_extrato(transacoes):
    saldo = Decimal("0")
    for t in transacoes:
        if t.tipo == "credito":
            saldo += t.valor
        else:
            saldo -= t.valor
    print(f"Total de transações: {len(transacoes)}")
    print(f"Saldo final: R$ {saldo:.2f}")
    if saldo < 0:
        print("⚠️ SALDO NEGATIVO")
    elif saldo > 10000:
        print("✓ SALDO ALTO")
    else:
        print("saldo normal")
depois.py
def imprimir_extrato(transacoes):
    saldo = _calcular_saldo(transacoes)
    _imprimir_resumo(transacoes, saldo)
    _imprimir_alerta(saldo)

def _calcular_saldo(transacoes):
    return sum(
        (t.valor if t.tipo == "credito" else -t.valor
         for t in transacoes),
        Decimal("0"),
    )

def _imprimir_resumo(transacoes, saldo):
    print(f"Total de transações: {len(transacoes)}")
    print(f"Saldo final: R$ {saldo:.2f}")

def _imprimir_alerta(saldo):
    if saldo < 0:
        print("⚠️ SALDO NEGATIVO")
    elif saldo > 10000:
        print("✓ SALDO ALTO")
    else:
        print("saldo normal")
Fácil
Exercício 2 · Rename

Renomeie variáveis e função para nomes que descrevem domínio. Não mude estrutura.

antes.py
def proc(x, y, z):
    a = x * 0.0825
    b = y + a
    c = b - z
    return c if c > 0 else 0
depois.py
ALIQUOTA_ISS = Decimal("0.0825")

def calcular_total_a_receber(
    valor_servico: Decimal,
    custos_diretos: Decimal,
    retencao_ir: Decimal,
) -> Decimal:
    iss = valor_servico * ALIQUOTA_ISS
    bruto = custos_diretos + iss
    liquido = bruto - retencao_ir
    return max(liquido, Decimal("0"))
Médio
Exercício 3 · Replace Conditional with Polymorphism

Refatore para polimorfismo (Strategy via Protocol). Adicionar novo tipo de assinatura deve exigir apenas nova classe.

antes.py
def cobrar_mensalidade(assinatura):
    if assinatura.tipo == "basico":
        return Decimal("29.90")
    if assinatura.tipo == "premium":
        return Decimal("59.90")
    if assinatura.tipo == "familia":
        base = Decimal("79.90")
        return base + (assinatura.membros - 1) * Decimal("10")
    raise ValueError()

def limite_de_acesso(assinatura):
    if assinatura.tipo == "basico": return 1
    if assinatura.tipo == "premium": return 3
    if assinatura.tipo == "familia": return assinatura.membros
depois.py
from typing import Protocol

class PlanoAssinatura(Protocol):
    def mensalidade(self) -> Decimal: ...
    def limite_acesso(self) -> int: ...

class PlanoBasico:
    def mensalidade(self): return Decimal("29.90")
    def limite_acesso(self): return 1

class PlanoPremium:
    def mensalidade(self): return Decimal("59.90")
    def limite_acesso(self): return 3

class PlanoFamilia:
    def __init__(self, membros: int):
        if membros < 1:
            raise ValueError("mínimo 1 membro")
        self._membros = membros
    def mensalidade(self):
        return Decimal("79.90") + (self._membros - 1) * Decimal("10")
    def limite_acesso(self):
        return self._membros

def cobrar_mensalidade(plano: PlanoAssinatura):
    return plano.mensalidade()

def limite_de_acesso(plano: PlanoAssinatura):
    return plano.limite_acesso()
Difícil
Exercício 4 · Refatoração completa de legado

Você herdou esta função. Não tem testes. Faça: (1) refatoração mínima para criar seams, (2) testes de caracterização cobrindo casos, (3) extração progressiva de métodos, (4) substituição de dict por dataclass. Comite cada passo.

legado.py
def avaliar_credito(c):
    score = 500
    if c["renda"] > 10000: score += 200
    elif c["renda"] > 5000: score += 100
    if c["idade"] > 25: score += 50
    if c["tem_imovel"]: score += 100
    if c["dividas"] > c["renda"] * 3: score -= 300

    log = open("/var/log/credito.log", "a")
    log.write(f"{c['cpf']} score={score}\n")
    log.close()

    if score >= 700: return "APROVADO"
    if score >= 500: return "REVISAO"
    return "NEGADO"
refatorado_final.py
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol, Literal

@dataclass(frozen=True)
class Cliente:
    cpf: str
    renda: Decimal
    idade: int
    tem_imovel: bool
    dividas: Decimal

Decisao = Literal["APROVADO", "REVISAO", "NEGADO"]

SCORE_BASE = 300
BONUS_RENDA_ALTA = 200
BONUS_RENDA_MEDIA = 100
BONUS_IDADE_MADURA = 50
BONUS_IMOVEL = 100
PENALIDADE_ENDIVIDAMENTO = 300
LIMIAR_RENDA_ALTA = Decimal("10000")
LIMIAR_RENDA_MEDIA = Decimal("5000")
MULTIPLICADOR_DIVIDA_LIMITE = 3

class LoggerCredito(Protocol):
    def registrar(self, cpf: str, score: int) -> None: ...

def avaliar_credito(c: Cliente, logger: LoggerCredito) -> Decisao:
    score = _calcular_score(c)
    logger.registrar(c.cpf, score)
    return _decidir(score)

def _calcular_score(c: Cliente) -> int:
    score = SCORE_BASE
    score += _bonus_renda(c.renda)
    score += BONUS_IDADE_MADURA if c.idade > 25 else 0
    score += BONUS_IMOVEL if c.tem_imovel else 0
    score -= _penalidade_dividas(c.renda, c.dividas)
    return score

def _bonus_renda(renda: Decimal) -> int:
    if renda > LIMIAR_RENDA_ALTA:
        return BONUS_RENDA_ALTA
    if renda > LIMIAR_RENDA_MEDIA:
        return BONUS_RENDA_MEDIA
    return 0

def _penalidade_dividas(renda, dividas):
    if dividas > renda * MULTIPLICADOR_DIVIDA_LIMITE:
        return PENALIDADE_ENDIVIDAMENTO
    return 0

def _decidir(score: int) -> Decisao:
    if score >= 700: return "APROVADO"
    if score >= 500: return "REVISAO"
    return "NEGADO"

Score base ajustado de 500 para 300 + bônuses, mantendo total equivalente. Magic numbers viraram constantes. Logger injetado. Dict virou dataclass. Função decomposta. Cada passo seria um commit; aqui o resultado final está sintetizado.

Fim do capítulo 12
Próximo capítulo: code smells avançados — os sinais de degradação além dos óbvios.
Parte III · Capítulo 13 · Qualidade e evolução

Code smells:
os cheiros
do código degradado.

Code smells são sintomas, não doenças. Avisam que algo está acumulando custo de manutenção — mas não dizem o que. Reconhecê-los te dá vocabulário para apontar o problema antes que ele vire crise.

A metáfora do "cheiro" vem de Kent Beck. Diferente de "bug" (binário, presente ou ausente) ou de "violação" (julgamento moralista), cheiro é diagnóstico modesto: algo aqui sinaliza que pode estar errado. Pode ser, pode não ser. Você investiga. Senioridade técnica é, em parte, sensibilidade a esses sinais.

13.1 A história — Kent Beck no banheiro

Contexto histórico

Em Refactoring (1999), Martin Fowler atribui a Kent Beck a metáfora dos "code smells". A história é que Beck estava no banheiro, refletindo sobre como descrever sinais de código ruim sem ser dogmático, e a palavra "smell" surgiu naturalmente: você sente, é desconfortável, mas pode ser apenas leite estragado na geladeira — pode não ser nada grave.

O catálogo original de Fowler tinha 22 cheiros. Outros autores adicionaram ao longo dos anos: Joshua Kerievsky, em Refactoring to Patterns (2004), conectou cheiros a refatorações específicas. Sandi Metz, em palestras e livros, popularizou versões mais centradas em OOP idiomática.

Hoje, ferramentas estáticas (SonarQube, pylint, ruff) detectam automaticamente vários cheiros. Mas as ferramentas pegam só os superficiais — métodos longos, parâmetros demais. Os cheiros mais importantes — Feature Envy, Divergent Change, Primitive Obsession — exigem entendimento do domínio que só humano tem.

13.2 O que é (e o que não é) cheiro

Cheiro é indício, não prova. Não é "código ruim"; é "código que merece um olhar". Três características:

Cuidado com o moralismo
"Esse código tem cheiro" deve ser observação técnica, não julgamento. A pessoa que escreveu fez o melhor que conseguia naquele momento, com as restrições daquele momento. Cheiros viram problema acumulado; o trabalho é resolver, não culpar.

Vamos cobrir os cheiros mais úteis, agrupados por natureza.

13.3 Long Method (Bloater)

O cheiro

Método com 50, 100, 300 linhas. Lê-se com scroll. Faz "muitas coisas". Bug é difícil de localizar; mudanças têm efeitos colaterais imprevisíveis.

Por que aparece

Crescimento orgânico. Cada feature nova "adiciona umas linhas". Ninguém olha o método inteiro de uma vez. Em seis meses, é o monstro de 200 linhas que ninguém quer abrir.

Por que dói

Refatoração

Extract Method (capítulo 12) é a resposta direta. Cada bloco com identidade clara vira função com nome próprio. Frequentemente isso revela que o método na verdade era várias coisas misturadas.

Quando aceitar

Algoritmos genuinamente sequenciais (parser, máquina de estados, processamento em pipeline) podem ser longos sem cheiro real — extrair função para "um passo do parser" às vezes piora a legibilidade. Use bom senso.

13.4 Large Class (Bloater)

O cheiro

Classe com 500, 1.000, 2.000 linhas. Muitos atributos, muitos métodos. Frequentemente é a "classe central" do sistema — todo mundo conhece, ninguém quer mexer.

Já cobrimos isso como anti-padrão (God Object, capítulo 10). Como cheiro, o foco é diferente: você detecta cedo, antes de virar God Object. Sinais:

Refatoração

Extract Class. Identifique grupos coesos de atributos+métodos e mude para classe separada. Frequentemente Move Method para outra classe existente.

13.5 Long Parameter List (Bloater)

O cheiro

Função com 5, 7, 10 parâmetros. Quem chama precisa lembrar a ordem; quem mantém precisa entender o que cada um faz; refatorações ficam frágeis.

✗ Parâmetros demais
antes.py
def enviar_email(
    de_nome, de_email, para_nome, para_email,
    assunto, corpo, html, anexos, prioridade,
    smtp_host, smtp_port, smtp_user, smtp_pass,
):
    ...
✓ Agrupado
depois.py
@dataclass(frozen=True)
class Pessoa:
    nome: str
    email: str

@dataclass(frozen=True)
class Mensagem:
    assunto: str
    corpo: str
    html: bool = False
    anexos: tuple = ()
    prioridade: str = "normal"

class ServicoEmail:  # config SMTP no construtor
    def __init__(self, smtp_config: SmtpConfig):
        ...
    def enviar(self, de: Pessoa, para: Pessoa, m: Mensagem):
        ...

Refatorações

Cuidado pythônico

Em Python, kwargs nomeados mitigam parte do problema (enviar_email(de_nome=..., para_email=...) é legível). Mas seis ou mais ainda é sinal de design pulverizado — agrupe.

13.6 Divergent Change

O cheiro

Uma única classe muda por razões diferentes. Toda mudança em UI mexe nela, toda mudança em banco mexe nela, toda mudança em regra fiscal mexe nela. Cada feature da empresa toca essa classe por motivos não relacionados entre si.

É o sinal de SRP violado: "uma classe deve ter um motivo para mudar". Quando a mesma classe muda por motivos diferentes, ela tem responsabilidades demais.

Como detectar

Olhe o histórico do Git. Liste os últimos 20 commits que tocaram a classe. Quantos motivos diferentes aparecem? Se a resposta é "todos", você tem divergent change.

detectar.sh
# Listar commits que tocaram um arquivo + mensagem
git log --oneline -- meu_modulo/pedido.py | head -30

# Se vê coisas como:
# "fix: imposto SC corrigido"
# "feat: novo template de email de confirmação"
# "refactor: query do dashboard mais rápida"
# "feat: validação de CPF no cadastro"
# ... mistura clara de responsabilidades.

Refatoração

Split Class. Identifique os grupos de mudança e separe em classes responsáveis por cada um. Frequentemente uma God Object emergindo.

13.7 Shotgun Surgery

O cheiro

Inverso do anterior: uma mudança precisa ser feita em muitos lugares. Trocar nome de um campo do pedido exige editar 12 arquivos. Adicionar nova forma de pagamento toca em validação, em domínio, em persistência, em formatação, em testes... cada um num arquivo diferente.

Já cobrimos como anti-padrão (capítulo 10). Como cheiro, atenção precoce evita a forma crônica.

Como detectar

Métrica simples: tamanho mediano dos PRs. Se PRs "pequenos" (do ponto de vista de funcionalidade) tocam consistentemente 8+ arquivos, há shotgun surgery.

Refatoração

Inline Class / Move Method: junte o que está espalhado. Conceito "imposto" que vive em 10 lugares vira módulo coeso. Frequentemente revela um Bounded Context (capítulo 21) escondido.

13.8 Feature Envy

O cheiro

Método de uma classe A acessa muito mais atributos/métodos de outra classe B do que da própria A. Ele está com inveja de pertencer a B.

feature_envy.py
# Antes: método em Pedido fala mais com Endereco do que com Pedido
class Pedido:
    def formatar_endereco_entrega(self):
        e = self.endereco
        return f"{e.rua}, {e.numero} - {e.bairro}, {e.cidade}/{e.uf} - {e.cep}"
# 5 acessos a atributos de endereço, 1 acesso a pedido.

# Depois: o método vai para Endereco
class Endereco:
    def formatado(self):
        return f"{self.rua}, {self.numero} - {self.bairro}, {self.cidade}/{self.uf} - {self.cep}"

class Pedido:
    def formatar_endereco_entrega(self):
        return self.endereco.formatado()

Refatoração

Move Method para a classe que tem os dados. Frequentemente um sintoma de Anemic Domain Model — método deveria ser do objeto invejado.

Exceção

Strategies, Visitors e similares existem para trabalhar com dados de outras classes — não é cheiro nesses casos. Cheiro é quando você poderia simplesmente ter colocado o método na classe certa.

13.9 Data Clumps

O cheiro

Mesmos 3-5 campos aparecem juntos em vários lugares. rua, numero, cidade, cep em vários parâmetros, em várias dataclasses, em vários métodos. São pedaços de um conceito (endereço) que ninguém modelou.

Pista clássica: você se pega copiando vários parâmetros entre funções relacionadas.

Refatoração

Extract Class para o conceito implícito. Os 5 campos viram dataclass Endereco. Funções passam a receber Endereco em vez dos 5 separados.

Ganho extra: Value Object pode carregar validação (cep tem formato), métodos (formatado()), e centralizar mudanças futuras.

13.10 Primitive Obsession

O cheiro

Usar tipos primitivos (str, int, float) para conceitos que merecem tipo próprio. CPF é str; e-mail é str; dinheiro é float; ID de usuário é int... e tudo se mistura.

✗ Tudo é primitivo
antes.py
def transferir(de: str, para: str, valor: float):
    # de e para são CPFs ou UUIDs ou emails?
    # valor está em centavos ou reais? E moeda?
    # Tudo está "documentado" — leia a wiki.
    ...

def criar_usuario(email: str, cpf: str, telefone: str):
    # Validação espalhada por todo lugar.
    # Bug clássico: passar email no campo cpf.
    if "@" not in email: ...
    if len(cpf) != 11: ...
✓ Tipos de domínio
depois.py
@dataclass(frozen=True)
class CPF:
    valor: str
    def __post_init__(self):
        if not _valido(self.valor):
            raise ValueError("CPF inválido")

@dataclass(frozen=True)
class Email:
    valor: str
    def __post_init__(self):
        if "@" not in self.valor:
            raise ValueError()

@dataclass(frozen=True)
class Dinheiro:
    valor: Decimal
    moeda: str
    # métodos: somar, multiplicar, etc

def transferir(de: CPF, para: CPF, quantia: Dinheiro):
    # Tipos certos. Impossível passar email no lugar de CPF.
    ...

Em Python, NewType (capítulo 5) é alternativa leve quando você quer só distinção sem validação extra. Para conceitos com regras (CPF, Email, Dinheiro), Value Object com __post_init__ validando é o caminho.

13.11 Temporary Field

O cheiro

Atributo de uma classe é usado às vezes, em alguns métodos, em condições específicas. Em outros métodos, é None ou ignorado. A classe carrega estado que não é parte intrínseca dela.

temporary.py
class CalculadoraFrete:
    def __init__(self):
        self.peso = None           # só usado em um método
        self.distancia = None      # só usado em outro método
        self.modal = None          # só usado em outro

    def calcular_aereo(self, peso, distancia):
        self.peso = peso
        self.distancia = distancia
        self.modal = "aereo"
        return self._aplicar_tabela()
    # self.peso, distancia, modal só fazem sentido durante essa chamada

Refatoração

Esses campos não são da classe — são da operação. Extract Class para o conceito de "operação". Ou simplesmente passe como parâmetros entre métodos auxiliares, sem promover a atributo.

13.12 Message Chains

O cheiro

Cadeias de chamadas longas: pedido.cliente.endereco.cidade.estado.codigo. Cada ponto te acopla a uma estrutura interna. Mudar a estrutura quebra os clientes.

Refatoração

Hide Delegate: a classe inicial expõe método que retorna direto o que cliente quer. pedido.codigo_estado_entrega() em vez de pedido.cliente.endereco.cidade.estado.codigo.

Lei de Demeter (princípio próximo): cada unidade deve falar só com seus "amigos imediatos". a.b() ✓, a.b.c() ⚠️, a.b.c.d() ✗.

Cuidado com aplicação rígida da Lei de Demeter
Aplicada estritamente, ela cria muitos métodos delegadores. Em código Python idiomático com dataclasses imutáveis, pedido.cliente.nome é frequentemente OK — você está só lendo um Value Object, não escondendo lógica. A regra vale principalmente para cadeias com comportamento, não acesso a dados.

13.13 Middle Man

O cheiro

Inverso do anterior. Uma classe tem 80% de métodos que só delegam para outra. Ela existe sem agregar nada — pura indireção.

middle_man.py
class GerenciadorPedido:
    def __init__(self, repo):
        self._repo = repo
    def buscar(self, id):
        return self._repo.buscar(id)
    def salvar(self, p):
        self._repo.salvar(p)
    def deletar(self, id):
        self._repo.deletar(id)
    # Nenhuma lógica. Só repassa. Por que existe?

Refatoração

Remove Middle Man. Clientes falam direto com a classe alvo. A intermediária some.

Cuidado

Às vezes a intermediária existe por razão real — adapter, decorator, ou point de extensão futura. Antes de remover, confirme que ela não tem propósito.

13.14 Comments — quando são cheiro

Comentários não são intrinsecamente ruins. Mas muitos comentários podem ser sinal de algo errado. Em particular:

Comentários bons explicam por que algo é feito, não o que. "Não use Decimal aqui — performance crítica, fizemos análise" é bom. "Soma os valores" não é.

13.15 Estudo de caso — diagnosticando uma classe real

Identificando múltiplos cheiros e definindo plano de ação

Você abriu uma classe e está com a sensação de "alguma coisa está errada aqui". Vamos enumerar os cheiros e prescrever as refatorações.

O paciente
servico_relatorio.py
class ServicoRelatorio:
    def __init__(self):
        self.dados_atual = None      # temporary field
        self.total_atual = None      # temporary field

    def gerar(self,
              ano, mes, dia, hora,
              cliente_nome, cliente_email, cliente_cep,
              cliente_cidade, cliente_estado,
              formato, idioma, moeda, fuso):  # long parameter list
        # 250 linhas de:
        self.dados_atual = []
        for p in pedidos:
            if p.cliente.endereco.cidade.estado.codigo == cliente_estado:  # chain
                self.dados_atual.append({...})
        # ... cálculo de imposto
        # ... formatação HTML
        # ... envio de e-mail
        # ... persistência em S3
        # ... registro em analytics
        return self.dados_atual

    def _formatar(self, d):
        # método que só lê atributos de d, nada de self
        return f"<tr><td>{d.codigo}</td><td>{d.valor}</td></tr>"  # feature envy
Diagnóstico
CheiroEvidênciaRefatoração
Long Methodgerar com 250 linhasExtract Method em sub-passos
Large Classorquestra dados, formatação, e-mail, S3, analyticsExtract Class por responsabilidade
Long Parameter List13 parâmetros em gerarIntroduce Parameter Object (Cliente, Periodo, Localizacao)
Data Clumpscliente_nome/email/cep/cidade/estado andam juntosExtract Class Cliente
Data Clumpsano/mes/dia/hora/fuso andam juntosUse datetime nativo ou dataclass
Temporary Fielddados_atual, total_atualVariável local, não atributo
Message Chainp.cliente.endereco.cidade.estado.codigoHide Delegate em Pedido
Feature Envy_formatar só mexe em dMove Method para classe de d
Divergent Changemuda por motivo de imposto, e-mail, S3...Split Class por responsabilidade
Plano de ação (ordenado por valor / risco)
  1. Escrever testes de caracterização do gerar (3-5 cenários cobrindo combinações principais).
  2. Move Method de _formatar para a classe correta (low risk, alto sinal).
  3. Extract Method para os passos do gerar (cálculo, formatação, e-mail, S3, analytics).
  4. Introduce Parameter Object: Cliente, Periodo, FormatoSaida.
  5. Remover temporary fields — passar entre métodos auxiliares.
  6. Hide Delegate no Pedido: p.estado_entrega().
  7. Extract Class por responsabilidade (envio, analytics, S3 viram classes próprias).

Cada item dura horas, não semanas. Cada um comitado individualmente. Cada um deixa o sistema melhor sem deixá-lo pior. Em 1-2 semanas, classe irreconhecível.

13.16 Erros comuns ao caçar cheiros

Erro 1 · Caçar cheiro em código bom

Cheiros não são leis. Função de 60 linhas pode ser perfeitamente OK se for algoritmo coeso. Não force decomposição artificial.

Erro 2 · Trocar cheiro A por cheiro B

Refatorar Long Method em 8 Tiny Methods (cada um chamado de um lugar só) é trocar inchaço por fragmentação. Equilíbrio.

Erro 3 · Decorar nomes sem desenvolver olhar

Saber listar os 22 cheiros do Fowler de cabeça é diferente de sentir que algo está errado ao ler código. O olhar vem com prática, code review, e tempo.

Erro 4 · Usar cheiros para julgar pessoas

"Quem escreveu isso?" não ajuda em nada. Cheiros são oportunidade de melhoria, não munição em conflito. Manteinda postura técnica em revisões.

Verifique seu entendimento
"Você está revisando classe FormatadorPedido e o método formatar_endereco acessa 6 atributos de Endereco e nenhum de FormatadorPedido. Qual é o cheiro principal?"

13.17 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identifique os cheiros

Para cada código, identifique todos os cheiros que você consegue ver:

  1. Função processar(d) de 180 linhas com 9 parâmetros.
  2. Classe Pedido com método calcular_distancia_da_loja() que só acessa atributos de self.loja.
  3. Várias funções recebem juntos: rua, numero, complemento, cidade, cep.
  4. Classe ServiceFacade onde 90% dos métodos só delegam para self._inner.
  5. Função cadastrar(nome_str, cpf_str, email_str, telefone_str, renda_float).
  1. Long Method + Long Parameter List. Provavelmente também Large Class se for método de uma.
  2. Feature Envy: método pertenceria à Loja.
  3. Data Clumps: conceito Endereco não modelado.
  4. Middle Man: indireção pura. Remova ou justifique.
  5. Primitive Obsession: CPF, Email, Dinheiro deveriam ser tipos próprios.
Médio
Exercício 2 · Eliminar Primitive Obsession

Refatore a função abaixo eliminando obsessão por primitivos. Crie tipos próprios para conceitos do domínio.

antes.py
def criar_pedido(
    cliente_email: str,
    valor_centavos: int,
    moeda: str,
    cupom_codigo: str | None,
):
    if "@" not in cliente_email:
        raise ValueError("email")
    if valor_centavos < 0:
        raise ValueError()
    if moeda not in {"BRL", "USD", "EUR"}:
        raise ValueError()
    # ...
depois.py
from dataclasses import dataclass
from typing import Literal

Moeda = Literal["BRL", "USD", "EUR"]

@dataclass(frozen=True)
class Email:
    valor: str
    def __post_init__(self):
        if "@" not in self.valor:
            raise ValueError("email inválido")

@dataclass(frozen=True)
class Dinheiro:
    centavos: int
    moeda: Moeda
    def __post_init__(self):
        if self.centavos < 0:
            raise ValueError("valor negativo")

@dataclass(frozen=True)
class CodigoCupom:
    valor: str
    def __post_init__(self):
        if not self.valor.isupper() or not self.valor.isalnum():
            raise ValueError("cupom inválido")

def criar_pedido(
    cliente_email: Email,
    quantia: Dinheiro,
    cupom: CodigoCupom | None = None,
):
    # Validação já aconteceu na criação dos Value Objects.
    # Impossível passar string solta no lugar de Email.
    ...
Médio
Exercício 3 · Eliminar Message Chain com Hide Delegate

Refatore os usos abaixo eliminando as cadeias de mensagens. Adicione métodos delegadores onde fizer sentido.

antes.py
# Vários lugares no código:
codigo_uf = pedido.cliente.endereco.cidade.estado.codigo
nome_cidade = pedido.cliente.endereco.cidade.nome
cep = pedido.cliente.endereco.cep
if pedido.cliente.endereco.cidade.estado.codigo == "SP":
    ...
depois.py
class Pedido:
    @property
    def uf_entrega(self) -> str:
        return self.cliente.uf_entrega

    @property
    def cidade_entrega(self) -> str:
        return self.cliente.cidade_entrega

    @property
    def cep_entrega(self) -> str:
        return self.cliente.cep_entrega

class Cliente:
    @property
    def uf_entrega(self): return self.endereco.uf
    @property
    def cidade_entrega(self): return self.endereco.cidade_nome
    @property
    def cep_entrega(self): return self.endereco.cep

class Endereco:
    @property
    def uf(self): return self.cidade.estado.codigo
    @property
    def cidade_nome(self): return self.cidade.nome

# Uso:
codigo_uf = pedido.uf_entrega
if pedido.uf_entrega == "SP":
    ...

Cliente já não sabe da estrutura interna (cidade dentro de endereço dentro de estado). Reorganização futura da estrutura afeta só os delegadores.

Difícil
Exercício 4 · Análise completa

Pegue a classe abaixo. Identifique pelo menos 5 cheiros. Para cada um: descreva, aponte a linha/região, sugira a refatoração. Não precisa escrever a refatoração — só o plano.

servico.py
class ServicoCheckout:
    def __init__(self, db_conn, smtp_host, smtp_port, smtp_user, smtp_pass):
        self.conn = db_conn
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port
        self.smtp_user = smtp_user
        self.smtp_pass = smtp_pass
        self.ultimo_pedido = None
        self.ultimo_total = None

    def processar(self, itens, cep_str, cupom_str, email_str,
                  cliente_nome, cliente_cpf, cliente_telefone):
        # 320 linhas de:
        # - validação inline (cpf, email, cep)
        # - cálculo de subtotal, desconto, frete, imposto
        # - conexão com gateway de pagamento
        # - persistência (SQL inline)
        # - envio de email (SMTP inline)
        # - logging em arquivo
        # - registro em analytics (HTTP inline)
        if itens[0].produto.categoria.imposto.aliquota > 0.1:
            ...

    def _formatar_endereco(self, end):
        return f"{end.rua}, {end.numero} - {end.bairro}, {end.cidade}/{end.uf}"
  1. Large Class: 320 linhas, faz validação, cálculo, pagamento, persistência, e-mail, log, analytics → Extract Class por responsabilidade.
  2. Long Method: processar tem 320 linhas → Extract Method em sub-passos.
  3. Long Parameter List: processar com 7 parâmetros e __init__ com 5 → Parameter Object para Cliente, Pagamento, SmtpConfig.
  4. Data Clumps: cliente_nome/cpf/telefone andam juntos; smtp_host/port/user/pass andam juntos → Extract Class Cliente e SmtpConfig.
  5. Primitive Obsession: cep_str, cupom_str, email_str, cliente_cpf → Tipos de domínio CEP, Email, CPF, CodigoCupom.
  6. Temporary Field: ultimo_pedido, ultimo_total → variável local ou Resultado retornado.
  7. Message Chain: itens[0].produto.categoria.imposto.aliquota → Hide Delegate ou ler aliquota direto via método de Item.
  8. Feature Envy: _formatar_endereco só acessa atributos de end → Move Method para classe Endereco.
  9. Divergent Change: classe muda por motivo de pagamento, e-mail, analytics, fiscal — todos diferentes → Split Class.
Fim do capítulo 13
Próximo capítulo: documentação técnica — como escrever documentos que envelhecem bem. Último da Parte III.
Parte III · Capítulo 13 · Qualidade e evolução

Code smells:
os sinais de
degradação.

Code smell não é bug. É um cheiro — um sinal de que algo pode estar errado. Reconhecer cheiros cedo é o que separa engenheiros experientes dos iniciantes: você não espera o sistema travar para perceber que está degradando.

O cheiro nem sempre significa que há problema. Às vezes o contexto justifica. Mas é gatilho para parar, olhar com cuidado, e decidir conscientemente: refatoro agora, anoto como dívida técnica, ou deixo assim de propósito? Sem reconhecer o cheiro, você não chega nem a essa decisão.

13.1 A história — Kent Beck cunha o termo

Contexto histórico

Em 1999, no capítulo 3 do livro Refactoring, Martin Fowler creditou a Kent Beck a metáfora dos "code smells". A ideia: em vez de regras rígidas ("nunca faça X"), descrever sintomas que sugerem problema. Cheiros não são verdade absoluta — são pista para investigação.

O livro catalogou 22 cheiros, cada um com nome, descrição e refatorações que costumam aliviar. Os nomes pegaram: "God Class", "Feature Envy", "Shotgun Surgery", "Primitive Obsession". Viraram vocabulário compartilhado da indústria.

A força do conceito é justamente sua imprecisão controlada. "Função tem mais de 50 linhas" é regra; "função está inchada" é cheiro. A regra dá falso conforto (passou em 49 linhas) e falso alerta (50 linhas legítimas). O cheiro força pensamento — vale a pena olhar, pode ou não ser problema.

Em 2018, a segunda edição expandiu o catálogo. Ferramentas modernas (SonarQube, CodeClimate, Ruff, Pylint) automatizam detecção de muitos cheiros — útil como alerta automático, perigoso se virar métrica para "zerar".

13.2 O que é code smell (e o que não é)

Um code smell tem três características:

Cheiros não são bugs. Código com cheiro pode estar correto. O risco que os cheiros sinalizam é de evolução: vai ser caro mudar, propenso a regressão, difícil de raciocinar.

13.3 Bloaters — o que cresceu demais

Long Method

Função com 40, 60, 100+ linhas. Sinal clássico de múltiplas responsabilidades não nomeadas. Refatoração: Extract Method, repetido. Cada bloco com comentário ("# Calcula imposto") é candidato a virar função com nome.

Long Parameter List

Função recebendo 5, 7, 10 parâmetros. Difícil de chamar, difícil de testar, fácil de confundir ordem. Refatoração: Introduce Parameter Object (ver cap. 12) — agrupar parâmetros que andam juntos.

Large Class

Classe com 30+ atributos, 50+ métodos, 1000+ linhas. É a forma OOP do Long Method. Frequentemente é God Object (anti-padrão coberto no cap. 10). Refatoração: Extract Class, agrupando atributos e métodos coesos.

Primitive Obsession

Tudo é str, int, dict. Conceitos do domínio que merecem tipo próprio (CPF, CEP, Dinheiro, Endereço) vivem como primitivos espalhados, com validação repetida.

✗ Obsessão por primitivos
antes.py
def criar_pedido(
    cliente_cpf: str,
    valor: float,
    moeda: str,
    cep: str,
    rua: str,
    numero: str,
):
    # validação espalhada repetida em N lugares
    if len(cliente_cpf.replace(".", "").replace("-", "")) != 11:
        raise ValueError("cpf inválido")
    if len(cep) != 9 or cep[5] != "-":
        raise ValueError("cep inválido")
    if moeda not in {"BRL", "USD", "EUR"}:
        raise ValueError("moeda inválida")
    ...
✓ Value Objects
depois.py
def criar_pedido(
    cpf: CPF,
    valor: Dinheiro,
    endereco: Endereco,
):
    # CPF, Dinheiro e Endereco são Value Objects que
    # garantem invariantes na construção.
    # criar_pedido recebe valores JÁ válidos.
    ...

# Os tipos foram definidos uma vez:
@dataclass(frozen=True)
class CPF:
    valor: str
    def __post_init__(self):
        if len(self.valor) != 11 or not self.valor.isdigit():
            raise ValueError("cpf inválido")

Tipos próprios para conceitos do domínio resolvem três problemas: validação fica num lugar; o checker te impede de passar um CPF onde se espera CEP (mesmo ambos sendo str); o código fica autodocumentado.

Data Clumps

Grupo de variáveis que aparecem sempre juntas: data_inicio, hora_inicio, fuso; rua, numero, complemento, cidade, cep; valor, moeda. Cinco aparições em métodos diferentes do mesmo grupo = pede dataclass. Esse é o sinal para Introduce Parameter Object.

13.4 Acoplamento — quem depende de quem

Inappropriate Intimacy

Classe A conhece detalhes íntimos de classe B — acessa atributos privados, sabe estrutura interna, depende de ordem específica de operações. Cada mudança em B exige mudança em A.

Refatoração: identifique o que A precisa fazer, exponha em B método de alto nível, A passa a chamar isso. Tira o conhecimento interno do lado errado.

Feature Envy

Método em uma classe que usa muito mais atributos de outra classe do que da própria. Indício forte de que o método está no lugar errado.

feature_envy.py
# Antes — método de Pedido invejando Cliente
class Pedido:
    def calcular_desconto_fidelidade(self):
        if self.cliente.compras_total > 10 and self.cliente.cadastro_anos > 2:
            return self.cliente.nivel_fidelidade * Decimal("0.02")
        return Decimal("0")
# Acessa 3 atributos de cliente, nada de pedido.

# Depois — método vai para Cliente
class Cliente:
    def desconto_fidelidade(self) -> Decimal:
        if self.compras_total > 10 and self.cadastro_anos > 2:
            return self.nivel_fidelidade * Decimal("0.02")
        return Decimal("0")

class Pedido:
    def calcular_desconto_fidelidade(self):
        return self.cliente.desconto_fidelidade()

13.5 Cheiros de mudança

Divergent Change

Uma classe muda por vários motivos diferentes. Hoje você mexe nela porque a regra de imposto mudou; amanhã, porque o formato do PDF mudou; depois, porque o gateway de pagamento mudou. Violação de SRP em ação.

Refatoração: Extract Class por motivo de mudança. Imposto vai para classe de imposto; geração de PDF para gerador de PDF; integração com gateway para adaptador.

Shotgun Surgery

Inverso. Uma mudança simples — "campo X agora tem outro nome" — exige mexer em 12 arquivos espalhados. Conceito do domínio está pulverizado.

Refatoração: Move Method/Field para concentrar. Tudo relacionado a "imposto" vai para módulo de imposto; ninguém mais o referencia diretamente.

Os dois lados da mesma moeda
Divergent Change: muitos motivos → uma classe. Shotgun Surgery: um motivo → muitos arquivos. Ambos são problemas de coesão. Software bem organizado tem o oposto: cada conceito vive num lugar, cada lugar muda por uma razão.

13.6 Parallel Inheritance Hierarchies

Cada vez que você cria subclasse em uma hierarquia, precisa criar a correspondente em outra. Funcionario, FuncionarioCLT, FuncionarioPJ + FolhaPagamento, FolhaPagamentoCLT, FolhaPagamentoPJ. Sintoma: você não esquece de criar uma, mas sente o trabalho repetido.

Refatoração: composição em vez de herança. Funcionario ganha campo regime: RegimeContratacao, e a folha consulta o regime para calcular. Uma hierarquia some.

13.7 Comentário como cheiro

Comentários não são ruins por natureza. Comentários explicativos sobre código que poderia se autoexplicar são. Vou listar quando comentário é cheiro vs quando é OK:

Cheiro:

Comentário legítimo:

13.8 Outros cheiros úteis

Data Class

Classe que é só getters e setters, sem comportamento. Diferente de Value Object (que tem invariantes), aqui está vazia de propósito. Sinal de Anemic Domain Model (anti-padrão do cap. 10).

Em Python, com dataclass(frozen=True) usado para Value Object, esse cheiro só aplica quando os dados deveriam ter comportamento — pedido sem método de domínio, conta sem método de domínio. Em DTOs e eventos, dados puros são corretos.

Refused Bequest

Subclasse que herda da pai mas não usa a maioria do que ela oferece — ou pior, sobrescreve com NotImplementedError. Sinal de hierarquia errada (Liskov violado).

Refatoração: a hierarquia mente. Quebre em interfaces menores (ISP), ou troque herança por composição.

Speculative Generality

Abstração criada "para o caso de precisar no futuro": classes abstratas sem segunda implementação, parâmetros opcionais nunca usados, hierarquias preparadas para extensão que nunca veio. YAGNI violado (anti-padrão do cap. 10).

Refatoração: Inline, Collapse Hierarchy, Remove Parameter. Volta para o simples.

13.9 Message Chains

Cadeia de chamadas: pedido.cliente.endereco.cidade.estado.codigo(). Cada elo é acoplamento. Você depende de toda essa estrutura existir, em ordem, com cada nó tendo o método certo.

message_chains.py
# Antes — cadeia frágil
codigo = pedido.cliente.endereco.cidade.estado.codigo

# Depois — método na fonte que conhece o que importa
codigo = pedido.estado_cliente()

class Pedido:
    def estado_cliente(self) -> str:
        return self.cliente.endereco.cidade.estado.codigo
        # A cadeia ainda existe internamente, mas só num lugar.

Conhecido como "Law of Demeter" (mais um princípio que regra): fale só com seus vizinhos imediatos. Quando você precisa atravessar três níveis para chegar a algo, expõe método de alto nível no objeto mais próximo.

13.10 Middle Man

Classe que só repassa chamadas para outra classe. Cada método é uma linha: def x(self): return self._real.x(). Existe sem agregar valor.

middle_man.py
# Cheiro — Middle Man
class RepositorioPedido:
    def __init__(self, dao):
        self._dao = dao

    def salvar(self, p): return self._dao.salvar(p)
    def buscar(self, id): return self._dao.buscar(id)
    def deletar(self, id): return self._dao.deletar(id)
    def listar(self): return self._dao.listar()
# Nenhum método adiciona nada. É só camada extra.

Cuidado: Middle Man legítimo existe — Proxy, Adapter, Decorator parecem middle man mas adicionam valor (cache, autorização, tradução de interface). O cheiro é quando há zero valor agregado.

13.11 Cheiros sutis — os mais difíceis

Temporary Field

Atributo de classe que só é populado em condições específicas, ficando None ou vazio na maioria das vezes. Esconde acoplamento — algum método externo depende daquele atributo estar setado em ordem específica.

Refatoração: Extract Class para encapsular o estado temporário, ou Replace Temp with Query (deixa de ser atributo, vira cálculo).

Switch Statement (em Python: if/elif)

Não toda cadeia de if/elif é cheiro. Ela vira cheiro quando:

Duplicate Code

Talvez o cheiro mais antigo do livro. Mas com nuance: nem toda duplicação é problema. Coincidental duplication (duas funções que parecem iguais por acaso, mas evoluem em direções diferentes) é diferente de essential duplication (mesmo conceito repetido).

Antes de "DRY-ar" duplicação, pergunte: as duas cópias mudam pelo mesmo motivo? Se sim, unifique. Se não, deixe duplicado — abstrair vai te morder quando uma das duas tiver que mudar e a abstração não comportar.

13.12 Como detectar — ferramentas e leitura

Cheiros se detectam de duas formas: leitura humana atenta e ferramentas automáticas.

Ferramentas para Python

Leitura humana

Ferramentas pegam cheiros mecânicos (linhas, parâmetros). Cheiros estruturais (Feature Envy, Inappropriate Intimacy, Divergent Change) precisam de olhos humanos. Use code review como momento principal — quem revisa frequentemente nota cheiros que quem escreveu não percebe.

config_ruff.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
# Habilita várias categorias úteis
select = [
    "E",    # pycodestyle erros
    "W",    # pycodestyle warnings
    "F",    # pyflakes
    "C90",  # mccabe complexity
    "B",    # bugbear (anti-pattern detection)
    "PL",   # pylint rules
    "RUF",  # ruff-specific
]

[tool.ruff.lint.mccabe]
max-complexity = 10  # funções acima de 10 = cheiro

[tool.ruff.lint.pylint]
max-args = 5
max-branches = 12
max-statements = 50

13.13 Estudo de caso — diagnosticando um módulo real

Olhando para um módulo com cheiros e nomeando cada um

Você abre processamento_pedido.py num projeto que herdou. Em 5 minutos de leitura, anote os cheiros. Vamos fazer junto.

Trecho 1 · O que você vê primeiro
processamento_pedido.py (linhas 1-60)
class ProcessadorPedido:
    def __init__(self):
        self.conn = psycopg2.connect("...")
        self.smtp = smtplib.SMTP("...", 587)
        self.cache = {}
        self.contador = 0
        self.ultimo_erro = None
        self.config_carregada = False
        self.modo_debug = False
        self.timeout = 30
        self.retry_count = 3
        self.fallback_email = "sis@x.com"
        # ... mais 12 atributos

    def processar(self, pedido_id, cliente_id, valor,
                  forma_pgto, parcelas, cep, rua,
                  numero, complemento, cidade, estado,
                  observacao, tags, prioridade):
        # Calcula o subtotal
        subtotal = 0
        for i in self.itens_do_pedido(pedido_id):
            if i.cliente.endereco.cidade.estado.codigo == "SP":
                subtotal += i.preco * i.qtd * 1.18
            else:
                subtotal += i.preco * i.qtd
        # ... 80 linhas

Cheiros visíveis nessas 30 linhas:

  • Large Class: 20+ atributos no construtor.
  • Long Parameter List: processar() tem 14 parâmetros.
  • Data Clumps: cep, rua, numero, complemento, cidade, estado sempre juntos = pede Endereço.
  • Primitive Obsession: valor como número primitivo, forma_pgto e prioridade provavelmente como strings.
  • Long Method: 80+ linhas no processar.
  • Magic Numbers: 1.18 sem nome.
  • Message Chains: i.cliente.endereco.cidade.estado.codigo — cadeia de 5.
  • Comentários como cheiro: # Calcula o subtotal é candidato a extrair função.
Trecho 2 · Continuando a leitura
processamento_pedido.py (linhas 80-130)
    def enviar_email_confirmacao(self, pedido):
        if pedido.cliente.email_secundario:
            email = pedido.cliente.email_secundario
        elif pedido.cliente.email_principal:
            email = pedido.cliente.email_principal
        else:
            email = self.fallback_email

        assunto = f"Pedido {pedido.id} confirmado"
        if pedido.cliente.nivel_fidelidade > 3:
            assunto = "⭐ " + assunto

        corpo = self.gerar_corpo_email(pedido)
        self.smtp.sendmail(self.fallback_email, email, f"{assunto}\n{corpo}")

    def gerar_corpo_email(self, pedido):
        return f"Olá {pedido.cliente.nome}, seu pedido foi recebido."

    def aplicar_desconto_fidelidade(self, pedido):
        if pedido.cliente.nivel_fidelidade > 3 and pedido.cliente.compras_total > 20:
            return pedido.valor * pedido.cliente.percentual_desconto
        return 0

Mais cheiros:

  • Feature Envy: aplicar_desconto_fidelidade acessa 3 atributos de cliente, nenhum próprio. Move para Cliente.desconto_fidelidade().
  • Divergent Change: esta classe muda quando: e-mail muda, regra de imposto muda, regra de desconto muda, banco muda. SRP violado em escala.
  • Inappropriate Intimacy: pedido.cliente.email_secundario + .email_principal + .nome — conhece estrutura interna de cliente. Cliente devia expor método email_de_contato().
  • Temporary Field: atributos como self.ultimo_erro, self.contador — populados ocasionalmente, esquecidos depois.
Plano de ação

Com o diagnóstico em mãos, priorize. Não tente atacar tudo:

  1. Primeiro: Extract Class das responsabilidades grandes — separar persistência, e-mail, regras de imposto, regras de desconto. Resolve Divergent Change.
  2. Depois: Move Method dos cheiros de Feature Envy — desconto fidelidade vai para Cliente.
  3. Depois: Introduce Parameter Object — agrupar endereço, valor+moeda.
  4. Por último: Extract Method dentro de processar — depois que já tirou o resto, fica fácil ver o que sobra.

Cada passo é um PR. Cada PR tem testes de caracterização cobrindo. Em 4-6 semanas, o módulo está irreconhecivelmente melhor — sem nunca ter ficado quebrado.

Lição: diagnóstico vem antes de tratamento. "Está feio" não é diagnóstico; "este módulo tem Divergent Change, Feature Envy em três métodos e Primitive Obsession nas assinaturas" é. Com nomes, você prioriza e comunica.

13.14 Erros comuns ao "consertar cheiros"

Erro 1 · Atacar todos de uma vez

Lista de 20 cheiros num módulo, tenta resolver tudo num PR. Resultado: PR gigante, impossível de revisar, alto risco de regressão. Resolva um a um, comitando.

Erro 2 · Confundir cheiro com regra

"Método com 51 linhas, REFATORA AGORA". Cheiro é gatilho, não veredito. Às vezes 60 linhas legítimas. Olhe; decida; siga.

Erro 3 · Confiar 100% em ferramentas

SonarQube mostra zero cheiros = código limpo? Não. Ferramentas pegam cheiros mecânicos; cheiros estruturais (Feature Envy, Divergent Change) escapam. Use ferramentas + revisão humana.

Erro 4 · "Limpar enquanto faz a feature"

Tenta resolver cheiro junto com mudança de comportamento. Quando teste quebra, você não sabe se foi feature ou refatoração. Faça um, comita; depois o outro.

13.15 Quando ignorar um cheiro

Reconheça o contexto
Cheiro nem sempre é problema
  • Código que vai morrer: sistema sendo descomissionado, módulo a ser substituído. Não vale gastar energia.
  • Pressão crítica: incidente em produção. Resolva primeiro, refatore depois — registre como dívida técnica.
  • Cheiro pequeno em código estável: função de 60 linhas que funciona, ninguém mexe há 2 anos. Custo de refatorar > benefício.
  • Long Parameter List em API pública: mudar assinatura quebra clientes. Avalie se o ganho compensa.
  • Duplicação que pode ser coincidente: esperar evolução para saber se é mesma intenção ou só semelhança superficial.

Registrar cheiros não resolvidos como dívida técnica explícita (issue no repo, comentário com # TODO: ... + data) é melhor que ignorar silenciosamente.

Verifique seu entendimento
"Você nota que método Pedido.calcular_idade_cliente() acessa self.cliente.data_nascimento e self.cliente.timezone, e nada do próprio pedido. Qual cheiro é esse e qual a refatoração natural?"

13.16 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Nomear cheiros

Para cada trecho, identifique o cheiro principal:

  1. Função recebendo (nome, sobrenome, email, telefone, rua, numero, cep, cidade, estado) em vários lugares do código.
  2. Método em Carrinho que usa principalmente atributos de Cliente.
  3. Classe Funcionario com 25 atributos públicos e nenhum método de comportamento.
  4. Mudar campo "telefone" para "celular" exige editar 14 arquivos.
  5. Classe Pedido com atributo self.imposto_recalculado_em que só é preenchido depois de chamar recalcular_imposto().
  6. Cadeia pedido.fatura.parcelas[0].vencimento.formatado().
  7. Subclasse NotificadorMockTeste herda de Notificador mas todos os métodos lançam NotImplementedError exceto um.
  1. Data Clumps (+ Long Parameter List). Refatoração: Introduce Parameter Object (Contato + Endereço).
  2. Feature Envy. Refatoração: Move Method para Cliente.
  3. Data Class / Anemic Domain Model. Refatoração: identificar comportamentos do domínio e mover para a classe.
  4. Shotgun Surgery. Refatoração: Move Method/Field para concentrar.
  5. Temporary Field. Refatoração: Extract Class para encapsular ou Replace Temp with Query.
  6. Message Chains. Refatoração: expor método de alto nível na fonte (Law of Demeter).
  7. Refused Bequest. Refatoração: quebrar interface (ISP) ou trocar herança por composição.
Médio
Exercício 2 · Eliminar Primitive Obsession

Refatore o código abaixo criando Value Objects para CPF, Email e Telefone. Validação na construção; tipo próprio garante que não dá pra trocar.

antes.py
def cadastrar_cliente(nome: str, cpf: str, email: str, telefone: str):
    if len(cpf.replace(".", "").replace("-", "")) != 11:
        raise ValueError("cpf")
    if "@" not in email or "." not in email.split("@")[1]:
        raise ValueError("email")
    if len(telefone) < 10:
        raise ValueError("telefone")
    ...

def enviar_sms(telefone: str, mensagem: str):
    if len(telefone) < 10:  # repetido
        raise ValueError("telefone")
    ...
depois.py
from dataclasses import dataclass
import re

@dataclass(frozen=True)
class CPF:
    valor: str
    def __post_init__(self):
        limpo = self.valor.replace(".", "").replace("-", "")
        if len(limpo) != 11 or not limpo.isdigit():
            raise ValueError(f"CPF inválido: {self.valor}")
        object.__setattr__(self, "valor", limpo)

@dataclass(frozen=True)
class Email:
    valor: str
    RE = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
    def __post_init__(self):
        if not Email.RE.match(self.valor):
            raise ValueError(f"Email inválido: {self.valor}")

@dataclass(frozen=True)
class Telefone:
    valor: str
    def __post_init__(self):
        digitos = re.sub(r"\D", "", self.valor)
        if len(digitos) < 10:
            raise ValueError(f"Telefone inválido: {self.valor}")
        object.__setattr__(self, "valor", digitos)

def cadastrar_cliente(nome: str, cpf: CPF, email: Email, telefone: Telefone):
    # Validação JÁ aconteceu na construção dos Value Objects
    ...

def enviar_sms(telefone: Telefone, mensagem: str):
    # Idem — sem revalidar
    ...

# Checker te impede de passar Email onde se espera Telefone, etc.
Médio
Exercício 3 · Feature Envy

Refatore movendo cada método para a classe certa.

antes.py
class Pedido:
    def __init__(self, cliente, itens):
        self.cliente = cliente
        self.itens = itens

    def nome_completo_cliente(self):
        return f"{self.cliente.nome} {self.cliente.sobrenome}"

    def cliente_tem_endereco_completo(self):
        return (self.cliente.endereco.rua and
                self.cliente.endereco.numero and
                self.cliente.endereco.cep)

    def peso_total_itens(self):
        return sum(i.peso * i.qtd for i in self.itens)
depois.py
class Endereco:
    def esta_completo(self):
        return bool(self.rua and self.numero and self.cep)

class Cliente:
    def nome_completo(self):
        return f"{self.nome} {self.sobrenome}"

    def tem_endereco_completo(self):
        return self.endereco.esta_completo()

class Pedido:
    def peso_total(self):  # esse é genuíno de pedido
        return sum(i.peso * i.qtd for i in self.itens)
    # nome_completo_cliente e cliente_tem_endereco_completo saíram

# Uso: pedido.cliente.nome_completo() e pedido.cliente.tem_endereco_completo()
Difícil
Exercício 4 · Diagnóstico completo

Analise o módulo abaixo. Liste TODOS os cheiros que conseguir identificar, e para cada um, sugira refatoração e prioridade (alta/média/baixa).

relatorio.py
class GeradorRelatorio:
    def __init__(self):
        self.dados = []
        self.html_gerado = ""
        self.pdf_path = ""
        self.email_enviado = False
        self.ultimo_erro = None

    def gerar(self, tipo_relatorio, mes, ano, formato,
              destinatario, incluir_grafico, incluir_resumo,
              ordem_colunas, idioma):
        # Busca dados
        conn = psycopg2.connect("...")
        cur = conn.cursor()
        cur.execute(f"SELECT * FROM rel_{tipo_relatorio} WHERE mes={mes} AND ano={ano}")
        self.dados = cur.fetchall()

        # Calcula totais
        total = 0
        for d in self.dados:
            if d[3] == "venda":
                total += d[5] * 1.18
            elif d[3] == "servico":
                total += d[5] * 1.05

        # Formata
        if formato == "html":
            self.html_gerado = f"<h1>Total: {total}</h1>"
        elif formato == "pdf":
            self.pdf_path = f"/tmp/{tipo_relatorio}_{mes}_{ano}.pdf"
            # gera pdf...

        # Envia email
        smtp = smtplib.SMTP("smtp", 587)
        smtp.sendmail("sis@x", destinatario, self.html_gerado or self.pdf_path)
        self.email_enviado = True

        return total

    def cliente_quer_relatorio_em_excel(self, cliente):
        return cliente.preferencias.formato == "excel" and cliente.plano.tipo == "premium"
CheiroOndeRefatoraçãoPrioridade
Long Parameter Listgerar() com 9 parâmetrosIntroduce Parameter Object: ConfigRelatorioAlta
Long Methodgerar() faz 5 coisasExtract Method: buscar, calcular, formatar, enviarAlta
Divergent ChangeClasse muda por: SQL, regra de imposto, formato, e-mailExtract Class: Repositório, Calculadora, Formatador, NotificadorAlta
Magic Numbers1.18, 1.05Constantes nomeadas: ALIQUOTA_VENDA, ALIQUOTA_SERVICOMédia
Magic Strings"venda", "servico", "html", "pdf", "excel", "premium"Enum / LiteralMédia
Temporary Fieldself.html_gerado, self.pdf_path, self.email_enviado, self.ultimo_erroRemover atributos; retornar valoresAlta
Primitive ObsessionMes/ano como int, formato como stringValue Objects: Periodo, FormatoRelatorioMédia
Inappropriate Intimacycliente.preferencias.formato + cliente.plano.tipoMove Method para Cliente: quer_excel()Média
Feature Envycliente_quer_relatorio_em_excelMove para ClienteAlta
SQL injectionf-string em queryParâmetros bound (não cheiro, é bug crítico)Crítica
Conexão hardcodedpsycopg2.connect("...")DIP: injetar repositórioAlta
Fim do capítulo 13
Próximo capítulo: documentação técnica — última peça da Parte III. Como documentar sem mentir, sem repetir o código, e sem que envelheça antes do release.
Parte III · Capítulo 14 · Qualidade e evolução

Documentação
técnica que
envelhece bem.

Documentação ruim é pior que documentação ausente — ela mente. Documentação boa, em compensação, é uma das maiores alavancas de produtividade num time. A diferença entre as duas raramente é volume; é critério sobre o que documentar.

A intuição que muita gente tem é "vou documentar tudo". Resultado: documento de 200 páginas, escrito uma vez, nunca atualizado, ninguém lê, e quem precisa de resposta acaba lendo o código. A intuição certa é outra: documente o que o código não consegue dizer sozinho — decisões, contexto, "por quês", contratos públicos. Para tudo o mais, código autoexplicativo é melhor documentação.

14.1 A história — Knuth, Javadoc, Markdown, Docs-as-Code

Contexto histórico

Em 1984, Donald Knuth propôs literate programming: escrever código e documentação juntos, no mesmo arquivo, com o documento como cidadão de primeira classe. A ideia era brilhante, a adoção foi pequena — o overhead de manter texto extenso ao lado do código se mostrou alto.

Em 1995, com o Java, surgiu Javadoc — gerar HTML automaticamente a partir de comentários estruturados em /** */. O modelo pegou: Python ganhou docstrings, JavaScript o JSDoc, Rust o rustdoc. Documentação como subproduto do código.

Nos anos 2010, com o crescimento de open source no GitHub, Markdown virou padrão de fato para README. Docs-as-Code se popularizou: documentação no mesmo repositório do código, versionada junto, com PRs e review como o resto.

Em paralelo, em 2011, Michael Nygard propôs os Architecture Decision Records (ADRs): arquivos pequenos em Markdown que registram decisões arquiteturais e seu contexto. Hoje virou padrão em times sérios.

Em 2017, Daniele Procida publicou o Diátaxis framework, que organiza documentação técnica em quatro categorias com propósitos distintos. Mudou completamente como times grandes pensam documentação. Vamos cobrir adiante.

14.2 O que documentar (e o que não)

A pergunta certa não é "documento isso?" — é "que tipo de leitor, com que objetivo, vai chegar nessa informação?". Documentação que serve a todos não serve a ninguém.

Documente:

NÃO documente:

Princípio fundamental
Documentação envelhece. Toda linha de documentação tem custo de manutenção. Quanto mais documenta, mais coisas envelhecem em silêncio enquanto o código avança. A estratégia certa é documentar menos, mas o que vale, e investir em código autoexplicativo para o resto.

14.3 Os quatro tipos — Diátaxis

Daniele Procida observou que toda documentação técnica útil se encaixa em quatro categorias com propósitos diferentes. Misturar é o erro mais comum.

📚
Tutoriais
Para aprender. Guiados, passo a passo, com objetivo claro. "Construa seu primeiro X."
🛠
How-tos
Para resolver problema. Receita objetiva. "Como fazer X."
📖
Referência
Para consultar. Exaustiva, precisa, sem narrativa. "Todos os parâmetros de Y."
💡
Explicação
Para entender. Conceitos, decisões, contexto. "Por que escolhemos Z."

Cada uma serve um momento diferente do leitor. Tutorial e how-to parecem iguais e não são: tutorial te leva a aprender (cobre teoria conforme avança); how-to assume que você já sabe e quer resolver. Referência e explicação parecem iguais e não são: referência é catálogo neutro; explicação tem narrativa, ponto de vista, contexto.

Sua documentação melhora drasticamente quando cada peça é claramente uma das quatro. README com "tutorial + how-to + referência misturados" é frustrante para todos os leitores.

14.4 README útil

O README é a porta de entrada. Leitor chega ali sem contexto. Se confundir, ele sai. Estrutura testada na prática:

README.md
# Nome do projeto

Frase de uma linha dizendo *o que é* — substantivo concreto, sem
buzzword. Não "uma plataforma cloud-native escalável"; algo como
"API HTTP para gerenciar pedidos de e-commerce".

## Status

- ✅ Em produção desde 2024-03
- 📦 Versão atual: 2.4.1
- 🧪 Cobertura: 87%

## O problema que resolve

Dois parágrafos. Qual problema, para quem. Resista à tentação
de "vender" — descreva.

## Como rodar localmente

```bash
git clone ...
make dev    # sobe banco, faz migrate, popula seed
make test   # roda suíte
```

Pré-requisitos: Python 3.12, Docker.

## Estrutura

```
src/
  pedidos/      # bounded context principal
  shared/       # value objects e utilitários compartilhados
tests/
  unit/
  integration/
  e2e/
docs/
  decisions/    # ADRs
```

## Para onde olhar

- Documentação de API: `/docs` (Swagger UI em prod e dev)
- Decisões arquiteturais: `docs/decisions/`
- Runbook de incidentes: `docs/runbook.md`
- Como contribuir: `CONTRIBUTING.md`

## Contato

Time @squad-pedidos no Slack. Para incidentes: pagerduty/...

O que esse README não tem: tutorial extenso de uso, lista exaustiva de endpoints, history of changes. Cada um desses vive no lugar próprio (linkado, se necessário). README é mapa, não destino.

14.5 Docstrings — o que vale documentar

Docstrings são caras: aparecem em IDE, geram documentação automática, mas envelhecem rápido. Use estrategicamente. Não toda função precisa.

Vale docstring quando:

Não vale docstring quando:

✗ Docstring tautológica
tautologica.py
def calcular_total(itens: list[Item]) -> Decimal:
    """Calcula o total dos itens.

    Args:
        itens: lista de itens

    Returns:
        o total
    """
    return sum(i.preco * i.qtd for i in itens)
# A docstring não adiciona NADA. Tipos já dizem tudo.
✓ Docstring útil
util.py
def calcular_total(
    itens: list[Item],
    incluir_impostos: bool = True,
) -> Decimal:
    """Total final cobrado do cliente.

    Aplica ICMS conforme estado do endereço de entrega quando
    `incluir_impostos=True`. Para cálculo interno (relatórios
    gerenciais), passe False — esse modo NÃO deve ser usado
    em fluxo de cobrança real.

    Raises:
        ValueError: se algum item tem preço <= 0.
    """
    ...

Estilo de docstring

Em Python, três estilos circulam: Google, NumPy, reStructuredText. Escolha um e padronize o projeto. O Google style é o mais legível em código fonte; NumPy é melhor quando vira documentação HTML extensa. Não misture.

14.6 Architecture Decision Records (ADRs)

Talvez a forma de documentação que mais paga ao longo do tempo. ADR é um arquivo Markdown pequeno (1-2 páginas) que registra uma decisão arquitetural e seu contexto. Quando alguém pergunta "por que vocês fizeram desse jeito?" três anos depois, a resposta está lá.

Estrutura simples (Nygard 2011):

docs/decisions/0007-postgres-sobre-mongodb.md
# ADR 0007: PostgreSQL como banco principal

**Data:** 2026-02-14
**Status:** Aceita
**Decisores:** @ana, @bruno, @carla

## Contexto

Precisamos escolher banco principal para o serviço de pedidos.
Volume estimado: 50k pedidos/dia, com queries relacionais
complexas (joins entre pedido, item, cliente, fatura).
Histórico do time é maior com Postgres; alguns membros têm
experiência com MongoDB.

## Decisão

Usaremos **PostgreSQL 16** como banco principal.

## Alternativas consideradas

### MongoDB
- ✓ Schemaless permite iteração rápida de modelo
- ✗ Joins complexos exigem agregações lentas
- ✗ Transações multi-documento limitadas
- ✗ Menos experiência no time

### PostgreSQL
- ✓ Joins eficientes, queries relacionais
- ✓ Transações ACID nativas
- ✓ JSONB cobre necessidades schemaless pontuais
- ✓ Time tem experiência operacional sólida
- ✗ Schema migrations precisam de disciplina

## Consequências

- **Positivas:** queries de relatório serão simples; ACID por padrão; ferramental conhecido.
- **Negativas:** evoluções de schema exigirão migrations cuidadosas; precisaremos investir em replicação para escalar leitura.
- **Neutras:** se algum subsistema precisar de schemaless extremo, JSONB ou banco auxiliar específico.

Características importantes:

14.7 Documentação de API

API pública precisa de documentação de referência precisa. Aqui é onde "auto-gerada do código" brilha — porque envelhecer junto com o código é exatamente o que você quer.

Em Python, ferramentas modernas geram OpenAPI a partir das próprias anotações:

api_documentada.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from decimal import Decimal

app = FastAPI(
    title="API de Pedidos",
    description="Gerencia ciclo de vida de pedidos de e-commerce",
    version="2.4.1",
)

class CriarPedidoRequest(BaseModel):
    cliente_id: str = Field(..., description="ID do cliente cadastrado", examples=["c_abc123"])
    cep_entrega: str = Field(..., pattern=r"^\d{5}-\d{3}$")

class PedidoCriadoResponse(BaseModel):
    id: str = Field(..., description="ID único do pedido criado")
    status: str = Field(..., description="Status inicial — sempre 'aberto'")
    criado_em: str = Field(..., description="ISO 8601")

@app.post(
    "/pedidos",
    response_model=PedidoCriadoResponse,
    status_code=201,
    summary="Cria um pedido aberto",
    description="""
Cria um pedido em estado **aberto**, sem itens ainda. Após criado,
adicione itens via `POST /pedidos/{id}/itens` e finalize com
`POST /pedidos/{id}/fechar`.
    """,
    responses={
        404: {"description": "Cliente não encontrado"},
        409: {"description": "Cliente bloqueado"},
    },
)
def criar_pedido(req: CriarPedidoRequest) -> PedidoCriadoResponse:
    ...

O FastAPI gera Swagger UI em /docs e Redoc em /redoc automaticamente. Quem consome a API vê documentação sempre sincronizada com o código real. Vale o investimento inicial.

O que escrever fora do código

Mesmo com Swagger automático, você ainda precisa de documento humano para coisas que o OpenAPI não captura:

14.8 Runbooks — quando algo dá errado

Runbook é documentação operacional: o que fazer quando incidente acontece. Cresce com a operação: cada incidente que vira post-mortem deveria gerar (ou atualizar) uma entrada.

docs/runbook.md (trecho)
# Runbook do serviço de pedidos

## Alerta: latência p99 > 2s

**Severidade:** Alta (acorda alguém)

### Diagnóstico rápido (5 minutos)

1. Confira dashboard `pedidos-saude` (link)
2. Veja se é spike geral ou endpoint específico
3. Checar saturação do banco: query `pg_stat_activity`

### Causas comuns observadas

| Sintoma | Causa provável | Ação |
|---------|---------------|------|
| CPU do banco em 100% | Query lenta sem índice | EXPLAIN, adicionar índice via PR de hotfix |
| Memória da app subindo | Vazamento em cache | Restart rolling, abrir issue |
| Saturação de conexão | Pool esgotado | Aumentar pool temporariamente, investigar leak |

### Como mitigar enquanto investiga

```bash
kubectl scale deployment pedidos --replicas=8  # dobra capacidade
```

### Como NÃO mitigar

- ❌ Reiniciar o banco. Causa downtime de minutos.
- ❌ Truncar tabela de cache. Já causou perda de dados em 2025.

### Pós-incidente

Sempre abrir issue de post-mortem usando template em `docs/post-mortem-template.md`.

Runbook bom tem três características: acionável (passos concretos, não "investigar a causa raiz"), realista (escrito por quem já passou pela situação, não especulação), e atualizado (revisado depois de cada incidente coberto).

14.9 Diagramas que envelhecem bem

Diagrama bonito feito no Figma envelhece em semanas. Diagrama em texto, versionado junto com o código, sobrevive anos.

C4 Model

Simon Brown propôs em 2018 o C4 Model: quatro níveis de zoom para arquitetura — Context (sistema e seus atores externos), Containers (apps, bancos, filas), Components (módulos dentro de cada container), Code (raramente desenhado). A maioria dos times precisa só do nível 1 e 2.

Diagramas como texto

Use ferramentas que aceitam texto: Mermaid (renderiza direto em Markdown do GitHub), PlantUML, Structurizr (especificamente para C4). Quando o sistema muda, você edita texto, faz PR como qualquer código.

docs/arquitetura.md
```mermaid
graph LR
    cliente[Cliente Web]
    api[API Pedidos]
    fila[Fila RabbitMQ]
    worker[Worker]
    db[(Postgres)]
    cache[(Redis)]

    cliente -->|HTTP| api
    api -->|read/write| db
    api -->|cache| cache
    api -->|publica| fila
    fila -->|consome| worker
    worker -->|notifica| cliente
```

Renderiza diretamente no GitHub, no GitLab, em Notion, em quase qualquer ferramenta moderna. Quando arquitetura muda, edite o texto. Diferenças aparecem em diff, revisores conseguem revisar.

14.10 Estudo de caso — documentação de um serviço

Estrutura completa para um serviço novo

Você está iniciando um serviço novo. Em vez de documentar reativamente, monte a estrutura desde o dia 1. Cada peça tem propósito claro.

Estrutura mínima recomendada
repo/
servico-pagamentos/
├── README.md                # porta de entrada (Diátaxis: guia inicial)
├── CONTRIBUTING.md          # como contribuir (Diátaxis: how-to)
├── CHANGELOG.md             # histórico de versões
├── docs/
│   ├── arquitetura.md       # visão geral + diagrama Mermaid (explicação)
│   ├── decisions/           # ADRs numerados
│   │   ├── 0001-tudo-em-postgres.md
│   │   ├── 0002-circuit-breaker.md
│   │   └── 0003-pix-via-celcoin.md
│   ├── runbook.md           # procedimentos operacionais
│   ├── tutoriais/           # Diátaxis: aprendizagem
│   │   └── primeira-integracao.md
│   ├── how-tos/             # Diátaxis: receitas
│   │   ├── adicionar-novo-gateway.md
│   │   ├── debugar-pagamento-pendente.md
│   │   └── rodar-migration-em-prod.md
│   └── api/                 # Diátaxis: referência (gerada parcialmente)
│       ├── webhooks.md
│       └── rate-limits.md
└── src/
    └── ...                  # docstrings nos pontos certos
README de entrada
README.md
# Serviço de pagamentos

Processa pagamentos via cartão de crédito, Pix e boleto para o e-commerce.

## Status

- ✅ Produção desde 2025-08
- 🧪 Cobertura: 91%
- 📦 Versão: 1.7.3

## Para começar

```bash
make dev      # sobe stack local (DB + Redis + gateways fake)
make test     # roda suíte completa
```

Pré: Docker, Python 3.12.

## Para onde olhar

- **Arquitetura geral** → `docs/arquitetura.md`
- **Decisões importantes** → `docs/decisions/`
- **Quero adicionar um gateway** → `docs/how-tos/adicionar-novo-gateway.md`
- **Pagamento travado em produção** → `docs/runbook.md#pagamento-pendente`
- **Vou consumir esta API** → `https://pagamentos.empresa.com/docs` (Swagger)

## Time

Squad @squad-pagamentos — Slack `#pagamentos-dev`.
Para incidentes fora do horário: PagerDuty rotação "pagamentos-primary".
Architecture Decision Record
docs/decisions/0002-circuit-breaker.md
# ADR 0002: Circuit Breaker entre nosso serviço e gateways

**Data:** 2025-09-12
**Status:** Aceita
**Decisores:** @ana, @bruno

## Contexto

Em 2025-09-07 tivemos incidente onde gateway Celcoin ficou
respondendo lento (~30s). Nosso serviço continuou tentando,
acumulando conexões pendentes, até esgotar pool e ficar
indisponível mesmo para outros métodos de pagamento.

## Decisão

Implementaremos Circuit Breaker entre nosso serviço e cada
gateway externo. Estados: FECHADO, ABERTO, MEIO_ABERTO.
Parâmetros iniciais: 5 falhas em 60s abrem o circuito;
timeout de 30s antes de tentar MEIO_ABERTO.

## Alternativas consideradas

- **Apenas retry com backoff:** não resolve, gateway lento
  continua sendo chamado, esgotando recursos.
- **Hystrix (Netflix):** Java, não cabe; existem libs Python
  equivalentes mas pesadas para nosso uso.
- **Implementação caseira:** escolhida — escopo é simples
  o suficiente, controle total dos parâmetros.

## Consequências

- **Positivas:** falha de um gateway não afeta outros.
- **Negativas:** quando circuito abre, usuário recebe erro
  imediato (ao invés de esperar timeout). Comunicar no UI.
- **Operacional:** dashboard de estado dos circuitos
  precisa ser construído (issue #234).

O que ganhamos: seis meses depois, quem entrar no time consegue ler o README e em 15 minutos saber onde tudo está. Quando alguém perguntar "por que tem circuit breaker aqui?", a resposta está no ADR 0002 com o contexto completo. Quando um pagamento travar de madrugada, o on-call segue o runbook.

14.11 Erros comuns

Erro 1 · Wiki imensa, ninguém lê

Confluence com 200 páginas. Atualização rara. Quando alguém procura informação, encontra três versões conflitantes e desiste — vai perguntar para uma pessoa. Concentre documentação no repositório, em poucos arquivos bem cuidados.

Erro 2 · Documentar comportamento atual em vez de contrato

"A função retorna 5 quando você passa 3" é teste. "A função soma 2 ao input" é documentação. Contrato é mais estável que comportamento concreto.

Erro 3 · Diagrama feito em ferramenta visual

Diagrama no Figma. Arquitetura muda; diagrama não. Em seis meses, mostrar diagrama em onboarding vira "ah, ignora essa caixa aqui, já mudou". Use ferramentas que aceitam texto.

Erro 4 · Misturar os quatro tipos

README que tenta ser tutorial, referência, how-to e explicação ao mesmo tempo. Resulta em texto longo, confuso, mau para todos. Separe por intenção do leitor.

14.12 Quando NÃO documentar

Reconheça o contexto
Documentação tem custo — vale onde?
  • Código que vai ser descartado: protótipo, spike, script one-off. Documentação seria desperdício.
  • Comportamento óbvio: função calcular_subtotal que calcula subtotal. Não documente o óbvio.
  • API privada de uso interno: métodos privados de uma classe não precisam de docstring extensa; o nome e os tipos bastam.
  • Decisões triviais: "escolhemos Python porque é a linguagem do time". Não vira ADR.
  • Detalhes voláteis: tudo que muda em todo refactor. Documentação vai envelhecer antes de você atualizar.

Quando em dúvida: prefira código melhor ao invés de documentação compensatória. Nome melhor, função extraída, tipo próprio — frequentemente eliminam a necessidade de docstring.

Verifique seu entendimento
"Time discute por que escolheu PostgreSQL em vez de MongoDB para um serviço. Decisão foi tomada em call há dois anos, ninguém lembra todos os argumentos. Como você documentaria a partir de hoje?"

14.13 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Classificar peças de documentação

Para cada item, classifique como Tutorial, How-to, Referência ou Explicação (Diátaxis):

  1. "Como adicionar um novo método de pagamento ao sistema"
  2. "Lista completa de parâmetros do endpoint /pedidos"
  3. "Construindo seu primeiro pedido em 10 minutos"
  4. "Por que escolhemos arquitetura hexagonal nesse serviço"
  5. "Como debugar webhook falhando em produção"
  6. "Glossário de termos do domínio"
  7. "Conceitos fundamentais de event sourcing aplicado aqui"
  1. How-to — assume conhecimento; resolve problema específico.
  2. Referência — catálogo neutro de parâmetros.
  3. Tutorial — guiado, com objetivo de aprendizado.
  4. Explicação — narrativa de decisão (pode virar ADR).
  5. How-to — procedimento para problema concreto.
  6. Referência — consulta.
  7. Explicação — entendimento conceitual.
Fácil
Exercício 2 · README esqueleto

Escreva esqueleto de README para um serviço fictício "API de notificações" que você está iniciando. Inclua seções essenciais sem encher de conteúdo placeholder.

README.md
# API de Notificações

Envia notificações por e-mail, SMS e push para usuários do produto.
Centraliza templates, controla rate limit por destinatário e
mantém histórico de entregas.

## Status

- 🚧 Em desenvolvimento (target: prod em 2026-06)
- 🧪 Cobertura atual: 78%
- 📦 Versão: 0.3.0 (pré-release)

## Como rodar localmente

```bash
make dev
make test
```

Pré: Python 3.12, Docker.

## Estrutura

```
src/notificacoes/  - bounded context principal
src/templates/     - templates de mensagens
src/canais/        - adapters (email, sms, push)
```

## Onde olhar

- Arquitetura: `docs/arquitetura.md`
- Decisões: `docs/decisions/`
- API: `/docs` (Swagger em dev)
- Runbook: `docs/runbook.md`

## Time

@squad-mensageria — `#notificacoes-dev` no Slack.
Médio
Exercício 3 · Escrever um ADR

Você é parte de um time que vai escolher entre requests e httpx como cliente HTTP do projeto. Escreva o ADR completo dessa decisão, com contexto plausível, alternativas, decisão e consequências.

docs/decisions/0005-httpx-como-cliente-http.md
# ADR 0005: httpx como cliente HTTP padrão

**Data:** 2026-05-12
**Status:** Aceita
**Decisores:** @ana, @bruno, @carla

## Contexto

Vários módulos do serviço fazem chamadas HTTP para APIs externas
(gateway de pagamento, serviço de e-mail, OAuth provider).
Hoje cada módulo usa biblioteca diferente — `requests` em alguns,
`urllib3` direto em outros. Isso traz problemas:

- Configuração inconsistente de timeout e retry
- Difícil instrumentar (cada lib tem hook próprio)
- Suporte assíncrono (parte do código está migrando para async)
  requer biblioteca diferente

## Decisão

Padronizar em **httpx 0.27+** como cliente HTTP único do projeto.
Migração gradual: novos módulos usam httpx; existentes migram
oportunamente em refatorações já planejadas.

## Alternativas consideradas

### requests
- ✓ Maturíssima, amplamente conhecida
- ✓ Documentação extensa
- ✗ Sem suporte async nativo
- ✗ Manutenção em modo "estabilidade", poucas features novas

### urllib3 direto
- ✓ Já é dependência transitiva
- ✗ API de baixo nível, verbosa
- ✗ Cada chamada exige boilerplate

### aiohttp
- ✓ Suporte async maduro
- ✗ API só async — código síncrono fica feio
- ✗ Comunidade menor que httpx

### httpx (escolhida)
- ✓ API similar a requests (curva de aprendizado baixa)
- ✓ Suporte async + sync com mesma API
- ✓ HTTP/2 nativo
- ✓ Manutenção ativa

## Consequências

- **Positivas:** configuração centralizada de timeout/retry/auth
  via instância única; instrumentação simples; pronto para async.
- **Negativas:** migração de código existente custa esforço;
  bibliotecas auxiliares que dependem de `requests` (boto3,
  por exemplo) seguem usando ela — temos duas libs em paralelo
  durante transição.
- **Operacional:** documentar configuração padrão em
  `src/shared/http_client.py` (issue #412).
Difícil
Exercício 4 · Runbook completo para um alerta

Imagine alerta: "Filas de webhook acumulando — mais de 1000 mensagens pendentes". Escreva entrada de runbook completa: severidade, diagnóstico passo a passo, causas comuns, ações de mitigação, ações a evitar, pós-incidente.

docs/runbook.md (trecho)
## Alerta: Filas de webhook acumulando (>1000 pendentes)

**Severidade:** Média (horário comercial) / Alta (fora dele,
se passar de 5000)

**Impacto:** Webhooks de saída para clientes ficam atrasados.
Eles não recebem confirmação de eventos (criação de pedido,
mudança de status). Cliente pode percber e abrir chamado.

### Diagnóstico rápido (5 minutos)

1. **Dashboard `webhooks-saude`** — confira:
   - Taxa de entrega atual vs baseline
   - Tempo médio de entrega
   - Erros por código de retorno (4xx, 5xx, timeout)

2. **Identifique se é geral ou específico:**

   ```bash
   make webhook-status
   # Mostra contagem por destinatário (URL alvo)
   ```

3. **Veja logs do worker:**

   ```bash
   kubectl logs -l app=webhook-worker --tail=200 -f
   ```

### Causas comuns observadas

| Sintoma | Causa provável | Ação |
|---------|---------------|------|
| Um destino acumulando | Cliente com endpoint fora do ar | Pausar entrega para ele, abrir ticket |
| Todos lentos | Worker com problema (DNS, cert) | Reiniciar worker pods |
| Erros 429 do cliente | Estamos enviando rápido demais | Reduzir paralelismo do worker |
| Pico legítimo | Promoção/black friday | Escalar workers (ver abaixo) |

### Ações de mitigação

**Escalar workers (mais comum):**
```bash
kubectl scale deployment webhook-worker --replicas=10
```
Limite máximo de réplicas: 20 (acima disso vamos saturar pool
do banco — ver ADR 0008).

**Pausar destinatário problemático:**
```bash
make webhook-pause URL=https://cliente-x.com/hook
```
Isso para entregas para aquele endpoint específico,
preservando as outras. Reabilitar com `make webhook-resume`.

**Drenar fila prioritariamente:**
Se acima de 10000, considere migrar entregas mais antigas
(mais de 24h) para dead-letter queue manualmente:
```bash
make webhook-drain-old HOURS=24
```

### Como NÃO mitigar

- ❌ **Não delete mensagens** sem migrar para DLQ. Já causamos
  perda de dados em 2025-11.
- ❌ **Não suba para 30+ workers.** Vai esgotar pool do Postgres
  e derrubar o resto do sistema (ADR 0008 explica por quê).
- ❌ **Não reinicie o RabbitMQ.** Causa redelivery massivo;
  webhooks duplicados pra cliente; pior que o problema.

### Pós-incidente

1. Se causou impacto a cliente, abrir post-mortem
   (template em `docs/post-mortem-template.md`).
2. Verificar se algum cliente precisa de reentregas manuais
   das mensagens em DLQ.
3. Se foi causa recorrente, considerar issue de melhoria estrutural.
Fim do capítulo 14 · Fim da Parte III
Você concluiu a Parte III — Qualidade e evolução. Testes, refatoração, code smells, documentação. Próxima: Parte IV — Dados e persistência. Modelagem relacional, SQL e performance, transações e concorrência, NoSQL. Peça "continua" para receber.
Parte IV
Dados e persistência

Onde a aplicação para de existir só em memória. Modelagem que aguenta o tempo, SQL que escala, transações que não corrompem, e NoSQL aplicado com critério.

Modelagem relacional SQL e performance Transações e concorrência NoSQL com critério
Parte IV · Capítulo 15 · Dados e persistência

Modelagem
relacional
que sobrevive.

Modelo de dados é o ativo mais difícil de mudar em um sistema. Código você refatora; banco com 50 milhões de linhas, não tanto. Acertar a modelagem cedo paga juros compostos por anos.

A maioria dos bugs duradouros em sistemas de software vem de modelagem inadequada. Quando o modelo não representa bem o domínio, código tenta compensar com lógica esparramada, queries complicadas, dados redundantes que dessincronizam. Quando o modelo está bem feito, código fica simples, queries são claras, e o sistema aguenta evolução por anos. Este capítulo é sobre acertar essa parte difícil.

15.1 A história — de Codd ao banco moderno

Contexto histórico

Em 1970, Edgar F. Codd, pesquisador da IBM, publicou "A Relational Model of Data for Large Shared Data Banks". O paper introduziu uma ideia revolucionária: dados deveriam ser organizados em relações matemáticas (tabelas), independentes de como são armazenados fisicamente, e manipulados via uma linguagem declarativa baseada em álgebra relacional.

A indústria resistiu por quase uma década — os bancos hierárquicos e em rede da época (IMS, CODASYL) eram dogma. Em 1979, a Relational Software lançou o que viraria Oracle. Em 1986, o padrão SQL foi formalizado (ANSI). Nos anos 90, com PostgreSQL e MySQL maduros, o modelo relacional virou padrão de fato.

Codd ganhou o Turing Award em 1981 por esse trabalho. Suas 12 regras (publicadas em 1985) definem o que torna um banco "verdadeiramente relacional" — nenhum produto comercial as cumpre totalmente até hoje, mas elas seguem como norte.

Nos anos 2000-2010, o movimento NoSQL desafiou o modelo relacional. Em retrospectiva, mais nuance: relacionais incorporaram features que se atribuíam a NoSQL (JSON nativo, replicação eficiente, sharding), e NoSQL incorporou features relacionais (transações, joins limitados). Hoje, escolha de banco é decisão consciente por caso de uso — não modismo.

15.2 Fundamentos relacionais

Antes de modelar, é preciso entender o paradigma. Quatro conceitos centrais:

O ponto central é: o banco é guardião do estado consistente. Não confie em "a aplicação garante". Aplicações têm bugs, têm múltiplas versões em produção ao mesmo tempo, têm scripts manuais. Banco bem modelado recusa dados inválidos.

Princípio fundamental
Make illegal states unrepresentable — também no banco. Constraints (NOT NULL, UNIQUE, CHECK, FK) não são burocracia; são a versão SQL do princípio que vimos no capítulo 1. Coluna que pode ser NULL abre porta para 3 estados (presente / ausente / desconhecido); use apenas quando esses três estados forem semanticamente distintos.

15.3 Normalização — sem dogma

Normalização é o processo de organizar tabelas para reduzir redundância. Codd definiu várias formas normais (1FN até 6FN); na prática, o trabalho diário lida com as três primeiras.

Primeira Forma Normal (1FN)

Cada célula contém um único valor atômico. Sem listas separadas por vírgula, sem dicionários serializados em string.

✗ Viola 1FN
pedido_ruim
id  | cliente | itens
----|---------|---------------------------
1   | Alice   | "caneta:5,lápis:3,papel:10"
2   | Bob     | "caderno:1"

Querer "todos pedidos com caneta" exige parsing de string. Quantidade nunca soma corretamente. Adicionar atributo a um item (cor, tamanho) vira pesadelo.

✓ Em 1FN
tabelas_separadas
CREATE TABLE pedidos (
    id SERIAL PRIMARY KEY,
    cliente_id INT NOT NULL
);

CREATE TABLE pedido_itens (
    pedido_id INT NOT NULL REFERENCES pedidos(id),
    sku VARCHAR(50) NOT NULL,
    quantidade INT NOT NULL CHECK (quantidade > 0),
    PRIMARY KEY (pedido_id, sku)
);

Segunda Forma Normal (2FN)

Em 1FN, e nenhum atributo não-chave depende de parte da chave primária composta. Aplica-se a tabelas com chave composta. Se um atributo só depende de uma das colunas da chave, ele pertence a outra tabela.

Terceira Forma Normal (3FN)

Em 2FN, e nenhum atributo não-chave depende de outro atributo não-chave. É a forma que dá nome ao princípio "cada fato em um único lugar".

viola_3fn.sql
CREATE TABLE funcionarios_ruim (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(100),
    departamento_id INT,
    departamento_nome VARCHAR(100),   -- depende de departamento_id
    departamento_andar INT             -- também depende
);
-- Departamento muda de andar: precisa atualizar TODOS os funcionários.
-- Esquece um → estado inconsistente.

-- Refatorado em 3FN:
CREATE TABLE departamentos (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    andar INT NOT NULL
);

CREATE TABLE funcionarios (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    departamento_id INT NOT NULL REFERENCES departamentos(id)
);
Regra prática
Comece sempre normalizado até 3FN. Desnormalize apenas com motivo concreto e medido (vamos cobrir adiante). O custo de desnormalizar prematuramente é alto: redundância vira inconsistência, e inconsistência vira bug silencioso.

15.4 Chaves bem escolhidas

Toda tabela precisa de chave primária — a identidade canônica do registro. A escolha é mais sutil do que parece.

Surrogate vs natural

Auto-incremento ou UUID?

BIGSERIAL / SERIAL

Quando: sistema centralizado, sem necessidade de gerar IDs antes de inserir, sequência crescente é útil (paginação por ID).

Vantagens: compacto (8 bytes), índices eficientes, fácil de debugar.

Desvantagens: expõe volume ("já temos 1.2M usuários"), difícil em sistemas distribuídos, problema em migrations entre bancos.

UUID

Quando: gerar ID na aplicação antes de inserir, sistemas distribuídos, IDs visíveis a usuário externo.

Vantagens: globalmente único, geração sem coordenação, não expõe volume.

Desvantagens: 16 bytes (vs 8), índices maiores, UUIDs v4 fragmentam B-tree. Prefira UUIDv7 ou ULID — ordenáveis por tempo.

chaves.sql
-- BIGSERIAL: simples, eficiente
CREATE TABLE usuarios (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- UUID v7 (ordenável por tempo, ideal em PostgreSQL 18+ nativo)
-- Antes disso: extensão pgcrypto + função custom, ou gerar na app.
CREATE TABLE pedidos (
    id UUID PRIMARY KEY DEFAULT uuidv7(),
    usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Chave natural quando faz sentido — mas mantenha surrogate como PK
CREATE TABLE produtos (
    id BIGSERIAL PRIMARY KEY,        -- canônico interno
    sku VARCHAR(50) NOT NULL UNIQUE,  -- natural, exposto a usuário
    nome VARCHAR(200) NOT NULL
);

15.5 Relacionamentos — 1:1, 1:N, N:N

Um-para-muitos (1:N) — o caso comum

Um pedido tem vários itens; cada item pertence a um pedido. A chave estrangeira fica do lado "muitos" (em pedido_itens.pedido_id).

Muitos-para-muitos (N:N) — exige tabela de junção

Um aluno cursa várias disciplinas; uma disciplina tem vários alunos. SQL não representa N:N diretamente — você cria uma tabela de junção:

relacao_nn.sql
CREATE TABLE alunos (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL
);

CREATE TABLE disciplinas (
    id BIGSERIAL PRIMARY KEY,
    codigo VARCHAR(10) NOT NULL UNIQUE,
    nome VARCHAR(100) NOT NULL
);

-- Tabela de junção
CREATE TABLE matriculas (
    aluno_id BIGINT NOT NULL REFERENCES alunos(id),
    disciplina_id BIGINT NOT NULL REFERENCES disciplinas(id),
    semestre VARCHAR(7) NOT NULL,             -- "2026-01"
    nota_final NUMERIC(4,2) CHECK (nota_final BETWEEN 0 AND 10),
    PRIMARY KEY (aluno_id, disciplina_id, semestre)
);
-- A junção pode (e geralmente deve) ter atributos próprios.
-- Aqui: nota e semestre são da matrícula, não do aluno nem da disciplina.

Um-para-um (1:1) — quando faz sentido

Raro. Geralmente, atributos 1:1 ficam na mesma tabela. Justifica separar quando: alguns atributos são opcionais e raramente preenchidos (perfil estendido); há motivos de segurança (separar dados sensíveis); ou estão em camadas diferentes do domínio.

Auto-referência — hierarquias

Categorias com subcategorias, comentários com respostas, organogramas. Uma coluna na tabela referencia outra linha da mesma tabela.

hierarquia.sql
CREATE TABLE categorias (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    parent_id BIGINT REFERENCES categorias(id)
    -- NULL = categoria raiz
);

-- Consulta com CTE recursiva para pegar árvore completa:
WITH RECURSIVE arvore AS (
    SELECT id, nome, parent_id, 1 AS nivel
    FROM categorias WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.nome, c.parent_id, a.nivel + 1
    FROM categorias c JOIN arvore a ON c.parent_id = a.id
)
SELECT * FROM arvore;

Para hierarquias com profundidade variável e queries frequentes, considere padrões alternativos: nested sets, materialized path, ou closure table. Cada um tem trade-off — adjacency list (acima) é o mais simples e suficiente para a maioria dos casos.

15.6 Modelando o tempo

Tempo é o tipo de dado que mais causa bugs em sistemas. Cinco regras práticas:

tempo.sql
CREATE TABLE pedidos (
    id BIGSERIAL PRIMARY KEY,
    cliente_id BIGINT NOT NULL,
    valor NUMERIC(12,2) NOT NULL,

    -- evento real (quando aconteceu no mundo)
    confirmado_em TIMESTAMPTZ,

    -- auditoria do registro (quando entrou no nosso banco)
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Trigger para manter atualizado_em correto
CREATE OR REPLACE FUNCTION set_atualizado_em()
RETURNS TRIGGER AS $$
BEGIN
    NEW.atualizado_em = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_pedidos_atualizado_em
BEFORE UPDATE ON pedidos
FOR EACH ROW EXECUTE FUNCTION set_atualizado_em();

Diferença: tempo de evento vs tempo do registro

Não confunda: confirmado_em é quando o pagamento foi aprovado (evento real); atualizado_em é quando a linha foi modificada no banco. Em sistemas que processam eventos com atraso ou retroativamente, essa distinção é vital. Documente.

15.7 Soft delete e auditoria

"Apagar" registros raramente é simples. Auditoria, recuperação, relacionamentos órfãos — tudo aponta para soft delete (marcar como apagado, não remover) como prática frequente.

soft_delete.sql
ALTER TABLE pedidos
ADD COLUMN deletado_em TIMESTAMPTZ;

-- Queries normais filtram automaticamente
-- Use VIEW para esconder a coluna:
CREATE VIEW pedidos_ativos AS
    SELECT * FROM pedidos WHERE deletado_em IS NULL;

-- Índice parcial: só indexa linhas não-deletadas
CREATE INDEX idx_pedidos_ativos_cliente
ON pedidos (cliente_id)
WHERE deletado_em IS NULL;

Quando NÃO usar soft delete

Auditoria mais profunda — tabelas de histórico

Para casos sensíveis (financeiro, jurídico), soft delete não basta. Cada mudança precisa ser registrada. Estratégia: tabela espelho que recebe inserts em cada update/delete via trigger.

historico.sql
CREATE TABLE pedidos_historico (
    historico_id BIGSERIAL PRIMARY KEY,
    pedido_id BIGINT NOT NULL,
    operacao VARCHAR(10) NOT NULL,  -- INSERT/UPDATE/DELETE
    dados JSONB NOT NULL,             -- snapshot completo
    quando TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    quem VARCHAR(100)               -- usuário/sistema que fez
);

CREATE OR REPLACE FUNCTION audit_pedidos()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO pedidos_historico (pedido_id, operacao, dados, quem)
    VALUES (
        COALESCE(NEW.id, OLD.id),
        TG_OP,
        to_jsonb(COALESCE(NEW, OLD)),
        current_setting('app.usuario', true)
    );
    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_pedidos_audit
AFTER INSERT OR UPDATE OR DELETE ON pedidos
FOR EACH ROW EXECUTE FUNCTION audit_pedidos();

15.8 Quando desnormalizar (com critério)

Desnormalizar é introduzir redundância de propósito. Aumenta complexidade (precisa manter cópias sincronizadas) em troca de performance. Faça apenas com motivo medido.

Casos legítimos

snapshot.sql
-- Nota fiscal preserva nome/endereço do cliente NO MOMENTO da emissão.
-- NÃO é desnormalização — é dado histórico legítimo.
CREATE TABLE notas_fiscais (
    id BIGSERIAL PRIMARY KEY,
    numero VARCHAR(20) NOT NULL UNIQUE,
    emitida_em TIMESTAMPTZ NOT NULL,

    -- Snapshot do cliente — congelado
    cliente_id BIGINT NOT NULL,
    cliente_nome VARCHAR(200) NOT NULL,
    cliente_cnpj VARCHAR(14) NOT NULL,
    cliente_endereco TEXT NOT NULL,

    -- Sem FK para clientes — porque o cliente pode mudar dados depois
    -- e a nota precisa permanecer histórica.

    valor NUMERIC(12,2) NOT NULL
);

15.9 Schema evolutivo — migrations

Banco em produção raramente tem janela de downtime aceitável para "trocar o schema". Mudanças precisam ser compatíveis com versões antigas e novas do código ao mesmo tempo (durante deploy gradual) e reversíveis se algo der errado.

Mudanças seguras

Mudanças perigosas

Pattern: expand-contract

Para mudanças que parecem "atômicas" mas exigem deploy gradual:

  1. Expand: adicione o novo (coluna, tabela) mantendo o antigo. Código escreve nos dois.
  2. Migrate: copie dados existentes do antigo para o novo.
  3. Contract: deploy do código que só usa o novo. Depois, remova o antigo.
expand_contract.sql
-- Migration 001: expand
ALTER TABLE usuarios ADD COLUMN nome_completo VARCHAR(200);

-- Migration 002: backfill
UPDATE usuarios SET nome_completo = nome || ' ' || sobrenome
WHERE nome_completo IS NULL;

-- Aplicação: deploy gradual, escrevendo nos dois campos.
-- Após verificar consistência:

-- Migration 003: contract
ALTER TABLE usuarios DROP COLUMN nome;
ALTER TABLE usuarios DROP COLUMN sobrenome;

15.10 Estudo de caso — modelando um e-commerce

De entidades soltas para schema consistente

Vamos modelar um e-commerce pequeno: usuários, produtos, pedidos com itens, pagamentos. Cada decisão tem justificativa.

Passo 1 · Usuários e endereços
schema.sql (parte 1)
CREATE TABLE usuarios (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    senha_hash VARCHAR(255) NOT NULL,
    nome VARCHAR(200) NOT NULL,
    cpf VARCHAR(11) UNIQUE,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deletado_em TIMESTAMPTZ
);

CREATE INDEX idx_usuarios_email_ativo
ON usuarios (email) WHERE deletado_em IS NULL;

-- Endereços em tabela separada — um usuário pode ter vários
-- (entrega, cobrança, antigo). Cada um marcado pelo tipo.
CREATE TABLE enderecos (
    id BIGSERIAL PRIMARY KEY,
    usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
    tipo VARCHAR(20) NOT NULL CHECK (tipo IN ('entrega', 'cobranca')),
    cep VARCHAR(8) NOT NULL,
    rua VARCHAR(200) NOT NULL,
    numero VARCHAR(20) NOT NULL,
    complemento VARCHAR(100),
    cidade VARCHAR(100) NOT NULL,
    estado CHAR(2) NOT NULL,
    eh_principal BOOLEAN NOT NULL DEFAULT FALSE,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Apenas um endereço principal por usuário+tipo
CREATE UNIQUE INDEX idx_endereco_principal
ON enderecos (usuario_id, tipo)
WHERE eh_principal = TRUE;
Passo 2 · Produtos com variantes
schema.sql (parte 2)
CREATE TABLE produtos (
    id BIGSERIAL PRIMARY KEY,
    sku VARCHAR(50) NOT NULL UNIQUE,
    nome VARCHAR(200) NOT NULL,
    descricao TEXT,
    preco NUMERIC(12,2) NOT NULL CHECK (preco >= 0),
    estoque INT NOT NULL DEFAULT 0 CHECK (estoque >= 0),
    ativo BOOLEAN NOT NULL DEFAULT TRUE,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Categorias (auto-referência para hierarquia)
CREATE TABLE categorias (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL,
    parent_id BIGINT REFERENCES categorias(id)
);

-- N:N entre produtos e categorias
CREATE TABLE produto_categorias (
    produto_id BIGINT NOT NULL REFERENCES produtos(id) ON DELETE CASCADE,
    categoria_id BIGINT NOT NULL REFERENCES categorias(id),
    PRIMARY KEY (produto_id, categoria_id)
);
Passo 3 · Pedidos com snapshot
schema.sql (parte 3)
CREATE TABLE pedidos (
    id BIGSERIAL PRIMARY KEY,
    usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
    status VARCHAR(20) NOT NULL DEFAULT 'aberto'
        CHECK (status IN ('aberto', 'confirmado', 'pago',
                          'enviado', 'entregue', 'cancelado')),

    -- Snapshot do endereço de entrega NO MOMENTO do pedido
    endereco_cep VARCHAR(8) NOT NULL,
    endereco_rua VARCHAR(200) NOT NULL,
    endereco_numero VARCHAR(20) NOT NULL,
    endereco_cidade VARCHAR(100) NOT NULL,
    endereco_estado CHAR(2) NOT NULL,

    subtotal NUMERIC(12,2) NOT NULL CHECK (subtotal >= 0),
    frete NUMERIC(12,2) NOT NULL DEFAULT 0,
    desconto NUMERIC(12,2) NOT NULL DEFAULT 0,
    total NUMERIC(12,2) NOT NULL GENERATED ALWAYS AS
        (subtotal + frete - desconto) STORED,

    confirmado_em TIMESTAMPTZ,
    pago_em TIMESTAMPTZ,
    enviado_em TIMESTAMPTZ,
    entregue_em TIMESTAMPTZ,
    cancelado_em TIMESTAMPTZ,
    motivo_cancelamento TEXT,

    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_pedidos_usuario ON pedidos (usuario_id, criado_em DESC);
CREATE INDEX idx_pedidos_status_aberto ON pedidos (criado_em)
    WHERE status = 'aberto';

-- Itens do pedido — também com snapshot do produto
CREATE TABLE pedido_itens (
    pedido_id BIGINT NOT NULL REFERENCES pedidos(id),
    sku VARCHAR(50) NOT NULL,
    produto_id BIGINT NOT NULL REFERENCES produtos(id),
    nome_snapshot VARCHAR(200) NOT NULL,
    preco_unitario NUMERIC(12,2) NOT NULL CHECK (preco_unitario > 0),
    quantidade INT NOT NULL CHECK (quantidade > 0),
    subtotal NUMERIC(12,2) NOT NULL GENERATED ALWAYS AS
        (preco_unitario * quantidade) STORED,
    PRIMARY KEY (pedido_id, sku)
);
Passo 4 · Pagamentos com histórico
schema.sql (parte 4)
CREATE TABLE pagamentos (
    id BIGSERIAL PRIMARY KEY,
    pedido_id BIGINT NOT NULL REFERENCES pedidos(id),
    forma VARCHAR(20) NOT NULL
        CHECK (forma IN ('cartao', 'pix', 'boleto')),
    valor NUMERIC(12,2) NOT NULL CHECK (valor > 0),
    status VARCHAR(20) NOT NULL DEFAULT 'pendente'
        CHECK (status IN ('pendente', 'aprovado', 'recusado', 'estornado')),

    -- ID externo do gateway, p/ rastreio
    gateway VARCHAR(50),
    gateway_tx_id VARCHAR(100),

    aprovado_em TIMESTAMPTZ,
    recusado_em TIMESTAMPTZ,
    motivo_recusa TEXT,

    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Um pedido pode ter múltiplas tentativas de pagamento.
-- Filtro para "qual está vigente" via status='aprovado'.

CREATE INDEX idx_pagamentos_pedido ON pagamentos (pedido_id, criado_em DESC);
CREATE UNIQUE INDEX idx_pagamentos_aprovado
ON pagamentos (pedido_id)
WHERE status = 'aprovado';
-- Garante que só há UM pagamento aprovado por pedido.

Decisões tomadas:

  • BIGSERIAL como PK em tudo — simples, suficiente para escala.
  • Endereço em tabela separada (1:N) — usuário pode ter vários, com flag de principal.
  • Snapshot de endereço e produto no pedido — preserva histórico mesmo se cliente trocar endereço ou produto mudar de nome.
  • Status com CHECK constraint — banco recusa estado inválido.
  • Datas separadas por evento (confirmado_em, pago_em, etc) — facilita auditoria e queries temporais.
  • Coluna total gerada — calculada pelo banco, nunca dessincroniza.
  • Índice parcial para "pagamento aprovado" único por pedido — garantia estrutural.

15.11 Erros comuns de modelagem

Erro 1 · Tudo nullable "por garantia"

Cada coluna nullable adiciona estado representável. Use NOT NULL como padrão; abra exceção apenas quando "ausência" tem significado semântico distinto.

Erro 2 · Sem chave estrangeira "porque é mais rápido"

FKs custam quase nada em performance e te dão integridade referencial. Sem FK, eventualmente vai aparecer linha órfã, e você vai gastar dias caçando.

Erro 3 · Status como string livre

status VARCHAR(50) sem CHECK constraint. Em meses você vai ter "pago", "PAGO", "Pago", "paid", " pago" todos significando a mesma coisa. Use CHECK ou ENUM.

Erro 4 · Esquecer atualizado_em

"Depois eu adiciono". Quando precisar saber quando algo mudou para investigar bug, vai ser tarde. Trigger automática para isso, em toda tabela com dados de negócio.

Erro 5 · Migrations sem reversão

"Funcionou em homologação." Em prod, algo dá errado em campo edge case e você precisa reverter. Toda migration deveria ter down definido — mesmo que seja "abortar dataset não pode ser revertido".

15.12 Quando NÃO usar relacional

Reconheça o contexto
Casos onde outro paradigma faz mais sentido
  • Documentos com estrutura variável: catálogo onde cada categoria de produto tem atributos diferentes. JSONB em Postgres resolve a maioria dos casos; MongoDB é alternativa.
  • Time-series massivo: métricas de monitoramento, logs estruturados. InfluxDB, TimescaleDB (extensão do Postgres) são otimizados.
  • Grafos densos: redes sociais com queries de "amigos dos amigos dos amigos". Neo4j é construído pra isso. Mas: Postgres + CTE recursiva resolve até razoavelmente grande.
  • Key-value puro: sessões, cache distribuído. Redis, Memcached.
  • Full-text search exigente: Elasticsearch é especializado. Mas Postgres tem GIN + tsvector que cobre 80% dos casos.

Default para sistemas com transações de negócio: relacional, especialmente PostgreSQL. Você só vai para outros quando o caso justifica e você consegue articular a justificativa.

Verifique seu entendimento
"Você precisa modelar pedidos onde cada item carrega o preço unitário e o nome do produto no momento da compra. Como modelar?"

15.13 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Normalizar uma tabela

A tabela abaixo viola 3FN. Quebre em tabelas adequadas, mantendo integridade.

livros_ruim
id  | titulo       | autor       | editora        | cidade_editora
----|--------------|-------------|----------------|---------------
1   | Refactoring  | Fowler      | Addison-Wesley | Boston
2   | Clean Code   | Martin      | Prentice Hall  | New Jersey
3   | DDD          | Evans       | Addison-Wesley | Boston
normalizado.sql
CREATE TABLE editoras (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(100) NOT NULL UNIQUE,
    cidade VARCHAR(100) NOT NULL
);

CREATE TABLE autores (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(200) NOT NULL UNIQUE
);

CREATE TABLE livros (
    id BIGSERIAL PRIMARY KEY,
    titulo VARCHAR(300) NOT NULL,
    editora_id BIGINT NOT NULL REFERENCES editoras(id)
);

-- Um livro pode ter vários autores (N:N)
CREATE TABLE livro_autores (
    livro_id BIGINT NOT NULL REFERENCES livros(id),
    autor_id BIGINT NOT NULL REFERENCES autores(id),
    PRIMARY KEY (livro_id, autor_id)
);
-- Cidade da editora não é mais redundante — vive em editoras.
-- Renomear editora (Addison-Wesley → "Pearson") muda só uma linha.
Médio
Exercício 2 · Modelar reservas de hotel

Modele schema para hotel: quartos com tipos diferentes (single, double, suite), hóspedes, reservas (com check-in, check-out, status). Inclua constraints: não pode reservar quarto já reservado nas datas; preço da diária pode mudar no tempo.

hotel.sql
CREATE TABLE tipos_quarto (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(50) NOT NULL UNIQUE,    -- 'single', 'double', 'suite'
    capacidade INT NOT NULL CHECK (capacidade > 0)
);

CREATE TABLE quartos (
    id BIGSERIAL PRIMARY KEY,
    numero VARCHAR(10) NOT NULL UNIQUE,
    tipo_id BIGINT NOT NULL REFERENCES tipos_quarto(id),
    ativo BOOLEAN NOT NULL DEFAULT TRUE
);

-- Diária pode mudar no tempo — guardamos por período
CREATE TABLE tarifas (
    id BIGSERIAL PRIMARY KEY,
    tipo_quarto_id BIGINT NOT NULL REFERENCES tipos_quarto(id),
    valor_diaria NUMERIC(10,2) NOT NULL CHECK (valor_diaria > 0),
    vigente_de DATE NOT NULL,
    vigente_ate DATE,
    CHECK (vigente_ate IS NULL OR vigente_ate >= vigente_de)
);

CREATE TABLE hospedes (
    id BIGSERIAL PRIMARY KEY,
    nome VARCHAR(200) NOT NULL,
    documento VARCHAR(20) NOT NULL UNIQUE,
    telefone VARCHAR(20)
);

CREATE TABLE reservas (
    id BIGSERIAL PRIMARY KEY,
    quarto_id BIGINT NOT NULL REFERENCES quartos(id),
    hospede_id BIGINT NOT NULL REFERENCES hospedes(id),
    check_in DATE NOT NULL,
    check_out DATE NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'reservada'
        CHECK (status IN ('reservada', 'em_estadia', 'concluida', 'cancelada')),
    valor_total NUMERIC(10,2) NOT NULL CHECK (valor_total >= 0),
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CHECK (check_out > check_in)
);

-- Constraint EXCLUDE garante que quarto não pode ser reservado
-- com sobreposição de datas (apenas reservas ativas).
-- Requer extensão btree_gist em Postgres.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE reservas
ADD CONSTRAINT sem_overlap
EXCLUDE USING gist (
    quarto_id WITH =,
    daterange(check_in, check_out, '[)') WITH &&
) WHERE (status IN ('reservada', 'em_estadia'));
Médio
Exercício 3 · Migration expand-contract

Você tem coluna endereco_completo VARCHAR(500) no formato "rua, número - cidade/estado". Quer quebrar em rua, numero, cidade, estado. Sistema está em produção, com deploy gradual. Escreva as três migrations no padrão expand-contract.

migrations.sql
-- ============================================
-- Migration 001: EXPAND
-- Adiciona novas colunas, mantendo a antiga
-- ============================================
ALTER TABLE clientes ADD COLUMN rua VARCHAR(200);
ALTER TABLE clientes ADD COLUMN numero VARCHAR(20);
ALTER TABLE clientes ADD COLUMN cidade VARCHAR(100);
ALTER TABLE clientes ADD COLUMN estado CHAR(2);

-- Aplicação faz deploy escrevendo em AMBOS:
--   endereco_completo (antigo) E rua/numero/cidade/estado (novo)
-- Leitura ainda usa endereco_completo.

-- ============================================
-- Migration 002: BACKFILL
-- Em batches para evitar lock longo
-- ============================================
UPDATE clientes SET
    rua = split_part(split_part(endereco_completo, ',', 1), ' ', 1),
    numero = trim(split_part(split_part(endereco_completo, ',', 2), '-', 1)),
    cidade = trim(split_part(split_part(endereco_completo, '-', 2), '/', 1)),
    estado = trim(split_part(endereco_completo, '/', 2))
WHERE rua IS NULL AND endereco_completo IS NOT NULL
AND id IN (SELECT id FROM clientes
              WHERE rua IS NULL AND endereco_completo IS NOT NULL
              LIMIT 10000);
-- Repete em loop até zero linhas. Em prod, com pg_repack ou similar.

-- Verifica consistência antes de continuar:
-- SELECT COUNT(*) FROM clientes
-- WHERE endereco_completo IS NOT NULL AND rua IS NULL;

-- Aplicação faz NOVO deploy: leitura PASSA a usar campos separados.

-- ============================================
-- Migration 003: CONTRACT
-- Remove coluna antiga após app só usar a nova
-- ============================================
ALTER TABLE clientes DROP COLUMN endereco_completo;
Difícil
Exercício 4 · Modelagem completa

Modele schema para um sistema de cursos online: cursos com módulos e aulas; alunos podem se inscrever em cursos; cada aula tem progresso por aluno (assistida ou não, percentual); avaliações no fim do curso. Inclua: hierarquia (curso → módulo → aula); progresso por aluno; integridade referencial; soft delete; auditoria.

cursos.sql
CREATE TABLE alunos (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    nome VARCHAR(200) NOT NULL,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deletado_em TIMESTAMPTZ
);

CREATE TABLE cursos (
    id BIGSERIAL PRIMARY KEY,
    titulo VARCHAR(300) NOT NULL,
    descricao TEXT,
    publicado BOOLEAN NOT NULL DEFAULT FALSE,
    publicado_em TIMESTAMPTZ,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deletado_em TIMESTAMPTZ
);

CREATE TABLE modulos (
    id BIGSERIAL PRIMARY KEY,
    curso_id BIGINT NOT NULL REFERENCES cursos(id) ON DELETE CASCADE,
    titulo VARCHAR(300) NOT NULL,
    ordem INT NOT NULL CHECK (ordem > 0),
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (curso_id, ordem)
);

CREATE TABLE aulas (
    id BIGSERIAL PRIMARY KEY,
    modulo_id BIGINT NOT NULL REFERENCES modulos(id) ON DELETE CASCADE,
    titulo VARCHAR(300) NOT NULL,
    duracao_segundos INT NOT NULL CHECK (duracao_segundos > 0),
    video_url TEXT NOT NULL,
    ordem INT NOT NULL CHECK (ordem > 0),
    UNIQUE (modulo_id, ordem)
);

CREATE TABLE inscricoes (
    id BIGSERIAL PRIMARY KEY,
    aluno_id BIGINT NOT NULL REFERENCES alunos(id),
    curso_id BIGINT NOT NULL REFERENCES cursos(id),
    inscrito_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    concluido_em TIMESTAMPTZ,
    UNIQUE (aluno_id, curso_id)
);

CREATE TABLE progresso_aulas (
    aluno_id BIGINT NOT NULL REFERENCES alunos(id),
    aula_id BIGINT NOT NULL REFERENCES aulas(id),
    percentual SMALLINT NOT NULL DEFAULT 0
        CHECK (percentual BETWEEN 0 AND 100),
    assistida_em TIMESTAMPTZ,
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (aluno_id, aula_id)
);

CREATE INDEX idx_progresso_aluno ON progresso_aulas (aluno_id);

CREATE TABLE avaliacoes (
    id BIGSERIAL PRIMARY KEY,
    inscricao_id BIGINT NOT NULL UNIQUE REFERENCES inscricoes(id),
    nota SMALLINT NOT NULL CHECK (nota BETWEEN 1 AND 5),
    comentario TEXT,
    criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Fim do capítulo 15
Próximo capítulo: SQL e performance — como fazer queries que aguentam o sistema crescer. Índices, EXPLAIN, problemas N+1 e por que sua query rápida com 10 mil linhas vira gargalo com 10 milhões.
Parte IV · Capítulo 16 · Dados e persistência

SQL
e performance:
queries que escalam.

Sua query roda em 12 ms em homologação e em 4 segundos em produção. A diferença não é mistério — é volume + falta de índice + plano errado. Saber diagnosticar é o que separa quem brigaria com o banco de quem domina ele.

SQL é declarativo: você descreve o que quer, o banco decide como buscar. Essa abstração é a maior força da linguagem, e também o lugar onde performance se decide. O mesmo SELECT pode rodar em milissegundos ou em minutos dependendo do plano que o otimizador escolhe. E ele escolhe baseado em estatísticas, índices disponíveis, e na forma como você escreveu a query. Este capítulo é sobre dominar esse jogo.

16.1 A história — de Ingres ao otimizador moderno

Contexto histórico

Nos primeiros bancos relacionais (Ingres em 1974, System R da IBM em 1976), o usuário escrevia queries em linguagem de baixo nível que descrevia como buscar (varrer essa tabela, depois cruzar com aquela). O salto da década seguinte foi o otimizador baseado em custo: SQL declarativo + um componente que enumera planos de execução possíveis e escolhe o mais barato segundo estatísticas das tabelas.

Patricia Selinger, em paper seminal de 1979 no System R, descreveu o algoritmo de otimização que até hoje é base. Pat publicou no IBM e moveu a indústria — a ideia de "deixe o banco decidir o plano" pareceu radical na época.

Nos anos 90, com PostgreSQL e MySQL maduros, otimizadores baseados em custo viraram padrão. Hoje, o trabalho de quem usa banco é menos "dizer ao banco o que fazer" e mais "dar a ele informação suficiente": índices certos, estatísticas atualizadas, queries escritas de forma que o otimizador entenda intenção.

Em 2025, com bases de dados de bilhões de linhas comuns mesmo em empresas médias, dominar perfil de query virou habilidade central de back-end. Não opcional.

16.2 Modelo mental do planejador

Antes de qualquer técnica, o modelo mental certo: o banco, ao receber sua query, faz três coisas:

  1. Parsing: transforma SQL em árvore.
  2. Planejamento: enumera planos possíveis (qual ordem de joins? usar índice ou varredura completa? hash join ou nested loop?), estima custo de cada um usando estatísticas, escolhe o mais barato.
  3. Execução: roda o plano escolhido.

Os custos são estimativas — número arbitrário interno do banco que combina I/O, CPU e memória. O plano não é "ótimo absoluto"; é "ótimo segundo o que o banco sabe". Se as estatísticas estão desatualizadas, o plano pode ser absurdo. Por isso, o banco roda VACUUM e ANALYZE periodicamente — para manter estatísticas em dia.

Cada query tem operações básicas que aparecem no plano:

16.3 EXPLAIN ANALYZE — sua melhor ferramenta

A primeira ação ao investigar query lenta é rodar EXPLAIN ANALYZE. Ele mostra o plano + tempos reais de cada operação.

explain.sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.id, p.total, u.email
FROM pedidos p
JOIN usuarios u ON u.id = p.usuario_id
WHERE p.criado_em > NOW() - INTERVAL '30 days'
  AND u.email LIKE '%@empresa.com'
ORDER BY p.criado_em DESC
LIMIT 100;

Exemplo de saída anotada:

plano.txt
Limit  (cost=12.34..456.78 rows=100 width=120)
       (actual time=0.234..14.567 rows=100 loops=1)
  Buffers: shared hit=234 read=12
  →  Hash Join  (cost=12.34..1234.56 rows=850 width=120)
              (actual time=0.123..14.456 rows=900 loops=1)
       Hash Cond: (p.usuario_id = u.id)
       Buffers: shared hit=234 read=12
       →  Index Scan using idx_pedidos_criado_em on pedidos p
                (cost=0.42..789.10 rows=5000 width=80)
                (actual time=0.045..8.234 rows=5234 loops=1)
            Index Cond: (criado_em > (now() - '30 days'::interval))
            Buffers: shared hit=120 read=8
       →  Hash  (cost=8.50..8.50 rows=120 width=40)
                (actual time=0.067..0.067 rows=120 loops=1)
            →  Seq Scan on usuarios u
                     (cost=0.00..8.50 rows=120 width=40)
                     (actual time=0.012..0.056 rows=120 loops=1)
                  Filter: (email ~~ '%@empresa.com')
                  Rows Removed by Filter: 4880
                  Buffers: shared hit=42

Planning Time: 0.452 ms
Execution Time: 14.789 ms

O que ler aqui, em ordem de prioridade:

Nesse plano, a tabela usuarios é varrida inteira (Seq Scan) e 98% das linhas são descartadas pelo filtro LIKE '%@empresa.com'. Há oportunidade para índice — mas LIKE com curinga no início (%algo) não usa índice B-tree comum. Vamos cobrir adiante.

Ferramenta visual
Para planos grandes, lê-los em texto é cansativo. Use explain.dalibo.com ou pgmustard.com — cole a saída e veja análise visual com gargalos destacados.

16.4 Índices — quando e como

Índice é uma estrutura de dados auxiliar que permite encontrar linhas rapidamente sem varrer a tabela. Default: B-tree. Para a maioria dos casos, é o que você quer.

Regras para indexar bem

Índices compostos: ordem das colunas

indice_composto.sql
CREATE INDEX idx_pedidos_usuario_data
ON pedidos (usuario_id, criado_em DESC);

-- Esse índice serve:
-- ✓ WHERE usuario_id = 42
-- ✓ WHERE usuario_id = 42 AND criado_em > '2026-01-01'
-- ✓ WHERE usuario_id = 42 ORDER BY criado_em DESC
-- ✗ WHERE criado_em > '2026-01-01'  ← coluna não-líder, não usa
-- ✗ ORDER BY criado_em DESC          ← sem filtro de usuario, não usa

Índices parciais

Quando você só consulta um subconjunto pequeno da tabela, índice parcial economiza espaço e melhora performance:

indice_parcial.sql
-- 99% dos pedidos estão "concluídos". Você raramente filtra por eles.
-- Mas você consulta os "abertos" constantemente — só 1% do total.
CREATE INDEX idx_pedidos_abertos
ON pedidos (criado_em DESC)
WHERE status = 'aberto';

-- Índice 100x menor. Query "todos abertos ordenados por data"
-- usa esse índice e fica muito mais rápida.

Index Only Scan

O melhor cenário: query responde direto do índice, sem ler a tabela. Acontece quando todas as colunas que você precisa estão no índice.

index_only.sql
-- INCLUDE adiciona colunas no índice sem usá-las para ordenação.
-- Index only scan funciona se a query lê apenas essas colunas.
CREATE INDEX idx_pedidos_resumo
ON pedidos (usuario_id)
INCLUDE (total, status, criado_em);

SELECT total, status, criado_em
FROM pedidos WHERE usuario_id = 42;
-- → Index Only Scan, sem tocar na tabela.

16.5 Tipos de índice além de B-tree

B-tree é default e cobre 90% dos casos, mas existem outros tipos com casos específicos.

GIN — para arrays, JSONB, full-text

gin.sql
-- Tags como array
CREATE TABLE artigos (
    id BIGSERIAL PRIMARY KEY,
    titulo TEXT,
    tags TEXT[]
);

CREATE INDEX idx_artigos_tags ON artigos USING GIN (tags);

SELECT * FROM artigos WHERE tags @> ARRAY['python', 'sql'];
-- ↑ "contém todos esses"

-- JSONB
CREATE TABLE eventos (
    id BIGSERIAL PRIMARY KEY,
    payload JSONB
);

CREATE INDEX idx_eventos_payload ON eventos USING GIN (payload);

SELECT * FROM eventos WHERE payload @> '{"tipo":"venda"}';

-- Full-text search
CREATE INDEX idx_artigos_texto
ON artigos
USING GIN (to_tsvector('portuguese', titulo));

SELECT * FROM artigos
WHERE to_tsvector('portuguese', titulo) @@ to_tsquery('portuguese', 'banco & performance');

BRIN — para colunas correlacionadas com ordem física

Útil quando dados são inseridos em ordem (logs, métricas, eventos por timestamp). Muito menor que B-tree, ideal para tabelas gigantes.

brin.sql
-- Tabela de eventos de telemetria, 500 milhões de linhas,
-- inseridos cronologicamente.
CREATE INDEX idx_eventos_timestamp_brin
ON eventos USING BRIN (timestamp);

-- Índice 1000x menor que B-tree equivalente. Queries por
-- intervalo de tempo continuam rápidas.

Hash, GiST, SP-GiST — casos específicos

16.6 N+1 queries — o bug que ninguém vê

Talvez o problema de performance mais comum em código de aplicação: você carrega N pedidos numa query, depois itera carregando o cliente de cada um (mais N queries). O total: 1 + N queries, frequentemente milhares.

n_mais_1.py
# 1 query: busca todos os pedidos
pedidos = list(Pedido.objects.filter(criado_em__gte=ontem))

# N queries: para cada pedido, busca o usuário
for p in pedidos:
    print(p.usuario.email)  # cada acesso → SELECT FROM usuarios WHERE id = ?

# Em Django: 1001 queries para 1000 pedidos. Em outros ORMs idem.

Solução: eager loading

eager_loading.py
# Django: select_related (JOIN) para 1:1, 1:N inverso (FK no objeto)
pedidos = Pedido.objects.filter(criado_em__gte=ontem) \
    .select_related('usuario')
# 1 query com JOIN

# prefetch_related para 1:N direto, N:N
pedidos = Pedido.objects.filter(criado_em__gte=ontem) \
    .select_related('usuario') \
    .prefetch_related('itens')
# 2 queries: uma para pedidos+usuários, outra para itens com IN (...)

# SQLAlchemy: joinedload e selectinload
from sqlalchemy.orm import joinedload, selectinload

with Session() as s:
    pedidos = s.query(Pedido)\
        .options(
            joinedload(Pedido.usuario),
            selectinload(Pedido.itens),
        )\
        .filter(Pedido.criado_em >= ontem)\
        .all()

Detecção em testes

Use django-debug-toolbar em dev, ou em testes assert no número máximo de queries:

test_n_mais_1.py
from django.test import TestCase

class TestListaPedidos(TestCase):
    def test_lista_nao_tem_n_mais_1(self):
        criar_pedidos(50)
        with self.assertNumQueries(3):  # 3 queries fixas, independente de N
            response = self.client.get('/api/pedidos')
        self.assertEqual(response.status_code, 200)

16.7 Joins e suas armadilhas

INNER vs LEFT vs FULL OUTER

Armadilha: LEFT JOIN escondido

left_armadilha.sql
-- Pretendia: pedidos com cliente "Premium" ou sem cliente
SELECT p.*
FROM pedidos p
LEFT JOIN clientes c ON p.cliente_id = c.id
WHERE c.tipo = 'Premium';
-- ❌ ERRADO: o WHERE descarta pedidos sem cliente (c.tipo é NULL).
-- LEFT JOIN virou INNER JOIN efetivo.

-- Correto:
SELECT p.*
FROM pedidos p
LEFT JOIN clientes c ON p.cliente_id = c.id
WHERE c.tipo = 'Premium' OR c.id IS NULL;
-- ✓ Inclui os NULLs explicitamente

-- Ou: move o filtro para o ON
SELECT p.*
FROM pedidos p
LEFT JOIN clientes c ON p.cliente_id = c.id AND c.tipo = 'Premium';
-- ✓ Filtro no JOIN, preserva pedidos sem cliente

JOIN com tabelas grandes

Quando o banco precisa juntar duas tabelas grandes, três estratégias possíveis (o planejador escolhe):

Se o planejador escolhe estratégia ruim, frequentemente é porque as estatísticas estão desatualizadas. Rode ANALYZE tabela; e veja se o plano muda.

16.8 Window functions — quando agrupar não basta

Window functions calculam valores agregados por linha, sem reduzir o número de linhas como GROUP BY. Resolvem problemas que antes exigiam subqueries complicadas ou loops de aplicação.

window.sql
-- Para cada pedido, mostre o número de pedidos do mesmo cliente
-- e o ranking dele (mais recente primeiro):
SELECT
    p.id,
    p.cliente_id,
    p.criado_em,
    p.total,
    COUNT(*) OVER (PARTITION BY p.cliente_id) AS total_pedidos_cliente,
    ROW_NUMBER() OVER (
        PARTITION BY p.cliente_id
        ORDER BY p.criado_em DESC
    ) AS ranking_cliente
FROM pedidos p;

-- Top 3 pedidos por cliente — sem precisar de subquery complicada:
WITH ranked AS (
    SELECT p.*,
           ROW_NUMBER() OVER (
               PARTITION BY cliente_id
               ORDER BY total DESC
           ) AS rn
    FROM pedidos p
)
SELECT * FROM ranked WHERE rn <= 3;

-- Diferença para o pedido anterior do mesmo cliente:
SELECT
    p.id, p.cliente_id, p.total,
    LAG(p.total) OVER (
        PARTITION BY p.cliente_id ORDER BY p.criado_em
    ) AS total_anterior,
    p.total - LAG(p.total) OVER (
        PARTITION BY p.cliente_id ORDER BY p.criado_em
    ) AS diferenca
FROM pedidos p;

-- Média móvel de 7 dias:
SELECT
    criado_em::date AS dia,
    SUM(total) AS total_dia,
    AVG(SUM(total)) OVER (
        ORDER BY criado_em::date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS media_movel_7d
FROM pedidos
GROUP BY dia
ORDER BY dia;

Aprender window functions é um dos maiores ganhos de produtividade em SQL avançado. O que antes exigia processamento em Python (carregar tudo, calcular ranking, filtrar top 3) vira uma query.

16.9 CTEs e materialização

Common Table Expressions (WITH) tornam queries complexas legíveis. Mas tem uma nuance importante de performance.

cte.sql
-- Múltiplos passos numa query legível:
WITH pedidos_mes AS (
    SELECT cliente_id, SUM(total) AS total_mes
    FROM pedidos
    WHERE criado_em >= DATE_TRUNC('month', NOW())
    GROUP BY cliente_id
),
clientes_vip AS (
    SELECT * FROM pedidos_mes WHERE total_mes > 10000
)
SELECT c.nome, v.total_mes
FROM clientes c
JOIN clientes_vip v ON v.cliente_id = c.id
ORDER BY v.total_mes DESC;

Atenção: em PostgreSQL antigos (até 11), CTEs eram otimization barriers — sempre materializadas. Resultado: às vezes mais lentas que subqueries equivalentes. Desde o PG 12, CTEs são inlinadas por padrão; você força materialização com WITH ... AS MATERIALIZED ....

Use CTE para legibilidade. Para resultados que viram caros e são reusados na mesma query, force materialização.

16.10 Paginação que escala

Paginação por OFFSET (LIMIT 50 OFFSET 50000) parece simples, mas degrada conforme o offset cresce: o banco precisa contar 50.000 linhas antes de descartar para chegar nas 50 que importam.

Paginação por cursor (keyset pagination)

keyset.sql
-- ❌ OFFSET lento em página alta
SELECT * FROM pedidos
ORDER BY id DESC
LIMIT 50 OFFSET 10000;
-- Banco varre 10050 linhas para retornar 50.

-- ✓ Keyset: ancora a próxima página no último ID da página anterior
SELECT * FROM pedidos
WHERE id < 12345   -- último ID da página anterior
ORDER BY id DESC
LIMIT 50;
-- Banco salta direto para id=12345 via índice e lê 50 linhas.
-- Constante: mesma velocidade na página 1 ou na página 10000.

Paginação por cursor não suporta "ir para página X" — só "próxima/anterior". Mas para feeds infinitos, listas longas, exportações, é a forma certa. Twitter, Facebook, GitHub — todos usam cursor.

Quando OFFSET ainda vale

Para conjuntos pequenos (tabela com poucos milhares de linhas, ou query já filtrada a pouco resultado), OFFSET é OK. O problema aparece quando a página está longe do começo num conjunto grande.

16.11 Batch e bulk — não faça 1 a 1

Inserir 10.000 linhas com 10.000 INSERTs individuais é catastrófico. Cada INSERT é round-trip de rede, transação, lock. Bulk insert agrupa tudo numa operação só.

bulk.py
# ❌ Catastrófico
for dado in dados_10mil:
    Pedido.objects.create(**dado)  # 10.000 INSERTs

# ✓ Django: bulk_create
Pedido.objects.bulk_create([Pedido(**d) for d in dados_10mil],
                           batch_size=1000)
# 10 INSERTs com 1000 linhas cada — 100x ou mais rápido

# ✓ SQLAlchemy 2.x: bulk_insert_mappings
session.execute(
    insert(Pedido),
    dados_10mil  # lista de dicts
)

# ✓ SQL puro com VALUES múltiplos:
# INSERT INTO pedidos (col1, col2) VALUES (...), (...), (...);

# ✓ Postgres COPY — o mais rápido para volumes grandes:
with conn.cursor() as cur:
    cur.copy_expert(
        "COPY pedidos (col1, col2) FROM STDIN WITH CSV",
        file_csv,
    )

UPDATE em batch

batch_update.sql
-- Atualizar todos os pedidos antigos para "arquivado".
-- ❌ Em um UPDATE só, com tabela grande: bloqueia tabela por muito tempo

-- ✓ Em batches, com pausas:
DO $$
DECLARE
    atualizadas INT;
BEGIN
    LOOP
        UPDATE pedidos
        SET status = 'arquivado'
        WHERE id IN (
            SELECT id FROM pedidos
            WHERE criado_em < NOW() - INTERVAL '2 years'
              AND status != 'arquivado'
            LIMIT 1000
        );

        GET DIAGNOSTICS atualizadas = ROW_COUNT;
        EXIT WHEN atualizadas = 0;
        COMMIT;
        PERFORM pg_sleep(0.1);  -- alivia carga
    END LOOP;
END $$;

16.12 Estudo de caso — dashboard que travou

Diagnosticando e resolvendo lentidão progressiva

Dashboard de vendas mensal funcionou bem por meses. Hoje, leva 28 segundos para carregar. Vamos diagnosticar e resolver.

Passo 1 · A query original
query_lenta.sql
SELECT
    c.nome,
    COUNT(p.id) AS total_pedidos,
    SUM(p.total) AS faturamento,
    AVG(p.total) AS ticket_medio,
    (SELECT COUNT(*) FROM pedido_itens pi
     JOIN pedidos p2 ON pi.pedido_id = p2.id
     WHERE p2.cliente_id = c.id
       AND p2.criado_em >= DATE_TRUNC('month', NOW())) AS itens_total
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
    AND p.criado_em >= DATE_TRUNC('month', NOW())
GROUP BY c.id, c.nome
ORDER BY faturamento DESC NULLS LAST
LIMIT 50;

EXPLAIN ANALYZE mostra: 28s, com Seq Scan em pedidos (12M linhas), subquery correlacionada executando uma vez por cliente, Sort gigante antes do LIMIT.

Passo 2 · Primeiro ajuste: índices
indices.sql
-- Índice em pedidos.criado_em + cliente_id para o filtro temporal.
CREATE INDEX CONCURRENTLY idx_pedidos_cliente_data
ON pedidos (cliente_id, criado_em DESC);

-- Refaz EXPLAIN: passa de 28s para 4s.
-- Seq Scan virou Index Scan. Mas ainda há subquery correlacionada.
Passo 3 · Reescrever sem subquery correlacionada
query_v2.sql
WITH pedidos_mes AS (
    SELECT
        p.cliente_id,
        COUNT(*) AS total_pedidos,
        SUM(p.total) AS faturamento,
        AVG(p.total) AS ticket_medio
    FROM pedidos p
    WHERE p.criado_em >= DATE_TRUNC('month', NOW())
    GROUP BY p.cliente_id
),
itens_mes AS (
    SELECT p.cliente_id, COUNT(*) AS itens_total
    FROM pedido_itens pi
    JOIN pedidos p ON pi.pedido_id = p.id
    WHERE p.criado_em >= DATE_TRUNC('month', NOW())
    GROUP BY p.cliente_id
)
SELECT
    c.nome,
    COALESCE(pm.total_pedidos, 0) AS total_pedidos,
    COALESCE(pm.faturamento, 0) AS faturamento,
    COALESCE(pm.ticket_medio, 0) AS ticket_medio,
    COALESCE(im.itens_total, 0) AS itens_total
FROM clientes c
LEFT JOIN pedidos_mes pm ON pm.cliente_id = c.id
LEFT JOIN itens_mes im ON im.cliente_id = c.id
ORDER BY faturamento DESC NULLS LAST
LIMIT 50;
-- Cada agregação é feita UMA vez. Sem subquery correlacionada.
-- Tempo cai para 700ms.
Passo 4 · Cache de agregação para hot path

Dashboard é acessado por dezenas de gestores várias vezes por dia. Mesmo 700ms multiplica. Solução: tabela materializada atualizada a cada 5 minutos.

materializada.sql
CREATE MATERIALIZED VIEW mv_dashboard_mensal AS
SELECT
    c.id AS cliente_id,
    c.nome,
    COALESCE(COUNT(p.id), 0) AS total_pedidos,
    COALESCE(SUM(p.total), 0) AS faturamento,
    COALESCE(AVG(p.total), 0) AS ticket_medio
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
    AND p.criado_em >= DATE_TRUNC('month', NOW())
GROUP BY c.id, c.nome;

CREATE UNIQUE INDEX ON mv_dashboard_mensal (cliente_id);

-- Job atualiza a cada 5 minutos:
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard_mensal;
-- (REFRESH não-CONCURRENTLY bloqueia leituras durante atualização.)

Query do dashboard agora lê direto da MV: menos de 50ms. Latência aceitável de até 5 minutos para dados — aceitável para dashboard gerencial.

Caminho percorrido: 28s → 4s (índice) → 700ms (reescrever sem subquery correlacionada) → 50ms (materialização). Cada passo foi mensurável e justificado. Sem chutar — sem otimização prematura.

16.13 Erros comuns

Erro 1 · SELECT * em todos os lugares

Trazer 30 colunas quando só usa 3 desperdiça memória, rede e impede Index Only Scan. Liste só as colunas que importam.

Erro 2 · Função em coluna indexada no WHERE

WHERE LOWER(email) = 'x@y.com' ou WHERE EXTRACT(YEAR FROM data) = 2026. O índice em email ou data não é usado — a função transforma a coluna antes da comparação. Soluções: índice na expressão (CREATE INDEX ... ON tabela (LOWER(email))) ou query usando intervalo (WHERE data >= '2026-01-01' AND data < '2027-01-01').

Erro 3 · ORM cego sobre N+1

Confiar no ORM sem inspecionar SQL gerado. Frameworks emitem queries de aplicação às vezes sem você notar. Em desenvolvimento, ative log de queries ou use Django debug toolbar.

Erro 4 · Índice em tudo

Cada índice tem custo de escrita. Tabela com 20 índices fica lenta para INSERT/UPDATE. Indexe o que é usado, não o que "pode ser usado".

Erro 5 · Tunar antes de medir

Adicionar índices, mudar query, refazer — sem rodar EXPLAIN ANALYZE antes. Você não sabe se o problema era o que você "achou". Sempre meça antes; meça depois; compare.

Verifique seu entendimento
"Seu endpoint /usuarios/<id>/pedidos retorna pedidos do usuário com seus itens. Cliente reporta lentidão. Você verifica logs: 1 query para buscar pedidos + 1 query por pedido para buscar itens. Qual a correção?"

16.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar problema

Para cada query, identifique o problema principal de performance:

  1. SELECT * FROM pedidos WHERE LOWER(email) = 'a@b.com';
  2. SELECT * FROM pedidos ORDER BY criado_em DESC LIMIT 50 OFFSET 50000;
  3. SELECT * FROM pedidos p, clientes c WHERE p.cliente_id = c.id; (sem índice em pedido.cliente_id)
  4. SELECT *, (SELECT COUNT(*) FROM itens WHERE pedido_id = p.id) FROM pedidos p;
  5. SELECT * FROM pedidos WHERE total > 100 OR cliente_id = 42; (índices separados em total e cliente_id)
  1. Função em coluna indexada. Crie índice em expressão ou normalize email lowercase ao inserir.
  2. OFFSET alto. Use keyset pagination.
  3. Sem índice em FK. JOIN faz Seq Scan. Indexe pedidos.cliente_id.
  4. Subquery correlacionada. Reescreva com JOIN ou window function.
  5. OR entre colunas diferentes. Banco frequentemente faz BitmapOr de dois índices, mas se um filtro retorna muito, vira Seq Scan. Considere UNION ALL de duas queries.
Fácil
Exercício 2 · Índice composto

Dado:

indices.sql
CREATE INDEX idx_a ON pedidos (cliente_id, criado_em DESC, status);

Para cada query, indique se o índice idx_a pode ser usado eficientemente:

  1. WHERE cliente_id = 42
  2. WHERE cliente_id = 42 AND criado_em > '2026-01-01'
  3. WHERE cliente_id = 42 AND status = 'pago'
  4. WHERE criado_em > '2026-01-01'
  5. WHERE cliente_id = 42 ORDER BY criado_em DESC
  1. Sim — coluna líder.
  2. Sim — líder + range na segunda.
  3. Parcialmente — usa para cliente_id, depois precisa filtrar status linha a linha (pula criado_em).
  4. Não eficiente — coluna não-líder. Seq Scan provável.
  5. Sim — líder e ordem coincide com DESC do índice. Sem necessidade de Sort.
Médio
Exercício 3 · Reescrever sem subquery correlacionada

Reescreva a query usando JOIN com agregação:

antes.sql
SELECT c.id, c.nome,
    (SELECT COUNT(*) FROM pedidos p WHERE p.cliente_id = c.id) AS total_pedidos,
    (SELECT SUM(total) FROM pedidos p WHERE p.cliente_id = c.id) AS faturamento
FROM clientes c
WHERE c.ativo = TRUE;
depois.sql
SELECT c.id, c.nome,
    COALESCE(agreg.total_pedidos, 0) AS total_pedidos,
    COALESCE(agreg.faturamento, 0) AS faturamento
FROM clientes c
LEFT JOIN (
    SELECT cliente_id,
        COUNT(*) AS total_pedidos,
        SUM(total) AS faturamento
    FROM pedidos
    GROUP BY cliente_id
) agreg ON agreg.cliente_id = c.id
WHERE c.ativo = TRUE;
-- Uma única passada na tabela pedidos, agrupada por cliente.
-- Antes: para cada cliente ativo, duas subqueries inteiras.
Médio
Exercício 4 · Window function

Liste todos os pedidos com: posição do pedido na ordem cronológica do cliente (1º, 2º, ...) e diferença em dias para o pedido anterior do mesmo cliente. NULL no primeiro pedido.

window.sql
SELECT
    p.id,
    p.cliente_id,
    p.criado_em,
    ROW_NUMBER() OVER (
        PARTITION BY p.cliente_id
        ORDER BY p.criado_em
    ) AS ordem_no_cliente,
    p.criado_em - LAG(p.criado_em) OVER (
        PARTITION BY p.cliente_id
        ORDER BY p.criado_em
    ) AS dias_desde_anterior
FROM pedidos p
ORDER BY p.cliente_id, p.criado_em;
Difícil
Exercício 5 · Diagnóstico completo

Você tem essa query rodando em 18 segundos. Tabela pedidos tem 50M linhas. Proponha um plano de otimização em 3 etapas, do mais barato ao mais caro.

lenta.sql
SELECT u.email, u.nome,
       COUNT(p.id) AS total_pedidos,
       SUM(p.valor) AS total_gasto
FROM usuarios u
JOIN pedidos p ON p.usuario_id = u.id
WHERE p.criado_em >= NOW() - INTERVAL '90 days'
  AND u.tipo = 'premium'
  AND p.status = 'pago'
GROUP BY u.id, u.email, u.nome
ORDER BY total_gasto DESC
LIMIT 100;

Etapa 1 · medir antes de mexer.

diagnostico.sql
EXPLAIN (ANALYZE, BUFFERS) /* query */;
-- Identificar: Seq Scan? Hash Join enorme? Sort caro?

Etapa 2 · índices.

indices.sql
-- Para o filtro temporal + status
CREATE INDEX CONCURRENTLY idx_pedidos_status_data
ON pedidos (status, criado_em DESC)
WHERE status = 'pago';

-- Filtro de usuário premium (se poucos premium, índice parcial)
CREATE INDEX CONCURRENTLY idx_usuarios_premium
ON usuarios (id) WHERE tipo = 'premium';

-- Para o JOIN
CREATE INDEX CONCURRENTLY idx_pedidos_usuario_status
ON pedidos (usuario_id, status, criado_em DESC);

Etapa 3 · materialização se persistir.

mv.sql
-- Se mesmo com índices a query continua lenta (provável com 50M linhas),
-- considere MV atualizada a cada 30 minutos:
CREATE MATERIALIZED VIEW mv_top_premium_90d AS
SELECT u.id AS usuario_id, u.email, u.nome,
       COUNT(p.id) AS total_pedidos,
       SUM(p.valor) AS total_gasto
FROM usuarios u
JOIN pedidos p ON p.usuario_id = u.id
WHERE p.criado_em >= NOW() - INTERVAL '90 days'
  AND u.tipo = 'premium'
  AND p.status = 'pago'
GROUP BY u.id, u.email, u.nome;

CREATE INDEX ON mv_top_premium_90d (total_gasto DESC);

-- Query do dashboard: SELECT ... FROM mv_top_premium_90d ORDER BY ... LIMIT 100;
-- Tempo: <50ms. Latência aceitável de 30 minutos.
Fim do capítulo 16
Próximo capítulo: transações e concorrência. ACID na prática, níveis de isolamento, locks, deadlocks e estratégias para escalar leitura sem corromper dados.
Parte IV · Capítulo 17 · Dados e persistência

Transações
e concorrência:
integridade sob pressão.

Em um sistema com um usuário por vez, transação é detalhe técnico. Em produção com centenas ou milhares de usuários simultâneos, é a diferença entre dados íntegros e caos silencioso.

A maioria dos bugs mais traumáticos em sistemas reais — saldo negativo, estoque duplicado, dois usuários reservando o último ingresso — vêm de concorrência mal tratada. O problema é insidioso: em testes locais com um usuário, tudo funciona. Em produção com 50 requisições por segundo, o bug aparece uma vez por semana, em condições não reproduzíveis. Este capítulo é sobre evitar essa categoria inteira de problemas.

17.1 A história — Jim Gray e o conceito de transação

Contexto histórico

Em 1976, Jim Gray da IBM publicou "Notes on Data Base Operating Systems", formalizando o conceito de transação como unidade atômica de trabalho. Antes dele, sistemas de banco tinham locks ad hoc; depois dele, a ideia de "tudo ou nada" virou regra.

Em 1983, Andreas Reuter e Theo Härder cunharam o acrônimo ACID (Atomicity, Consistency, Isolation, Durability) em paper seminal. As quatro propriedades viraram contrato implícito entre banco e aplicação.

Gray também trabalhou em concurrent access: como múltiplas transações podem rodar em paralelo sem se atrapalhar. O resultado foi o conceito de níveis de isolamento, formalizado no padrão SQL de 1992 (Read Uncommitted, Read Committed, Repeatable Read, Serializable). Cada nível troca consistência por performance.

Em paralelo, surgiu o MVCC (Multi-Version Concurrency Control) — bancos como PostgreSQL guardam várias versões de cada linha para permitir leitura sem bloqueio. Hoje é o modelo dominante em bancos relacionais modernos.

Gray ganhou o Turing Award em 1998 por esse trabalho. Desapareceu no mar em 2007 enquanto velejava perto de São Francisco. Seu legado define como pensamos em transações até hoje.

17.2 ACID na prática

ACID é o que torna um banco "transacional". Vamos com nuance — não como bullet point de prova.

A — Atomicidade

Tudo na transação acontece, ou nada acontece. Se falha no meio, todas as mudanças são revertidas. Sem estados intermediários visíveis.

atomicidade.py
with conn.transaction():
    conn.execute("UPDATE contas SET saldo = saldo - 100 WHERE id = 1")
    conn.execute("UPDATE contas SET saldo = saldo + 100 WHERE id = 2")
    # Se o segundo UPDATE falhar (ex: conta 2 não existe),
    # o primeiro é DESFEITO automaticamente.
    # Em nenhum momento existe estado onde só um lado mudou.

C — Consistência

Transação leva o banco de um estado válido para outro estado válido. "Válido" inclui todas as constraints (FK, NOT NULL, CHECK, UNIQUE, triggers de validação). Se a transação tentaria deixar o banco em estado inválido, ela é rejeitada.

Importante: consistência aqui é técnica, não de negócio. Banco impede FK quebrada, não impede você de calcular total errado.

I — Isolamento

Transações concorrentes não devem ver dados intermediários umas das outras. O nível de isolamento define quão isolado: o nível mais alto (Serializable) garante que o resultado é como se transações tivessem rodado uma após a outra; o mais baixo (Read Uncommitted) permite ler dados não confirmados de outras.

D — Durabilidade

Depois do COMMIT, mudanças persistem mesmo em caso de queda do servidor. Garantida por escrita em WAL (Write-Ahead Log) antes do commit retornar.

Nuance prática
ACID é o contrato base. Em sistemas distribuídos, replicação assíncrona pode enfraquecer D (algumas réplicas podem perder o último commit em caso de falha). Documente o que sua infraestrutura garante de verdade — não confie em "ACID" como rótulo absoluto.

17.3 Os quatro fenômenos de concorrência

O padrão SQL define quatro fenômenos que podem (ou não) acontecer dependendo do nível de isolamento:

Dirty Read

Transação A lê dados que transação B modificou mas não commitou ainda. Se B fizer rollback, A leu valor que "nunca existiu".

Non-Repeatable Read

Transação A lê uma linha duas vezes na mesma transação e recebe valores diferentes (porque B commitou no meio).

Phantom Read

Transação A executa uma query de filtro duas vezes na mesma transação e recebe número diferente de linhas (porque B inseriu/deletou linhas que casam o filtro).

Serialization Anomaly

O resultado de transações concorrentes não corresponde a nenhuma ordem serial possível das transações. Exemplo clássico: dois saques simultâneos da mesma conta, cada um lendo saldo "positivo" e descontando, resultando em saldo negativo.

17.4 Níveis de isolamento

Cada nível permite ou previne os fenômenos acima:

Nível Dirty Read Non-Repeatable Phantom Serialization
Read UncommittedPossívelPossívelPossívelPossível
Read CommittedPrevinePossívelPossívelPossível
Repeatable ReadPrevinePrevinePossível*Possível
SerializablePrevinePrevinePrevinePrevine

* Em Postgres, Repeatable Read também previne phantoms; é mais forte que o padrão.

Defaults dos bancos

Read Committed na prática

read_committed.sql
-- Transação A: lê saldo, atualiza, lê de novo
BEGIN;
SELECT saldo FROM contas WHERE id = 1;  -- 1000

-- (Transação B, em paralelo, faz UPDATE e COMMIT)
-- UPDATE contas SET saldo = 800 WHERE id = 1; COMMIT;

SELECT saldo FROM contas WHERE id = 1;  -- 800 (Non-Repeatable Read)
COMMIT;
-- Mesma query, mesma transação, valores diferentes.
-- Aceitável em Read Committed.

Repeatable Read na prática

repeatable_read.sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT saldo FROM contas WHERE id = 1;  -- 1000

-- (Transação B atualiza e commita)

SELECT saldo FROM contas WHERE id = 1;  -- 1000 (snapshot da BEGIN)
-- Toda a transação vê snapshot consistente do banco
-- no momento do BEGIN.
COMMIT;

Serializable — quando você quer garantia total

Em Postgres, Serializable usa SSI (Serializable Snapshot Isolation): transações rodam em snapshots; ao commitar, o banco verifica se houve conflito real. Se sim, uma é abortada com erro de serialização.

serializable.py
from psycopg2.errors import SerializationFailure
import time

def transferir_com_retry(conn, de_id, para_id, valor, tentativas=3):
    for tentativa in range(tentativas):
        try:
            with conn:
                conn.set_isolation_level("SERIALIZABLE")
                with conn.cursor() as cur:
                    cur.execute("SELECT saldo FROM contas WHERE id = %s", (de_id,))
                    saldo = cur.fetchone()[0]
                    if saldo < valor:
                        raise ValueError("saldo insuficiente")
                    cur.execute(
                        "UPDATE contas SET saldo = saldo - %s WHERE id = %s",
                        (valor, de_id),
                    )
                    cur.execute(
                        "UPDATE contas SET saldo = saldo + %s WHERE id = %s",
                        (valor, para_id),
                    )
            return  # sucesso
        except SerializationFailure:
            if tentativa == tentativas - 1:
                raise
            time.sleep(0.05 * (2 ** tentativa))  # backoff exponencial

Serializable dá garantia mais forte, mas exige retry obrigatório em conflito. Sua aplicação precisa estar preparada — ignorar SerializationFailure é bug.

17.5 Locks — quando isolamento não basta

Isolamento por snapshot funciona para leituras, mas operações que dependem do estado atual para decidir frequentemente precisam de lock explícito.

SELECT FOR UPDATE

Trava as linhas lidas até o fim da transação. Outras transações que tentarem ler com FOR UPDATE essas linhas vão esperar.

for_update.sql
BEGIN;

-- Trava a linha do produto. Outras transações precisam esperar.
SELECT estoque FROM produtos
WHERE id = 42
FOR UPDATE;

-- Verifica regra de negócio com dado garantidamente consistente
-- (estoque é 5)

UPDATE produtos SET estoque = estoque - 3 WHERE id = 42;
COMMIT;
-- Outras transações destravam, leem estoque = 2.

SELECT FOR SHARE

Trava as linhas para que ninguém possa modificá-las, mas múltiplos SHARE podem coexistir. Útil quando você lê dados que precisa garantir que não mudem durante sua transação.

FOR UPDATE SKIP LOCKED

Variação útil para filas de trabalho: pega N itens disponíveis e pula os que já estão travados por outros workers.

skip_locked.sql
-- Worker pega próximas tarefas pendentes sem disputa
BEGIN;
SELECT id FROM tarefas
WHERE status = 'pendente'
ORDER BY criado_em
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 10 workers em paralelo pegam 10 tarefas cada,
-- sem nenhum bloquear o outro nem pegar tarefa duplicada.
UPDATE tarefas SET status = 'processando' WHERE id IN (...);
COMMIT;

Advisory locks

Locks "fora do dado" — apenas marcadores no banco para coordenação. Úteis para operações que não correspondem a uma linha específica: "só um worker pode rodar este job por vez".

advisory.sql
-- Tenta pegar lock; retorna FALSE se outro processo já tem
SELECT pg_try_advisory_lock(42);  -- 42 é ID arbitrário

-- Faz o trabalho que precisa ser exclusivo

-- Libera ao final
SELECT pg_advisory_unlock(42);

-- Ou: lock dura até o fim da sessão (auto-release)
SELECT pg_advisory_lock(42);  -- bloqueia esperando

17.6 Deadlocks — quando dois esperam um ao outro

Cenário clássico: transação A trava conta 1 e tenta travar conta 2. Transação B trava conta 2 e tenta travar conta 1. Cada uma espera a outra. O banco detecta e aborta uma com erro.

deadlock.sql
-- Transação A
BEGIN;
UPDATE contas SET saldo = saldo - 100 WHERE id = 1;  -- trava linha 1
-- aguardando linha 2...
UPDATE contas SET saldo = saldo + 100 WHERE id = 2;

-- Transação B (em paralelo)
BEGIN;
UPDATE contas SET saldo = saldo - 50 WHERE id = 2;   -- trava linha 2
-- aguardando linha 1... DEADLOCK!
UPDATE contas SET saldo = saldo + 50 WHERE id = 1;

-- Postgres detecta em ~1s e aborta uma das duas:
-- ERROR: deadlock detected

Estratégias para evitar

ordem_consistente.py
def transferir(conn, de_id, para_id, valor):
    # Sempre lock o menor ID primeiro — quebra ciclo de deadlock
    ids_ordenados = sorted([de_id, para_id])

    with conn.transaction():
        with conn.cursor() as cur:
            for conta_id in ids_ordenados:
                cur.execute(
                    "SELECT id FROM contas WHERE id = %s FOR UPDATE",
                    (conta_id,),
                )

            # Agora ambas travadas, sem risco de deadlock
            cur.execute("UPDATE contas SET saldo = saldo - %s WHERE id = %s",
                        (valor, de_id))
            cur.execute("UPDATE contas SET saldo = saldo + %s WHERE id = %s",
                        (valor, para_id))

17.7 Otimista vs pessimista

Duas estratégias para concorrência:

Locking pessimista

Assuma que vai haver conflito; trave antes de ler. Garante consistência mas reduz paralelismo. SELECT FOR UPDATE é pessimista.

Locking otimista

Leia sem lock; antes de gravar, verifique se o dado mudou. Se mudou, falha e retry. Mais paralelismo, mas requer disciplina de retry na aplicação.

otimista.sql
-- Modelo com coluna versão
CREATE TABLE contas (
    id BIGSERIAL PRIMARY KEY,
    saldo NUMERIC(12,2) NOT NULL,
    versao INT NOT NULL DEFAULT 1
);

-- App lê
SELECT saldo, versao FROM contas WHERE id = 1;
-- saldo=1000, versao=5

-- App calcula novo saldo, depois grava com checagem de versão:
UPDATE contas
SET saldo = 800, versao = versao + 1
WHERE id = 1 AND versao = 5;

-- Se rows_affected == 0, outra transação atualizou antes.
-- Aplicação faz retry: re-lê e tenta de novo.

Quando usar qual

17.8 MVCC em PostgreSQL

Postgres não usa locks para leitura. Cada UPDATE cria nova versão da linha; transações em andamento veem a versão que estava válida quando começaram. Daí o nome: Multi-Version Concurrency Control.

Implicações práticas importantes:

17.9 Escalar leitura — read replicas

Quando o gargalo é leitura (analytics, dashboards, busca), uma estratégia comum é replicar o banco e direcionar leituras para réplicas. Eis as nuances:

Replicação síncrona vs assíncrona

Lag de replicação — o que pode dar errado

lag_armadilha.py
# Usuário cria pedido
pedido = repo_escrita.criar(dados)  # vai para primário

# Redireciona para tela de "meus pedidos"
# Listagem usa réplica (assíncrona, lag de 50ms)
pedidos = repo_leitura.listar_do_usuario(usuario_id)

# ❌ Pedido recém-criado pode NÃO estar nos resultados.
# Usuário vê "vazio" depois de criar. Reclama.

Soluções para esse padrão:

Sharding — escalar escrita

Quando uma máquina não dá conta nem para escrita, divide-se dados em partições (shards). Decisão difícil — requer chave de shard que distribui carga e evita queries cross-shard. Postgres tem Citus, Vitess para MySQL. Fora do escopo deste capítulo, mas saiba que existe.

17.10 Estudo de caso — race condition em reserva de ingresso

De bug em produção a solução robusta

Sistema de venda de ingressos para shows. Cada evento tem N ingressos disponíveis. Em pico (Beyoncé vai a Porto Alegre), 5.000 pessoas tentam comprar simultaneamente. Bug: venderam 1.220 ingressos para evento com 1.200 vagas.

Código original — onde está o bug
v0_buggy.py
def reservar_ingresso(conn, evento_id, usuario_id):
    with conn.cursor() as cur:
        # 1. Lê quantidade disponível
        cur.execute(
            "SELECT disponiveis FROM eventos WHERE id = %s",
            (evento_id,),
        )
        disponiveis = cur.fetchone()[0]

        # 2. Verifica
        if disponiveis <= 0:
            raise EsgotadoError()

        # 3. Decrementa e cria reserva
        cur.execute(
            "UPDATE eventos SET disponiveis = disponiveis - 1 WHERE id = %s",
            (evento_id,),
        )
        cur.execute(
            "INSERT INTO reservas (evento_id, usuario_id) VALUES (%s, %s)",
            (evento_id, usuario_id),
        )
        conn.commit()

Por que falha: entre a leitura (passo 1) e o UPDATE (passo 3), milhares de outras transações podem ter feito o mesmo. Cada uma lê "ainda tem 20" e decrementa. Resultado: 20 vagas viram -1000.

Solução 1 · UPDATE com CHECK no WHERE
v1_check_no_where.py
def reservar_ingresso(conn, evento_id, usuario_id):
    with conn.cursor() as cur:
        cur.execute(
            """
            UPDATE eventos
            SET disponiveis = disponiveis - 1
            WHERE id = %s AND disponiveis > 0
            """,
            (evento_id,),
        )
        if cur.rowcount == 0:
            raise EsgotadoError()

        cur.execute(
            "INSERT INTO reservas (evento_id, usuario_id) VALUES (%s, %s)",
            (evento_id, usuario_id),
        )
        conn.commit()

Funciona: UPDATE é atômico. Só decrementa se ainda tiver vaga. Se duas transações tentam simultaneamente e só sobra 1 vaga, uma vai obter rowcount=1 e outra rowcount=0.

Solução 2 · SELECT FOR UPDATE explícito
v2_for_update.py
def reservar_ingresso(conn, evento_id, usuario_id):
    with conn.cursor() as cur:
        cur.execute(
            "SELECT disponiveis FROM eventos WHERE id = %s FOR UPDATE",
            (evento_id,),
        )
        disponiveis = cur.fetchone()[0]

        if disponiveis <= 0:
            raise EsgotadoError()

        cur.execute(
            "UPDATE eventos SET disponiveis = disponiveis - 1 WHERE id = %s",
            (evento_id,),
        )
        cur.execute(
            "INSERT INTO reservas (evento_id, usuario_id) VALUES (%s, %s)",
            (evento_id, usuario_id),
        )
        conn.commit()

Também funciona. Mas serializa todas as reservas do evento — cada transação espera a anterior. Em alto pico, fila gigante. Solução 1 é mais eficiente para esse caso.

Solução 3 · Constraint no banco como cinto de segurança
constraint.sql
-- Adiciona check: disponiveis nunca pode ser negativo
ALTER TABLE eventos
ADD CONSTRAINT disponiveis_nao_negativo
CHECK (disponiveis >= 0);

-- Agora, mesmo se algum bug futuro tentar UPDATE incorretamente,
-- o banco recusa. Cinto de segurança contra bug de aplicação.
Discussão · escolhendo a solução

Em concorrência alta para um único recurso (5.000 pessoas no mesmo evento), Solução 1 é melhor: UPDATE atômico não serializa. Postgres usa lock interno breve só durante o UPDATE. Throughput muito maior.

Para reservas com múltiplos passos (verificar saldo do usuário, calcular taxas, depois decrementar), Solução 2 faz mais sentido — você precisa ler dados, decidir e gravar atomicamente.

A constraint é defesa adicional independente da estratégia. Sempre vale.

Lição central: race conditions são silenciosas em teste, devastadoras em produção. Sempre que código lê-decide-grava sobre o mesmo dado que outros podem mexer, há corrida. Resolva no banco (UPDATE atômico ou FOR UPDATE) — não na aplicação.

17.11 Erros comuns

Erro 1 · "Vou validar na aplicação"

Read-then-write na aplicação sem lock = race condition garantida em concorrência alta. A regra é: validações de integridade que dependem de estado do banco vão NO banco (constraint, UPDATE atômico, ou lock).

Erro 2 · Transação longa

BEGIN, faz consulta a serviço externo (HTTP), COMMIT. Durante a chamada externa, transação segura locks e versões antigas. Vai entupir o banco. Transações devem ser curtas; chamadas externas vão fora.

Erro 3 · Ignorar SerializationFailure

Usar Serializable sem retry. Sob conflito, transação falha; aplicação retorna erro 500 ao usuário. Retry com backoff é parte do contrato de Serializable.

Erro 4 · Confiar em "tem cliente lendo o saldo agora"

Em sistemas com user input, transações duram segundos ou minutos. Você não pode segurar lock durante esse tempo. Use otimista com versão, ou faça check final no momento da operação real.

Erro 5 · Ler de réplica e gravar no primário sem cuidado

Léu da réplica saldo=1000, calculou desconto na app, vai gravar no primário. Mas no primário o saldo é 800 (réplica atrasada). Você sobrescreveu uma escrita perdida. Escritas devem ler do mesmo nó onde gravam.

Verifique seu entendimento
"Sistema de saques de carteira digital. Em concorrência alta, mesma carteira recebe dois pedidos simultâneos de saque. Lógica: lê saldo, verifica suficiência, debita. Acontece saldo negativo. Como resolver de forma escalável?"

17.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar fenômeno

Para cada cenário, identifique o fenômeno de concorrência:

  1. Transação lê saldo, banco trava. Após reiniciar, transação volta e lê de novo — vê valor diferente porque outra transação commitou no meio.
  2. Transação A executa SELECT * FROM pedidos WHERE total > 100 duas vezes. Recebe 50 linhas, depois 53 — porque B inseriu pedidos no meio.
  3. Transação A lê valor de uma linha que B atualizou mas ainda não commitou. B faz rollback. A leu valor que nunca existiu.
  4. Duas transações leem saldo 1000, cada uma calcula saldo - 100 e grava 900. Saldo final é 900 em vez de 800.
  1. Non-Repeatable Read. Prevenido em Repeatable Read e Serializable.
  2. Phantom Read. Prevenido em Serializable; em Postgres, também em Repeatable Read.
  3. Dirty Read. Prevenido em Read Committed e acima.
  4. Lost Update / Serialization Anomaly. Prevenido em Serializable, ou com lock explícito / UPDATE atômico.
Fácil
Exercício 2 · UPDATE atômico para contador

Escreva SQL para incrementar contador de visualizações de um post, com proteção contra race condition. Não use FOR UPDATE.

contador.sql
UPDATE posts
SET visualizacoes = visualizacoes + 1
WHERE id = 42;
-- UPDATE é atômico no banco. Mil incrementos simultâneos
-- resultam em mil incrementos corretos. Nada de read-then-write.
Médio
Exercício 3 · Locking otimista

Implemente atualização otimista de perfil de usuário. Schema: usuarios(id, nome, email, versao). Função deve: ler dados, deixar app modificar, depois gravar com check de versão. Em caso de conflito, retornar erro claro.

otimista.py
from dataclasses import dataclass

@dataclass
class UsuarioComVersao:
    id: int
    nome: str
    email: str
    versao: int

class ConflitoVersaoError(Exception):
    pass

def ler_usuario(conn, id) -> UsuarioComVersao:
    with conn.cursor() as cur:
        cur.execute("SELECT id, nome, email, versao FROM usuarios WHERE id = %s",
                    (id,))
        row = cur.fetchone()
        if not row:
            raise ValueError("não existe")
        return UsuarioComVersao(*row)

def atualizar_otimista(conn, u: UsuarioComVersao):
    with conn.cursor() as cur:
        cur.execute(
            """
            UPDATE usuarios
            SET nome = %s, email = %s, versao = versao + 1
            WHERE id = %s AND versao = %s
            """,
            (u.nome, u.email, u.id, u.versao),
        )
        if cur.rowcount == 0:
            raise ConflitoVersaoError(
                f"Usuário {u.id} foi modificado por outra operação. "
                "Recarregue e tente novamente."
            )
        conn.commit()

# Uso:
u = ler_usuario(conn, 42)
u.nome = "Novo nome"
try:
    atualizar_otimista(conn, u)
except ConflitoVersaoError as e:
    print(e)  # avisa o usuário, recarrega tela
Médio
Exercício 4 · Fila de trabalho com SKIP LOCKED

Implemente pegar_proximas_tarefas(n) que pega até n tarefas pendentes, marca como "processando" e retorna seus IDs. Múltiplos workers em paralelo não devem pegar a mesma tarefa nem bloquear uns aos outros.

worker.py
def pegar_proximas_tarefas(conn, n: int) -> list[int]:
    with conn.cursor() as cur:
        cur.execute(
            """
            UPDATE tarefas
            SET status = 'processando', pegada_em = NOW()
            WHERE id IN (
                SELECT id FROM tarefas
                WHERE status = 'pendente'
                ORDER BY criado_em
                LIMIT %s
                FOR UPDATE SKIP LOCKED
            )
            RETURNING id
            """,
            (n,),
        )
        ids = [row[0] for row in cur.fetchall()]
        conn.commit()
        return ids

# 10 workers em paralelo chamam essa função.
# Cada um pega um lote sem disputa.
# SKIP LOCKED faz o trabalho — sem ele, workers ficariam em fila.
Difícil
Exercício 5 · Transferência entre contas robusta

Implemente transferência bancária entre duas contas, considerando: validação de saldo, atomicidade, ordem de lock para evitar deadlock, retry em caso de SerializationFailure, e auditoria via tabela de transações. Use isolamento Serializable.

transferencia.py
from decimal import Decimal
from psycopg2.errors import SerializationFailure
import time, logging

logger = logging.getLogger(__name__)

class SaldoInsuficienteError(Exception):
    pass

def transferir(
    conn,
    de_id: int,
    para_id: int,
    valor: Decimal,
    descricao: str,
    tentativas_max: int = 5,
) -> int:
    """Transfere valor entre contas; retorna ID da transação criada."""
    if valor <= 0:
        raise ValueError("valor deve ser positivo")
    if de_id == para_id:
        raise ValueError("mesma conta")

    # Ordem consistente: menor ID primeiro
    primeiro, segundo = sorted([de_id, para_id])

    for tentativa in range(tentativas_max):
        try:
            conn.set_isolation_level("SERIALIZABLE")
            with conn:
                with conn.cursor() as cur:
                    # Lock em ordem
                    cur.execute(
                        "SELECT id, saldo FROM contas WHERE id IN (%s, %s) FOR UPDATE",
                        (primeiro, segundo),
                    )
                    saldos = {r[0]: r[1] for r in cur.fetchall()}

                    if de_id not in saldos or para_id not in saldos:
                        raise ValueError("conta inexistente")

                    if saldos[de_id] < valor:
                        raise SaldoInsuficienteError()

                    cur.execute(
                        "UPDATE contas SET saldo = saldo - %s WHERE id = %s",
                        (valor, de_id),
                    )
                    cur.execute(
                        "UPDATE contas SET saldo = saldo + %s WHERE id = %s",
                        (valor, para_id),
                    )

                    # Auditoria — sempre, dentro da mesma transação
                    cur.execute(
                        """
                        INSERT INTO transacoes (origem, destino, valor, descricao)
                        VALUES (%s, %s, %s, %s) RETURNING id
                        """,
                        (de_id, para_id, valor, descricao),
                    )
                    transacao_id = cur.fetchone()[0]

            return transacao_id

        except SerializationFailure:
            logger.warning(f"Conflito na tentativa {tentativa+1}")
            if tentativa == tentativas_max - 1:
                raise
            time.sleep(0.05 * (2 ** tentativa))  # backoff exponencial

    raise RuntimeError("esgotou tentativas")

# Constraint adicional no banco:
# ALTER TABLE contas ADD CONSTRAINT saldo_nao_negativo CHECK (saldo >= 0);
Fim do capítulo 17
Próximo capítulo: NoSQL com critério — encerra a Parte IV. Quando vale, quando não vale, e como avaliar com honestidade.
Parte IV · Capítulo 18 · Dados e persistência

NoSQL
com critério:
quando vale.

NoSQL não é "moderno"; SQL não é "ultrapassado". Cada família de banco resolve um problema diferente — e adotar a errada para o seu caso é uma das decisões mais caras de reverter.

Houve uma década onde "vamos migrar para NoSQL" era posicionamento técnico cool. Hoje, com a poeira assentada, a visão é mais sóbria: relacionais incorporaram features que se atribuíam a NoSQL (JSON nativo, replicação, sharding); NoSQL ganhou features que se atribuíam a relacionais (transações, consultas estruturadas). A pergunta certa não é "qual é melhor" — é "qual cabe no seu padrão de acesso?".

18.1 A história — do hype à maturidade

Contexto histórico

O termo "NoSQL" surgiu em 1998 (Carlo Strozzi nomeou um banco relacional sem SQL com esse nome), mas só ganhou tração em 2009, quando Johan Oskarsson organizou um meetup em San Francisco. Na época, gigantes como Google (BigTable, 2006), Amazon (Dynamo, 2007) e Facebook (Cassandra, 2008) publicaram papers que mostravam soluções não-relacionais para problemas de escala extrema.

Entre 2010 e 2015, o movimento virou modinha. MongoDB cresceu mostrando JSON nativo. Redis dominou cache distribuído. Cassandra atendia escrita de telcos e fintechs. Muitos times adotaram NoSQL "porque relacional não escala" — sem nunca ter chegado em escala onde isso importasse.

Depois de 2015, a indústria recalibrou. Vários sistemas migraram de volta para relacional após perceberem que: (a) a maioria dos casos cabia em Postgres; (b) joins faziam falta; (c) transações faziam mais falta ainda. MongoDB adicionou transações em 2018. Bancos relacionais adicionaram JSONB, replicação eficiente, particionamento.

Hoje, escolha de banco é decisão consciente por caso de uso. NoSQL tem espaço legítimo — mas o default sensato para sistemas com transações de negócio segue sendo relacional, especialmente Postgres.

18.2 Famílias de NoSQL — cinco categorias distintas

"NoSQL" é guarda-chuva para coisas muito diferentes. Cinco categorias principais, cada uma com modelo de dados próprio:

📄
Document
Documentos JSON/BSON aninhados. MongoDB, CouchDB. Para dados semi-estruturados que variam por entidade.
🔑
Key-value
Mapa distribuído. Redis, DynamoDB, Memcached. Para cache, sessões, contadores.
📊
Colunar
Famílias de colunas, escrita massiva. Cassandra, ScyllaDB, HBase. Para escrita em volume gigante.
🕸
Grafos
Nós e arestas como cidadãos de primeira classe. Neo4j, JanusGraph. Para relações densas e queries de caminho.
Time-series
Otimizado para pontos no tempo. InfluxDB, TimescaleDB. Para métricas, IoT, logs estruturados.
🔍
Search
Índices invertidos, full-text, agregações. Elasticsearch, OpenSearch. Para busca textual e analytics ad-hoc.

Note que search engines (Elasticsearch) tecnicamente não são NoSQL no sentido original, mas frequentemente aparecem no ecossistema. Cada família resolve problema distinto, com trade-offs próprios.

18.3 Teorema CAP — o trade-off central

Em 2000, Eric Brewer propôs o teorema CAP: num sistema distribuído, você pode garantir no máximo duas dessas três propriedades durante uma partição de rede:

Em sistemas distribuídos modernos, partições acontecem — não é opção. Então a escolha real é entre consistência e disponibilidade durante a partição:

Para sistemas de pagamento, controle de estoque, contas bancárias — escolha CP. Para feeds de notícias, contadores aproximados, métricas — AP é aceitável e frequentemente preferível.

Refinamento moderno
O CAP é simplificado demais. PACELC (Daniel Abadi, 2010) adiciona: se partição (P), escolhe entre A e C; senão (E), escolhe entre latência (L) e consistência (C). Captura melhor a realidade — sistemas distribuídos negociam consistência por latência mesmo sem partição.

18.4 Document stores — MongoDB e família

Modelo: coleções de documentos JSON-like aninhados. Sem schema rígido (cada documento pode ter campos diferentes). Queries em campos aninhados, índices em qualquer chave.

mongo_pedido.js
// Pedido como documento único — itens aninhados
{
  _id: ObjectId("..."),
  cliente: {
    id: "u_123",
    nome: "Alice",
    email: "alice@example.com"
  },
  itens: [
    { sku: "X1", nome: "Caneta", qtd: 2, preco: 5.00 },
    { sku: "X2", nome: "Papel", qtd: 1, preco: 12.00 }
  ],
  total: 22.00,
  status: "pago",
  criado_em: ISODate("2026-05-15T10:00:00Z")
}

// Query natural — busca por campo aninhado
db.pedidos.find({
  "cliente.email": "alice@example.com",
  status: "pago"
}).sort({ criado_em: -1 }).limit(10);

Pontos fortes

Pontos fracos

Quando faz sentido

18.5 Key-value stores — Redis em destaque

Modelo simplíssimo: chave → valor. Operações O(1) para get/set. Em memória (Redis, Memcached) ou em disco (LevelDB, RocksDB).

Redis em particular passou de "key-value" para "estrutura de dados em memória distribuída": além de strings, suporta listas, sets, sorted sets, hashes, streams, geo. Virou ferramenta indispensável.

redis_uso.py
import redis

r = redis.Redis(host="localhost", port=6379)

# Cache simples com TTL
r.setex("usuario:42:perfil", 3600, json.dumps(perfil))

cached = r.get("usuario:42:perfil")
if cached:
    perfil = json.loads(cached)
else:
    perfil = carregar_perfil_do_banco(42)
    r.setex("usuario:42:perfil", 3600, json.dumps(perfil))

# Rate limit com sorted set
agora = time.time()
chave = f"rate:user:42"
r.zremrangebyscore(chave, 0, agora - 60)  # remove antigos
qtd = r.zcard(chave)
if qtd >= 100:
    raise RateLimitExcedido()
r.zadd(chave, {str(agora): agora})
r.expire(chave, 120)

# Fila simples com listas
r.lpush("tarefas", json.dumps({"tipo": "enviar_email", "id": 42}))
# Worker:
_, tarefa_json = r.brpop("tarefas", timeout=30)
if tarefa_json:
    processar(json.loads(tarefa_json))

# Contador atômico (sem race conditions)
visitas = r.incr("pagina:home:visitas")

Quando usar Redis

Cuidados

18.6 Colunares — Cassandra para escrita massiva

Modelo: linhas particionadas por chave, com colunas dinâmicas dentro de cada partição. Otimizado para escritas (append-mostly) em escala extrema, distribuídas em centenas de nós.

O ponto-chave do Cassandra: você modela baseado nas queries, não em normalização. Cada padrão de consulta exige sua própria tabela, frequentemente com dados duplicados.

cassandra_modelo.cql
-- Mensagens de chat — partition key define onde dados moram fisicamente
CREATE TABLE mensagens_por_conversa (
    conversa_id UUID,
    enviada_em TIMESTAMP,
    autor_id UUID,
    texto TEXT,
    PRIMARY KEY (conversa_id, enviada_em)
) WITH CLUSTERING ORDER BY (enviada_em DESC);

-- Query natural: últimas mensagens de uma conversa
SELECT * FROM mensagens_por_conversa
WHERE conversa_id = ? LIMIT 50;

-- Para "mensagens enviadas pelo autor X", precisa de OUTRA tabela:
CREATE TABLE mensagens_por_autor (
    autor_id UUID,
    enviada_em TIMESTAMP,
    conversa_id UUID,
    texto TEXT,
    PRIMARY KEY (autor_id, enviada_em)
);
-- Duplicação intencional. Quem escreve mantém ambas as tabelas atualizadas.

Quando usar Cassandra

Para a grande maioria dos casos, Postgres particionado resolve. Cassandra é ferramenta pesada — só justifica em escala onde Postgres claramente não daria conta.

18.7 Bancos de grafo — Neo4j

Modelo: nós conectados por arestas, ambos com propriedades. Queries de grafo são expressas em linguagens próprias (Cypher em Neo4j) que são naturalmente concisas para "caminhos no grafo".

cypher.cypher
// Criar nós e relações
CREATE (alice:Pessoa {nome: 'Alice'}),
       (bob:Pessoa {nome: 'Bob'}),
       (carla:Pessoa {nome: 'Carla'}),
       (alice)-[:CONHECE]->(bob),
       (bob)-[:CONHECE]->(carla);

// "Amigos de amigos" — 3 níveis de profundidade
MATCH (eu:Pessoa {nome: 'Alice'})-[:CONHECE*1..3]-(amigo)
WHERE amigo <> eu
RETURN DISTINCT amigo.nome;

// Caminho mais curto
MATCH path = shortestPath(
  (a:Pessoa {nome: 'Alice'})-[:CONHECE*]-(b:Pessoa {nome: 'Carla'})
)
RETURN path;

Quando usar bancos de grafo

Para grafos pequenos a médios, Postgres + CTE recursiva ou extensão Apache AGE resolvem. Neo4j vale a complexidade extra quando o grafo é o produto central, não apenas uma feature.

18.8 Time-series — TimescaleDB e InfluxDB

Modelo: pontos no tempo (timestamp, valor, tags). Otimizado para inserções append-only e queries em ranges temporais.

TimescaleDB é particularmente interessante: extensão do PostgreSQL que adiciona "hypertables" particionadas por tempo automaticamente, com compressão e retention policies. Você ganha capacidades de time-series sem abandonar SQL nem perder JOINs com tabelas relacionais comuns.

timescale.sql
CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE metricas (
    quando TIMESTAMPTZ NOT NULL,
    sensor_id INT NOT NULL,
    temperatura DOUBLE PRECISION,
    umidade DOUBLE PRECISION
);

-- Transforma em hypertable particionada por tempo
SELECT create_hypertable('metricas', 'quando');

-- Compressão automática de dados antigos
ALTER TABLE metricas SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'sensor_id'
);
SELECT add_compression_policy('metricas', INTERVAL '7 days');

-- Continuous aggregate — pré-computa agregações
CREATE MATERIALIZED VIEW metricas_horarias
WITH (timescaledb.continuous) AS
SELECT
    time_bucket(INTERVAL '1 hour', quando) AS hora,
    sensor_id,
    AVG(temperatura) AS media_temp,
    MAX(temperatura) AS max_temp
FROM metricas
GROUP BY hora, sensor_id;

Quando usar time-series dedicado

18.9 Como decidir — perguntas concretas

Antes de adotar qualquer banco não-relacional, responda honestamente:

  1. Qual é o padrão de acesso primário? Lookup por chave? Range temporal? Caminhos em grafo? Busca textual? Query relacional complexa?
  2. Que volume de leitura/escrita você realmente tem? Não estimativa otimista do CEO — número real ou projeção fundamentada.
  3. O dado é transacional ou eventual? Pode haver inconsistência por segundos sem causar problema?
  4. Você precisa de joins? Sistemas reais frequentemente precisam — e a falta dói depois.
  5. Seu time tem experiência com o sistema escolhido? Operar em produção é diferente de escrever código.
  6. Postgres não resolveria? Verifique: JSONB para schemaless, GIN para search, BRIN para time-series, CTE recursiva para grafos. Frequentemente sim, resolve.
Antipattern recorrente
"Vamos começar com NoSQL para garantir escalabilidade futura." Quase sempre o sistema não chega na escala que justifica, e o custo de complexidade é pago durante toda a vida do produto. Comece com relacional; migre quando há evidência clara de que ele não comporta.

18.10 Postgres como NoSQL — quanto cabe

Antes de adotar outro banco, vale conhecer o que Postgres faz nos territórios "NoSQL":

postgres_nosql.sql
-- 1. JSONB — schemaless dentro de Postgres
CREATE TABLE documentos (
    id BIGSERIAL PRIMARY KEY,
    tipo VARCHAR(50),
    dados JSONB NOT NULL,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_dados ON documentos USING GIN (dados);

-- Query rica em JSON:
SELECT * FROM documentos
WHERE dados @> '{"status": "ativo", "tags": ["urgente"]}';

-- 2. Full-text search com tsvector
CREATE INDEX idx_busca ON artigos
USING GIN (to_tsvector('portuguese', conteudo));

SELECT id, titulo
FROM artigos
WHERE to_tsvector('portuguese', conteudo) @@ to_tsquery('arquitetura & software');

-- 3. Arrays nativos
CREATE TABLE posts (
    id BIGSERIAL PRIMARY KEY,
    titulo VARCHAR(200),
    tags TEXT[]
);
SELECT * FROM posts WHERE 'python' = ANY(tags);

-- 4. UPSERT atômico (chave-valor)
INSERT INTO contadores (chave, valor)
VALUES ('visitas_home', 1)
ON CONFLICT (chave) DO UPDATE SET valor = contadores.valor + 1;

-- 5. LISTEN/NOTIFY para pub/sub básico
NOTIFY canal_pedidos, '{"tipo": "novo", "id": 42}';
-- Cliente: LISTEN canal_pedidos;

Quando alguém propõe "vamos usar MongoDB", "vamos usar Redis", "vamos usar Elasticsearch", vale a pergunta: o que estamos ganhando que JSONB / UPSERT / tsvector não nos daria? Frequentemente: nada material.

18.11 Persistência poliglota — quando vale

"Persistência poliglota" é a ideia de usar bancos diferentes para problemas diferentes no mesmo sistema. Frequentemente sensata em escala — mas adiciona complexidade operacional significativa.

Configuração comum em sistemas maduros:

Custo: cada sistema é um runtime para operar, monitorar, atualizar, fazer backup, recuperar de incidente. Adote conforme há justificativa concreta — não preventivamente.

18.12 Estudo de caso — escolhendo bancos para um sistema

Sistema de marketplace com múltiplos padrões de acesso

Vamos modelar a escolha de persistência para um marketplace com: pedidos transacionais, busca de produtos, recomendações, métricas de operação, sessões de usuário.

Núcleo transacional — Postgres

Pedidos, pagamentos, usuários, produtos. Tudo que precisa de ACID, joins, integridade referencial. Volume: 100k pedidos/dia, totalmente comportável em Postgres bem configurado.

Decisão: Postgres como source of truth. Read replicas para offload de leitura conforme crescimento.

Busca de produtos

Usuários buscam por nome, descrição, categoria, com filtros e ordenação. Volume de buscas: 10x o volume de pedidos.

Decisão inicial: Postgres full-text search com GIN+tsvector. Atende muito bem até alguns milhões de produtos.

Quando migrar para Elasticsearch: quando você precisar de relevância sofisticada (boost por categoria, sinônimos, fuzzy search), faceted search complexa, ou tiver problema de performance comprovado.

Cache e sessões — Redis

Perfil de usuário acessado muitas vezes por requisição; sessões com TTL; rate limiting por usuário e endpoint; cache de páginas de produto.

Decisão: Redis. Casos de uso onde memory store dá ganho real de latência.

Métricas e analytics — TimescaleDB

Métricas de produto (visualizações, conversões, tempo em página), métricas de sistema (latência, throughput por endpoint).

Decisão: TimescaleDB. Mantém SQL, integra com Postgres principal via foreign data wrapper, evita adicionar outro sistema. Para escala muito maior, ClickHouse seria o próximo passo.

Recomendações — onde?

"Quem comprou X também comprou Y" e "produtos similares ao que você viu".

Decisão pragmática: começar com Postgres + queries de agregação rodadas em batch (Airflow noturno), resultados em tabela materializada. Para personalização real-time em escala, considerar ML pipelines com feature store dedicada — mas só quando o produto justificar.

Note: para a maioria dos marketplaces, recomendações batch são boas o suficiente. ML real-time é overkill para escala média.

Configuração final: Postgres (núcleo) + Redis (cache/sessão) + TimescaleDB (métricas) = três sistemas para operar. Comparado com Postgres + MongoDB + Cassandra + Elasticsearch + Redis (cinco sistemas), o ganho operacional é gigante. Adicione mais bancos só quando o caso justifica concretamente.

18.13 Erros comuns

Erro 1 · Escolher NoSQL "para escalar"

Adotado preventivamente, antes do volume justificar. Sistema fica complexo desde o dia 1, e o problema de escala (se chegar) seria resolvido por outros meios.

Erro 2 · Esperar transações em sistema eventual

Cassandra ou DynamoDB com "transações multi-partição garantidas". Não existem (ou existem com asterisco). Quando se descobre, é tarde.

Erro 3 · MongoDB para dados relacionais

Modelar pedidos com cliente embedded — até o cliente mudar o nome, e você ter milhões de pedidos com nome antigo. Sem joins eficientes, a "denormalização" vira pesadelo.

Erro 4 · Redis como source of truth

Redis em memória, sem persistência adequada. Crash do servidor = dados perdidos. Use para cache; source of truth fica em banco persistente.

Erro 5 · Múltiplos bancos sem necessidade

Stack com Postgres + MongoDB + Redis + Elasticsearch para sistema com 1000 usuários. Cinco runtimes para operar é cinco fontes potenciais de problema. Mantenha simples.

Verifique seu entendimento
"Sistema de pedidos com 50k transações/dia, queries de relatório agregando vários campos, busca por status do pedido. Time tem experiência com Postgres. Qual escolha?"

18.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar a família certa

Para cada caso, sugira a família de banco mais adequada (e justifique):

  1. Catálogo de 5 milhões de produtos, cada categoria com atributos diferentes (eletrônicos têm voltagem, roupas têm tamanho/cor).
  2. Métricas de servidores: 1000 servidores enviando 50 métricas a cada 10s.
  3. Sessões de usuário em aplicação web — leitura/escrita rápida, TTL de 30 min.
  4. Sistema de recomendação baseado em conexões "amigos de amigos" em rede social com 10M de usuários.
  5. Sistema financeiro com transações entre contas, requer ACID.
  1. Document store ou Postgres com JSONB. Schema variável encaixa. Postgres JSONB é alternativa válida se houver poucos atributos extras.
  2. Time-series (TimescaleDB ou InfluxDB). Padrão clássico — append-only, queries em range temporal, agregações.
  3. Redis. TTL nativo, baixa latência. Caso canônico.
  4. Banco de grafo (Neo4j) ou Postgres com CTE recursiva. Neo4j brilha quando consultas de caminho são frequentes e profundas. Postgres resolve em escala média.
  5. Relacional (Postgres). ACID, transações, integridade. Categoria onde relacional é claramente superior.
Médio
Exercício 2 · Implementar cache com Redis

Escreva função em Python que implementa cache-aside com Redis: obter_usuario(id) verifica Redis primeiro; se não houver, busca no banco e popula cache com TTL de 1h. Trate erro de conexão com Redis (fallback para banco direto).

cache_aside.py
import redis
import json
import logging
from dataclasses import dataclass, asdict
from typing import Protocol

logger = logging.getLogger(__name__)
TTL_CACHE = 3600  # 1 hora

@dataclass
class Usuario:
    id: int
    nome: str
    email: str

class RepoUsuarios(Protocol):
    def buscar(self, id: int) -> Usuario | None: ...

class UsuariosComCache:
    def __init__(self, repo: RepoUsuarios, redis_client):
        self._repo = repo
        self._redis = redis_client

    def obter(self, id: int) -> Usuario | None:
        chave = f"usuario:{id}"

        # 1. Tenta cache
        try:
            cached = self._redis.get(chave)
            if cached:
                return Usuario(**json.loads(cached))
        except redis.RedisError as e:
            logger.warning(f"Redis indisponível ({e}); seguindo para banco")

        # 2. Busca no banco
        usuario = self._repo.buscar(id)
        if usuario is None:
            return None

        # 3. Popula cache (best-effort)
        try:
            self._redis.setex(chave, TTL_CACHE, json.dumps(asdict(usuario)))
        except redis.RedisError as e:
            logger.warning(f"Falha ao popular cache ({e}); seguindo")

        return usuario

    def invalidar(self, id: int) -> None:
        try:
            self._redis.delete(f"usuario:{id}")
        except redis.RedisError as e:
            logger.warning(f"Falha ao invalidar cache ({e})")
Médio
Exercício 3 · Decisão arquitetural

Time propôs migrar serviço de catálogo de produtos do Postgres (atual: 2 milhões de produtos, JSONB para atributos variáveis) para MongoDB. Justificativa: "schema flexível, escala melhor". Escreva análise crítica em forma de ADR, recomendando manter ou migrar.

ADR-0012-postgres-vs-mongo.md
# ADR 0012: Manter Postgres para catálogo (não migrar para MongoDB)

**Data:** 2026-XX-XX
**Status:** Aceita
**Decisores:** time-plataforma

## Contexto

Catálogo atual em PostgreSQL com 2M de produtos, atributos
específicos por categoria modelados em JSONB. Proposta de migração
para MongoDB com justificativa "schema flexível e melhor escala".

## Decisão

**Manter PostgreSQL.** Não há justificativa técnica para migração
neste momento.

## Análise

### "Schema flexível"
Postgres com JSONB já oferece. Atributos por categoria são
mantidos em coluna JSONB; índices GIN cobrem queries por chave;
schema da estrutura comum (id, sku, nome, preço) segue com
validação relacional. Migrar para MongoDB removeria validação
estrutural sem ganho real de flexibilidade.

### "Escala melhor"
Catálogo tem 2M produtos. Postgres confortavelmente atende dezenas
de milhões com indexação adequada. Não há indicação de gargalo
atual nem projeção de crescimento explosivo. Otimização preventiva
não justifica complexidade operacional.

### Perdas concretas da migração
- Joins com pedidos, estoque, fornecedores ficariam em aplicação.
- Transações multi-coleção mais lentas e limitadas.
- Time precisa adquirir experiência operacional em novo runtime.
- Migração custa semanas; benefício mensurável: zero.

### Quando reconsiderar
- Catálogo passar de 50M produtos com queries lentas em Postgres
  bem indexado.
- Atributos por categoria explodirem em variabilidade ao ponto
  de JSONB ficar inviável.
- Time crescer e dedicar especialistas a operar MongoDB.

## Consequências

- **Positivas:** mantemos foco em features de negócio; operação
  segue simples com Postgres como banco único.
- **Negativas:** time entusiasta de MongoDB pode ficar frustrado;
  reforçar que decisão é por necessidade, não por preferência
  de stack.
Difícil
Exercício 4 · Modelagem poliglota

Sistema de e-learning com: cursos e aulas (relacional), reprodução de vídeo com tracking de progresso (alto volume), busca de conteúdo (textual rica), métricas de engajamento (analytics), sessões de usuário. Proponha stack completa com justificativa para cada escolha.

ComponenteBancoJustificativa
Cursos, aulas, inscrições, faturasPostgreSQLNúcleo transacional. Joins frequentes; ACID importa para cobrança e progresso oficial.
Tracking de reprodução (heartbeats a cada 30s)TimescaleDB (extensão do Postgres) ou ClickHouse se >10k usuários simultâneosAppend-only, range queries por usuário/aula, retention curta. Manter no ecossistema Postgres simplifica operação.
Busca de cursos e aulasPostgres FTS inicialmente; Elasticsearch quando precisar de relevância sofisticadaPostgres tsvector cobre 80% dos casos. Migrar para Elasticsearch só quando ranking/sinônimos/fuzzy forem necessários.
Métricas de engajamento (tempo médio, conclusão por curso)TimescaleDB com continuous aggregates; ou pipeline para data warehouse (BigQuery) se analytics for produtoPre-agregar reduz custo de queries de dashboard. Continuous aggregates do TimescaleDB são ideais.
Sessões e cacheRedisSessões com TTL, cache de páginas de cursos populares, rate limiting de API.
Arquivos de vídeoS3 (ou MinIO on-prem) com CDNArquivos grandes nunca em banco. CDN para entrega.

Stack final: Postgres + TimescaleDB (mesmo cluster) + Redis + S3 + CDN. Três sistemas operacionais (Postgres counts as one, com extensão; Redis; storage). Configuração comprovada em escala média/alta sem complexidade excessiva.

Fim do capítulo 18 · Fim da Parte IV
Quatro capítulos sobre dados: modelagem relacional, SQL e performance, transações e concorrência, NoSQL com critério. Próxima parte: arquitetura aplicada — camadas, hexagonal, DDD, REST, GraphQL, mensageria. Onde tudo que vimos se junta em sistemas maiores. Peça "continua" para receber.
Parte V
Arquitetura aplicada

Onde tudo se junta. Como sistemas maiores são organizados — camadas, hexagonal, DDD, APIs REST e GraphQL, mensageria e eventos. Decisões que ficam por anos.

Camadas Hexagonal e Clean DDD aplicado REST bem feito GraphQL com critério Mensageria e eventos
Parte V · Capítulo 19 · Arquitetura aplicada

Arquitetura
em camadas:
o default
razoável.

Camadas é a arquitetura mais antiga e mais subestimada do catálogo. Não é cool, não é moderna, mas resolve a maioria dos problemas para a maioria dos sistemas. Aprender quando usá-la bem é mais útil do que aprender estilos mais sofisticados.

Existe pressão silenciosa, na indústria, para sempre adotar arquiteturas mais elaboradas — hexagonal, clean, microsserviços. Para sistemas grandes, vale; para a maioria, é overengineering. Camadas bem feitas, em monolito modular, atendem mais sistemas do que qualquer outra arquitetura. Vamos cobrir bem antes de seguir para as variantes.

19.1 A história — de mainframe a web

Contexto histórico

Arquitetura em camadas vem dos anos 60-70, com mainframes da IBM separando "apresentação 3270" (terminal), "lógica COBOL" e "armazenamento IMS/DB2". A divisão era física — diferentes máquinas, diferentes responsabilidades.

Nos anos 90, com cliente-servidor, virou padrão: cliente desktop como apresentação, servidor de aplicação no meio, banco como armazenamento. A "arquitetura 3-tier" dominou a indústria.

Com a web, o padrão se internalizou: dentro do mesmo processo, código se organiza em camadas lógicas. Controllers (HTTP) → Services (negócio) → Repositories (banco). Frameworks como Spring (Java), Rails, Django, Laravel consolidaram a estrutura.

Hoje, "arquitetura em camadas" frequentemente é descrita pejorativamente — "monolito tradicional" — em comparação com hexagonal ou microsserviços. A reputação é injusta. Camadas bem aplicadas, com modularização interna, são tão evolutivas quanto qualquer outra arquitetura para a maioria dos sistemas reais.

19.2 Para que servem camadas

A intuição central: separar coisas que mudam por motivos diferentes em níveis diferentes. Cada camada tem responsabilidade única; depende só das camadas "abaixo" dela; pode ser substituída sem afetar as outras.

Sem camadas, código vira espaguete típico:

Com camadas:

19.3 Três camadas clássicas

O modelo mais comum, suficiente para a maioria dos sistemas:

🌐
Apresentação
HTTP, serialização, validação de payload. Controllers em web; views em CLI; handlers em workers. Não contém regra de negócio.
⚙️
Aplicação / Negócio
Onde a lógica vive. Orquestra operações, valida regras, garante invariantes. Independente de HTTP, banco, framework.
💾
Persistência
Repositórios, mappers, queries. Único lugar que conhece o banco. Trocável (banco diferente, fakes em teste).
tres_camadas.py
# --- Camada de Persistência ---
class RepoPedidos:
    def __init__(self, conn):
        self._conn = conn

    def salvar(self, pedido: Pedido) -> None:
        with self._conn.cursor() as cur:
            cur.execute(
                "INSERT INTO pedidos (id, total, status) VALUES (%s, %s, %s)",
                (pedido.id, pedido.total, pedido.status),
            )

    def buscar(self, id: str) -> Pedido | None:
        with self._conn.cursor() as cur:
            cur.execute("SELECT id, total, status FROM pedidos WHERE id = %s", (id,))
            row = cur.fetchone()
            return Pedido(*row) if row else None

# --- Camada de Aplicação / Negócio ---
class ServicoPedido:
    def __init__(self, repo: RepoPedidos, gateway_pagamento, notif):
        self._repo = repo
        self._gateway = gateway_pagamento
        self._notif = notif

    def criar_e_pagar(self, cliente_id: str, itens: list, cartao: str) -> Pedido:
        pedido = Pedido.criar(cliente_id, itens)
        pedido.validar()
        self._repo.salvar(pedido)
        tx = self._gateway.cobrar(pedido.total, cartao)
        pedido.confirmar(tx.id)
        self._repo.salvar(pedido)
        self._notif.confirmar_pedido(pedido)
        return pedido

# --- Camada de Apresentação ---
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel

app = FastAPI()

class CriarPedidoRequest(BaseModel):
    cliente_id: str
    itens: list[dict]
    cartao: str

@app.post("/pedidos")
def criar_pedido(req: CriarPedidoRequest, servico: ServicoPedido = Depends()):
    try:
        pedido = servico.criar_e_pagar(req.cliente_id, req.itens, req.cartao)
        return {"id": pedido.id, "status": pedido.status}
    except PedidoInvalido as e:
        raise HTTPException(status_code=422, detail=str(e))
    except PagamentoRecusado as e:
        raise HTTPException(status_code=402, detail=str(e))

19.4 Quatro camadas — quando vale

Para sistemas maiores, a separação fica:

  1. Apresentação: HTTP / CLI / event handlers. Tradução de protocolo.
  2. Aplicação (use cases): orquestra fluxos. Não tem regra de negócio profunda; apenas coordena.
  3. Domínio: entidades, value objects, agregados, regras de negócio puras. Sem dependência de framework.
  4. Infraestrutura: banco, fila, e-mail, gateways. Implementações concretas das abstrações que o domínio define.

A diferença entre aplicação e domínio é sutil: aplicação responde "qual é o passo a passo deste caso de uso?"; domínio responde "quais são as regras da entidade?". Aplicação muda quando o caso de uso muda; domínio muda quando a regra de negócio muda.

quatro_camadas.py
# --- Camada de Domínio (entidades, regras puras) ---
from dataclasses import dataclass, field
from decimal import Decimal

@dataclass
class Pedido:
    id: str
    cliente_id: str
    itens: list["Item"] = field(default_factory=list)
    status: str = "aberto"

    def adicionar_item(self, item: "Item"):
        if self.status != "aberto":
            raise PedidoJaFechado()
        self.itens.append(item)

    def confirmar(self):
        if not self.itens:
            raise PedidoVazio()
        self.status = "confirmado"

    @property
    def total(self) -> Decimal:
        return sum((i.subtotal for i in self.itens), Decimal("0"))

# --- Camada de Aplicação (use cases) ---
from typing import Protocol

class RepoPedidos(Protocol):
    def salvar(self, p: Pedido) -> None: ...
    def buscar(self, id: str) -> Pedido | None: ...

class GatewayPagamento(Protocol):
    def cobrar(self, valor: Decimal, cartao: str) -> "Transacao": ...

class CriarPedidoUseCase:
    def __init__(self, repo: RepoPedidos, gateway: GatewayPagamento):
        self._repo = repo
        self._gateway = gateway

    def executar(self, cliente_id: str, itens, cartao: str) -> Pedido:
        pedido = Pedido(id=gerar_id(), cliente_id=cliente_id)
        for it in itens:
            pedido.adicionar_item(Item(**it))
        pedido.confirmar()
        self._repo.salvar(pedido)
        try:
            self._gateway.cobrar(pedido.total, cartao)
        except Exception:
            pedido.status = "falha_pagamento"
            self._repo.salvar(pedido)
            raise
        return pedido

# --- Camada de Infraestrutura (implementações concretas) ---
class RepoPedidosPostgres:
    def __init__(self, conn):
        self._conn = conn
    def salvar(self, p): ...  # SQL aqui
    def buscar(self, id): ...

class GatewayStripe:
    def cobrar(self, valor, cartao):
        # chamada HTTP para Stripe
        ...

# --- Camada de Apresentação ---
@app.post("/pedidos")
def criar_pedido(req: CriarPedidoRequest, uc: CriarPedidoUseCase = Depends()):
    pedido = uc.executar(req.cliente_id, req.itens, req.cartao)
    return PedidoDTO.de_dominio(pedido)

19.5 Regra de dependência

A regra que diferencia "camadas que funcionam" de "camadas no nome":

Camadas internas (domínio, aplicação) não conhecem camadas externas (apresentação, infraestrutura).

Domínio nunca importa Flask/FastAPI/Django. Não conhece SQL. Não sabe se o request é HTTP, gRPC ou CLI. Quando você quer adicionar dependência externa ao domínio (banco, e-mail), você define interface (Protocol) no domínio/aplicação, e implementa na infraestrutura — Dependency Inversion.

O fluxo de imports vira:

dependencias.txt
presentation  ──>  application  ──>  domain
infrastructure  ──>  application  ──>  domain

domain         ──>  (nada — só stdlib)
application    ──>  domain
infrastructure ──>  application + domain
presentation   ──>  application + domain

Em Python, dá pra impor isso com linter (Ruff regra TID, ou import-linter):

importlinter.toml
[importlinter]
root_package = "meuapp"

[[importlinter.contracts]]
name = "Camadas têm dependência one-way"
type = "layers"
layers = [
    "meuapp.presentation",
    "meuapp.infrastructure",
    "meuapp.application",
    "meuapp.domain",
]
# Camada acima não pode ser importada por camada abaixo.

Rodar lint-imports no CI: violações viram falha de build. Disciplina automatizada vale mais do que documentação.

19.6 DTOs entre camadas — o ponto mais discutido

Pergunta recorrente: cada camada deve ter seus tipos, ou as entidades de domínio podem trafegar até a borda?

Posição 1: DTOs separados em toda fronteira

Prós: camadas verdadeiramente desacopladas; mudança em uma não afeta outras. Contras: muito código de tradução; verboso; pode virar cerimônia.

Posição 2: domain types em toda parte

Prós: simples, menos código. Contras: mudança no dominio pode mudar contrato HTTP acidentalmente; expor campos internos no JSON; acoplamento das camadas.

Posição 3 (recomendada): DTOs nas fronteiras externas

Use DTOs onde há contrato com o mundo externo (HTTP, eventos publicados, mensagens em fila) — esses precisam de versionamento independente. Entre application e domain, use as entidades direto — overhead de DTOs sem ganho.

dtos.py
# --- Domínio ---
@dataclass
class Pedido:
    id: str
    cliente_id: str
    itens: list[Item]
    status: str
    _audit_token: str  # campo interno, NÃO vai pra fora

# --- DTO de apresentação ---
from pydantic import BaseModel

class PedidoResponse(BaseModel):
    id: str
    cliente_id: str
    itens: list["ItemResponse"]
    status: str

    @classmethod
    def de_dominio(cls, p: Pedido) -> "PedidoResponse":
        return cls(
            id=p.id,
            cliente_id=p.cliente_id,
            itens=[ItemResponse.de_dominio(i) for i in p.itens],
            status=p.status,
            # _audit_token NÃO é exposto
        )

@app.get("/pedidos/{id}", response_model=PedidoResponse)
def obter(id: str, repo: RepoPedidos = Depends()):
    pedido = repo.buscar(id)
    if not pedido:
        raise HTTPException(404)
    return PedidoResponse.de_dominio(pedido)

19.7 Organizando código — package by feature ou by layer?

Duas escolas de organização de arquivos:

⚠ Package by layer
arvore.txt
src/
├── controllers/
│   ├── pedidos.py
│   ├── usuarios.py
│   └── produtos.py
├── services/
│   ├── pedidos.py
│   ├── usuarios.py
│   └── produtos.py
├── repositories/
│   ├── pedidos.py
│   ├── usuarios.py
│   └── produtos.py
└── models/
    ├── pedido.py
    ├── usuario.py
    └── produto.py

Mudança em "pedidos" toca em 4 diretórios. Time grande, conflito constante.

✓ Package by feature
arvore.txt
src/
├── pedidos/
│   ├── domain.py
│   ├── service.py
│   ├── repository.py
│   ├── controller.py
│   └── dto.py
├── usuarios/
│   ├── domain.py
│   ├── service.py
│   └── ...
└── shared/
    └── ...

Cada feature é coeso. Mudança fica num diretório. Modular por natureza.

Recomendação: package by feature, com camadas dentro. Para projetos pequenos (uma feature dominante), by-layer pode ser suficiente. Para projetos médios e grandes, by-feature ganha em modularidade.

19.8 Estudo de caso — refatorando para camadas

De código colado a arquitetura legível

Sistema legado típico: tudo no controller. Vamos refatorar para três camadas, em passos seguros.

Estado inicial · 200 linhas no controller
antes.py
@app.post("/pedidos")
def criar_pedido(req: dict):
    # validação
    if not req.get("itens"):
        return {"error": "vazio"}, 400

    # busca cliente direto no banco
    conn = psycopg2.connect("...")
    cur = conn.cursor()
    cur.execute("SELECT * FROM clientes WHERE id = %s", (req["cliente_id"],))
    cliente = cur.fetchone()
    if not cliente:
        return {"error": "cliente não existe"}, 404

    # calcula total
    total = sum(it["preco"] * it["qtd"] for it in req["itens"])

    # insere pedido
    cur.execute("INSERT INTO pedidos ...", ...)
    pedido_id = cur.fetchone()[0]
    for it in req["itens"]:
        cur.execute("INSERT INTO itens ...", ...)
    conn.commit()

    # cobra cartão
    response = requests.post("https://stripe.com/charge", json={...})
    if response.status_code != 200:
        # tenta reverter, mas pode falhar...
        cur.execute("UPDATE pedidos SET status='falha'")
        conn.commit()
        return {"error": "pagamento"}, 402

    # envia email
    smtp = smtplib.SMTP("...")
    smtp.sendmail("sis@", cliente[2], "Pedido confirmado")

    return {"id": pedido_id}, 201

Problemas: HTTP misturado com SQL misturado com lógica de negócio misturado com integração externa. Impossível testar isoladamente. Mudança de banco quebra controller. Sem garantias transacionais.

Passo 1 · Extrair Repository
repo.py
class RepoClientes:
    def __init__(self, conn):
        self._conn = conn
    def buscar(self, id: str) -> Cliente | None:
        with self._conn.cursor() as cur:
            cur.execute("SELECT id, nome, email FROM clientes WHERE id = %s", (id,))
            row = cur.fetchone()
            return Cliente(*row) if row else None

class RepoPedidos:
    def __init__(self, conn):
        self._conn = conn
    def salvar(self, p: Pedido) -> None:
        ...
Passo 2 · Extrair Service
service.py
class ServicoPedidos:
    def __init__(self, repo_clientes, repo_pedidos, gateway, notif):
        self._clientes = repo_clientes
        self._pedidos = repo_pedidos
        self._gateway = gateway
        self._notif = notif

    def criar_e_cobrar(self, cliente_id, itens_data, cartao) -> Pedido:
        cliente = self._clientes.buscar(cliente_id)
        if not cliente:
            raise ClienteNaoEncontrado(cliente_id)

        pedido = Pedido(id=gerar_id(), cliente_id=cliente_id)
        for it in itens_data:
            pedido.adicionar_item(Item(**it))
        pedido.confirmar()
        self._pedidos.salvar(pedido)

        try:
            self._gateway.cobrar(pedido.total, cartao)
        except Exception:
            pedido.marcar_falha_pagamento()
            self._pedidos.salvar(pedido)
            raise

        self._notif.confirmar_pedido(pedido, cliente)
        return pedido
Passo 3 · Controller magro
controller.py
class CriarPedidoRequest(BaseModel):
    cliente_id: str
    itens: list[ItemRequest]
    cartao: str

class PedidoResponse(BaseModel):
    id: str
    status: str
    total: str

    @classmethod
    def de_dominio(cls, p: Pedido):
        return cls(id=p.id, status=p.status, total=str(p.total))

@app.post("/pedidos", response_model=PedidoResponse, status_code=201)
def criar_pedido(req: CriarPedidoRequest, svc: ServicoPedidos = Depends()):
    try:
        pedido = svc.criar_e_cobrar(req.cliente_id, [i.model_dump() for i in req.itens], req.cartao)
        return PedidoResponse.de_dominio(pedido)
    except ClienteNaoEncontrado:
        raise HTTPException(404, detail="cliente não existe")
    except PedidoVazio:
        raise HTTPException(422, detail="pedido vazio")
    except PagamentoRecusado as e:
        raise HTTPException(402, detail=str(e))

O que mudou:

  • Controller tem 15 linhas. HTTP only.
  • Service tem testes sem precisar de banco (fakes nos protocolos).
  • Repository pode ser trocado por implementação MongoDB sem alterar service.
  • Domínio tem regras (vazio, status, total) sem dependências externas.
  • Erros viraram exceções de domínio mapeadas para HTTP no controller.

19.9 Erros comuns

Erro 1 · Anemic services com fat controllers

Service é "fachada" que só delega; controller tem lógica de negócio. Inversão da intenção. Lógica vai pro service; controller só traduz HTTP.

Erro 2 · Vazamento da camada de persistência

Service retorna objetos do ORM (Django QuerySet, SQLAlchemy session). Controller chama .save() em entidade. Persistência infectou tudo. Use repositórios como fronteira.

Erro 3 · Domínio importando framework

from fastapi import ... dentro de entidade de domínio. Framework deve ser invisível para regras de negócio. Se aparece, há erro arquitetural.

Erro 4 · Camadas sem real isolamento

Três pastas com nomes diferentes, mas tudo mistura. Sem linter validando dependências, "camadas" são só convenção visual. Use import-linter ou similar.

19.10 Quando NÃO usar camadas estritas

Reconheça o contexto
Casos onde simplicidade vence
  • Scripts e ferramentas internas: CLI de 200 linhas, processo batch noturno. Camadas viram cerimônia.
  • Protótipos exploratórios: validar hipótese de produto. Faça simples; estruture depois se virar produto.
  • Sistemas data-pipeline puros: ETL onde "lógica" é transformação de dados. Pode ser função pura encadeada, sem camadas tradicionais.
  • Microservices muito pequenos: serviço com dois endpoints e uma tabela. Estruturar em 4 camadas é overkill.

Princípio: camadas pagam quando há lógica de negócio para isolar e código vai evoluir. Para o resto, simplicidade primeiro.

Verifique seu entendimento
"Sua entidade Pedido está em domain/. Você precisa salvá-la no banco. Onde fica o código de INSERT SQL, e como o service usa isso?"

19.11 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar camada

Para cada trecho, identifique a qual camada pertence (domínio, aplicação, infraestrutura, apresentação):

  1. def cobrar(self, valor): r = requests.post("https://stripe.com/...", ...)
  2. def confirmar(self): if not self.itens: raise PedidoVazio()
  3. def criar_e_pagar(self, dados): pedido = Pedido.novo(); self._repo.salvar(pedido); self._gateway.cobrar(...)
  4. @app.post("/pedidos") def criar(req: CriarPedidoRequest): ...
  5. cur.execute("SELECT ... FROM pedidos WHERE id = %s", (id,))
  6. class PedidoResponse(BaseModel): id: str; total: Decimal
  1. Infraestrutura — chamada HTTP a serviço externo.
  2. Domínio — regra de negócio pura.
  3. Aplicação — orquestra fluxo, sem regras profundas.
  4. Apresentação — endpoint HTTP.
  5. Infraestrutura — SQL.
  6. Apresentação — DTO de resposta HTTP.
Fácil
Exercício 2 · Detectar violação de camada

Em cada caso, qual a violação arquitetural?

  1. from sqlalchemy import Column no arquivo domain/pedido.py
  2. Controller chamando diretamente psycopg2.connect()
  3. Service retornando QuerySet do Django
  4. Entidade Pedido com método renderizar_html()
  1. Domínio importando ORM (infraestrutura). Domínio deve ser puro Python; ORM mora no repository.
  2. Controller vazando para infraestrutura sem passar por camada intermediária. Deveria receber repository injetado.
  3. Service expondo abstração de persistência (QuerySet) à apresentação. Mapeie para domínio antes.
  4. Domínio com responsabilidade de apresentação. Renderização HTML mora em layer de apresentação.
Médio
Exercício 3 · Refatorar para camadas

Pegue esse controller "fat" e refatore em 3 camadas (apresentação, aplicação/serviço, infraestrutura/repository) + domínio. Mantenha comportamento.

fat_controller.py
@app.post("/transferencias")
def transferir(req: dict):
    conn = psycopg2.connect("...")
    cur = conn.cursor()

    # busca origem e destino
    cur.execute("SELECT saldo FROM contas WHERE id = %s FOR UPDATE", (req["origem"],))
    origem = cur.fetchone()
    if not origem or origem[0] < req["valor"]:
        return {"error": "saldo insuficiente"}, 422

    cur.execute("UPDATE contas SET saldo = saldo - %s WHERE id = %s",
                (req["valor"], req["origem"]))
    cur.execute("UPDATE contas SET saldo = saldo + %s WHERE id = %s",
                (req["valor"], req["destino"]))
    cur.execute("INSERT INTO transferencias ...", ...)
    conn.commit()

    return {"status": "ok"}, 200
refatorado.py
# --- Domínio ---
@dataclass
class Conta:
    id: str
    saldo: Decimal

    def debitar(self, valor: Decimal):
        if valor <= 0:
            raise ValueError("valor positivo")
        if self.saldo < valor:
            raise SaldoInsuficiente()
        self.saldo -= valor

    def creditar(self, valor: Decimal):
        if valor <= 0:
            raise ValueError("valor positivo")
        self.saldo += valor

class SaldoInsuficiente(Exception): pass

# --- Aplicação ---
class RepoContas(Protocol):
    def buscar_para_atualizar(self, id: str) -> Conta | None: ...
    def atualizar(self, c: Conta) -> None: ...

class UnidadeDeTrabalho(Protocol):
    def __enter__(self): ...
    def __exit__(self, *args): ...

class ServicoTransferencia:
    def __init__(self, repo: RepoContas, uow: UnidadeDeTrabalho):
        self._repo = repo
        self._uow = uow

    def transferir(self, origem_id, destino_id, valor: Decimal):
        with self._uow:
            origem = self._repo.buscar_para_atualizar(origem_id)
            destino = self._repo.buscar_para_atualizar(destino_id)
            if not origem or not destino:
                raise ContaNaoEncontrada()
            origem.debitar(valor)
            destino.creditar(valor)
            self._repo.atualizar(origem)
            self._repo.atualizar(destino)

# --- Infraestrutura ---
class RepoContasPostgres:
    def __init__(self, conn): self._conn = conn
    def buscar_para_atualizar(self, id):
        with self._conn.cursor() as cur:
            cur.execute("SELECT id, saldo FROM contas WHERE id = %s FOR UPDATE", (id,))
            row = cur.fetchone()
            return Conta(*row) if row else None
    def atualizar(self, c):
        with self._conn.cursor() as cur:
            cur.execute("UPDATE contas SET saldo = %s WHERE id = %s", (c.saldo, c.id))

# --- Apresentação ---
class TransferenciaRequest(BaseModel):
    origem: str
    destino: str
    valor: Decimal

@app.post("/transferencias")
def transferir(req: TransferenciaRequest, svc: ServicoTransferencia = Depends()):
    try:
        svc.transferir(req.origem, req.destino, req.valor)
        return {"status": "ok"}
    except SaldoInsuficiente:
        raise HTTPException(422, "saldo insuficiente")
    except ContaNaoEncontrada:
        raise HTTPException(404, "conta não encontrada")
Difícil
Exercício 4 · Estrutura completa por feature

Esboce a estrutura de pastas para um sistema de e-commerce com features: usuarios, produtos, pedidos, pagamentos, notificacoes. Use package-by-feature, com camadas dentro de cada feature. Inclua pasta shared. Adicione configuração do import-linter.

estrutura.txt
src/
└── meuapp/
    ├── usuarios/
    │   ├── domain.py
    │   ├── application.py
    │   ├── repository.py
    │   ├── controller.py
    │   └── dto.py
    ├── produtos/
    │   ├── domain.py
    │   ├── application.py
    │   ├── repository.py
    │   ├── controller.py
    │   └── dto.py
    ├── pedidos/
    │   ├── domain.py
    │   ├── application.py
    │   ├── repository.py
    │   ├── controller.py
    │   └── dto.py
    ├── pagamentos/
    │   └── ...
    ├── notificacoes/
    │   └── ...
    └── shared/
        ├── errors.py
        ├── ids.py
        └── unit_of_work.py
setup.cfg (import-linter)
[importlinter]
root_package = meuapp

[importlinter:contract:camadas-por-feature]
name = Camadas têm dependência one-way em cada feature
type = layers
layers =
    meuapp.pedidos.controller
    meuapp.pedidos.repository
    meuapp.pedidos.application
    meuapp.pedidos.domain

[importlinter:contract:dominio-puro]
name = Domínios nunca importam frameworks
type = forbidden
source_modules =
    meuapp.usuarios.domain
    meuapp.produtos.domain
    meuapp.pedidos.domain
forbidden_modules =
    fastapi
    sqlalchemy
    psycopg2
    requests
Fim do capítulo 19
Próximo capítulo: arquitetura hexagonal e Clean Architecture. A evolução natural de camadas — com inversão explícita de dependências e portas/adaptadores.
Parte V · Capítulo 20 · Arquitetura aplicada

Hexagonal
e Clean:
inversão
aplicada.

Hexagonal Architecture e Clean Architecture são variações da mesma ideia: o núcleo do sistema (regras de negócio) não conhece nada sobre o mundo externo (banco, HTTP, framework). Tudo se inverte através de interfaces que o domínio define.

A diferença para arquitetura em camadas tradicional é sutil mas significativa: em camadas, o domínio frequentemente conhece a camada de persistência (mesmo que abstraída). Em hexagonal/clean, o domínio define o contrato que a persistência deve cumprir; o domínio não sabe que existe um banco. Isso vira em testabilidade extrema e flexibilidade para trocar componentes externos.

20.1 A história — duas formulações da mesma ideia

Contexto histórico

Em 2005, Alistair Cockburn publicou o artigo "Hexagonal Architecture", também conhecido como "Ports and Adapters". A motivação: ele estava cansado de aplicações onde lógica de negócio estava entrelaçada com framework, banco, UI — tornando testes e evolução um pesadelo.

A metáfora do hexágono: o núcleo é a aplicação; cada lado do hexágono é uma "porta" — uma interface pela qual o mundo externo conversa com ela. Cada porta tem um ou mais "adaptadores": implementações concretas que falam protocolos específicos (HTTP, SQL, AMQP, file system).

Em paralelo, em 2008, Jeffrey Palermo propôs a Onion Architecture — mesmo princípio, metáfora diferente (camadas concêntricas em vez de hexágono).

Em 2012, Robert C. Martin ("Uncle Bob") publicou no blog o artigo "The Clean Architecture", que ficou famoso no livro homônimo de 2017. Clean Architecture unifica ideias de hexagonal, onion e DCI (Data-Context-Interaction) — mesmo princípio nuclear, com vocabulário próprio (entities, use cases, interface adapters, frameworks & drivers).

Hoje, "Clean Architecture", "Hexagonal", "Ports & Adapters" são frequentemente usados como sinônimos. Diferenças entre eles são pequenas. O que importa é o princípio: direção de dependência aponta sempre para o domínio.

20.2 Que problema essas arquiteturas resolvem

Para entender o ganho, vejamos o problema que aparece em arquitetura em camadas "naïve":

problema.py
# Em camadas tradicionais, services frequentemente acabam assim:

from meuapp.infrastructure.repositorio_pedidos import RepoPedidosPostgres
from meuapp.infrastructure.gateway_stripe import GatewayStripe
from meuapp.infrastructure.email_smtp import EmailSMTP

class ServicoPedido:
    def __init__(self):
        self._repo = RepoPedidosPostgres()
        self._gateway = GatewayStripe()
        self._email = EmailSMTP()

    def criar_e_cobrar(self, ...):
        ...

# Problemas:
# - Service DEPENDE de classes concretas da infra. Testar exige
#   subir banco real, ou monkey-patch.
# - Trocar Postgres por outro banco exige editar service.
# - O fluxo de imports: application → infrastructure (do "alto" para o "baixo").
#   Inverte o que deveria ser.

A solução de hexagonal/clean é simples e poderosa:

  1. Domínio/aplicação define interfaces (portas) que descrevem o que precisa do mundo externo.
  2. Infraestrutura implementa essas interfaces (adaptadores).
  3. Service recebe as interfaces por injeção — não conhece implementações concretas.
  4. Imports vão da infraestrutura para o domínio. Nunca o contrário.

20.3 Portas e adaptadores

"Porta" = interface que o núcleo define. Duas direções:

"Adaptador" = implementação concreta de uma porta. Duas direções correspondentes:

diagrama.txt
  ┌──────────────┐                            ┌──────────────┐
  │   Driving    │                            │   Driven     │
  │  adapters    │                            │  adapters    │
  │              │                            │              │
  │  HTTP        │──> [primary  ──> CORE ──>  secondary] ──> │  Postgres    │
  │  CLI         │     port              port              │  Stripe      │
  │  Consumer    │                                          │  SMTP        │
  └──────────────┘                            └──────────────┘

  Imports apontam SEMPRE para o centro.
  Núcleo nunca importa adaptador.

20.4 Clean Architecture — anéis concêntricos

Clean Architecture organiza o sistema em quatro anéis, com a mesma regra:

  1. Entities: regras de negócio empresariais. Os objetos mais profundos. Mudam por razões de negócio, raramente.
  2. Use Cases: regras de negócio da aplicação específica. Orquestram entidades.
  3. Interface Adapters: traduzem entre o mundo externo e os use cases. Controllers, presenters, gateways.
  4. Frameworks & Drivers: bibliotecas, web frameworks, banco, UI. Tudo mais externo.

A regra da dependência: código em anel interno não pode mencionar nada de anel externo. Inversão de controle (DI) faz a comunicação ir do externo para o interno via interfaces.

A diferença entre Clean e Hexagonal
Hexagonal tem dois tipos de portas (primary/secondary). Clean distingue entities (regras empresariais) de use cases (regras de aplicação). Para sistemas grandes, essa distinção paga; para sistemas médios, frequentemente vira sutileza. Use o que sua equipe entende; o princípio é o mesmo.

20.5 Camadas vs hexagonal vs clean — diferenças práticas

AspectoCamadas tradicionaisHexagonal / Clean
Direção do import Application → Infrastructure (frequentemente) Infrastructure → Application (sempre)
Onde estão interfaces Frequentemente em infrastructure Sempre no núcleo (domain/application)
Service conhece... Pode conhecer classes concretas de repo Só conhece interfaces (Protocols)
Trocar banco Pode exigir mudança em service Apenas novo adaptador, zero mudança em service
Testar com fakes Possível, mas frequentemente exige patches Trivial — DI natural com Protocol
Complexidade inicial Baixa Média

20.6 Implementando em Python

Vamos construir um exemplo completo: serviço de pedidos com hexagonal architecture. Note como o núcleo nunca importa infraestrutura.

domain/pedido.py — núcleo, sem dependências externas
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime

class PedidoVazio(Exception): pass
class PedidoJaFechado(Exception): pass

@dataclass
class Item:
    sku: str
    nome: str
    preco_unitario: Decimal
    quantidade: int

    @property
    def subtotal(self) -> Decimal:
        return self.preco_unitario * self.quantidade

@dataclass
class Pedido:
    id: str
    cliente_id: str
    itens: list[Item] = field(default_factory=list)
    status: str = "aberto"
    criado_em: datetime = field(default_factory=datetime.utcnow)

    def adicionar_item(self, item: Item):
        if self.status != "aberto":
            raise PedidoJaFechado()
        self.itens.append(item)

    def confirmar(self):
        if not self.itens:
            raise PedidoVazio()
        self.status = "confirmado"

    @property
    def total(self) -> Decimal:
        return sum((i.subtotal for i in self.itens), Decimal("0"))
application/ports.py — portas que o núcleo define
from typing import Protocol
from decimal import Decimal
from dataclasses import dataclass
from meuapp.domain.pedido import Pedido

# --- Driven ports (saída) ---

class RepoPedidos(Protocol):
    def salvar(self, pedido: Pedido) -> None: ...
    def buscar(self, id: str) -> Pedido | None: ...

@dataclass(frozen=True)
class ResultadoPagamento:
    sucesso: bool
    transacao_id: str | None
    erro: str | None = None

class GatewayPagamento(Protocol):
    def cobrar(self, valor: Decimal, cartao: str) -> ResultadoPagamento: ...

class Notificador(Protocol):
    def confirmar_pedido(self, pedido: Pedido) -> None: ...
application/use_cases.py — driving port
from dataclasses import dataclass
from decimal import Decimal
from meuapp.domain.pedido import Pedido, Item
from meuapp.application.ports import RepoPedidos, GatewayPagamento, Notificador

class PagamentoRecusado(Exception): pass

@dataclass(frozen=True)
class DadosItem:
    sku: str
    nome: str
    preco: Decimal
    quantidade: int

class CriarPedidoUseCase:
    def __init__(
        self,
        repo: RepoPedidos,
        gateway: GatewayPagamento,
        notif: Notificador,
        gerar_id,
    ):
        self._repo = repo
        self._gateway = gateway
        self._notif = notif
        self._gerar_id = gerar_id

    def executar(
        self,
        cliente_id: str,
        itens: list[DadosItem],
        cartao: str,
    ) -> Pedido:
        pedido = Pedido(id=self._gerar_id(), cliente_id=cliente_id)
        for dado in itens:
            pedido.adicionar_item(Item(
                sku=dado.sku,
                nome=dado.nome,
                preco_unitario=dado.preco,
                quantidade=dado.quantidade,
            ))
        pedido.confirmar()
        self._repo.salvar(pedido)

        resultado = self._gateway.cobrar(pedido.total, cartao)
        if not resultado.sucesso:
            raise PagamentoRecusado(resultado.erro or "desconhecido")

        self._notif.confirmar_pedido(pedido)
        return pedido

Veja o que o use case não importa: nada de FastAPI, nada de psycopg2, nada de requests. Apenas as interfaces (Protocols) que ele mesmo define. Isso é o coração do hexagonal.

infrastructure/repos.py — driven adapter
import psycopg2
from meuapp.domain.pedido import Pedido, Item
from meuapp.application.ports import RepoPedidos

# Note: infrastructure IMPORTA do domain e application.
# Nunca o contrário. A direção da seta é UMA SÓ.

class RepoPedidosPostgres:
    # Não precisa herdar nem implementar — Protocol é estrutural

    def __init__(self, conn):
        self._conn = conn

    def salvar(self, pedido: Pedido) -> None:
        with self._conn.cursor() as cur:
            cur.execute(
                """INSERT INTO pedidos (id, cliente_id, status, criado_em)
                   VALUES (%s, %s, %s, %s)
                   ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status""",
                (pedido.id, pedido.cliente_id, pedido.status, pedido.criado_em),
            )
            for item in pedido.itens:
                cur.execute(
                    """INSERT INTO pedido_itens (pedido_id, sku, nome, preco, qtd)
                       VALUES (%s, %s, %s, %s, %s)
                       ON CONFLICT DO NOTHING""",
                    (pedido.id, item.sku, item.nome,
                     item.preco_unitario, item.quantidade),
                )
        self._conn.commit()

    def buscar(self, id: str) -> Pedido | None:
        with self._conn.cursor() as cur:
            cur.execute(
                "SELECT id, cliente_id, status, criado_em FROM pedidos WHERE id = %s",
                (id,),
            )
            row = cur.fetchone()
            if not row:
                return None
            # buscar itens, montar Pedido
            ...
infrastructure/gateway_stripe.py
import stripe
from decimal import Decimal
from meuapp.application.ports import GatewayPagamento, ResultadoPagamento

class GatewayStripe:
    def __init__(self, api_key: str):
        stripe.api_key = api_key

    def cobrar(self, valor: Decimal, cartao: str) -> ResultadoPagamento:
        try:
            charge = stripe.Charge.create(
                amount=int(valor * 100),  # Stripe usa centavos
                currency="brl",
                source=cartao,
            )
            return ResultadoPagamento(
                sucesso=charge.status == "succeeded",
                transacao_id=charge.id,
            )
        except stripe.error.CardError as e:
            return ResultadoPagamento(
                sucesso=False,
                transacao_id=None,
                erro=str(e),
            )
presentation/api.py — driving adapter
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from decimal import Decimal
from meuapp.application.use_cases import CriarPedidoUseCase, DadosItem, PagamentoRecusado
from meuapp.domain.pedido import PedidoVazio
from meuapp.bootstrap import obter_criar_pedido_uc

app = FastAPI()

class ItemRequest(BaseModel):
    sku: str
    nome: str
    preco: Decimal
    quantidade: int

class CriarPedidoRequest(BaseModel):
    cliente_id: str
    itens: list[ItemRequest]
    cartao: str

class PedidoResponse(BaseModel):
    id: str
    status: str
    total: Decimal

@app.post("/pedidos", response_model=PedidoResponse, status_code=201)
def criar_pedido(
    req: CriarPedidoRequest,
    uc: CriarPedidoUseCase = Depends(obter_criar_pedido_uc),
):
    try:
        pedido = uc.executar(
            cliente_id=req.cliente_id,
            itens=[DadosItem(**i.model_dump()) for i in req.itens],
            cartao=req.cartao,
        )
        return PedidoResponse(id=pedido.id, status=pedido.status, total=pedido.total)
    except PedidoVazio:
        raise HTTPException(422, "pedido vazio")
    except PagamentoRecusado as e:
        raise HTTPException(402, str(e))

20.7 Testes ficam triviais

A grande vitória da arquitetura: testes do use case sem subir banco, sem mocks elaborados, sem patches. Apenas fakes que implementam os protocolos.

tests/test_criar_pedido.py
import pytest
from decimal import Decimal
from meuapp.application.use_cases import CriarPedidoUseCase, DadosItem, PagamentoRecusado
from meuapp.application.ports import ResultadoPagamento
from meuapp.domain.pedido import Pedido, PedidoVazio

# --- Fakes ---

class RepoFake:
    def __init__(self):
        self.pedidos: dict[str, Pedido] = {}
    def salvar(self, p): self.pedidos[p.id] = p
    def buscar(self, id): return self.pedidos.get(id)

class GatewayFakeOk:
    def cobrar(self, valor, cartao):
        return ResultadoPagamento(sucesso=True, transacao_id="tx_123")

class GatewayFakeRecusa:
    def cobrar(self, valor, cartao):
        return ResultadoPagamento(sucesso=False, transacao_id=None, erro="insuficiente")

class NotificadorFake:
    def __init__(self):
        self.notificacoes: list = []
    def confirmar_pedido(self, p):
        self.notificacoes.append(p.id)

# --- Helper ---

def criar_uc(gateway=None):
    return CriarPedidoUseCase(
        repo=RepoFake(),
        gateway=gateway or GatewayFakeOk(),
        notif=NotificadorFake(),
        gerar_id=lambda: "p_1",
    )

# --- Testes ---

def test_cria_e_confirma_pedido():
    uc = criar_uc()
    pedido = uc.executar(
        cliente_id="c1",
        itens=[DadosItem(sku="X", nome="X", preco=Decimal("10"), quantidade=2)],
        cartao="4242",
    )
    assert pedido.status == "confirmado"
    assert pedido.total == Decimal("20")

def test_pedido_vazio_lanca_excecao():
    uc = criar_uc()
    with pytest.raises(PedidoVazio):
        uc.executar(cliente_id="c1", itens=[], cartao="4242")

def test_pagamento_recusado_lanca_excecao():
    uc = criar_uc(gateway=GatewayFakeRecusa())
    with pytest.raises(PagamentoRecusado, match="insuficiente"):
        uc.executar(
            cliente_id="c1",
            itens=[DadosItem(sku="X", nome="X", preco=Decimal("10"), quantidade=1)],
            cartao="4242",
        )

Sem banco. Sem mock library. Sem patches. Sem fixtures complicadas. Testes rápidos, claros, fáceis de adicionar. Isso é o ganho real do hexagonal.

20.8 Bootstrap e Dependency Injection

Em algum lugar precisamos "ligar os fios": injetar as implementações concretas nos use cases. Esse lugar é o composition root ou bootstrap — o único lugar do sistema que conhece tudo.

bootstrap.py
import os
import psycopg2
import uuid
from meuapp.application.use_cases import CriarPedidoUseCase
from meuapp.infrastructure.repos import RepoPedidosPostgres
from meuapp.infrastructure.gateway_stripe import GatewayStripe
from meuapp.infrastructure.email_smtp import EmailSMTP

# Conexão única (na vida real, pool)
_conn = psycopg2.connect(os.environ["DATABASE_URL"])

# Adaptadores
_repo = RepoPedidosPostgres(_conn)
_gateway = GatewayStripe(api_key=os.environ["STRIPE_KEY"])
_notif = EmailSMTP(host=os.environ["SMTP_HOST"])

# Use cases já com dependências injetadas
_criar_pedido_uc = CriarPedidoUseCase(
    repo=_repo,
    gateway=_gateway,
    notif=_notif,
    gerar_id=lambda: str(uuid.uuid4()),
)

def obter_criar_pedido_uc():
    # Função para FastAPI Depends() — retorna instância pronta
    return _criar_pedido_uc

Em sistemas maiores, vale uma biblioteca de DI (dependency-injector, punq, lagom). Para sistemas médios, função manual é suficiente. Frameworks como FastAPI têm DI embutido (Depends) que cobre bem.

20.9 Estudo de caso — trocando banco sem mexer no domínio

Migrando de Postgres para DynamoDB sem alterar use cases

Time precisa migrar parte do sistema de Postgres para DynamoDB (decisão de produto: latência global). Vamos ver o que muda — e o que não muda.

O que NÃO precisa mudar
  • Nenhum arquivo em domain/.
  • Nenhum arquivo em application/ — use cases não conhecem banco.
  • Nenhum DTO em presentation/.
  • Nenhum teste do core.
O que precisa mudar

Apenas: novo adaptador em infrastructure/ + linha do bootstrap.

infrastructure/repos_dynamo.py
import boto3
from decimal import Decimal
from meuapp.domain.pedido import Pedido, Item

class RepoPedidosDynamo:
    def __init__(self, table_name: str):
        dynamodb = boto3.resource("dynamodb")
        self._table = dynamodb.Table(table_name)

    def salvar(self, pedido: Pedido) -> None:
        self._table.put_item(Item={
            "id": pedido.id,
            "cliente_id": pedido.cliente_id,
            "status": pedido.status,
            "itens": [{
                "sku": i.sku,
                "nome": i.nome,
                "preco_unitario": str(i.preco_unitario),
                "quantidade": i.quantidade,
            } for i in pedido.itens],
            "criado_em": pedido.criado_em.isoformat(),
        })

    def buscar(self, id: str) -> Pedido | None:
        r = self._table.get_item(Key={"id": id})
        item = r.get("Item")
        if not item:
            return None
        return Pedido(
            id=item["id"],
            cliente_id=item["cliente_id"],
            status=item["status"],
            itens=[Item(
                sku=i["sku"],
                nome=i["nome"],
                preco_unitario=Decimal(i["preco_unitario"]),
                quantidade=int(i["quantidade"]),
            ) for i in item["itens"]],
        )
Bootstrap troca uma linha
bootstrap.py
# De:
from meuapp.infrastructure.repos import RepoPedidosPostgres
_repo = RepoPedidosPostgres(_conn)

# Para:
from meuapp.infrastructure.repos_dynamo import RepoPedidosDynamo
_repo = RepoPedidosDynamo(table_name="pedidos")

O que isso significa na prática: a maior parte do código — onde mora valor de negócio — não sentiu a mudança. Migração de banco vira tarefa de infraestrutura, não refactor do sistema inteiro. Esse é o ROI do hexagonal/clean.

20.10 Erros comuns

Erro 1 · Adotar hexagonal para tudo

Script de 200 linhas com hexagonal completa. Você tem mais arquivos de infraestrutura/ports do que código real. Camadas simples bastariam.

Erro 2 · Vazamento sutil de framework

from pydantic import BaseModel em domain/. Pydantic é detalhe de serialização — não pertence ao domínio. Use dataclasses puras.

Erro 3 · Repository com SELECT customizado por caso

Adicionar buscar_por_status_e_data(), buscar_com_filtro_complexo(), etc. Repository vira coleção de queries. Para queries complexas, considere CQRS — read model separado.

Erro 4 · Esquecer que adapters podem ter regras

Adapter de e-mail decide se envia ou não baseado em horário. Isso é regra de negócio que deveria estar no use case. Adapter traduz protocolos, não decide.

20.11 Quando NÃO usar

Reconheça o contexto
Hexagonal/Clean tem custo — vale onde?
  • Sistemas pequenos: CRUD com poucos endpoints e regras simples. Camadas básicas resolvem; hexagonal vira cerimônia.
  • Protótipos: validar hipótese. Hexagonal atrasa MVP.
  • Sistemas data-pipeline: ETLs que são transformações puras. Funções compostas funcionam melhor.
  • Time sem maturidade: introduzir hexagonal sem o time entender por que vira frustração. Eduque antes de adotar.

Quando vale: sistemas com regras de negócio complexas, longa vida útil, expectativa de evolução tecnológica (banco, integração, framework). Aí o investimento paga muito.

Verifique seu entendimento
"Em hexagonal architecture, onde vive a interface RepoPedidos (Protocol) e onde vive a classe RepoPedidosPostgres?"

20.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar driving vs driven

Para cada item, classifique como porta/adaptador driving (entrada) ou driven (saída):

  1. Endpoint HTTP POST /pedidos
  2. Repository implementado com SQLAlchemy
  3. Consumer de fila RabbitMQ
  4. Gateway para API de pagamento
  5. Use case CriarPedidoUseCase
  6. Notificador via SMTP
  7. Worker que processa cron
  1. Driving adapter — protocolo externo invoca o núcleo.
  2. Driven adapter — núcleo precisa de persistência.
  3. Driving adapter — mensagens vêm de fora pro núcleo.
  4. Driven adapter — núcleo precisa cobrar via API externa.
  5. Driving port — interface de entrada que o núcleo expõe.
  6. Driven adapter — núcleo precisa notificar.
  7. Driving adapter — cron schedule invoca o núcleo periodicamente.
Fácil
Exercício 2 · Definir uma porta

Escreva o Protocol para RepoUsuarios que o domínio precisaria: buscar por id, buscar por email, salvar, marcar como deletado (soft delete), listar usuários ativos.

ports/usuarios.py
from typing import Protocol, Iterable
from meuapp.domain.usuario import Usuario

class RepoUsuarios(Protocol):
    def buscar_por_id(self, id: str) -> Usuario | None: ...
    def buscar_por_email(self, email: str) -> Usuario | None: ...
    def salvar(self, usuario: Usuario) -> None: ...
    def marcar_deletado(self, id: str) -> None: ...
    def listar_ativos(self, limit: int = 100) -> Iterable[Usuario]: ...
Médio
Exercício 3 · Use case com testes

Implemente CancelarPedidoUseCase que: recebe id do pedido; busca via repo; valida que pedido não está já entregue (lança PedidoJaEntregue); marca como cancelado; salva; envia notificação. Escreva 3 testes: sucesso, pedido não encontrado, pedido entregue não pode cancelar.

application/cancelar_pedido.py
from meuapp.application.ports import RepoPedidos, Notificador
from meuapp.domain.pedido import Pedido

class PedidoNaoEncontrado(Exception): pass
class PedidoJaEntregue(Exception): pass

class CancelarPedidoUseCase:
    def __init__(self, repo: RepoPedidos, notif: Notificador):
        self._repo = repo
        self._notif = notif

    def executar(self, id: str, motivo: str) -> Pedido:
        pedido = self._repo.buscar(id)
        if not pedido:
            raise PedidoNaoEncontrado(id)
        if pedido.status == "entregue":
            raise PedidoJaEntregue(id)
        pedido.status = "cancelado"
        self._repo.salvar(pedido)
        self._notif.confirmar_pedido(pedido)
        return pedido
tests/test_cancelar_pedido.py
import pytest
from meuapp.application.cancelar_pedido import (
    CancelarPedidoUseCase, PedidoNaoEncontrado, PedidoJaEntregue,
)
from meuapp.domain.pedido import Pedido

class RepoFake:
    def __init__(self, pedidos=None):
        self.pedidos = pedidos or {}
    def buscar(self, id): return self.pedidos.get(id)
    def salvar(self, p): self.pedidos[p.id] = p

class NotifFake:
    def __init__(self): self.notificou = []
    def confirmar_pedido(self, p): self.notificou.append(p.id)

def test_cancela_pedido_existente():
    p = Pedido(id="p1", cliente_id="c1", status="confirmado")
    repo = RepoFake({"p1": p})
    notif = NotifFake()
    uc = CancelarPedidoUseCase(repo, notif)
    resultado = uc.executar("p1", motivo="cliente desistiu")
    assert resultado.status == "cancelado"
    assert repo.pedidos["p1"].status == "cancelado"
    assert "p1" in notif.notificou

def test_pedido_inexistente():
    uc = CancelarPedidoUseCase(RepoFake(), NotifFake())
    with pytest.raises(PedidoNaoEncontrado):
        uc.executar("inexistente", motivo="x")

def test_pedido_entregue_nao_cancela():
    p = Pedido(id="p2", cliente_id="c1", status="entregue")
    repo = RepoFake({"p2": p})
    uc = CancelarPedidoUseCase(repo, NotifFake())
    with pytest.raises(PedidoJaEntregue):
        uc.executar("p2", motivo="x")
Difícil
Exercício 4 · Sistema completo hexagonal

Esboce a estrutura completa de um serviço de envio de SMS: EnviarSMSUseCase recebe destinatário e mensagem; valida número (porta ValidadorTelefone); persiste pedido (porta RepoEnvios); envia via gateway (porta GatewaySMS); registra resultado. Inclua: estrutura de pastas, todas as portas com Protocols, dois adaptadores possíveis para o gateway (Twilio e Zenvia), e bootstrap.

estrutura
sms/
├── domain/
│   └── envio.py            # Envio, StatusEnvio
├── application/
│   ├── ports.py             # RepoEnvios, GatewaySMS, ValidadorTelefone
│   └── enviar_sms.py        # EnviarSMSUseCase
├── infrastructure/
│   ├── repo_postgres.py
│   ├── gateway_twilio.py
│   ├── gateway_zenvia.py
│   └── validador_libphone.py
├── presentation/
│   └── api.py
└── bootstrap.py
application/ports.py
from typing import Protocol
from dataclasses import dataclass
from meuapp.domain.envio import Envio

@dataclass(frozen=True)
class ResultadoEnvio:
    sucesso: bool
    gateway_id: str | None
    erro: str | None = None

class RepoEnvios(Protocol):
    def salvar(self, envio: Envio) -> None: ...
    def buscar(self, id: str) -> Envio | None: ...

class GatewaySMS(Protocol):
    def enviar(self, numero: str, mensagem: str) -> ResultadoEnvio: ...

class ValidadorTelefone(Protocol):
    def eh_valido(self, numero: str) -> bool: ...
    def normalizar(self, numero: str) -> str: ...
application/enviar_sms.py
from meuapp.domain.envio import Envio
from meuapp.application.ports import RepoEnvios, GatewaySMS, ValidadorTelefone

class TelefoneInvalido(Exception): pass

class EnviarSMSUseCase:
    def __init__(self, repo, gateway, validador, gerar_id):
        self._repo = repo
        self._gateway = gateway
        self._validador = validador
        self._gerar_id = gerar_id

    def executar(self, numero: str, mensagem: str) -> Envio:
        if not self._validador.eh_valido(numero):
            raise TelefoneInvalido(numero)
        normalizado = self._validador.normalizar(numero)
        envio = Envio(id=self._gerar_id(), destinatario=normalizado, mensagem=mensagem)
        self._repo.salvar(envio)
        resultado = self._gateway.enviar(normalizado, mensagem)
        envio.marcar_resultado(resultado.sucesso, resultado.gateway_id, resultado.erro)
        self._repo.salvar(envio)
        return envio
infrastructure/gateway_twilio.py
from twilio.rest import Client
from meuapp.application.ports import ResultadoEnvio

class GatewayTwilio:
    def __init__(self, account_sid, auth_token, from_number):
        self._client = Client(account_sid, auth_token)
        self._from = from_number

    def enviar(self, numero, mensagem):
        try:
            m = self._client.messages.create(body=mensagem, from_=self._from, to=numero)
            return ResultadoEnvio(sucesso=True, gateway_id=m.sid)
        except Exception as e:
            return ResultadoEnvio(sucesso=False, gateway_id=None, erro=str(e))

Adapter Zenvia segue mesma estrutura com biblioteca diferente. Bootstrap escolhe um deles via env var. Trocar provedor é trocar uma linha do bootstrap — use case e domínio intocados.

Fim do capítulo 20
Próximo capítulo: Domain-Driven Design aplicado. O lado da modelagem que se conecta naturalmente com hexagonal — entidades, value objects, agregados, bounded contexts.
Parte V · Capítulo 21 · Arquitetura aplicada

DDD
aplicado:
modelagem
que conversa.

Domain-Driven Design não é receita; é abordagem. Você modela o software refletindo a linguagem real do negócio, não a estrutura técnica do banco. Quando bem aplicado, código e conversa com especialistas do domínio convergem.

DDD é o conjunto de ideias mais influente — e mais mal aplicado — em arquitetura de software dos últimos vinte anos. Times "fazem DDD" porque criaram pastas chamadas domain/; outros entregam software realmente bom sem usar nem o nome. Vamos cobrir o que vale, com honestidade sobre quando aplicar e quando não vale o investimento.

21.1 A história — do livro azul ao livro vermelho

Contexto histórico

Em 2003, Eric Evans publicou Domain-Driven Design: Tackling Complexity in the Heart of Software — o "livro azul". A motivação: ele tinha observado que projetos de software que mais agregavam valor eram aqueles onde a modelagem refletia profundamente o negócio. Termos como linguagem ubíqua, bounded context, agregado nasceram aí.

O livro era denso, com 560 páginas. Foi mal-lido por muita gente: pegaram os padrões táticos (entidades, value objects) e ignoraram a parte estratégica (linguagem, contextos). Resultado: códigos com pastas domain/ mas sem cultura de modelagem real com o negócio.

Em 2013, Vaughn Vernon publicou Implementing Domain-Driven Design — o "livro vermelho". Mais prático, com mais código, popularizou DDD numa segunda onda. Trouxe ideias como aggregate root, regras de design de agregados, e como DDD se conecta com event sourcing e CQRS.

Em 2014, Vernon publicou também Domain-Driven Design Distilled — versão curta e acessível, recomendada como porta de entrada.

Hoje, DDD é vocabulário comum em sistemas corporativos. O movimento de microsserviços (Sam Newman, 2015) tomou emprestado o conceito de bounded context para definir fronteiras de serviços. Quase toda discussão moderna de arquitetura ainda usa o vocabulário cunhado por Evans.

21.2 Estratégico vs tático — a divisão central

DDD tem duas metades, frequentemente confundidas:

Estratégico
  • Linguagem ubíqua
  • Bounded contexts
  • Context map
  • Subdomínios (core, supporting, generic)

Pergunta: "como dividir o sistema, e que linguagem usar em cada parte?"

Onde paga: sempre que há domínio complexo, mesmo em sistemas pequenos.

Tático
  • Entidades, value objects
  • Agregados
  • Domain services
  • Repositories, factories
  • Domain events

Pergunta: "como modelar dentro de cada parte?"

Onde paga: sistemas com regras de negócio complexas em cada contexto.

A lição mais importante de DDD
O lado estratégico paga mais que o tático. Você pode ter código simples (sem agregados, sem value objects sofisticados) e ainda ganhar muito com linguagem ubíqua e bounded contexts bem definidos. O inverso — agregados elegantes em sistema sem clareza estratégica — gera código bonito que não conversa com o negócio.

21.3 Linguagem ubíqua — o conceito mais subestimado

A ideia: mesmos termos no código, nas conversas, na documentação e na fala do especialista do negócio. Sem tradução. Sem "nome técnico" diferente do "nome de negócio".

Parece óbvio, é raríssimo. Cenário comum:

Resultado: toda discussão sobre o assunto exige tradução. Bugs vêm de "entendi diferente". Onboarding leva o triplo do tempo. Time perde horas por semana só por isso.

Como construir linguagem ubíqua

  1. Faça workshops com especialistas: ouça atentamente. Anote os termos que eles usam — não os que você acha que deveriam usar.
  2. Mapeie sinônimos: "cliente" vs "comprador" vs "usuário" — descubra se são a mesma coisa ou conceitos distintos.
  3. Use no código sem traduzir: classe Apolice, não Policy. Variável data_emissao, não issued_at. Sim, frequentemente em português — a linguagem é do negócio.
  4. Atualize quando mudar: negócio muda terminologia. Refatore (rename é trivial em IDEs modernas). Vocabulário desatualizado polui.
linguagem_ubiqua.py
# Sem linguagem ubíqua — código fala "técnico"
class Customer:
    customer_id: str
    full_name: str

class Order:
    order_id: str
    customer_id: str
    items: list
    state: str

    def change_state(self, new_state: str):
        ...

# Com linguagem ubíqua — código fala "negócio"
# (negócio de e-commerce brasileiro, em português)
class Cliente:
    cpf: str
    nome_completo: str

class Pedido:
    numero: str
    cliente_cpf: str
    itens: list
    situacao: str  # termo que o negócio usa

    def confirmar(self):  # operações nomeadas pelo verbo do negócio
        ...
    def cancelar(self, motivo: str):
        ...
    def faturar(self):
        ...

Quando alguém do negócio lê o código (sim, isso acontece em times maduros), ele entende sem precisar de tradutor. Quando um desenvolvedor entra na call com o negócio, fala o mesmo idioma. Isso reduz fricção de jeitos não-óbvios.

Sobre idioma
DDD recomenda escrever na linguagem natural do negócio — frequentemente português, no Brasil. Há quem prefira inglês por convenção/comunidade open-source. Pragmatismo: seja consistente. Misturar Pedido.calculate_total() é pior que qualquer uma das opções puras. Para domínios muito brasileiros (regulação fiscal, jurídico, saúde pública), português costuma vencer.

21.4 Bounded contexts — fronteiras explícitas

Em sistemas reais, "cliente" não é uma coisa só. Para o time de marketing, cliente é lead com histórico de campanhas. Para vendas, cliente é conta com pipeline de oportunidades. Para faturamento, cliente é devedor com CPF, endereço de cobrança, status de inadimplência. Para suporte, cliente é usuário com tickets abertos.

Em DDD, cada um desses é um bounded context — uma região onde a linguagem é coerente e os modelos têm significado bem-definido. Forçar uma classe Cliente universal que sirva a todos é o erro arquitetural mais comum em sistemas corporativos.

bounded_contexts.py
# Bounded context: Vendas
from dataclasses import dataclass

@dataclass
class Cliente:  # o nome "Cliente" aqui significa: prospect/conta
    id: str
    nome: str
    fase_funil: str          # lead / qualificado / proposta / fechado
    oportunidades_ativas: int
    proximo_followup: "datetime"

# Bounded context: Faturamento
@dataclass
class Cliente:  # aqui "Cliente" significa: pagador
    cnpj_cpf: str
    razao_social: str
    endereco_cobranca: "Endereco"
    forma_pagamento_padrao: str
    inadimplente: bool

# Bounded context: Suporte
@dataclass
class Cliente:  # aqui significa: usuário com problema
    user_id: str
    nivel_suporte: str
    tickets_abertos: int
    ultima_interacao: "datetime"

# Cada contexto vive em seu próprio módulo / package / serviço.
# Não existe "a classe Cliente" universal — existem TRÊS clientes,
# cada um adequado à sua finalidade.

Como identificar contextos

Sinais que indicam que você está cruzando uma fronteira de contexto:

21.5 Context map — relações entre contextos

Bounded contexts não vivem isolados — eles se relacionam. Mapear como eles conversam é parte essencial de DDD estratégico. Os padrões mais comuns:

anti_corruption_layer.py
# Sistema legado externo (mainframe da matriz)
# retorna dados em formato horrível e estável há 20 anos:
#   {"CD_CLI": "00000042", "NM_CLI_RZ_SOC": "EMPRESA X LTDA",
#    "FL_INADIMP": "S", "VL_ULT_FAT": "1234.56"}

# ACL: traduz para o nosso modelo de domínio limpo

class AdaptadorClienteLegado:
    def __init__(self, cliente_legado_api):
        self._api = cliente_legado_api

    def buscar_cliente(self, id: str) -> Cliente:
        # Tudo o que é feio fica AQUI, isolado.
        dados = self._api.consultar(id.zfill(8))
        return Cliente(
            id=dados["CD_CLI"].lstrip("0"),
            razao_social=dados["NM_CLI_RZ_SOC"].strip(),
            inadimplente=dados["FL_INADIMP"] == "S",
            ultimo_faturamento=Decimal(dados["VL_ULT_FAT"]),
        )
# Nosso domínio nunca vê "CD_CLI", "FL_INADIMP", etc.
# A feiura do legado morre na fronteira.

21.6 Value Objects — identidade por valor

Value Object é objeto cuja identidade é seu próprio valor. Dois CEPs "01310-100" são iguais — não precisa de id, não precisa de "qual instância". CEP, dinheiro, intervalo de data, coordenada GPS, endereço — todos são valores.

Características:

value_objects.py
from dataclasses import dataclass
from decimal import Decimal
from typing import Self

@dataclass(frozen=True)
class Dinheiro:
    valor: Decimal
    moeda: str

    def __post_init__(self):
        if self.valor < 0:
            raise ValueError("valor não pode ser negativo")
        if len(self.moeda) != 3:
            raise ValueError("moeda em ISO 4217 (3 letras)")
        # Normaliza para 2 casas decimais
        object.__setattr__(self, "valor", self.valor.quantize(Decimal("0.01")))
        object.__setattr__(self, "moeda", self.moeda.upper())

    def somar(self, outro: Self) -> Self:
        if self.moeda != outro.moeda:
            raise ValueError(f"moedas diferentes: {self.moeda}, {outro.moeda}")
        return Dinheiro(self.valor + outro.valor, self.moeda)

    def multiplicar(self, fator: Decimal) -> Self:
        return Dinheiro(self.valor * fator, self.moeda)

    def __str__(self):
        return f"{self.moeda} {self.valor}"

# Uso natural — banco do domínio fala em Dinheiro, não em Decimal
preco = Dinheiro(Decimal("10.00"), "BRL")
frete = Dinheiro(Decimal("5.50"), "BRL")
total = preco.somar(frete)  # Dinheiro(15.50, BRL)

# Bug de moeda misturada é IMPOSSÍVEL de cometer acidentalmente

21.7 Entidades — identidade própria

Entidade é objeto cuja identidade é independente dos atributos. Pedido P001 é o mesmo Pedido P001 mesmo que mude de status, mude de itens, ganhe novo total. Identidade vem de um id, não dos campos.

Características:

entidades.py
from datetime import datetime

class Pedido:
    def __init__(self, id: str, cliente_cpf: str):
        self._id = id
        self._cliente_cpf = cliente_cpf
        self._itens: list = []
        self._situacao = "aberto"
        self._criado_em = datetime.utcnow()

    @property
    def id(self) -> str:
        return self._id

    def __eq__(self, outro):
        # Igualdade por ID — não por atributos
        return isinstance(outro, Pedido) and self._id == outro._id

    def __hash__(self):
        return hash(self._id)

    def adicionar_item(self, item):
        if self._situacao != "aberto":
            raise ValueError("pedido não está aberto")
        self._itens.append(item)

    def confirmar(self):
        if not self._itens:
            raise ValueError("pedido vazio")
        self._situacao = "confirmado"

21.8 Agregados — o conceito mais importante (e mais difícil)

Agregado é um cluster de objetos tratado como uma unidade para fins de modificação. Tem uma entidade raiz (aggregate root), e todas as operações externas passam por ela. As outras entidades e value objects do agregado são acessíveis apenas através da raiz.

Por que isso importa:

Exemplo: pedido + itens. Pedido é a raiz; itens estão dentro. Para adicionar item, você chama pedido.adicionar_item(...), não acessa a lista de itens diretamente. Isso garante que regras (não adicionar em pedido fechado, validar quantidades, recalcular total) sejam aplicadas.

agregado_pedido.py
from dataclasses import dataclass
from decimal import Decimal
from typing import Iterable

# Value object — parte interna do agregado
@dataclass(frozen=True)
class ItemPedido:
    sku: str
    nome: str
    preco: Dinheiro
    quantidade: int

    def __post_init__(self):
        if self.quantidade <= 0:
            raise ValueError("quantidade deve ser positiva")

    @property
    def subtotal(self) -> Dinheiro:
        return self.preco.multiplicar(Decimal(self.quantidade))

# Aggregate root — fronteira de consistência
class Pedido:
    def __init__(self, id: str, cliente_cpf: str, moeda: str = "BRL"):
        self._id = id
        self._cliente_cpf = cliente_cpf
        self._moeda = moeda
        self._itens: list[ItemPedido] = []
        self._situacao = "aberto"

    @property
    def id(self) -> str:
        return self._id

    @property
    def itens(self) -> Iterable[ItemPedido]:
        # Retorna iteradora — externos NÃO podem modificar a lista interna
        return iter(self._itens)

    @property
    def total(self) -> Dinheiro:
        if not self._itens:
            return Dinheiro(Decimal("0"), self._moeda)
        total = Dinheiro(Decimal("0"), self._moeda)
        for item in self._itens:
            total = total.somar(item.subtotal)
        return total

    def adicionar_item(self, item: ItemPedido) -> None:
        if self._situacao != "aberto":
            raise ValueError("pedido não está aberto")
        if item.preco.moeda != self._moeda:
            raise ValueError("moeda do item diferente da do pedido")
        # Se já tem o sku, soma quantidade ao invés de duplicar
        for i, existente in enumerate(self._itens):
            if existente.sku == item.sku:
                self._itens[i] = ItemPedido(
                    sku=existente.sku,
                    nome=existente.nome,
                    preco=existente.preco,
                    quantidade=existente.quantidade + item.quantidade,
                )
                return
        self._itens.append(item)

    def remover_item(self, sku: str) -> None:
        if self._situacao != "aberto":
            raise ValueError("pedido não está aberto")
        self._itens = [i for i in self._itens if i.sku != sku]

    def confirmar(self) -> None:
        if not self._itens:
            raise ValueError("pedido vazio")
        if self.total.valor <= 0:
            raise ValueError("total não positivo")
        self._situacao = "confirmado"

Regras de design de agregados

Vaughn Vernon (no livro vermelho) destilou em quatro regras úteis:

  1. Modele invariantes verdadeiras dentro de fronteiras de consistência. Se duas coisas precisam ficar sempre consistentes, estão no mesmo agregado.
  2. Projete agregados pequenos. Agregados gigantes (Order com 100 OrderItems) são lentos para carregar e causam contenção. Quando possível, divida.
  3. Referencie outros agregados por id, não por objeto. Pedido refere a Cliente por cliente_cpf, não tendo self.cliente: Cliente.
  4. Atualize outros agregados via eventual consistency. Mudança em um agregado dispara evento; outros agregados reagem assincronamente.

21.9 Eventos de domínio

Evento de domínio é fato passado registrado pelo domínio: "PedidoConfirmado", "PagamentoAprovado", "EstoqueReservado". Verbo no passado, sempre. Imutável (já aconteceu).

Eventos servem para:

eventos.py
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
import uuid

@dataclass(frozen=True)
class EventoDominio:
    # Metadados comuns
    event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    ocorrido_em: datetime = field(default_factory=datetime.utcnow)

@dataclass(frozen=True)
class PedidoConfirmado(EventoDominio):
    pedido_id: str = ""
    cliente_cpf: str = ""
    total: Decimal = Decimal("0")

@dataclass(frozen=True)
class PedidoCancelado(EventoDominio):
    pedido_id: str = ""
    motivo: str = ""

# Agregado coleta eventos durante operações
class Pedido:
    def __init__(self, id: str, cliente_cpf: str):
        self._id = id
        self._cliente_cpf = cliente_cpf
        self._situacao = "aberto"
        self._eventos: list[EventoDominio] = []

    def confirmar(self):
        if self._situacao != "aberto":
            raise ValueError()
        self._situacao = "confirmado"
        self._eventos.append(PedidoConfirmado(
            pedido_id=self._id,
            cliente_cpf=self._cliente_cpf,
            total=self.total.valor,
        ))

    def cancelar(self, motivo: str):
        if self._situacao in ("entregue", "cancelado"):
            raise ValueError()
        self._situacao = "cancelado"
        self._eventos.append(PedidoCancelado(pedido_id=self._id, motivo=motivo))

    def drenar_eventos(self) -> list[EventoDominio]:
        """Use case retira eventos após persistir o agregado."""
        eventos = self._eventos
        self._eventos = []
        return eventos

# No use case:
def confirmar_pedido(id, repo, bus):
    pedido = repo.buscar(id)
    pedido.confirmar()
    repo.salvar(pedido)
    for evento in pedido.drenar_eventos():
        bus.publicar(evento)
# Bus distribui eventos para handlers — em-processo ou mensageria

21.10 Domain services — quando o comportamento não cabe

Às vezes, comportamento de negócio não cabe naturalmente em nenhuma entidade ou value object. Envolve várias entidades, ou requer dependências externas (consulta a outro sistema, regra de cálculo complexa).

Esses casos viram domain services: funções ou classes que fazem parte do domínio, mas não pertencem a uma entidade específica.

Cuidado: criar domain services prematuramente leva ao anti-padrão anemic domain model. Antes de criar service, pergunte: "posso colocar isso na entidade certa?". Service só quando a resposta é claramente "não".

domain_services.py
# Domain service: cálculo de tarifa entre contas (envolve duas)
class CalculadoraTarifaTransferencia:
    # Não é entity, não é VO — é serviço de domínio

    def __init__(self, tabela_tarifas: "TabelaTarifas"):
        self._tabela = tabela_tarifas

    def calcular(self, origem: Conta, destino: Conta, valor: Dinheiro) -> Dinheiro:
        if origem.banco == destino.banco:
            return self._tabela.tarifa_mesmo_banco(valor)
        if origem.tipo == "premium":
            return self._tabela.tarifa_premium(valor)
        return self._tabela.tarifa_padrao(valor)

21.11 Estudo de caso — modelando subdomínios de uma fintech

Da bagunça inicial aos bounded contexts

Fintech com cinco anos, sistema monolítico onde "tudo conversa com tudo". Vamos mapear os contextos e mostrar como isolar.

Estado inicial — "Cliente" significa tudo
modelo_inchado.py
class Cliente:
    # Onboarding
    cpf: str
    nome: str
    score_kyc: int
    documentos_validados: bool

    # Conta corrente
    saldo: Decimal
    cartao_associado: str
    limite_pix: Decimal

    # Crédito
    score_credito: int
    limite_aprovado: Decimal
    emprestimos_ativos: list

    # Investimentos
    perfil_investidor: str  # conservador/moderado/agressivo
    posicao_atual: dict

    # Suporte
    tickets_abertos: int
    nivel_atendimento: str

    # Marketing
    segmentos: list[str]
    campanhas_recebidas: list

# 30 atributos. 12 métodos. Mudança em qualquer área toca a classe.
# Times pisam um no pé do outro. Modelagem mista. Cresce mal.
Identificação dos contextos

Após workshops com cada time, identificamos:

Bounded ContextConceito de clienteTime dono
OnboardingSolicitante (em validação KYC)Compliance
ContaCorrentista (com saldo, cartão, PIX)Conta digital
CréditoMutuário (score, limite, empréstimos)Crédito
InvestimentosInvestidor (perfil, posição)Wealth
SuporteUsuário (tickets, nível)CX
MarketingPessoa (segmentos, jornada)Growth
Modelos separados — cada contexto com seu agregado
conta/modelo.py
# Bounded context: Conta
class Correntista:  # aggregate root
    def __init__(self, cpf: str, conta: str):
        self._cpf = cpf
        self._conta = conta
        self._saldo = Dinheiro(Decimal("0"), "BRL")
        self._limite_pix_diario = Dinheiro(Decimal("5000"), "BRL")
        self._pix_hoje = Dinheiro(Decimal("0"), "BRL")

    def enviar_pix(self, valor: Dinheiro, destinatario: str):
        if self._saldo.valor < valor.valor:
            raise SaldoInsuficiente()
        if (self._pix_hoje.valor + valor.valor) > self._limite_pix_diario.valor:
            raise LimitePixExcedido()
        self._saldo = Dinheiro(self._saldo.valor - valor.valor, "BRL")
        self._pix_hoje = self._pix_hoje.somar(valor)
        # dispara evento PixEnviado
credito/modelo.py
# Bounded context: Crédito
class Mutuario:  # aggregate root
    def __init__(self, cpf: str, score: int, limite: Dinheiro):
        self._cpf = cpf
        self._score = score
        self._limite = limite
        self._emprestimos: list[Emprestimo] = []

    def solicitar_emprestimo(self, valor: Dinheiro, prazo_meses: int):
        if self._score < 600:
            raise ScoreInsuficiente()
        comprometido = sum((e.saldo_devedor.valor for e in self._emprestimos), Decimal("0"))
        if (comprometido + valor.valor) > self._limite.valor:
            raise LimiteExcedido()
        emp = Emprestimo.novo(valor, prazo_meses)
        self._emprestimos.append(emp)
        return emp
Comunicação entre contextos — eventos

Quando Onboarding aprova um cliente, publica evento SolicitanteAprovado. Conta escuta e cria Correntista. Crédito escuta e cria Mutuario inicial (com score). Cada contexto reage à sua maneira.

integracao.py
# Em Onboarding:
class Solicitante:
    def aprovar(self):
        self._status = "aprovado"
        self._eventos.append(SolicitanteAprovado(
            cpf=self._cpf,
            nome=self._nome,
            data_aprovacao=datetime.utcnow(),
        ))

# Em Conta — handler reagindo ao evento:
def on_solicitante_aprovado(evento: SolicitanteAprovado, repo: RepoCorrentistas):
    correntista = Correntista(cpf=evento.cpf, conta=gerar_numero_conta())
    repo.salvar(correntista)

# Em Crédito — handler análogo:
def on_solicitante_aprovado(evento: SolicitanteAprovado, repo: RepoMutuarios):
    score_inicial = consultar_bureau(evento.cpf)  # integração externa
    mutuario = Mutuario(cpf=evento.cpf, score=score_inicial, limite=...)
    repo.salvar(mutuario)

Resultado: cada time evolui seu contexto sem pisar nos outros. Conta pode mudar regra de PIX sem coordenar com Crédito. Crédito pode mudar fórmula de score sem mexer em Conta. Comunicação via eventos é desacoplada. Mesma "pessoa" tem cinco representações — cada uma adequada ao que aquele time precisa.

21.12 Erros comuns ao "fazer DDD"

Erro 1 · Só tático, sem estratégico

Criar pastas entities/, value_objects/, aggregates/ sem nunca ter conversado com o negócio para descobrir linguagem e contextos. É DDD apenas no nome.

Erro 2 · Forçar agregados em CRUD

Cadastro simples (entidade com 5 campos sem regras complexas) virando agregado com aggregate root e eventos. Cerimônia sem ganho. CRUD pode ser CRUD.

Erro 3 · Anemic domain model disfarçado

Entidades só com atributos; toda lógica em services. Você tem DDD no nome, código procedural na prática. Coloque comportamento de volta nas entidades.

Erro 4 · Bounded context = microservice

"Cada bounded context vira microservice." Não necessariamente. Em monolito modular, contextos podem ser packages bem isolados. Microservice é decisão de deploy, não consequência automática de modelagem.

Erro 5 · Agregados gigantes

Cliente como agregado contendo Pedidos, Faturas, Tickets, Endereços. Carregar fica lento, contenção em escrita é alta, regras explodem. Quebre em vários agregados pequenos referenciando-se por id.

21.13 Quando NÃO investir em DDD

Reconheça o contexto
DDD tem custo de aprendizado — vale onde?
  • Sistemas CRUD simples: formulário in/out, sem regras de negócio profundas. DDD não tem onde brilhar.
  • Protótipos e MVPs: ainda descobrindo o que o negócio é. Modele simples; refine quando entender melhor.
  • Sistemas técnicos sem domínio rico: processadores de ETL, monitoramento, infraestrutura. Não há "domínio de negócio" — é só transformação de dados.
  • Time sem maturidade: aprender DDD enquanto entrega não dá certo. Eduque antes de adotar.
  • Domínio simples bem entendido: blog, e-commerce básico, gerenciador de tarefas. CRUD direto entrega mais rápido.

Onde DDD brilha: sistemas com regras de negócio densas, vocabulário próprio do setor (saúde, jurídico, fiscal, seguros), múltiplos times com responsabilidades distintas, e expectativa de evolução por anos.

Verifique seu entendimento
"Você precisa modelar Pedido com Itens. Regras: itens não podem ter quantidade zero, pedido fechado não aceita novo item, total deve ser positivo na confirmação. Como modelar?"

21.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar tipos

Para cada conceito, classifique como Entity, Value Object, Aggregate Root, ou Domain Event:

  1. CPF — usado para identificar pessoa
  2. Pedido com itens — pode ser modificado, tem ciclo de vida
  3. Endereço — rua, número, CEP, bairro, cidade, estado
  4. PedidoPago — registro de fato passado
  5. Faixa de Datas (início, fim)
  6. Conta corrente com extrato e operações
  7. Coordenada geográfica (latitude, longitude)
  8. UsuarioCriado — sinaliza criação no sistema
  1. Value Object — identidade pelo valor (dois "12345678901" são iguais).
  2. Aggregate Root — entidade que coordena itens internos.
  3. Value Object — endereço é definido por seus campos.
  4. Domain Event — fato passado, imutável.
  5. Value Object — intervalo é definido pelos limites.
  6. Aggregate Root — conta com identidade e operações que afetam estado.
  7. Value Object — duas mesmas coordenadas são o mesmo ponto.
  8. Domain Event — sinaliza algo que aconteceu.
Médio
Exercício 2 · Value Object completo

Modele Value Object CEP em Python: validação no construtor (apenas dígitos, exatamente 8 dígitos após remover hífen), método formatado() que retorna no padrão "01310-100", método regiao() que retorna primeira letra da região (1=SP, 2=RJ/ES, ...). Imutável, frozen.

cep.py
from dataclasses import dataclass

REGIOES = {
    "0": "SP", "1": "SP",
    "2": "RJ/ES",
    "3": "MG",
    "4": "BA/SE",
    "5": "PE/AL/PB/RN",
    "6": "CE/PI/MA/PA/AP/AM/RR/AC",
    "7": "DF/GO/TO/MT/MS/RO",
    "8": "PR/SC",
    "9": "RS",
}

@dataclass(frozen=True)
class CEP:
    valor: str

    def __post_init__(self):
        limpo = self.valor.replace("-", "").strip()
        if len(limpo) != 8 or not limpo.isdigit():
            raise ValueError(f"CEP inválido: {self.valor}")
        object.__setattr__(self, "valor", limpo)

    def formatado(self) -> str:
        return f"{self.valor[:5]}-{self.valor[5:]}"

    def regiao(self) -> str:
        return REGIOES[self.valor[0]]

    def __str__(self):
        return self.formatado()

# Uso
cep = CEP("01310-100")
assert cep.formatado() == "01310-100"
assert cep.regiao() == "SP"
assert CEP("01310100") == CEP("01310-100")  # igualdade por valor
Médio
Exercício 3 · Agregado de Conta

Modele agregado Conta com operações depositar, sacar, transferir_para. Regras: saldo não pode ficar negativo (exceto se conta tem cheque especial); transferência atualiza ambas as contas; cada operação gera evento de domínio. Use Dinheiro como value object.

conta.py
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime
import uuid

@dataclass(frozen=True)
class DepositoRealizado:
    conta_id: str
    valor: Decimal
    ocorrido_em: datetime = field(default_factory=datetime.utcnow)

@dataclass(frozen=True)
class SaqueRealizado:
    conta_id: str
    valor: Decimal
    ocorrido_em: datetime = field(default_factory=datetime.utcnow)

@dataclass(frozen=True)
class TransferenciaRealizada:
    origem_id: str
    destino_id: str
    valor: Decimal
    ocorrido_em: datetime = field(default_factory=datetime.utcnow)

class SaldoInsuficiente(Exception): pass

class Conta:  # aggregate root

    def __init__(self, id: str, saldo_inicial: Dinheiro,
                 cheque_especial: Dinheiro | None = None):
        self._id = id
        self._saldo = saldo_inicial
        self._cheque_especial = cheque_especial or Dinheiro(Decimal("0"), saldo_inicial.moeda)
        self._eventos: list = []

    @property
    def id(self): return self._id

    @property
    def saldo(self): return self._saldo

    def _saldo_disponivel(self) -> Decimal:
        return self._saldo.valor + self._cheque_especial.valor

    def depositar(self, valor: Dinheiro):
        if valor.moeda != self._saldo.moeda:
            raise ValueError("moeda incompatível")
        if valor.valor <= 0:
            raise ValueError("valor deve ser positivo")
        self._saldo = self._saldo.somar(valor)
        self._eventos.append(DepositoRealizado(conta_id=self._id, valor=valor.valor))

    def sacar(self, valor: Dinheiro):
        if valor.moeda != self._saldo.moeda:
            raise ValueError("moeda incompatível")
        if valor.valor <= 0:
            raise ValueError("valor deve ser positivo")
        if valor.valor > self._saldo_disponivel():
            raise SaldoInsuficiente()
        self._saldo = Dinheiro(self._saldo.valor - valor.valor, self._saldo.moeda)
        self._eventos.append(SaqueRealizado(conta_id=self._id, valor=valor.valor))

    def transferir_para(self, destino: "Conta", valor: Dinheiro):
        self.sacar(valor)  # reaproveita validações
        destino.depositar(valor)
        self._eventos.append(TransferenciaRealizada(
            origem_id=self._id, destino_id=destino._id, valor=valor.valor,
        ))

    def drenar_eventos(self):
        e, self._eventos = self._eventos, []
        return e
Difícil
Exercício 4 · Bounded contexts numa biblioteca

Sistema de biblioteca tem operações de catalogação (livros com ISBN, autor, editora, exemplares), empréstimo (usuário pega exemplar emprestado, prazo, multas), aquisição (compra de novos livros, fornecedores, NF). Identifique os bounded contexts; modele o aggregate root principal de cada um; descreva como se comunicam.

Três bounded contexts naturais:

  1. Catálogo: aggregate root Livro (ISBN, autor, editora). Cada Livro tem Exemplares como value objects internos com código de identificação e disponibilidade.
  2. Empréstimo: aggregate root Emprestimo (usuario_id, exemplar_id, data inicial, prazo, devolução). Multa é cálculo encapsulado.
  3. Aquisição: aggregate root OrdemCompra (fornecedor, itens, valor, status). Quando recebida, gera evento que Catálogo escuta para registrar novos exemplares.

Comunicação via eventos:

  • OrdemCompraRecebida (Aquisição) → Catálogo cria/atualiza Livros e Exemplares.
  • EmprestimoIniciado (Empréstimo) → Catálogo marca exemplar como indisponível.
  • EmprestimoFinalizado (Empréstimo) → Catálogo libera exemplar.

Acoplamento mínimo: Empréstimo conhece exemplar_id (string), não importa o Livro. Catálogo recebe eventos por seu próprio handler. Trocar regra de multa em Empréstimo não afeta Catálogo.

Fim do capítulo 21
Próximo capítulo: APIs REST bem feitas. Modelagem, versionamento, paginação, erros, idempotência. O contrato com o mundo externo.
Parte V · Capítulo 22 · Arquitetura aplicada

APIs
REST bem
feitas.

"Fazer uma API REST" parece simples — basta criar endpoints. Mas API é contrato com o mundo, e contrato mal feito gera dor por anos. Erros aqui são os mais caros de corrigir, porque há clientes lá fora dependendo deles.

Tem muita confusão sobre o que é REST. Roy Fielding, em 2000, propôs um estilo arquitetural com seis restrições. Praticamente nenhuma API que se chama "REST" cumpre todas. Na prática, "API REST" hoje significa "API HTTP com verbos, status codes e payloads JSON". Esse capítulo trata do bom uso pragmático — não da pureza acadêmica.

22.1 A história — de RPC a REST a tudo de novo

Contexto histórico

Nos anos 90, integração entre sistemas era feita via RPC (Remote Procedure Call). CORBA, DCOM, RMI dominavam. Sistemas chamavam funções em outros como se fossem locais — abstração que escondia complexidade da rede e dava resultados frustrantes.

Em 2000, Roy Fielding publicou sua tese de doutorado "Architectural Styles and the Design of Network-based Software Architectures". No capítulo 5, descreveu REST (Representational State Transfer) — não como protocolo, mas como conjunto de restrições arquiteturais que descreviam (em retrospecto) por que a Web tinha funcionado.

Entre 2005-2010, REST virou movimento, em oposição a SOAP (XML pesado, WSDL, ferramental complicado). Sites de referência (RESTful Web Services de Richardson & Ruby, 2007) popularizaram. APIs como Twitter, GitHub, Stripe definiram o que "boa API REST" significava na prática.

Em 2015, GraphQL (Facebook) começou a desafiar REST. Em 2018, gRPC (Google) ganhou tração para comunicação interna. Hoje, REST segue sendo padrão para APIs públicas externas, com GraphQL forte em consumer-facing complexo e gRPC dominante em microsserviços internos.

A maioria das "APIs REST" não cumpre todas as restrições de Fielding (especialmente HATEOAS). Isso virou tema de debate antigo. Hoje, vencer ou perder essa discussão importa pouco — o que importa é fazer API útil para os clientes.

22.2 Pensando em recursos

A primeira mudança mental ao desenhar API HTTP: pensar em recursos, não em ações. RPC é orientado a verbos: POST /criarPedido, POST /atualizarPedido, POST /buscarPedidos. REST é orientado a nomes: POST /pedidos, PATCH /pedidos/{id}, GET /pedidos.

Recursos são substantivos:

Convenções que pagam:

22.3 Verbos HTTP — significado e idempotência

Cada verbo tem semântica. Quem desenha API bem usa cada um para o que ele foi feito:

VerboSignificadoSeguro?Idempotente?
GETLer recurso(s)SimSim
POSTCriar recurso (ou ação não-CRUD)NãoNão (por padrão)
PUTSubstituir recurso completoNãoSim
PATCHAtualizar parcialmenteNãoNão (depende)
DELETERemover recursoNãoSim

Seguro = não tem efeitos colaterais observáveis (não modifica estado). Idempotente = chamar N vezes é equivalente a chamar uma vez. Essa distinção importa: clientes podem retentar verbos idempotentes em caso de falha de rede sem causar duplicação. POST não — pode criar duplicatas em retries.

PUT vs PATCH

PUT exige enviar o recurso completo: campos não enviados são interpretados como null. PATCH envia apenas o que muda.

put_vs_patch.http
# Estado atual de /usuarios/42:
# { "id": 42, "nome": "Alice", "email": "a@x.com", "telefone": "111" }

# PUT — substitui tudo
PUT /usuarios/42
Content-Type: application/json

{ "nome": "Alice Silva", "email": "a@x.com" }

# Resultado: telefone vira NULL ou some, dependendo do servidor.
# É comportamento esperado de PUT.

# PATCH — só altera campos enviados
PATCH /usuarios/42
Content-Type: application/json

{ "nome": "Alice Silva" }

# Resultado: nome atualizado; email e telefone intactos.

Para PATCH, dois formatos circulam: JSON Merge Patch (RFC 7396) — simples, é o que mostramos acima; e JSON Patch (RFC 6902) — mais expressivo, mas verboso. Use Merge Patch como padrão; JSON Patch só quando precisa de operações complexas (add em array, etc).

22.4 Códigos de status — use corretamente

HTTP define dezenas. Os essenciais:

2xx — sucesso

3xx — redirecionamento

4xx — erro do cliente

5xx — erro do servidor

Distinção sutil: 400 vs 422
400: payload está malformado ou inválido sintaticamente. JSON quebrado, campo obrigatório faltando, tipo errado. 422: payload OK, mas regra de domínio impede. Tentativa de saque maior que saldo, transferência para conta inexistente. A diferença ajuda o cliente a saber se tem que mudar a chamada ou se está esbarrando em uma regra.

22.5 Erros — RFC 7807 Problem Details

"Retornar 400" não basta. O cliente precisa saber por que. A RFC 7807 propõe formato padrão para erros em APIs HTTP — usado por Stripe, GitHub, e cada vez mais a indústria.

erro_rfc7807.json
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.exemplo.com/erros/saldo-insuficiente",
  "title": "Saldo insuficiente",
  "status": 422,
  "detail": "Conta 42 tem saldo R$ 100,00; tentativa de saque R$ 500,00.",
  "instance": "/contas/42/saques/req-abc123",
  // Campos custom permitidos:
  "saldo_atual": "100.00",
  "valor_solicitado": "500.00"
}

Campos da RFC 7807

Campos adicionais são livres — coloque o que ajude o cliente a entender e agir.

fastapi_erros.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from meuapp.domain.errors import SaldoInsuficiente, ContaNaoEncontrada

app = FastAPI()

@app.exception_handler(SaldoInsuficiente)
def _saldo_insuficiente(request: Request, exc: SaldoInsuficiente):
    return JSONResponse(
        status_code=422,
        content={
            "type": "https://api.exemplo.com/erros/saldo-insuficiente",
            "title": "Saldo insuficiente",
            "status": 422,
            "detail": str(exc),
            "instance": str(request.url),
            "saldo_atual": str(exc.saldo_atual),
            "valor_solicitado": str(exc.valor_solicitado),
        },
        headers={"Content-Type": "application/problem+json"},
    )

@app.exception_handler(ContaNaoEncontrada)
def _conta_nao_encontrada(request: Request, exc: ContaNaoEncontrada):
    return JSONResponse(
        status_code=404,
        content={
            "type": "https://api.exemplo.com/erros/conta-nao-encontrada",
            "title": "Conta não encontrada",
            "status": 404,
            "detail": f"Conta {exc.id} não existe ou foi removida.",
            "instance": str(request.url),
        },
        headers={"Content-Type": "application/problem+json"},
    )

22.6 Versionamento — como evoluir sem quebrar

APIs sempre evoluem. Versionamento define como mudanças quebradoras são gerenciadas.

Mudanças não-quebradoras (vão na mesma versão)

Mudanças quebradoras (exigem nova versão)

Onde colocar a versão

Três estratégias principais:

EstratégiaExemploComentário
URL path /v1/pedidos Mais comum, mais visível, mais simples de operar. Recomendado.
Header customizado X-API-Version: 1 Mais "puro" mas operacionalmente complicado.
Content negotiation Accept: application/vnd.api.v1+json Mais "RESTful" mas confunde clientes; pouco usado.

Use URL path a menos que tenha razão clara contra. Stripe, GitHub, Twitter usam URL path. Versão visível em toda chamada simplifica debug.

Política de deprecação

Documente o ciclo: nova versão lançada, versão antiga continua suportada por X meses, anuncia sunset date, header Deprecation nas respostas da versão antiga, depois desativa. Stripe é referência: versão fixada na chave de API; mudanças anunciadas com transparência.

22.7 Paginação — não retorne tudo

Listar recursos sem paginação é bomba-relógio. Com mil registros funciona; com milhão, derruba banco e cliente.

Duas estratégias, com trade-offs conhecidos:

Offset/limit — simples, mas lento em escala

offset.http
GET /pedidos?offset=100&limit=20

# Resposta inclui metadados de paginação
{
  "data": [...20 pedidos...],
  "pagination": {
    "offset": 100,
    "limit": 20,
    "total": 2453
  }
}

Prós: intuitivo, permite "pular para página X". Contras: em coleções grandes, banco tem que escanear todas as linhas até o offset; página 1000 é lentíssima. Resultados podem mudar entre páginas (registros inseridos no meio).

Cursor — performance constante, sem pular páginas

cursor.http
GET /pedidos?limit=20

{
  "data": [...20 pedidos, mais novos primeiro...],
  "pagination": {
    "next_cursor": "eyJjcmlhZG9fZW0iOiIyMDI2LTAxLTE1Iiwid",
    "has_more": true
  }
}

# Próxima página:
GET /pedidos?limit=20&cursor=eyJjcmlhZG9fZW0iOiIyMDI2LTAxLTE1Iiwid

Cursor é tipicamente um valor opaco (base64 de algo como {"criado_em": "2026-...", "id": "..."}) que o servidor decodifica para continuar a query. Prós: performance constante, robusto a inserções. Contras: não permite "página X arbitrária".

Use cursor em APIs públicas e em listas que crescem. Use offset apenas para coleções pequenas onde "navegar para página X" é requisito de UX.

22.8 Idempotência — POST sem duplicar

POST não é idempotente por padrão. Cliente que envia request, perde rede no meio, retenta — pode acabar criando dois pedidos. Como resolver?

Padrão estabelecido: header Idempotency-Key. Cliente envia chave única (UUID); servidor armazena resposta da primeira tentativa por algum tempo (24h é comum); requests subsequentes com mesma chave retornam o mesmo resultado, sem reprocessar.

idempotency.http
POST /pedidos
Idempotency-Key: 5a2c8f3e-8b1d-4c5d-9e2f-7a3b1c4d8e0f
Content-Type: application/json

{ "cliente_id": "c1", "itens": [...] }

# Primeira chamada: cria pedido, retorna 201
HTTP/1.1 201 Created
{
  "id": "p_abc",
  "status": "confirmado"
}

# Cliente perdeu conexão, retenta com MESMA chave:
POST /pedidos
Idempotency-Key: 5a2c8f3e-8b1d-4c5d-9e2f-7a3b1c4d8e0f

# Servidor reconhece a chave e retorna a mesma resposta:
HTTP/1.1 201 Created
{ "id": "p_abc", "status": "confirmado" }
# SEM criar pedido novo!

Implementação esquemática

idempotency_middleware.py
import hashlib, json
from fastapi import Request, HTTPException

class IdempotencyStore(Protocol):
    def obter(self, key: str) -> dict | None: ...
    def salvar(self, key: str, response: dict, ttl: int) -> None: ...

async def idempotency_middleware(request: Request, call_next, store: IdempotencyStore):
    if request.method != "POST":
        return await call_next(request)

    key = request.headers.get("Idempotency-Key")
    if not key:
        return await call_next(request)  # sem chave, não aplica

    # Hash do body para detectar reuso com payload diferente
    body = await request.body()
    payload_hash = hashlib.sha256(body).hexdigest()

    cached = store.obter(key)
    if cached:
        if cached["payload_hash"] != payload_hash:
            raise HTTPException(409, "chave idempotência reutilizada com payload diferente")
        return JSONResponse(
            status_code=cached["status"],
            content=cached["body"],
        )

    response = await call_next(request)
    body_response = json.loads(response.body)
    store.salvar(key, {
        "payload_hash": payload_hash,
        "status": response.status_code,
        "body": body_response,
    }, ttl=24 * 3600)
    return response

Stripe popularizou esse padrão. Para APIs que processam dinheiro (pagamentos, transferências), idempotência via header é praticamente obrigatória.

22.9 HATEOAS — pragmaticamente

Na tese original de Fielding, HATEOAS (Hypermedia as the Engine of Application State) é restrição fundamental: respostas trazem links que guiam o cliente para próximas ações. Cliente "descobre" a API navegando, como navegador faz com HTML.

hateoas.json
{
  "id": "p_abc",
  "status": "confirmado",
  "_links": {
    "self": { "href": "/pedidos/p_abc" },
    "cancelar": { "href": "/pedidos/p_abc/cancelamento", "method": "POST" },
    "itens": { "href": "/pedidos/p_abc/itens" }
  }
}

Na prática: quase nenhuma API REST popular implementa HATEOAS completo. Stripe, GitHub, Twilio, Twitter — todas usam paths fixos. Clientes consultam documentação, não navegam por links.

Vale o esforço de HATEOAS? Em APIs públicas grandes com clientes muito heterogêneos, talvez. Para a maioria dos times, é trabalho que não paga. Posição honesta: use HATEOAS leve (links self, paginação next/prev) por conveniência; não tente o REST puro de manual.

22.10 OpenAPI — documentação que vive com o código

OpenAPI (antes Swagger) é especificação para descrever APIs HTTP em YAML/JSON. Gera documentação interativa, clientes em várias linguagens, e validação automática.

Em Python, FastAPI gera OpenAPI automaticamente a partir dos tipos. Você não escreve YAML — você escreve código tipado, e o spec sai pronto.

fastapi_openapi.py
from fastapi import FastAPI, Path, Query, HTTPException
from pydantic import BaseModel, Field
from decimal import Decimal

app = FastAPI(
    title="API de Pedidos",
    description="Gerencia pedidos de e-commerce.",
    version="1.0.0",
)

class ItemRequest(BaseModel):
    sku: str = Field(..., examples=["PROD-001"])
    quantidade: int = Field(..., ge=1, examples=[2])

class CriarPedidoRequest(BaseModel):
    cliente_id: str = Field(..., examples=["c_abc"])
    itens: list[ItemRequest] = Field(..., min_length=1)

class PedidoResponse(BaseModel):
    id: str
    status: str
    total: Decimal

class ErrorResponse(BaseModel):
    type: str
    title: str
    status: int
    detail: str

@app.post(
    "/v1/pedidos",
    response_model=PedidoResponse,
    status_code=201,
    responses={
        422: {"model": ErrorResponse, "description": "Regra de negócio violada"},
        409: {"model": ErrorResponse, "description": "Conflito de idempotência"},
    },
    summary="Cria um pedido",
    description="Cria pedido com os itens informados. Use header `Idempotency-Key` para retries seguros.",
)
def criar_pedido(req: CriarPedidoRequest):
    ...

@app.get("/v1/pedidos/{id}", response_model=PedidoResponse)
def obter_pedido(
    id: str = Path(..., description="ID do pedido", examples=["p_abc"]),
):
    ...

@app.get("/v1/pedidos")
def listar_pedidos(
    limit: int = Query(20, ge=1, le=100),
    cursor: str | None = Query(None, description="Cursor da paginação"),
):
    ...

# Documentação automática em:
#   /docs   (Swagger UI)
#   /redoc  (ReDoc)
#   /openapi.json  (spec em JSON)

22.11 Estudo de caso — API de pedidos completa

Desenhando uma API REST do zero

Vamos modelar uma API de pedidos para um e-commerce. Cada decisão tem justificativa.

Recursos identificados
  • /pedidos — coleção de pedidos.
  • /pedidos/{id} — pedido específico.
  • /pedidos/{id}/itens — itens de um pedido.
  • /pedidos/{id}/pagamentos — pagamentos do pedido.
  • /pedidos/{id}/cancelamento — sub-recurso para ação de cancelar.
Endpoints e verbos
api_pedidos.http
# Criar pedido (com idempotência)
POST   /v1/pedidos
       Headers: Idempotency-Key
       → 201 Created + Location

# Listar (paginado por cursor)
GET    /v1/pedidos?limit=20&cursor=xxx&status=confirmado
       → 200 OK + array + next_cursor

# Obter um
GET    /v1/pedidos/{id}
       → 200 OK | 404 Not Found

# Atualizar (partial)
PATCH  /v1/pedidos/{id}
       → 200 OK | 404 | 422

# Adicionar item ao pedido
POST   /v1/pedidos/{id}/itens
       → 201 Created + Location | 409 (pedido fechado)

# Remover item
DELETE /v1/pedidos/{id}/itens/{sku}
       → 204 No Content | 404 | 409

# Cancelar (sub-recurso de ação)
POST   /v1/pedidos/{id}/cancelamento
       Body: { "motivo": "..." }
       → 202 Accepted (assíncrono) | 409 (já entregue)

# Pagar
POST   /v1/pedidos/{id}/pagamentos
       Headers: Idempotency-Key
       Body: { "forma": "cartao", "token": "..." }
       → 201 Created | 402 (recusado) | 409 (já pago)
Resposta padronizada para listagem
listagem.json
GET /v1/pedidos?limit=20&status=confirmado

HTTP/1.1 200 OK
{
  "data": [
    {
      "id": "p_abc",
      "status": "confirmado",
      "total": "199.90",
      "moeda": "BRL",
      "criado_em": "2026-05-15T10:00:00Z"
    },
    ...19 outros
  ],
  "pagination": {
    "limit": 20,
    "next_cursor": "eyJj...",
    "has_more": true
  }
}
Erro de regra de negócio
erro.json
POST /v1/pedidos/42/cancelamento

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://docs.api.com/erros/pedido-ja-entregue",
  "title": "Pedido já entregue",
  "status": 409,
  "detail": "Pedido 42 foi entregue em 2026-05-14 e não pode ser cancelado. Para devolução, use o fluxo de RMA.",
  "instance": "/v1/pedidos/42/cancelamento",
  "pedido_status": "entregue",
  "entregue_em": "2026-05-14T16:30:00Z"
}

Decisões tomadas:

  • URL com versão (/v1/) — visível, simples.
  • Cancelar via POST em sub-recurso — ação que não cabe em CRUD; mas trata como criação de "cancelamento" para manter REST razoável.
  • Paginação cursor — robusta a escala e a inserções.
  • Idempotency-Key em operações que criam ou cobram.
  • RFC 7807 nos erros, com campos extras úteis para o cliente.
  • OpenAPI auto-gerada via FastAPI.

22.12 Erros comuns

Erro 1 · Verbos em URLs

POST /createOrder, POST /getUserById. Volta para RPC, perde semântica de HTTP. Use POST /pedidos, GET /usuarios/{id}.

Erro 2 · Status 200 para tudo (inclusive erro)

Retornar 200 OK com {"erro": "..."}. Cliente HTTP não consegue distinguir; ferramentas de monitoramento perdem visibilidade. Use o código apropriado.

Erro 3 · Mensagens de erro sem ação

{"error": "invalid request"} sem dizer o que está inválido. Cliente vira detetive. RFC 7807 com detail descritivo é o mínimo.

Erro 4 · Vazar erro interno em produção

Stack trace de exceção Python em resposta 500. Vaza estrutura interna; ajuda atacante. Em prod, log interno + mensagem genérica + ID de correlação para suporte.

Erro 5 · Listar sem paginar

"Vai ter no máximo umas centenas." Em dois anos tem 50 mil; cliente trava ao listar. Sempre pagine, mesmo "se não precisar".

Erro 6 · Quebrar contrato sem versionar

Remover campo em produção "porque ninguém usa". Sempre tem cliente que usa. Adicione novo, marque antigo como deprecated, remova em V2.

Verifique seu entendimento
"Cliente envia POST /pedidos para criar pedido. Rede cai antes da resposta. Cliente retenta. Como evitar criar dois pedidos?"

22.13 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Verbo e status corretos

Para cada situação, qual verbo e qual status apropriados?

  1. Buscar perfil do usuário por id
  2. Criar novo cadastro de usuário
  3. Alterar email do usuário (apenas esse campo)
  4. Excluir conta de usuário
  5. Cliente envia payload com email já cadastrado
  6. Operação que demora; retorno será posterior
  7. Usuário sem token tenta acessar recurso protegido
  8. Usuário com token, mas sem permissão, tenta acesso
  1. GET /usuarios/{id} → 200 (ou 404 se não existe).
  2. POST /usuarios → 201 Created + header Location.
  3. PATCH /usuarios/{id} → 200 OK ou 204 No Content.
  4. DELETE /usuarios/{id} → 204 No Content.
  5. Status 409 Conflict (conflito com recurso existente).
  6. Status 202 Accepted + URL de polling no body.
  7. Status 401 Unauthorized (falta autenticação).
  8. Status 403 Forbidden (autenticado mas sem permissão).
Médio
Exercício 2 · Erros RFC 7807

Escreva resposta de erro completa (HTTP + body) para: tentativa de transferência de R$ 1500 onde saldo atual é R$ 800 e cheque especial é R$ 500. Inclua campos custom úteis.

resposta.http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://docs.api.com/erros/saldo-insuficiente",
  "title": "Saldo insuficiente para transferência",
  "status": 422,
  "detail": "Saldo disponível é R$ 1300,00 (R$ 800,00 + R$ 500,00 cheque especial). Valor solicitado: R$ 1500,00.",
  "instance": "/v1/contas/42/transferencias",
  "saldo_atual": "800.00",
  "limite_cheque_especial": "500.00",
  "valor_solicitado": "1500.00",
  "valor_disponivel": "1300.00",
  "valor_faltante": "200.00",
  "moeda": "BRL"
}
Médio
Exercício 3 · API de comentários

Desenhe a API REST para comentários em posts de blog. Operações: listar comentários de um post, criar comentário, editar próprio comentário, deletar comentário, listar respostas a um comentário (comentários podem ter pai). Inclua verbos, paths, status codes esperados.

api_comentarios.txt
# Listar comentários de um post
GET    /v1/posts/{post_id}/comentarios?limit=20&cursor=xxx
       → 200 + array + paginação
       → 404 se post não existe

# Criar comentário
POST   /v1/posts/{post_id}/comentarios
       Headers: Authorization, Idempotency-Key (opcional)
       Body: { "texto": "...", "parent_id": null }
       → 201 Created + Location
       → 401 sem auth
       → 404 se post não existe
       → 422 texto vazio ou inválido

# Obter comentário específico
GET    /v1/comentarios/{id}
       → 200 | 404

# Editar próprio comentário
PATCH  /v1/comentarios/{id}
       Body: { "texto": "..." }
       → 200 | 401 | 403 (se não é seu) | 404

# Deletar comentário
DELETE /v1/comentarios/{id}
       → 204 | 401 | 403 | 404

# Listar respostas a um comentário
GET    /v1/comentarios/{id}/respostas
       → 200 + array
       → 404

# Notas:
# - Comentário é "recurso" por si; tanto faz se vem por /posts/{id}/comentarios
#   ou diretamente /comentarios/{id}.
# - parent_id no body permite estruturar threading.
# - 403 é importante: usuário pode existir, comentário existir,
#   mas não ser dono.
Difícil
Exercício 4 · Migração de v1 para v2

API v1 tem endpoint GET /v1/usuarios/{id} retornando { "id", "nome", "email", "telefone": "11999998888" }. Time decidiu que em v2 telefone vira objeto: { "telefone": { "ddi": "55", "ddd": "11", "numero": "999998888" } }. Como migrar mantendo v1 funcionando? Inclua: estratégia de versionamento, headers úteis, política de deprecação, e dois exemplos de resposta (v1 e v2).

Estratégia:

  1. Lançar v2 em paralelo: /v2/usuarios/{id} com novo formato.
  2. v1 segue funcionando: mesma fonte de dados, formato adaptado pela camada de apresentação.
  3. Header de deprecação na v1: avisar clientes.
  4. Sunset date anunciada: 6 meses (ou mais, dependendo do contrato).
  5. Documentação clara da diferença e do prazo.
v1.http
GET /v1/usuarios/42

HTTP/1.1 200 OK
Deprecation: Sun, 31 Dec 2026 23:59:59 GMT
Sunset: Sun, 30 Jun 2027 00:00:00 GMT
Link: <https://docs.api.com/migracao-v2>; rel="sunset"

{
  "id": "42",
  "nome": "Alice",
  "email": "a@x.com",
  "telefone": "11999998888"
}
v2.http
GET /v2/usuarios/42

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "42",
  "nome": "Alice",
  "email": "a@x.com",
  "telefone": {
    "ddi": "55",
    "ddd": "11",
    "numero": "999998888"
  }
}

Headers: Deprecation (RFC 8594) e Sunset (RFC 8594) são padronizados para anúncio de fim de vida. Link rel="sunset" aponta para guia de migração.

Fim do capítulo 22
Próximo capítulo: GraphQL com critério. Quando vale, quando não vale, e como evitar os antipatterns clássicos.
Parte V · Capítulo 23 · Arquitetura aplicada

GraphQL
com critério:
onde vale.

GraphQL resolve problemas reais — over-fetching, under-fetching, múltiplas chamadas para montar uma tela. Mas adiciona complexidade considerável. Saber quando usar é mais importante do que saber como.

Existe pressão silenciosa para adotar GraphQL como sinal de modernidade técnica. A maioria das APIs internas não precisa dele; muitas APIs públicas também não. Mas há casos específicos onde ele é claramente superior a REST. Esse capítulo cobre o paradigma honestamente: como funciona, o que entrega, e quando vale o custo.

23.1 A história — Facebook 2012, open source 2015

Contexto histórico

Em 2012, o Facebook tinha aplicativo móvel pesado, com problema clássico: cada tela exigia múltiplas chamadas REST para montar; cada chamada trazia dados em excesso (campos não usados); a equipe mobile sofria com latência em conexões fracas. Lee Byron e equipe criaram uma alternativa: linguagem de query onde o cliente declara exatamente que dados precisa, e o servidor responde com o shape pedido.

GraphQL foi usado internamente no Facebook por 3 anos. Em 2015, foi liberado como open source, junto com a especificação. A adoção foi rápida: GitHub lançou API GraphQL em 2017, e empresas como Shopify, Netflix, Airbnb, Twitter passaram a usá-lo para casos específicos.

Em 2018, o Facebook transferiu a propriedade do GraphQL para a GraphQL Foundation (sob a Linux Foundation), tornando-o oficialmente neutro. A especificação evoluiu, mas o núcleo permanece o mesmo de 2015.

Hoje, GraphQL coexiste com REST. Não substituiu — adicionou-se ao toolkit. Sistemas maduros frequentemente têm REST e GraphQL: REST para webhooks, integrações simples, APIs públicas estáveis; GraphQL para front-ends complexos que precisam de flexibilidade de consulta.

23.2 O que GraphQL resolve

Três problemas concretos que aparecem em APIs REST sob certas condições:

Over-fetching

Cliente precisa só do nome do usuário; endpoint GET /usuarios/{id} retorna 30 campos. Banda desperdiçada, payload maior, serialização mais lenta. Em mobile com 3G, dói.

Under-fetching

Tela mostra pedido com cliente e itens. REST típico: GET /pedidos/{id}, depois GET /clientes/{cliente_id}, depois GET /pedidos/{id}/itens. Três viagens. Latência se soma.

Múltiplos shapes do mesmo recurso

App mobile precisa de versão resumida do produto; site precisa de versão completa; dashboard interno precisa de outra ainda. Em REST, viram endpoints diferentes (/produtos/resumo, /produtos/completo) — ou um endpoint com query params que escolhem campos (?fields=...), que vira gambiarra.

Resposta de GraphQL para essas dores: cliente declara o shape desejado em uma única query. Servidor entrega exatamente isso.

query_exemplo.graphql
# Cliente envia query como esta:
{
  pedido(id: "p_abc") {
    id
    status
    total
    cliente {
      nome
      email
    }
    itens {
      nome
      quantidade
      preco
    }
  }
}

# Servidor responde com EXATAMENTE esse shape:
{
  "data": {
    "pedido": {
      "id": "p_abc",
      "status": "confirmado",
      "total": "199.90",
      "cliente": {
        "nome": "Alice",
        "email": "a@x.com"
      },
      "itens": [
        { "nome": "Caneta", "quantidade": 2, "preco": "10.00" }
      ]
    }
  }
}

# Uma chamada HTTP. Nada faltando, nada sobrando.

23.3 Schema e tipos — o contrato

GraphQL é fortemente tipado. O servidor expõe um schema que descreve todos os tipos disponíveis, queries possíveis e mutations permitidas. Cliente e servidor compartilham essa definição — ferramentas de tooling (autocomplete, validação) ficam excelentes.

schema.graphql
# Tipos escalares built-in: String, Int, Float, Boolean, ID
# Custom escalares (Decimal, DateTime) podem ser adicionados.

type Cliente {
  id: ID!                # ! significa NÃO-NULL
  nome: String!
  email: String!
  pedidos: [Pedido!]!    # Lista NÃO-NULL de Pedidos NÃO-NULL
}

type Pedido {
  id: ID!
  status: StatusPedido!
  total: Decimal!
  cliente: Cliente!
  itens: [Item!]!
  criadoEm: DateTime!
}

type Item {
  sku: String!
  nome: String!
  quantidade: Int!
  preco: Decimal!
}

# Enums em vez de strings livres
enum StatusPedido {
  ABERTO
  CONFIRMADO
  PAGO
  CANCELADO
  ENTREGUE
}

# Inputs separados de outputs
input ItemInput {
  sku: String!
  quantidade: Int!
}

# Ponto de entrada para consultas
type Query {
  pedido(id: ID!): Pedido
  pedidos(limit: Int = 20, cursor: String): PedidoConnection!
  cliente(id: ID!): Cliente
}

# Ponto de entrada para mutações
type Mutation {
  criarPedido(input: CriarPedidoInput!): Pedido!
  cancelarPedido(id: ID!, motivo: String!): Pedido!
}

input CriarPedidoInput {
  clienteId: ID!
  itens: [ItemInput!]!
}

Pontos importantes:

23.4 Queries e mutations

GraphQL distingue:

operacoes.graphql
# Query simples
query {
  pedido(id: "p_abc") {
    status
    total
  }
}

# Query com aliases — pegar dois recursos ao mesmo tempo
query {
  pedidoA: pedido(id: "p1") { status }
  pedidoB: pedido(id: "p2") { status }
}

# Query parametrizada (variáveis)
query ObterPedido($id: ID!) {
  pedido(id: $id) {
    status
    total
    itens { nome }
  }
}
# Variáveis enviadas separadamente:
# { "id": "p_abc" }

# Fragmentos — reaproveitar shape entre queries
fragment ResumoPedido on Pedido {
  id
  status
  total
}

query {
  pedido(id: "p1") { ...ResumoPedido }
  pedidos { data { ...ResumoPedido } }
}

# Mutation — sempre retorna algo (geralmente o objeto alterado)
mutation CriarNovoPedido($input: CriarPedidoInput!) {
  criarPedido(input: $input) {
    id
    status
    total
  }
}

23.5 Resolvers — onde mora a lógica

Para cada campo do schema, o servidor tem um resolver: função que sabe como buscar aquele dado. GraphQL executa resolvers em árvore, do topo da query para as folhas.

Em Python, biblioteca dominante é Strawberry (moderna, type-hints) ou Graphene (mais antiga). Vamos usar Strawberry como referência.

schema_python.py
import strawberry
from typing import Optional
from decimal import Decimal

@strawberry.type
class Item:
    sku: str
    nome: str
    quantidade: int
    preco: Decimal

@strawberry.type
class Cliente:
    id: strawberry.ID
    nome: str
    email: str

    @strawberry.field
    def pedidos(self, info) -> list["Pedido"]:
        # Resolver para campo "pedidos" do Cliente
        repo = info.context["repo_pedidos"]
        return repo.listar_por_cliente(self.id)

@strawberry.type
class Pedido:
    id: strawberry.ID
    status: str
    total: Decimal
    itens: list[Item]

    @strawberry.field
    def cliente(self, info) -> Cliente:
        # Resolver para campo "cliente" do Pedido
        repo = info.context["repo_clientes"]
        return repo.buscar(self.cliente_id)

@strawberry.type
class Query:
    @strawberry.field
    def pedido(self, info, id: strawberry.ID) -> Optional[Pedido]:
        repo = info.context["repo_pedidos"]
        return repo.buscar(id)

    @strawberry.field
    def cliente(self, info, id: strawberry.ID) -> Optional[Cliente]:
        repo = info.context["repo_clientes"]
        return repo.buscar(id)

@strawberry.type
class Mutation:
    @strawberry.mutation
    def criar_pedido(self, info, cliente_id: strawberry.ID, itens: list[ItemInput]) -> Pedido:
        uc = info.context["criar_pedido_uc"]
        return uc.executar(cliente_id, itens)

schema = strawberry.Schema(query=Query, mutation=Mutation)

Note como os use cases de hexagonal (capítulo 20) seguem sendo o ponto onde a lógica vive. GraphQL é só uma porta de entrada diferente — driving adapter. Domínio e application não mudam.

23.6 O problema N+1 e DataLoader

A flexibilidade do GraphQL traz problema clássico: clientes podem montar queries que disparam N+1 problemas em cascata. Considere:

query_perigosa.graphql
query {
  pedidos(limit: 100) {        # 1 query
    data {
      cliente {                # 100 queries (uma por pedido)
        nome
      }
    }
  }
}
# Total: 101 queries. Lento.

Solução: DataLoader — padrão criado pelo Facebook para batch e cache de queries durante a execução de uma single GraphQL request.

dataloader.py
from strawberry.dataloader import DataLoader

async def batch_carregar_clientes(ids: list[str]) -> list[Cliente]:
    # Recebe TODOS os ids em uma chamada
    # Retorna na MESMA ORDEM dos ids pedidos
    repo = obter_repo_clientes()
    clientes_dict = {c.id: c for c in repo.buscar_varios(ids)}
    return [clientes_dict.get(id) for id in ids]

# No bootstrap, criar loader por request:
def criar_context():
    return {
        "loader_clientes": DataLoader(load_fn=batch_carregar_clientes),
        ...
    }

# Usar no resolver:
@strawberry.type
class Pedido:
    cliente_id: str

    @strawberry.field
    async def cliente(self, info) -> Cliente:
        loader = info.context["loader_clientes"]
        return await loader.load(self.cliente_id)
        # DataLoader acumula chamadas, dispara UMA query batch
        # para todos os pedidos do request.
        # Resultado: 1 + 1 = 2 queries (não 101).

DataLoader é obrigatório em qualquer implementação GraphQL séria. Sem ele, performance degrada para N+1 sempre que há relação entre tipos.

23.7 Erros e nulls — uma escolha de design

GraphQL tem modelo de erro próprio. Resposta sempre tem campos data e (opcionalmente) errors:

erro_graphql.json
{
  "data": {
    "pedido": null
  },
  "errors": [
    {
      "message": "Pedido não encontrado",
      "path": ["pedido"],
      "extensions": {
        "code": "NOT_FOUND",
        "pedidoId": "p_abc"
      }
    }
  ]
}

Duas escolas de tratamento de erro

Escola 1: errors padrão do GraphQL. Erros vão no campo errors. Simples; mas frontends frequentemente acham desconfortável (precisa olhar dois campos).

Escola 2: erros como parte do schema (Errors as Data). Resposta de mutation é union type: ou sucesso ou erro tipado.

errors_as_data.graphql
type CriarPedidoSuccess {
  pedido: Pedido!
}

type ClienteBloqueadoError {
  message: String!
  clienteId: ID!
  bloqueadoEm: DateTime!
}

type ItensInvalidosError {
  message: String!
  sksInvalidos: [String!]!
}

union CriarPedidoResult = CriarPedidoSuccess | ClienteBloqueadoError | ItensInvalidosError

type Mutation {
  criarPedido(input: CriarPedidoInput!): CriarPedidoResult!
}

Errors as Data tem ficado padrão em equipes que entendem TypeScript no front: cliente tipa cada caso, compilador força lidar com todos. Mais explícito; mais código.

23.8 Paginação Relay — o padrão da indústria

GraphQL não define paginação. A comunidade convergiu no padrão Relay Connections (criado pela equipe do Facebook). Cada lista paginada vira tipo Connection com edges, nodes e cursors.

relay_connections.graphql
type PedidoEdge {
  cursor: String!
  node: Pedido!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type PedidoConnection {
  edges: [PedidoEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type Query {
  pedidos(first: Int, after: String, last: Int, before: String): PedidoConnection!
}

# Query típica:
query {
  pedidos(first: 20, after: "cursor_xyz") {
    edges {
      cursor
      node {
        id
        status
        total
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Verboso, mas extremamente consistente entre projetos. Frontends como Apollo Client têm helpers automáticos para conexões Relay.

23.9 GraphQL vs REST — comparação honesta

AspectoRESTGraphQL
Over-fetching Comum; mitigado por sparse fieldsets Eliminado por design
Under-fetching Comum; várias chamadas Uma única query traz tudo
Cache HTTP Funciona naturalmente (GET, ETag) Não funciona (tudo é POST); precisa cache no cliente
Tipagem do contrato Via OpenAPI (opcional) Schema obrigatório, fortemente tipado
Curva de aprendizado Baixa Média
Versionamento Por URL ou header Por deprecation em campos (evolutivo)
Observabilidade Métricas por endpoint, fácil Mais complexo — uma única URL, mil queries possíveis
Rate limiting Por endpoint, simples Por complexidade da query (mais sofisticado)
Erros HTTP status codes + body Sempre 200; erro em errors[] ou como tipo
Real-time Webhooks ou SSE Subscriptions nativas
Idempotência GET/PUT/DELETE nativos; POST via Idempotency-Key Precisa implementar do zero
Mobile com rede ruim Vários round-trips Um round-trip; vantagem real

23.10 Quando vale GraphQL

Casos onde GraphQL claramente paga o investimento:

Casos onde GraphQL não vale (e REST é mais simples):

23.11 Estudo de caso — quando migrar parte de uma API

Adoção parcial em um sistema legado REST

Empresa tem API REST com 60+ endpoints. Time mobile reclama de latência: cada tela exige 4-6 chamadas. Avaliam migrar para GraphQL.

Diagnóstico — onde está a dor real?

Time mediu. Resultado:

  • Tela "Home do App": 6 chamadas, ~2.8s no 4G ruim.
  • Tela "Detalhes do Pedido": 4 chamadas, ~1.9s.
  • Resto do app: 1-2 chamadas, <500ms — sem problema.

Conclusão: apenas duas telas têm o problema. Migrar API inteira para GraphQL é overkill.

Decisão — GraphQL em paralelo, só para mobile

Adicionar endpoint /graphql que serve apenas necessidades do app mobile. Time web e integrações externas seguem usando REST. Backend reutiliza os mesmos use cases.

arquitetura.txt
┌─────────────┐    ┌──────────────┐
│ App Mobile  │───→│   GraphQL    │──┐
└─────────────┘    └──────────────┘  │
                                     ↓
                              ┌──────────────┐
┌─────────────┐    ┌──────────────┐  │ Use Cases   │
│ Web app     │───→│  REST API    │──→│ (compartil-  │
└─────────────┘    └──────────────┘  │ hados)       │
                                     │             │
┌─────────────┐    ┌──────────────┐  └──────┬───────┘
│ Integrações │───→│ REST Webhooks│         │
└─────────────┘    └──────────────┘         ↓
                                     ┌──────────────┐
                                     │ Domínio +    │
                                     │ Persistência │
                                     └──────────────┘
Implementação — schema enxuto, só o que mobile usa
schema_mobile.graphql
type Query {
  # Home do app: tudo necessário em uma chamada
  homeApp(usuarioId: ID!): HomeData!

  # Detalhes do pedido: tudo em uma
  pedidoDetalhe(id: ID!): PedidoDetalhe
}

type HomeData {
  usuario: Usuario!
  ultimosPedidos: [PedidoResumo!]!
  recomendados: [Produto!]!
  banner: Banner
}

type PedidoDetalhe {
  pedido: Pedido!
  cliente: Cliente!
  itens: [Item!]!
  pagamento: Pagamento
  entrega: Entrega
}
Resultado
  • Home: 6 chamadas → 1 chamada. ~2.8s → ~600ms no 4G ruim.
  • Detalhes: 4 → 1. ~1.9s → ~500ms.
  • Resto do app: REST inalterado. Sem impacto, sem refactor.
  • Backend: ganhou GraphQL como driving adapter; use cases não mudaram.

Lição: GraphQL é ferramenta para problema específico. Adoção parcial é frequentemente o melhor caminho. Migrar API inteira sem necessidade gera custo sem ganho equivalente.

23.12 Erros comuns

Erro 1 · Não implementar DataLoader

Implementação funcional, mas com N+1 em toda relação. Performance degrada drasticamente em queries aninhadas. DataLoader é requisito, não opcional.

Erro 2 · Sem limite de profundidade ou complexidade

Cliente envia query com 10 níveis de aninhamento; servidor faz milhares de queries. Use bibliotecas de análise de complexidade e profundidade; rejeite queries excessivas com erro.

Erro 3 · Expor tudo do banco

Schema GraphQL que espelha schema do banco 1:1. Vaza estrutura interna, dificulta evolução, faz mau uso da flexibilidade de GraphQL. Modele o schema pensando no consumidor, não no banco.

Erro 4 · Migrar tudo de REST para GraphQL

"Vamos modernizar." Time inteiro estuda GraphQL por meses; webhooks continuam REST por necessidade; resultado é dois sistemas para operar. Adoção parcial e focada é frequentemente melhor.

Erro 5 · Esquecer rate limiting baseado em complexidade

Rate limit "100 requests/min" não faz sentido em GraphQL — um request pode ser barato (uma field) ou caríssimo (mil edges com aninhamento). Use complexity points.

23.13 Quando NÃO usar GraphQL

Seja honesto sobre o custo
Casos onde REST é claramente melhor
  • API pública simples e estável: CRUD bem definido, poucos consumidores, sem over/under-fetching real.
  • Integrações server-to-server: webhooks, callbacks, comunicação entre serviços. gRPC ou REST simples são melhores.
  • Cache HTTP é crítico: CDN, edge cache, ETag. GraphQL atrapalha.
  • Time pequeno sem experiência: curva de aprendizado + tooling custa caro. Faça REST direito primeiro.
  • Operações stream-de-arquivo: upload de imagens, vídeos. REST/HTTP nativo.
  • Sistema com observabilidade exige métricas por endpoint: GraphQL tem um endpoint só; métricas precisam de instrumentação por operação/resolver.

Princípio: GraphQL paga onde over/under-fetching é dor real e medida. Sem essa dor, é só complexidade adicional.

Verifique seu entendimento
"Query GraphQL retorna lista de 100 pedidos, e para cada um, dispara busca do cliente. Servidor faz 101 queries SQL. O que falta?"

23.14 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Decidir GraphQL ou REST

Para cada cenário, justifique se GraphQL vale ou não:

  1. API pública de webhooks de pagamento (eventos para sistemas externos)
  2. App mobile de banco, com 30 telas diferentes acessando os mesmos recursos
  3. Integração entre 5 microsserviços internos
  4. API para parceiros B2B fazerem pedidos programaticamente
  5. Console interno administrativo (web), com várias views interligadas
  6. Backend para CMS de blog simples
  1. REST. Webhooks são server-to-server, push de eventos; GraphQL não traz benefício.
  2. GraphQL. Caso clássico — várias telas com shapes diferentes, mobile, latência sensível.
  3. gRPC ou REST. Comunicação interna prioriza performance e tipagem strict; GraphQL é over-engineering aqui.
  4. REST. Parceiros geralmente querem contratos estáveis e simples. OpenAPI bem documentado.
  5. GraphQL pode valer. Views complexas se beneficiam; mas REST + OpenAPI também atende se o time for pequeno.
  6. REST. CRUD simples, sem complexidade que justifique GraphQL.
Fácil
Exercício 2 · Schema GraphQL básico

Escreva schema GraphQL para sistema de tarefas: Tarefa tem id, título, descrição, prioridade (ALTA/MÉDIA/BAIXA), prazo (data), concluída (boolean), responsável (referência a Usuario). Inclua Query para listar tarefas pendentes e Mutation para criar tarefa.

schema.graphql
scalar DateTime

enum Prioridade {
  ALTA
  MEDIA
  BAIXA
}

type Usuario {
  id: ID!
  nome: String!
  email: String!
}

type Tarefa {
  id: ID!
  titulo: String!
  descricao: String
  prioridade: Prioridade!
  prazo: DateTime
  concluida: Boolean!
  responsavel: Usuario!
  criadaEm: DateTime!
}

input CriarTarefaInput {
  titulo: String!
  descricao: String
  prioridade: Prioridade!
  prazo: DateTime
  responsavelId: ID!
}

type Query {
  tarefa(id: ID!): Tarefa
  tarefasPendentes(responsavelId: ID): [Tarefa!]!
}

type Mutation {
  criarTarefa(input: CriarTarefaInput!): Tarefa!
  marcarConcluida(id: ID!): Tarefa!
}
Médio
Exercício 3 · Resolver com DataLoader

Implemente resolver de Tarefa.responsavel usando DataLoader (Strawberry). Mostre a função de batch e como configurar.

resolvers.py
import strawberry
from strawberry.dataloader import DataLoader
from typing import Optional

# Função de batch — recebe lista de ids,
# retorna lista NA MESMA ORDEM (None onde não achou)
async def batch_usuarios(ids: list[str]) -> list[Optional[Usuario]]:
    repo = obter_repo_usuarios()
    # Uma única query no banco
    usuarios = repo.buscar_varios(ids)
    # Indexar por id para garantir ordem
    map = {u.id: u for u in usuarios}
    return [map.get(id) for id in ids]

# No bootstrap — criar loader POR REQUEST
# (cache de DataLoader não deve atravessar requests)
async def criar_context():
    return {
        "loader_usuarios": DataLoader(load_fn=batch_usuarios),
        # ... outros loaders
    }

@strawberry.type
class Tarefa:
    id: strawberry.ID
    titulo: str
    responsavel_id: strawberry.Private[str]  # não exposto no schema

    @strawberry.field
    async def responsavel(self, info) -> Usuario:
        loader = info.context["loader_usuarios"]
        usuario = await loader.load(self.responsavel_id)
        if not usuario:
            raise Exception(f"Usuário {self.responsavel_id} não encontrado")
        return usuario

# Para 100 tarefas pedindo .responsavel:
# - Sem DataLoader: 100 queries.
# - Com DataLoader: 1 query (ids agrupados).
Difícil
Exercício 4 · Decidir adoção

Você é arquiteto numa empresa SaaS B2B com: backoffice web (10 telas, várias com tabelas grandes); app mobile (mais simples, 6 telas); API pública para clientes integrarem; webhooks de eventos para os clientes; 4 microsserviços internos. Escreva análise (5-10 linhas cada) recomendando o que cada interface deve usar (REST/GraphQL/gRPC) e justificando.

Backoffice web (10 telas com tabelas): REST com OpenAPI. Apesar de complexidade, tabelas administrativas tipicamente carregam dados em shapes estáveis; um endpoint por view atende; cache HTTP simplifica; time interno conhece REST.

App mobile (6 telas): depende. Se latência for crítica e telas combinarem dados de várias fontes, GraphQL vale. Para 6 telas simples, REST bem feito basta — adicionar GraphQL só por mobile é overkill.

API pública para clientes: REST com OpenAPI. Parceiros esperam contrato estável, ferramentas conhecidas, documentação clara. GraphQL público obriga clientes a aprender mais do que ganham.

Webhooks: REST. Push de eventos é server-to-server simples. POST com JSON payload, headers de assinatura. GraphQL aqui é overhead sem benefício.

4 microsserviços internos: gRPC (ou REST tipado com OpenAPI). Comunicação interna prioriza performance, tipagem strict, geração de clientes. gRPC entrega tudo isso melhor que REST/GraphQL.

Conclusão: resultado provável é stack mista (REST + gRPC, com GraphQL adicionado se mobile justificar) — não há vencedor único. Escolha por caso de uso, não por modismo.

Fim do capítulo 23
Próximo capítulo: mensageria e eventos. Como sistemas conversam de forma assíncrona, desacoplada, durável. Encerra a Parte V.
Parte V · Capítulo 24 · Arquitetura aplicada

Mensageria
e eventos:
desacoplamento
com cuidado.

Mensageria é o que permite sistemas grandes funcionarem sem se conhecerem diretamente. Mas é também onde mais bugs sutis aparecem: mensagens duplicadas, fora de ordem, perdidas, processadas duas vezes. Quem domina isso constrói sistemas resilientes; quem não domina constrói incidentes.

A motivação para mensageria é simples: chamadas síncronas acoplam sistemas. Se A chama B sincronicamente e B está fora, A falha junto. Filas e tópicos quebram esse acoplamento — A publica mensagem; B consome quando puder. Em troca, você herda uma classe inteira de problemas novos: garantias de entrega, ordem, idempotência, consistência eventual. Esse capítulo cobre os fundamentos e os padrões que viraram norma.

24.1 A história — de MOM a Kafka

Contexto histórico

Nos anos 90, sistemas corporativos integravam-se via Message-Oriented Middleware (MOM): IBM MQ, TIBCO, Sonic. Produtos pesados, caros, instalados em servidores dedicados. A ideia central — fila durável de mensagens entre produtores e consumidores — já estava lá.

Em 2007, surgiu RabbitMQ, implementando o protocolo AMQP. Open source, leve, com modelo flexível de exchanges e queues. Virou padrão para integração assíncrona em sistemas web ao longo dos anos 2010.

Em 2011, LinkedIn lançou Apache Kafka como open source. Modelo radicalmente diferente: log distribuído imutável, em vez de fila tradicional. Otimizado para alto throughput (milhões de mensagens/s) e replay histórico. Em poucos anos virou backbone de event streaming em empresas grandes.

Na nuvem: Amazon SQS (2006) trouxe fila como serviço gerenciado; Amazon Kinesis (2013) virou alternativa serverless ao Kafka; Google Pub/Sub, Azure Service Bus ofereceram serviços equivalentes em cada nuvem.

Em paralelo, padrões arquiteturais maduros foram destilados. Em 2003, Gregor Hohpe e Bobby Woolf publicaram Enterprise Integration Patterns, catalogando padrões que se tornaram vocabulário comum: outbox, saga, consumer groups, dead letter queue. Esses padrões valem mais que ferramentas — você troca de Kafka para Pulsar e os padrões seguem.

24.2 Síncrono vs assíncrono — quando trocar

Toda integração entre sistemas pode ser síncrona (RPC, REST) ou assíncrona (mensageria). Não é "moderno vs antigo" — é trade-off.

Síncrono (REST, RPC)
  • Cliente espera resposta.
  • Feedback imediato — sucesso ou erro.
  • Acopla disponibilidade — se B cai, A falha.
  • Mais simples de raciocinar.
  • Difícil suportar picos sem dimensionar para o pior caso.
Assíncrono (mensageria)
  • Produtor publica e segue; consumidor processa quando puder.
  • Resposta imediata é só "recebido" — resultado vem depois.
  • Desacopla disponibilidade — B fora? mensagens enfileiram.
  • Mais difícil de raciocinar (ordem, duplicação, consistência eventual).
  • Absorve picos naturalmente — fila se enche e drena.

Quando assíncrono é a escolha certa

Quando síncrono é a escolha certa

Sistemas reais misturam os dois. A questão certa é "qual usar onde", não "qual é melhor".

24.3 Modelos: fila vs tópico

Duas semânticas fundamentais, com implementações diferentes:

Fila (queue) — point-to-point

Cada mensagem é consumida por um único consumidor. Vários consumidores podem competir pela fila (load balancing). Quando processada, some.

Para: processamento de tarefas distribuído. "Envie e-mail para X" — qualquer worker pode processar.

Tópico (topic, pub/sub) — fan-out

Cada mensagem é entregue a todos os consumidores inscritos (cada um vê uma cópia). Cada consumidor processa independentemente.

Para: eventos de domínio. "PedidoConfirmado" — estoque processa, financeiro processa, marketing processa.

RabbitMQ implementa ambos via exchanges: direct, fanout, topic, headers. Kafka implementa via tópicos com consumer groups: dentro de um grupo é fila (load balanced); entre grupos é pub/sub.

24.4 RabbitMQ — modelo flexível

RabbitMQ implementa AMQP 0.9.1. Três conceitos centrais:

Tipos de exchange definem como mensagens vão para queues:

rabbitmq_publish.py
import pika
import json

def publicar_pedido_confirmado(pedido):
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(host="localhost")
    )
    channel = connection.channel()

    # Exchange topic — flexível para múltiplos consumidores
    channel.exchange_declare(
        exchange="pedidos",
        exchange_type="topic",
        durable=True,
    )

    mensagem = {
        "evento": "PedidoConfirmado",
        "pedido_id": pedido.id,
        "cliente_id": pedido.cliente_id,
        "total": str(pedido.total),
        "ocorrido_em": pedido.confirmado_em.isoformat(),
    }

    channel.basic_publish(
        exchange="pedidos",
        routing_key="pedido.confirmado",
        body=json.dumps(mensagem).encode(),
        properties=pika.BasicProperties(
            content_type="application/json",
            delivery_mode=2,            # persistente em disco
            message_id=pedido.id,         # para dedup no consumidor
            timestamp=int(time.time()),
        ),
    )
    connection.close()
rabbitmq_consume.py
def consumir_pedidos_confirmados(handler):
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(host="localhost")
    )
    channel = connection.channel()

    # Cada consumidor tem sua queue própria (ouvido independente)
    channel.queue_declare(queue="estoque.pedido_confirmado", durable=True)
    channel.queue_bind(
        queue="estoque.pedido_confirmado",
        exchange="pedidos",
        routing_key="pedido.confirmado",
    )

    # Processa uma mensagem por vez por worker
    channel.basic_qos(prefetch_count=1)

    def on_message(ch, method, props, body):
        try:
            dados = json.loads(body)
            handler(dados)
            # ACK explícito — só após processar com sucesso
            ch.basic_ack(delivery_tag=method.delivery_tag)
        except Exception as e:
            logger.exception("falha processando")
            # NACK + requeue=False envia para DLQ se configurada
            ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

    channel.basic_consume(
        queue="estoque.pedido_confirmado",
        on_message_callback=on_message,
    )
    channel.start_consuming()

RabbitMQ brilha em: roteamento flexível, prioridades, delayed messages (com plugin), TTL por mensagem, modelo conhecido e estável. Limita-se em throughput extremo (centenas de milhares/s, não milhões) e em replay histórico.

24.5 Kafka — log distribuído

Kafka é diferente. Em vez de fila que esvazia, é log imutável persistido. Mensagens (records) ficam armazenadas por tempo configurável (dias, semanas, meses). Consumidores leem do offset que quiserem — podem reler do começo, voltar atrás, processar duas vezes.

Conceitos:

kafka_publish.py
from kafka import KafkaProducer
import json

producer = KafkaProducer(
    bootstrap_servers=["localhost:9092"],
    value_serializer=lambda v: json.dumps(v).encode(),
    acks="all",                # todos os replicas reconhecem (durabilidade)
    enable_idempotence=True,    # produtor idempotente (sem duplicação)
)

def publicar_pedido_confirmado(pedido):
    mensagem = {
        "pedido_id": pedido.id,
        "cliente_id": pedido.cliente_id,
        "total": str(pedido.total),
        "ocorrido_em": pedido.confirmado_em.isoformat(),
    }
    # Key define partição — mesma key → mesma partição → ordem garantida
    future = producer.send(
        topic="pedidos.confirmado",
        key=pedido.cliente_id.encode(),     # cliente é a chave de ordenação
        value=mensagem,
    )
    future.get(timeout=10)              # sincronizar até confirmação
kafka_consume.py
from kafka import KafkaConsumer

consumer = KafkaConsumer(
    "pedidos.confirmado",
    bootstrap_servers=["localhost:9092"],
    group_id="estoque-worker",         # grupo define o "ouvido"
    enable_auto_commit=False,           # comit manual depois de processar
    auto_offset_reset="earliest",       # onde começar se for novo grupo
    value_deserializer=lambda v: json.loads(v.decode()),
)

for record in consumer:
    try:
        processar(record.value)
        # Comit explícito DEPOIS de processar — at-least-once
        consumer.commit()
    except Exception:
        logger.exception("falha")
        # Sem commit → mensagem será reentregue
        # Decida: skip + DLQ, ou pause e retry

24.6 Quando RabbitMQ, quando Kafka

AspectoRabbitMQKafka
Modelo mental Fila tradicional (consome e remove) Log distribuído imutável
Throughput típico Dezenas de milhares/s por queue Milhões/s
Replay histórico Não (mensagem some após ack) Sim, fundamental
Roteamento Muito flexível (exchanges) Simples (tópicos + partições)
Ordenação Por queue (single consumer) Por partição
Delayed messages Sim (plugin) Não nativo (workarounds)
Operação Single cluster, simples Cluster maior, ZooKeeper/KRaft
Casos clássicos Task queue, RPC assíncrono, integração interna Event streaming, analytics, event sourcing

Como escolher na prática:

24.7 Eventos de domínio externos — formato e contrato

Vimos no Cap 21 que agregados produzem eventos de domínio internos. Quando esses eventos saem do bounded context — viram eventos de integração, publicados em mensageria — eles viram contrato público. Como qualquer contrato, exigem disciplina.

Anatomia de um evento publicável

evento_externo.json
{
  "event_id": "evt_5a2c8f3e-8b1d-4c5d-9e2f-7a3b1c4d8e0f",
  "event_type": "pedido.confirmado",
  "event_version": "1.0",
  "occurred_at": "2026-05-15T10:30:00Z",
  "producer": "servico-pedidos",
  "correlation_id": "req_abc123",
  "data": {
    "pedido_id": "p_abc",
    "cliente_id": "c_xyz",
    "total": "199.90",
    "moeda": "BRL",
    "itens": [
      {"sku": "PROD-1", "quantidade": 2}
    ]
  }
}

Campos importantes

Schema registry

Para sistemas grandes, vale registrar schemas de eventos formalmente. Confluent Schema Registry (para Kafka) ou similar em outras tecnologias. Vantagens: validação no produtor (não publica evento mal-formado), evolução controlada de versões, geração automática de clientes.

24.8 Garantias de entrega — três níveis

Sistemas de mensageria oferecem semânticas diferentes. Saiba qual você está usando.

At-most-once (no máximo uma vez)

Mensagem é entregue zero ou uma vez. Pode perder. Rápido, sem garantia. Use para: métricas onde perda é aceitável, logs não-críticos.

At-least-once (pelo menos uma vez)

Mensagem entregue uma ou mais vezes (em caso de falha, é reentregue). Pode duplicar. Default da maioria dos sistemas. Use para: quase tudo, garantindo idempotência no consumidor.

Exactly-once (exatamente uma vez)

Mensagem processada exatamente uma vez. Caro, complexo, com asteriscos. Kafka oferece "exactly-once semantics" mas só em condições específicas (mesma transação Kafka). Distribuído sobre múltiplos sistemas externos, exactly-once verdadeiro é praticamente impossível.

A verdade prática
Trate tudo como at-least-once. Idempotência no consumidor (deduplicação por event_id, UPSERT em vez de INSERT, marcar processed_at) é mais barato e mais robusto que tentar exactly-once. Sistemas grandes resolvem assim: aceitar duplicação possível, processar de forma que duplicação não causa estrago.
consumer_idempotente.py
class EventosProcessadosRepo:
    def ja_processado(self, event_id: str) -> bool:
        with self._conn.cursor() as cur:
            cur.execute("SELECT 1 FROM eventos_processados WHERE event_id = %s", (event_id,))
            return cur.fetchone() is not None

    def marcar_processado(self, event_id: str):
        with self._conn.cursor() as cur:
            cur.execute(
                "INSERT INTO eventos_processados (event_id, processado_em) "
                "VALUES (%s, NOW()) ON CONFLICT DO NOTHING",
                (event_id,),
            )

def processar_evento(evento, repo: EventosProcessadosRepo, handler):
    if repo.ja_processado(evento["event_id"]):
        logger.info(f"evento {evento['event_id']} já processado, pulando")
        return

    handler(evento["data"])
    repo.marcar_processado(evento["event_id"])
    # Atenção: marcar_processado e handler() não são atômicos.
    # Se crashar entre os dois, o evento será reprocessado.
    # Solução robusta: processar e marcar na MESMA transação SQL.

24.9 Padrão Outbox — consistência entre banco e fila

Problema clássico: você precisa atualizar o banco e publicar evento. Tentação:

antipadrao.py
# ANTI-PADRÃO
def confirmar_pedido(pedido_id):
    with banco.transacao():
        pedido = repo.buscar(pedido_id)
        pedido.confirmar()
        repo.salvar(pedido)
    # banco committed. Tudo OK até aqui.

    publicar_em_kafka("pedido.confirmado", pedido)
    # E se cair aqui? Banco atualizado, evento PERDIDO.

Inverter a ordem (publicar antes de commitar) é pior — você publica, banco rolla back, mundo recebe evento de algo que nunca aconteceu.

Solução: padrão Outbox. Você grava o evento na mesma transação que o estado do agregado, em uma tabela outbox. Processo separado lê a tabela e publica em mensageria, marcando como enviado.

outbox.sql
CREATE TABLE outbox (
    id BIGSERIAL PRIMARY KEY,
    event_id VARCHAR(100) NOT NULL UNIQUE,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    publicado_em TIMESTAMPTZ
);

CREATE INDEX idx_outbox_pendentes ON outbox (criado_em)
WHERE publicado_em IS NULL;
outbox.py
def confirmar_pedido(pedido_id, conn):
    with conn:  # transação
        cur = conn.cursor()
        pedido = repo.buscar(pedido_id)
        pedido.confirmar()
        repo.salvar(pedido)

        # MESMA TRANSAÇÃO — atômico
        cur.execute(
            "INSERT INTO outbox (event_id, event_type, payload) "
            "VALUES (%s, %s, %s)",
            (
                str(uuid.uuid4()),
                "pedido.confirmado",
                json.dumps({
                    "pedido_id": pedido.id,
                    "cliente_id": pedido.cliente_id,
                    "total": str(pedido.total),
                }),
            ),
        )

# Worker separado publica e marca como enviado
def worker_outbox():
    while True:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, event_id, event_type, payload FROM outbox "
                "WHERE publicado_em IS NULL ORDER BY id LIMIT 100 "
                "FOR UPDATE SKIP LOCKED"
            )
            pendentes = cur.fetchall()
            for id, event_id, event_type, payload in pendentes:
                try:
                    publicar_em_kafka(event_type, event_id, payload)
                    cur.execute(
                        "UPDATE outbox SET publicado_em = NOW() WHERE id = %s",
                        (id,),
                    )
                except Exception:
                    logger.exception(f"falha publicando outbox id {id}")
            conn.commit()
        time.sleep(0.5)

Resultado: atomicidade entre banco e fila. FOR UPDATE SKIP LOCKED permite múltiplos workers cooperarem. Em sistemas grandes, ferramentas como Debezium automatizam: lê WAL do Postgres e publica em Kafka.

24.10 Saga — orquestrando transações distribuídas

Quando uma operação envolve múltiplos serviços que cada um faz parte do trabalho — confirmar pedido + reservar estoque + cobrar cartão — você não tem transação ACID atravessando todos. Cada serviço tem sua própria.

Solução: Saga. Sequência de transações locais, com compensação definida para cada uma. Se passo N falha, você executa compensações dos passos 1..N-1.

Duas formas:

Coreografia — sem orquestrador

Cada serviço reage a eventos e publica próximo evento. Distribuído, sem ponto central. Vantagem: simplicidade. Desvantagem: difícil ver o fluxo todo; mudanças exigem coordenação.

saga_coreografia.txt
API Pedidos: cria pedido, publica PedidoCriado
   ↓
Estoque: escuta, reserva, publica EstoqueReservado (ou EstoqueIndisponível)
   ↓
Pagamentos: escuta EstoqueReservado, cobra, publica PagamentoAprovado
   ↓
API Pedidos: escuta PagamentoAprovado, confirma pedido, publica PedidoConfirmado

# Compensação se algo falhar:
# - EstoqueIndisponível → API Pedidos cancela pedido
# - PagamentoRecusado → Estoque escuta, libera reserva; API cancela pedido

Orquestração — orquestrador central

Serviço dedicado coordena a saga: chama passo 1, espera resultado, chama passo 2, etc. Em caso de falha, executa compensações na ordem inversa.

saga_orquestrador.py
class SagaCriarPedido:
    def executar(self, comando):
        passos = [
            (self._reservar_estoque, self._compensar_estoque),
            (self._cobrar_pagamento, self._compensar_pagamento),
            (self._confirmar_pedido, self._compensar_pedido),
        ]
        executados = []
        for executar, compensar in passos:
            try:
                resultado = executar(comando)
                executados.append((compensar, resultado))
            except Exception as e:
                logger.error(f"saga falhou em {executar.__name__}: {e}")
                # Compensação reversa
                for compensar_fn, resultado in reversed(executados):
                    try:
                        compensar_fn(resultado)
                    except Exception:
                        logger.exception("compensação falhou — investigação manual")
                raise SagaFalhou(str(e))

Ferramentas como Temporal e AWS Step Functions implementam orquestração de sagas com durabilidade, retry, timeouts, observabilidade. Para sagas complexas em produção, usar essas ferramentas é frequentemente melhor que rolar a sua.

24.11 DLQ e replay — lidando com falhas

Toda mensageria séria tem conceito de Dead Letter Queue (DLQ): para onde vão mensagens que falharam ao ser processadas várias vezes.

Pattern típico:

  1. Consumidor tenta processar.
  2. Falha. Sistema retenta (com backoff).
  3. Após N tentativas, mensagem vai para DLQ.
  4. DLQ é monitorada por humanos: investiga, corrige, decide.

O que fazer com DLQ:

Princípio: DLQ não é onde mensagens vão morrer. É fila de "precisa atenção humana". Métricas e alertas no tamanho da DLQ são obrigatórios.

24.12 Estudo de caso — fluxo de pedido confirmado

Da chamada síncrona ao fluxo desacoplado

Sistema de pedidos que originalmente chamava 4 serviços sincronamente. Quando um deles ficava lento, todo o checkout travava. Vamos reestruturar.

Antes — checkout síncrono
antes.py
def confirmar_pedido(pedido_id):
    pedido = repo.buscar(pedido_id)
    pedido.confirmar()
    repo.salvar(pedido)

    # 4 chamadas síncronas — qualquer uma trava o checkout
    estoque_api.reservar(pedido)         # 300ms
    pagamento_api.cobrar(pedido)         # 800ms (Stripe)
    email_service.enviar(pedido)         # 500ms (SendGrid)
    analytics_service.registrar(pedido)  # 200ms

    # Total mínimo: 1.8s. Pior caso: 30s se Stripe lento.
    return pedido
Depois — fluxo desacoplado via eventos
depois.py
def confirmar_pedido(pedido_id, conn):
    with conn:  # transação atômica
        pedido = repo.buscar(pedido_id)
        pedido.confirmar()
        repo.salvar(pedido)
        # Outbox: evento gravado na mesma transação
        repo_outbox.adicionar({
            "event_id": str(uuid.uuid4()),
            "event_type": "pedido.confirmado",
            "data": {
                "pedido_id": pedido.id,
                "cliente_id": pedido.cliente_id,
                "total": str(pedido.total),
            },
        })
    # Função volta em ~50ms. Worker outbox publica em Kafka logo após.
    return pedido
Consumidores reagindo independentemente
consumidores.py
# Worker Estoque
def on_pedido_confirmado_estoque(evento):
    if repo_eventos.ja_processado(evento["event_id"]):
        return
    estoque.reservar(evento["data"]["pedido_id"])
    repo_eventos.marcar_processado(evento["event_id"])

# Worker E-mail
def on_pedido_confirmado_email(evento):
    if repo_eventos.ja_processado(evento["event_id"]):
        return
    email_service.enviar_confirmacao(evento["data"])
    repo_eventos.marcar_processado(evento["event_id"])

# Worker Analytics
def on_pedido_confirmado_analytics(evento):
    if repo_eventos.ja_processado(evento["event_id"]):
        return
    analytics.registrar(evento["data"])
    repo_eventos.marcar_processado(evento["event_id"])

# Pagamento continua síncrono (precisa confirmação imediata):
# fica no fluxo HTTP. Mas e-mail, estoque, analytics: desacoplados.
Resultados
  • Checkout síncrono: 50ms (banco) + 800ms (Stripe) = ~850ms. Antes era 1.8s+.
  • E-mail lento? Não afeta checkout. Mensagem fica na fila, processa depois.
  • Analytics fora do ar? Pedidos enfileiram, processam quando voltar. Sem perda.
  • Estoque pode reservar em lote para eficiência.
  • Cada worker escala independentemente.

Trade-offs aceitos: e-mail chega segundos depois (não imediatamente); estoque pode ficar inconsistente por instantes; observabilidade fica mais complexa (tracing distribuído necessário). Para esse caso, troca compensa muito.

24.13 Erros comuns

Erro 1 · Publicar evento fora da transação

Como vimos: banco commit + publicar Kafka separadamente = caminho para inconsistência. Use outbox sempre que precisar atomicidade entre banco e fila.

Erro 2 · Confiar em ordem global

Kafka garante ordem dentro de partição, não entre. Eventos que precisam de ordem (mesmo cliente, mesma conta) devem usar a mesma key para garantir mesma partição.

Erro 3 · Consumir sem idempotência

At-least-once é o normal. Sem dedup, mesma mensagem processada duas vezes vira pedido duplicado, e-mail duplo, cobrança dupla. Sempre idempotente.

Erro 4 · Sem monitoramento de DLQ

DLQ enche silenciosamente. Em dias, milhares de mensagens críticas falhadas, ninguém vê. Alerta no tamanho, dashboard, ownership claro.

Erro 5 · Evento com payload do estado completo

"PedidoAlterado" carrega tudo do pedido. Consumidor precisa de só um campo, recebe um JSON gigante. Eventos devem ter informação suficiente para o consumidor, sem virar dump.

Erro 6 · Schema sem versionamento

Time produz {"pedido_id": "...", "total": "..."}. Em 6 meses, adiciona moeda. Consumidores quebram. Use versionamento desde o início.

24.14 Quando NÃO usar mensageria

Reconheça o contexto
Casos onde síncrono direto é melhor
  • Consultas: queries que precisam de resposta. Mensageria aqui é overhead absurdo.
  • Operações curtas, com resposta imediata desejada: validação de cadastro, login. Usuário espera; mensageria não ajuda.
  • Transações financeiras com confirmação imediata: cliente quer ver "pagamento aprovado" antes de sair. Direto, síncrono.
  • Sistemas pequenos, com 1-2 serviços apenas: mensageria adiciona infraestrutura, monitoramento, observabilidade extra. Sem benefício correspondente.
  • Quando time não tem maturidade para lidar com consistência eventual: debugar fluxo assíncrono distribuído é difícil. Comece síncrono; introduza assíncrono onde dói.

Princípio: mensageria não é "moderno", é "específico". Use onde dor real (acoplamento, picos, latência percebida) justifica a complexidade adicional.

Verifique seu entendimento
"Service precisa atualizar banco E publicar evento em Kafka, e os dois precisam acontecer juntos (ou nenhum). Como garantir?"

24.15 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Síncrono ou assíncrono?

Para cada situação, decida: síncrono ou assíncrono, e justifique.

  1. Login do usuário (validar credenciais)
  2. Envio de e-mail de confirmação após cadastro
  3. Cobrança de cartão no checkout
  4. Notificação push para celular do usuário sobre promoção
  5. Geração de relatório anual em PDF (pode demorar minutos)
  6. Atualização do contador de visualizações de um produto
  7. Validação de CEP em formulário
  1. Síncrono. Usuário precisa saber se entrou ou não.
  2. Assíncrono. Pode chegar em segundos depois; cadastro não trava por isso.
  3. Síncrono. Usuário quer ver "pagamento aprovado" antes de sair da página.
  4. Assíncrono. Push pode chegar minutos depois sem problema.
  5. Assíncrono. Demora, retornar 202 Accepted com URL de polling ou e-mail quando pronto.
  6. Assíncrono. Pequena latência aceitável; permite agregar em batch.
  7. Síncrono. Usuário preenche e quer feedback imediato.
Médio
Exercício 2 · Outbox em SQLAlchemy

Implemente função confirmar_pedido_com_outbox(pedido_id) usando SQLAlchemy. Salve pedido e adicione evento na tabela outbox na mesma transação. Use UUID como event_id e o nome do tipo correto.

outbox_sqlalchemy.py
import uuid
import json
from sqlalchemy.orm import Session
from meuapp.models import Pedido, OutboxEvent

def confirmar_pedido_com_outbox(pedido_id: str, db: Session):
    with db.begin():  # transação
        pedido = db.query(Pedido).filter_by(id=pedido_id).one()
        pedido.confirmar()  # método de domínio

        evento = OutboxEvent(
            event_id=str(uuid.uuid4()),
            event_type="pedido.confirmado",
            event_version="1.0",
            payload=json.dumps({
                "pedido_id": pedido.id,
                "cliente_id": pedido.cliente_id,
                "total": str(pedido.total),
                "ocorrido_em": pedido.confirmado_em.isoformat(),
            }),
        )
        db.add(evento)
        # Commit acontece ao sair do `with db.begin()`
    return pedido

# Worker que processa outbox (rodando em paralelo):
def processar_outbox(db: Session, publisher):
    while True:
        with db.begin():
            pendentes = (
                db.query(OutboxEvent)
                .filter(OutboxEvent.publicado_em.is_(None))
                .with_for_update(skip_locked=True)
                .limit(100)
                .all()
            )
            for evento in pendentes:
                try:
                    publisher.publicar(
                        topico=evento.event_type,
                        event_id=evento.event_id,
                        payload=evento.payload,
                    )
                    evento.publicado_em = datetime.utcnow()
                except Exception:
                    logger.exception(f"falha publicando {evento.event_id}")
        time.sleep(0.5)
Médio
Exercício 3 · Consumer idempotente

Escreva consumer Kafka em Python que processa eventos pedido.confirmado e envia e-mail. Implemente idempotência usando tabela eventos_processados. Lide com falha de envio (retry algumas vezes, depois DLQ).

consumer_email.py
from kafka import KafkaConsumer, KafkaProducer
import json
import logging

logger = logging.getLogger(__name__)
MAX_TENTATIVAS = 3

consumer = KafkaConsumer(
    "pedido.confirmado",
    bootstrap_servers=["kafka:9092"],
    group_id="email-worker",
    enable_auto_commit=False,
    value_deserializer=lambda v: json.loads(v.decode()),
)
dlq_producer = KafkaProducer(
    bootstrap_servers=["kafka:9092"],
    value_serializer=lambda v: json.dumps(v).encode(),
)

class EventosRepo:
    def __init__(self, conn): self._conn = conn
    def ja_processado(self, event_id):
        with self._conn.cursor() as c:
            c.execute("SELECT 1 FROM eventos_processados WHERE event_id = %s", (event_id,))
            return c.fetchone() is not None
    def marcar_processado(self, event_id):
        with self._conn.cursor() as c:
            c.execute(
                "INSERT INTO eventos_processados (event_id, processado_em) "
                "VALUES (%s, NOW()) ON CONFLICT DO NOTHING",
                (event_id,),
            )
            self._conn.commit()

def processar(record, repo: EventosRepo, email_service):
    evento = record.value
    event_id = evento["event_id"]

    if repo.ja_processado(event_id):
        logger.info(f"evento {event_id} já processado, skip")
        return

    tentativas = 0
    while tentativas < MAX_TENTATIVAS:
        try:
            email_service.enviar_confirmacao(evento["data"])
            repo.marcar_processado(event_id)
            return
        except TransientError as e:
            tentativas += 1
            logger.warning(f"tentativa {tentativas} falhou: {e}")
            time.sleep(2 ** tentativas)
        except Exception as e:
            # Erro não-recuperável → DLQ imediato
            logger.exception(f"erro não-recuperável em {event_id}")
            _enviar_dlq(evento, str(e))
            return

    # Esgotou tentativas → DLQ
    logger.error(f"esgotadas tentativas para {event_id}")
    _enviar_dlq(evento, "max_tentativas")

def _enviar_dlq(evento, motivo):
    dlq_producer.send("pedido.confirmado.dlq", value={
        "evento_original": evento,
        "motivo": motivo,
        "enviado_dlq_em": datetime.utcnow().isoformat(),
    })

for record in consumer:
    try:
        processar(record, repo, email_service)
        consumer.commit()
    except Exception:
        logger.exception("falha geral; sem commit, será reentregue")
Difícil
Exercício 4 · Saga de checkout

Desenhe a saga para checkout de e-commerce: reservar estoque, cobrar pagamento, criar etiqueta de envio, confirmar pedido. Cada passo pode falhar. Defina: (1) evento publicado em cada passo, (2) compensação de cada passo, (3) se você usaria coreografia ou orquestração, com justificativa.

PassoEvento em sucessoEvento em falhaCompensação
1. Reservar estoque EstoqueReservado EstoqueIndisponivel Liberar reserva
2. Cobrar pagamento PagamentoAprovado PagamentoRecusado Reembolsar (se já cobrou)
3. Criar etiqueta de envio EtiquetaCriada FalhaTransportadora Cancelar etiqueta
4. Confirmar pedido PedidoConfirmado FalhaConfirmacao Marcar como pendente, alerta humano

Recomendação: orquestração. Justificativa:

  • Fluxo tem 4 passos com lógica de compensação reversa. Coreografia exigiria cada serviço escutar múltiplos eventos e saber compensar — difícil de raciocinar.
  • Saga finita e bem definida (checkout) é o caso típico para orquestrador.
  • Visibilidade do estado da saga importa para suporte. Orquestrador centraliza isso.
  • Mudar a sequência (ex: criar etiqueta antes do pagamento) fica simples no orquestrador.

Implementação: Temporal, AWS Step Functions, ou orquestrador caseiro se complexidade não justificar ferramental dedicado.

Fim do capítulo 24 · Fim da Parte V
Você concluiu a Parte V — Arquitetura aplicada. Seis capítulos sobre como sistemas maiores são organizados. Próxima parte: operação e equipe — concorrência, segurança, observabilidade, Docker, Git, CI/CD. O que entra em cena quando o software vai para produção e o time cresce. Peça "continua" para receber.
Parte VI
Operação e equipe

O que vem depois do código compilar. Concorrência sob pressão, segurança aplicada, observabilidade que paga, containers, código que vive em time, deploy contínuo. Onde a engenharia encontra a realidade.

Concorrência Segurança Observabilidade Docker Git e code review CI/CD
Parte VI · Capítulo 25 · Operação e equipe

Concorrência
e paralelismo:
Python sob
pressão.

Python tem fama (parte merecida) de ser ruim em concorrência. A verdade é mais nuançada: o GIL impõe restrição real, mas existem três modelos válidos — threading, multiprocessing, asyncio — cada um para um problema diferente. Errar a escolha é o erro mais comum.

Cada um dos três modelos resolve uma classe específica de problema. Quem mistura ou usa o errado para o caso paga em performance ou em bugs sutis (race conditions, deadlocks, recursos exauridos). Esse capítulo trata de cada modelo honestamente, com critérios práticos para escolher.

25.1 A história — do single-thread ao asyncio

Contexto histórico

Python foi criado em 1991 num mundo onde processadores tinham um único núcleo. Guido van Rossum implementou o Global Interpreter Lock (GIL) para simplificar o interpretador — um lock global que garante que apenas uma thread executa bytecode Python por vez. Decisão pragmática que se tornou polêmica.

Anos 2000, multicore virou regra. O GIL passou a doer: apesar de Python ter threading, threads não usavam múltiplos núcleos para CPU-bound. Discussões para remover o GIL surgiram repetidamente; cada tentativa esbarrava em incompatibilidade com extensões C.

Em 2008, multiprocessing foi adicionado à stdlib — solução pragmática: criar processos separados, cada um com seu próprio GIL. Funciona, mas com overhead.

Em 2014, Guido propôs asyncio (PEP 3156). Modelo de single-thread cooperativo com event loop, inspirado em Node.js e Twisted. Sintaxe async/await chegou em Python 3.5 (2015). Hoje é base de frameworks como FastAPI, aiohttp, Sanic.

Em 2023, PEP 703 propôs Python "no-GIL" como modo opcional. Python 3.13 (2024) entregou o primeiro build experimental sem GIL. Em alguns anos, single-threaded Python pode ter alternativa séria — mas, para os próximos anos, os três modelos atuais seguem como vocabulário.

25.2 Concorrência vs paralelismo — palavras importam

Concorrência: múltiplas tarefas em progresso (não necessariamente ao mesmo tempo). Switching entre elas — uma roda enquanto outra espera I/O.

Paralelismo: múltiplas tarefas executando simultaneamente, em núcleos diferentes da CPU.

Rob Pike resumiu: "concorrência é sobre lidar com muitas coisas ao mesmo tempo; paralelismo é sobre fazer muitas coisas ao mesmo tempo".

A diferença é o tipo de trabalho:

Em Python, essa distinção define qual modelo usar:

ModeloTipo de trabalhoParalelismo real?Overhead
asyncioI/O-boundNão (mas concorrência sim)Baixo
threadingI/O-boundNão (GIL bloqueia)Médio
multiprocessingCPU-boundSimAlto

25.3 O GIL — entendendo a restrição

O Global Interpreter Lock é um mutex que protege o estado interno do interpretador CPython. Sua consequência prática: apenas uma thread Python executa bytecode por vez, mesmo em máquina multicore.

Mas o GIL libera em alguns casos:

Por isso, threads são úteis para I/O-bound em Python — enquanto uma thread espera resposta de socket, outra roda. Para CPU-bound puro Python, threads não ajudam.

gil_demo.py
import threading
import time

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

def benchmark_threads(num_threads, n_per):
    inicio = time.perf_counter()
    threads = [threading.Thread(target=cpu_bound, args=(n_per,)) for _ in range(num_threads)]
    for t in threads: t.start()
    for t in threads: t.join()
    return time.perf_counter() - inicio

# Em CPU 8 núcleos:
print(f"1 thread, 8M iters:  {benchmark_threads(1, 8_000_000):.2f}s")
# ~1.5s

print(f"8 threads, 1M iters: {benchmark_threads(8, 1_000_000):.2f}s")
# ~1.5s — IGUAL, ou pior por overhead. GIL serializa.

# Mesmo trabalho, com multiprocessing:
from multiprocessing import Pool
with Pool(8) as pool:
    inicio = time.perf_counter()
    pool.map(cpu_bound, [1_000_000] * 8)
    print(f"8 processes:        {time.perf_counter() - inicio:.2f}s")
# ~0.3s — 5x mais rápido, paralelismo real.

25.4 threading — para I/O-bound clássico

threading em Python é útil quando o trabalho passa muito tempo esperando I/O. Cada thread tem stack próprio, compartilha memória com outras. Sincronização via Lock, Semaphore, Event, Queue.

threading_basico.py
import threading
import requests
from concurrent.futures import ThreadPoolExecutor

# Cenário: baixar 100 URLs. Cada uma é I/O-bound (esperar HTTP).

def baixar(url):
    response = requests.get(url, timeout=10)
    return len(response.content)

# Sequencial: ~100 * latência
totals = [baixar(u) for u in urls]  # lento se rede tiver latência

# Com ThreadPoolExecutor: paralelismo aparente, GIL libera durante I/O
with ThreadPoolExecutor(max_workers=20) as ex:
    totals = list(ex.map(baixar, urls))
# Tempo total ≈ tempo da URL mais lenta + overhead pequeno
# 100 URLs em paralelo em pool de 20 = 5 lotes ≈ 5x latência

Sincronização — Lock e Queue

lock_queue.py
import threading
from queue import Queue

# Lock para proteger estado compartilhado
contador = 0
lock = threading.Lock()

def incrementar():
    global contador
    with lock:
        contador += 1  # sem lock, race condition

# Queue: thread-safe por construção
fila = Queue()

def produtor():
    for i in range(100):
        fila.put(i)

def consumidor():
    while True:
        item = fila.get()  # bloqueia até ter item
        if item is None:
            break
        processar(item)
        fila.task_done()

Limitações

25.5 multiprocessing — para CPU-bound

Quando o trabalho é CPU-bound (cálculo, processamento de imagem, simulação numérica), você precisa de paralelismo real. multiprocessing cria processos separados — cada um com seu próprio interpretador e GIL.

multiprocessing_basico.py
from multiprocessing import Pool
from concurrent.futures import ProcessPoolExecutor
import os

def processar_imagem(caminho):
    img = abrir_imagem(caminho)
    return aplicar_filtro_pesado(img)  # CPU-bound

# Com Pool (API mais antiga)
with Pool(processes=os.cpu_count()) as pool:
    resultados = pool.map(processar_imagem, lista_imagens)

# Com ProcessPoolExecutor (API mais moderna, mesma família do ThreadPoolExecutor)
with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex:
    resultados = list(ex.map(processar_imagem, lista_imagens))

Custos

Compartilhar dados entre processos

shared_memory.py
from multiprocessing import Queue, Manager, shared_memory
import numpy as np

# Queue entre processos (mensagens serializadas via pickle)
fila = Queue()

# Manager: estruturas compartilhadas com locks automáticos (mais lento)
with Manager() as mgr:
    dict_compartilhado = mgr.dict()
    lista_compartilhada = mgr.list()

# shared_memory (Python 3.8+): zero-copy para arrays numéricos
arr_grande = np.zeros((10000, 10000))
shm = shared_memory.SharedMemory(create=True, size=arr_grande.nbytes)
np_view = np.ndarray(arr_grande.shape, dtype=arr_grande.dtype, buffer=shm.buf)
np_view[:] = arr_grande[:]
# Outros processos podem anexar à mesma região de memória sem cópia

25.6 asyncio — concorrência cooperativa

Modelo radicalmente diferente: um único thread executa um event loop. Tasks "se suspendem" em pontos cooperativos (await), permitindo que outras rodem enquanto esperam I/O. Não há paralelismo real (single thread); mas concorrência altíssima é possível — milhares de conexões simultâneas em um processo.

asyncio_basico.py
import asyncio
import aiohttp

async def baixar(session, url):
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as r:
        return len(await r.read())

async def main():
    async with aiohttp.ClientSession() as session:
        # Dispara 1000 requisições "simultâneas"
        tarefas = [baixar(session, u) for u in urls_1000]
        resultados = await asyncio.gather(*tarefas)
    return sum(resultados)

asyncio.run(main())
# Single thread, mas centenas de requisições rodando concorrentemente.
# Cada await libera o loop para outras tasks enquanto espera resposta.

O contrato async/await

Para uma função participar do event loop, ela precisa ser async e usar await em operações que esperam. Bibliotecas precisam ter versão async (asyncpg, aiohttp, httpx, aiosmtplib).

O grande perigo: chamar código bloqueante em função async trava o event loop inteiro. Tudo para. time.sleep(5) bloqueia 5 segundos; requests.get() síncrono bloqueia até a resposta.

nao_bloqueie.py
import asyncio

# ❌ ERRADO — bloqueia o loop inteiro
async def errado():
    time.sleep(5)  # bloqueia! Outras tasks param.

# ✓ CORRETO — versão async
async def certo():
    await asyncio.sleep(5)  # yield ao loop

# Para código sync que não tem versão async, use to_thread:
async def misturando():
    # Roda função sync em pool de threads
    resultado = await asyncio.to_thread(funcao_sincrona_lenta, arg1)
    return resultado

asyncio para o quê serve mesmo

25.7 Como escolher — fluxograma prático

Decisão em 3 passos
  1. É CPU-bound? (cálculo, processamento numérico, compressão) → multiprocessing.
  2. É I/O-bound e o ecossistema usado já é async? (web framework moderno, HTTP clients async) → asyncio.
  3. É I/O-bound mas as bibliotecas usadas são síncronas?threading (com ThreadPoolExecutor).

Casos mistos (web async + uma chamada CPU-bound pesada): use asyncio no nível do servidor, mas execute a parte CPU em ProcessPoolExecutor via loop.run_in_executor.

25.8 Race conditions — o bug que aparece em produção

Quando múltiplas threads/tarefas acessam o mesmo recurso sem sincronização, o resultado depende da ordem de execução. Bugs assim raramente aparecem em desenvolvimento (poucos requests) e explodem em produção (escala).

race_condition.py
# Cenário clássico: dois saques simultâneos em uma conta

class ContaInsegura:
    def __init__(self, saldo):
        self.saldo = saldo

    def sacar(self, valor):
        if self.saldo >= valor:   # THREAD A lê saldo=100
            time.sleep(0.001)         # dá tempo de B ler também
            self.saldo -= valor      # A grava 50
            return True                # B também grava 50 (não -50!)
        return False
# Saldo final: 50. Mas dois saques de 50 deveriam dar zero.
# Bug clássico: leitura e escrita não-atômicas.

# Solução: lock
class ContaSegura:
    def __init__(self, saldo):
        self.saldo = saldo
        self._lock = threading.Lock()

    def sacar(self, valor):
        with self._lock:
            if self.saldo >= valor:
                self.saldo -= valor
                return True
            return False

Para multiprocessos, locks não funcionam — use multiprocessing.Lock ou (melhor) delegue concorrência ao banco (transação + UPDATE atômico — vimos no Cap 17).

25.9 Deadlocks — quando todos esperam

Dois threads, dois locks. A pega lock_1 e quer lock_2. B pega lock_2 e quer lock_1. Ambos travam para sempre.

deadlock.py
lock_a = threading.Lock()
lock_b = threading.Lock()

def thread_1():
    with lock_a:
        time.sleep(0.001)
        with lock_b:  # espera B...
            ...

def thread_2():
    with lock_b:
        time.sleep(0.001)
        with lock_a:  # espera A...
            ...
# Deadlock garantido em alguma execução.

# Solução: ordenar locks. Sempre pegar na MESMA ordem.
def thread_1_ok():
    with lock_a:
        with lock_b:
            ...

def thread_2_ok():
    with lock_a:   # MESMA ordem
        with lock_b:
            ...

Princípio: sempre adquirir locks na mesma ordem. Outras estratégias: timeout (lock.acquire(timeout=1)) para detectar e abortar, evitar locks aninhados quando possível, usar RLock se a mesma thread vai pegar o lock várias vezes.

25.10 Estudo de caso — escolhendo o modelo certo

Três problemas, três modelos
Problema 1 · API que chama 5 serviços externos

FastAPI recebe request; para responder, precisa consultar 5 microsserviços diferentes. Sequencial: 100ms × 5 = 500ms. Quer baixar.

asyncio_api.py
import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/composto/{id}")
async def composto(id: str):
    async with httpx.AsyncClient() as client:
        # 5 chamadas em paralelo
        usuario, pedidos, faturas, recomendacoes, notificacoes = await asyncio.gather(
            client.get(f"https://svc-usuario/u/{id}"),
            client.get(f"https://svc-pedidos/p?u={id}"),
            client.get(f"https://svc-faturas/f?u={id}"),
            client.get(f"https://svc-reco/r?u={id}"),
            client.get(f"https://svc-notif/n?u={id}"),
        )
    return compor(usuario, pedidos, faturas, recomendacoes, notificacoes)
# Tempo total ≈ max(latência das 5) ≈ 100ms.
# 5x mais rápido. Modelo: asyncio (I/O-bound, ecossistema async).
Problema 2 · Job processando 1000 PDFs com OCR

Tarefa noturna: aplicar OCR em 1000 PDFs. Cada OCR demora ~2s usando CPU intensa. Sequencial: 2000s. Quer reduzir usando os 8 cores da máquina.

multiprocessing_ocr.py
from concurrent.futures import ProcessPoolExecutor
import os

def ocr_pdf(caminho_pdf):
    # tesseract / pytesseract — CPU intensa
    texto = aplicar_ocr(caminho_pdf)
    salvar_resultado(caminho_pdf, texto)

def main():
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex:
        list(ex.map(ocr_pdf, lista_pdfs))

# 1000 PDFs ÷ 8 cores = ~250s (com overhead, ~300s).
# 7x mais rápido. Modelo: multiprocessing (CPU-bound).
Problema 3 · Consumidor de fila lendo do RabbitMQ + processando + salvando em banco

Worker que consome mensagens. Cada mensagem: ler do RabbitMQ, processar (pouco CPU), salvar em Postgres. Cliente Python do Rabbit (pika) e psycopg2 são síncronos. Quer 50 mensagens simultâneas.

threading_worker.py
from concurrent.futures import ThreadPoolExecutor
import pika

def processar_mensagem(canal, method, props, body):
    dados = parsear(body)
    resultado = transformar(dados)        # CPU leve
    salvar_no_postgres(resultado)          # I/O
    canal.basic_ack(method.delivery_tag)

def consumidor():
    conn = pika.BlockingConnection(...)
    ch = conn.channel()
    ch.basic_qos(prefetch_count=50)

    with ThreadPoolExecutor(max_workers=50) as ex:
        for method, props, body in ch.consume("fila"):
            ex.submit(processar_mensagem, ch, method, props, body)
# 50 mensagens em paralelo. GIL libera durante I/O (banco, rede).
# Modelo: threading (I/O-bound, libs síncronas).
# Alternativa: aio-pika + asyncpg para reescrever em asyncio, se valer.

Lição: três problemas com aparência similar (todos "concorrentes"), três escolhas diferentes baseadas em natureza do trabalho e do ecossistema. Não há "melhor modelo" — há "modelo certo para o caso".

25.11 Erros comuns

Erro 1 · Usar threading para CPU-bound

"Vou paralelizar com threads." GIL serializa. Performance igual ou pior. Para CPU-bound: multiprocessing.

Erro 2 · Bloquear o event loop

time.sleep(), requests.get(), query síncrona dentro de função async. Trava o servidor inteiro. Use versão async ou asyncio.to_thread.

Erro 3 · Estado mutável sem lock

Variável global incrementada por múltiplas threads. Race condition silenciosa, dados perdidos. Use Lock, Queue ou estruturas thread-safe.

Erro 4 · Criar processos demais

multiprocessing com 1000 processos. Cada um custa memória e startup. Use Pool com tamanho proporcional aos cores.

Erro 5 · Esquecer cleanup

Threads que nunca terminam. ProcessPool fora de with. Conexões em pool não devolvidas. Use context managers e try/finally.

Verifique seu entendimento
"Você tem uma função Python que aplica filtros em milhares de imagens (CPU-bound, ~3s cada). Em máquina de 8 cores, qual modelo escolhe?"

25.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar I/O-bound vs CPU-bound

Classifique cada trabalho:

  1. Comprimir arquivo de 1GB em zip
  2. Fazer 200 requisições HTTP a APIs externas
  3. Calcular hash SHA-256 de arquivo grande
  4. Ler 10000 linhas de um arquivo de log
  5. Treinar modelo de machine learning
  6. Consultar 50 chaves do Redis
  7. Aplicar filtro Gaussiano em 1000 imagens
  8. Esperar 5 webhooks chegarem
  1. CPU-bound
  2. I/O-bound
  3. CPU-bound
  4. I/O-bound (mais I/O que CPU)
  5. CPU-bound
  6. I/O-bound
  7. CPU-bound
  8. I/O-bound
Médio
Exercício 2 · Async HTTP em paralelo

Implemente função baixar_paginas(urls, max_concurrency=20) usando asyncio e httpx. Limite o número máximo de requisições simultâneas com semáforo; retorne lista de tuplas (url, status, conteudo).

baixar_paginas.py
import asyncio
import httpx
from typing import Iterable

async def _baixar_um(client, sem, url):
    async with sem:  # limita concorrência
        try:
            r = await client.get(url, timeout=10)
            return (url, r.status_code, r.text)
        except Exception as e:
            return (url, None, f"erro: {e}")

async def baixar_paginas(
    urls: Iterable[str],
    max_concurrency: int = 20,
) -> list[tuple[str, int | None, str]]:
    sem = asyncio.Semaphore(max_concurrency)
    async with httpx.AsyncClient() as client:
        tarefas = [_baixar_um(client, sem, u) for u in urls]
        return await asyncio.gather(*tarefas)

# Uso:
resultados = asyncio.run(baixar_paginas(lista_de_500_urls))
Médio
Exercício 3 · Processar arquivos em paralelo

Use ProcessPoolExecutor para aplicar uma função CPU-bound processar_arquivo(caminho) -> dict a uma lista de arquivos. Mostre progresso (quantos terminaram); retorne lista de resultados na mesma ordem dos arquivos.

processar_arquivos.py
from concurrent.futures import ProcessPoolExecutor, as_completed
import os

def processar_arquivo(caminho: str) -> dict:
    # cálculo CPU-bound
    ...

def processar_em_paralelo(arquivos: list[str]) -> list[dict]:
    resultados = [None] * len(arquivos)
    feitos = 0
    total = len(arquivos)

    with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex:
        future_to_idx = {
            ex.submit(processar_arquivo, arq): idx
            for idx, arq in enumerate(arquivos)
        }

        for future in as_completed(future_to_idx):
            idx = future_to_idx[future]
            try:
                resultados[idx] = future.result()
            except Exception as e:
                resultados[idx] = {"erro": str(e)}
            feitos += 1
            if feitos % 10 == 0 or feitos == total:
                print(f"progresso: {feitos}/{total} ({feitos/total*100:.1f}%)")

    return resultados
Difícil
Exercício 4 · Pipeline misto async + multiprocessing

Sistema recebe URLs via fila, faz download (I/O-bound), depois processa imagem extraída (CPU-bound). Construa pipeline: asyncio para downloads em paralelo; ProcessPoolExecutor para processamento de imagem; coordenação eficiente entre eles. Use loop.run_in_executor.

pipeline.py
import asyncio
import httpx
from concurrent.futures import ProcessPoolExecutor
import os

# CPU-bound — rodado em pool de processos
def processar_imagem(bytes_img: bytes) -> dict:
    img = abrir_pillow(bytes_img)
    metricas = calcular_features(img)  # pesado
    return metricas

async def processar_url(client, executor, url):
    # I/O — async
    try:
        r = await client.get(url, timeout=20)
        r.raise_for_status()
    except Exception as e:
        return (url, None, f"download_falhou: {e}")

    # Despacha CPU-bound para outro processo, mas usando await
    # (loop fica livre enquanto processo trabalha)
    loop = asyncio.get_running_loop()
    try:
        metricas = await loop.run_in_executor(executor, processar_imagem, r.content)
        return (url, metricas, None)
    except Exception as e:
        return (url, None, f"processamento_falhou: {e}")

async def pipeline(urls: list[str]):
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
        async with httpx.AsyncClient() as client:
            sem = asyncio.Semaphore(50)  # limita downloads simultâneos

            async def _com_sem(u):
                async with sem:
                    return await processar_url(client, executor, u)

            tarefas = [_com_sem(u) for u in urls]
            return await asyncio.gather(*tarefas)

# Uso:
resultados = asyncio.run(pipeline(lista_urls))
# Downloads ocupam loop async; processamento pesado vai para processos.
# Sem bloqueio do loop; sem GIL atrapalhando CPU; ambos rolam em paralelo.
Fim do capítulo 25
Próximo capítulo: segurança aplicada. OWASP, autenticação, SQL injection, criptografia básica, secrets. O que separa software que vai pra produção do que não vai.
Parte VI · Capítulo 26 · Operação e equipe

Segurança
aplicada:
o básico bem
feito.

Segurança em software não é magia nem disciplina separada — é engenharia bem feita aplicada a um conjunto conhecido de ameaças. Quem domina o básico evita 95% dos incidentes; quem não, vira manchete.

Este capítulo cobre o que todo engenheiro deveria saber. Não vira você em pentester nem em criptógrafo — quem precisa de profundidade nessas áreas vai além do escopo aqui. Mas dá vocabulário e práticas que separam código "vai pra produção" de código "vira incidente".

26.1 A história — do Morris Worm ao OWASP

Contexto histórico

Em 1988, Robert Morris liberou o que viraria conhecido como o Morris Worm: um programa que explorava vulnerabilidades em sendmail, fingerd e rsh para se espalhar pela ARPANET. Em horas, derrubou 10% da internet da época. Foi o primeiro grande incidente de segurança em rede a alcançar notoriedade pública. Morris foi processado sob a Computer Fraud and Abuse Act, recém-promulgada.

Nos anos 90 e início dos 2000, a internet comercial cresceu sem cultura de segurança. SQL injection foi descoberto e documentado por Jeff Forristal em 1998. Cross-site scripting, CSRF, buffer overflows viraram pão diário.

Em 2001, foi fundada a OWASP (Open Worldwide Application Security Project), organização sem fins lucrativos dedicada a documentar vulnerabilidades comuns e mitigações. Em 2003, publicou a primeira versão do OWASP Top 10, lista das vulnerabilidades web mais comuns. Atualizada a cada 3-4 anos; última versão de 2021 (2025 esperada).

Em 2014, Heartbleed expôs vulnerabilidade no OpenSSL afetando ~17% dos servidores web do mundo. Em 2017, Equifax sofreu vazamento de 147 milhões de registros por causa de falha em biblioteca Apache Struts não atualizada. Em 2021, Log4Shell (CVE-2021-44228) virou um dos maiores incidentes da história — vulnerabilidade trivial de explorar em biblioteca onipresente.

Padrões e práticas amadureceram: SAST (análise estática), DAST (dinâmica), SCA (de dependências), threat modeling, secret management. Em 2023, NIST publicou Secure Software Development Framework (SSDF) consolidando práticas. Segurança virou disciplina contínua, não auditoria pontual.

26.2 Modelo de ameaças — pensando antes de codar

Antes de "como me proteger", a pergunta certa é "do que estou me protegendo?". Modelagem de ameaças é o exercício de pensar sistematicamente:

  1. O que estou construindo? Componentes, fluxos de dados, fronteiras de confiança.
  2. O que pode dar errado? Quem atacaria isso? Por quê? Como?
  3. O que vou fazer a respeito? Mitigações concretas.
  4. Fiz um bom trabalho? Revisar, testar.

Framework popular: STRIDE (Microsoft). Para cada componente, considere:

Princípio fundamental
Defense in depth. Não confie em uma única camada de proteção. Se uma falha, outra deve segurar. Firewall + autenticação + autorização + validação + logs + monitoramento. Cada camada compensa falhas das outras.

26.3 OWASP Top 10 — o que mais aparece

Lista de 2021, ainda referência. A ordem importa — refletem o que mais aparece em incidentes reais:

  1. Broken Access Control: autorização inadequada. Usuário acessa o que não deveria. Vencedor em incidentes recentes.
  2. Cryptographic Failures: dados sensíveis expostos. Senhas em texto plano, TLS mal configurado, criptografia caseira.
  3. Injection: SQL, command, LDAP, NoSQL. Continua relevante.
  4. Insecure Design: falhas na arquitetura, não no código. Faltam mitigações desde o desenho.
  5. Security Misconfiguration: defaults inseguros, headers faltando, debug ligado em prod.
  6. Vulnerable and Outdated Components: dependências com CVE conhecidos.
  7. Identification and Authentication Failures: sessões mal feitas, passwords fracos, brute force possível.
  8. Software and Data Integrity Failures: deserialização perigosa, supply chain attacks.
  9. Security Logging and Monitoring Failures: sem logs ou logs ignorados; ataques passam despercebidos.
  10. Server-Side Request Forgery (SSRF): servidor faz requests para URLs controladas pelo atacante.

O resto do capítulo aprofunda os mais relevantes para o dia a dia de quem desenvolve aplicações.

26.4 Injeção — o clássico que não morre

Injeção acontece quando entrada do usuário é interpretada como código/comando. SQL injection é o mais famoso, mas existe command injection, LDAP injection, template injection, NoSQL injection.

✗ Vulnerável
sql_injection.py
# Usuário envia: nome = "' OR '1'='1"
nome = request.form["nome"]
query = f"SELECT * FROM usuarios WHERE nome = '{nome}'"
cursor.execute(query)
# Query final:
# SELECT * FROM usuarios WHERE nome = '' OR '1'='1'
# Retorna TODOS os usuários. Ou pior:
# nome = "'; DROP TABLE usuarios; --"
# Drop table. Adeus.
✓ Parametrizado
seguro.py
# Driver substitui o placeholder com escape correto
nome = request.form["nome"]
cursor.execute(
    "SELECT * FROM usuarios WHERE nome = %s",
    (nome,),
)
# Mesmo se nome for "' OR '1'='1", driver trata como string literal.
# Query no banco busca literalmente esse texto, sem injeção possível.

Regra de ouro

Nunca concatene string para construir queries SQL com dados de usuário. Sempre use parâmetros (com %s, ? ou similar) ou query builder (ORM, SQLAlchemy core).

Command injection

command_injection.py
# ❌ ERRADO — shell=True com input do usuário
import subprocess
nome_arquivo = request.form["arquivo"]  # "x.txt; rm -rf /"
subprocess.run(f"cat {nome_arquivo}", shell=True)
# Executa cat x.txt E rm -rf /

# ✓ CORRETO — lista de argumentos, sem shell
subprocess.run(["cat", nome_arquivo], shell=False)
# Argumentos vão direto ao processo, sem interpretação de shell.

# Melhor ainda: valide que é um arquivo permitido antes de executar.

Outros tipos

26.5 Autenticação — quem é você?

Senhas — nunca em texto plano

Armazene apenas hashes (idealmente password hashes com salt + função lenta). Algoritmos recomendados (em 2026):

Nunca use: MD5, SHA-1, SHA-256 direto. São rápidos demais — atacante quebra bilhões/s.

password_hash.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()  # defaults razoáveis em 2026

def hash_senha(senha: str) -> str:
    return ph.hash(senha)

def verificar_senha(senha: str, hash_armazenado: str) -> bool:
    try:
        ph.verify(hash_armazenado, senha)
        return True
    except VerifyMismatchError:
        return False

# Bonus: rehash quando algoritmo evolui
if ph.check_needs_rehash(hash_armazenado):
    novo_hash = ph.hash(senha)
    salvar_no_banco(novo_hash)

Outras considerações de autenticação

26.6 Autorização — o que você pode fazer?

Autenticação prova quem você é; autorização determina o que você pode fazer. Broken Access Control é #1 do OWASP Top 10 — vazamento de dados por checks de permissão faltando é o incidente mais comum.

Modelos comuns

Vulnerabilidades clássicas

idor.py
# IDOR (Insecure Direct Object Reference)
# Usuário autenticado acessa recurso de outro

@app.get("/api/pedidos/{id}")
def obter_pedido(id: str, usuario=Depends(get_usuario_atual)):
    # ❌ FALHA: confere autenticação, NÃO confere ownership
    return repo.buscar(id)
    # Usuário 42 pega ID 99 (que é do usuário 17) e VÊ os dados.

# ✓ CORRETO: confere ownership
@app.get("/api/pedidos/{id}")
def obter_pedido(id: str, usuario=Depends(get_usuario_atual)):
    pedido = repo.buscar(id)
    if not pedido:
        raise HTTPException(404)
    if pedido.usuario_id != usuario.id and not usuario.eh_admin:
        raise HTTPException(403)  # ou 404 para não vazar existência
    return pedido

Centralizar checks

Espalhar permissões por controllers é caminho para esquecer um. Centralize:

26.7 Sessões e tokens

Depois de autenticar, o usuário precisa de uma "credencial" para próximas requisições. Duas estratégias dominantes:

Sessões server-side

Cookie com ID opaco; servidor mantém estado da sessão (em Redis, banco). Vantagem: revogação trivial (apaga registro). Desvantagem: estado central, mais infra.

Tokens stateless (JWT)

JWT (JSON Web Token) é payload assinado contendo claims (id, exp, etc). Servidor valida assinatura sem precisar consultar banco. Stateless, escalável. Mas:

JWT tem armadilhas
  • Revogação difícil: token válido até expirar; precisa de blacklist ou tokens muito curtos com refresh.
  • Algoritmo "none": vulnerabilidade clássica — bibliotecas mal configuradas aceitam token sem assinatura. Force algoritmo: jwt.decode(token, key, algorithms=["RS256"]).
  • Tamanho: cada request carrega o token inteiro. Não coloque dados pesados no payload.
  • Onde armazenar no cliente: localStorage é vulnerável a XSS; cookies httpOnly + secure + SameSite são mais seguros para web.
jwt_seguro.py
import jwt
from datetime import datetime, timedelta, timezone

SECRET = os.environ["JWT_SECRET"]  # mínimo 256 bits aleatórios
ALGORITMO = "HS256"

def criar_token(usuario_id: str) -> str:
    payload = {
        "sub": usuario_id,
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        # 15min de access token; refresh token mais longo
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITMO)

def validar_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            SECRET,
            algorithms=[ALGORITMO],  # LISTA EXPLÍCITA, não aceite "none"
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "token expirado")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "token inválido")

Padrão moderno: access + refresh tokens

26.8 Criptografia aplicada — sem reinventar

Regra zero: não escreva criptografia própria. Use bibliotecas auditadas. Em Python, cryptography é a referência moderna; secrets da stdlib para tokens e números aleatórios criptográficos.

Aleatoriedade segura

random_seguro.py
import secrets
import random  # NÃO USE PARA SEGURANÇA

# ❌ ERRADO: random é pseudo-aleatório previsível
token = "".join(random.choices("abc...", k=32))

# ✓ CORRETO: secrets usa CSPRNG do SO
token = secrets.token_urlsafe(32)        # 256 bits
hex_token = secrets.token_hex(32)            # em hex
numero_aleatorio = secrets.randbelow(10000)

# Comparação constant-time (não vaza informação por timing)
if secrets.compare_digest(token_recebido, token_esperado):
    ...

Criptografia simétrica — Fernet

Fernet da biblioteca cryptography é a API "para humanos": AES-128-CBC + HMAC-SHA256. Não exige você escolher modo, IV, padding.

cripto_simetrica.py
from cryptography.fernet import Fernet

# Gerar chave (32 bytes, base64). Armazenar com segurança!
chave = Fernet.generate_key()
f = Fernet(chave)

# Criptografar
texto = "dados sensíveis".encode()
cifrado = f.encrypt(texto)

# Descriptografar
decifrado = f.decrypt(cifrado).decode()

HTTPS/TLS

Hashing genérico

Para integridade (não senhas): SHA-256 ou SHA-512. hashlib da stdlib basta.

26.9 Secrets — onde NÃO colocar

Nunca:

Onde colocar:

Detectar secrets antes do commit

Ferramentas para CI:

Se vazar: rotacione imediatamente. Mesmo que tenha rebase/force-push removendo do histórico, considere comprometido (alguém pode ter visto / clonado).

26.10 Dependências — supply chain

Seu app pode estar perfeito, mas vulnerável por uma dependência. Equifax 2017: Apache Struts não atualizado. Log4Shell 2021: Log4j. SolarWinds 2020: compromisso de pipeline de build.

Práticas essenciais

ci_security.yaml
# GitHub Actions: SAST + SCA + secret scan no CI
name: security
on: [push, pull_request]

jobs:
  audit-deps:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Audit pip deps
        run: |
          pip install pip-audit
          pip-audit --requirement requirements.txt

  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Bandit (security linter Python)
        run: |
          pip install bandit
          bandit -r src/

  secrets-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # histórico completo
      - uses: gitleaks/gitleaks-action@v2

26.11 Estudo de caso — auditando um endpoint

Encontrando vulnerabilidades em código real

Você herda esse endpoint e precisa avaliar antes de promover a produção. Vamos achar os problemas.

Código original
vulneravel.py
@app.post("/api/login")
def login(req: dict):
    cursor = conn.cursor()
    query = f"SELECT id, senha FROM usuarios WHERE email = '{req['email']}'"
    cursor.execute(query)
    user = cursor.fetchone()
    if user and user[1] == req["senha"]:
        token = base64.b64encode(str(user[0]).encode()).decode()
        return {"token": token}
    return {"error": "falha"}, 401

@app.get("/api/usuarios/{id}/dados")
def dados(id: int, token: str = Header()):
    user_id = int(base64.b64decode(token).decode())
    cursor = conn.cursor()
    cursor.execute(f"SELECT * FROM usuarios WHERE id = {id}")
    return cursor.fetchone()
Vulnerabilidades encontradas
#ProblemaOWASPSeveridade
1SQL injection na query do emailA03 InjectionCrítica
2Senha em texto plano no bancoA02 Cryptographic FailuresCrítica
3Senha comparada com == (timing attack)A02Média
4"Token" é só base64 do ID — qualquer um adivinhaA07 AuthenticationCrítica
5Sem rate limit em login (brute force livre)A07Alta
6SQL injection na busca de dados ({id})A03Crítica
7Sem check de autorização (qualquer usuário vê qualquer outro)A01 Broken Access ControlCrítica
8SELECT * retorna campo senha juntoA02Crítica
9Mensagem de erro genérica (bom!) mas sem log estruturadoA09 LoggingBaixa
Reescrita segura
seguro.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
import jwt, secrets
from datetime import datetime, timedelta, timezone
from slowapi import Limiter
from slowapi.util import get_remote_address

ph = PasswordHasher()
limiter = Limiter(key_func=get_remote_address)
JWT_SECRET = os.environ["JWT_SECRET"]

class LoginRequest(BaseModel):
    email: EmailStr
    senha: str

@app.post("/api/login")
@limiter.limit("5/minute")
async def login(req: LoginRequest, request: Request):
    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, senha_hash FROM usuarios WHERE email = %s",
            (req.email,),
        )
        row = cur.fetchone()

    # Verifica mesmo sem encontrar — evita timing oracle
    try:
        if row:
            ph.verify(row[1], req.senha)
            user_id = row[0]
        else:
            # Verifica contra hash "dummy" para tempo constante
            ph.verify("$argon2id$v=19$m=65536,t=3,p=4$...", req.senha)
            raise VerifyMismatchError()
    except VerifyMismatchError:
        logger.warning(f"login_failed email={req.email[:3]}*** ip={request.client.host}")
        raise HTTPException(401, "credenciais inválidas")

    payload = {
        "sub": str(user_id),
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
    }
    token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
    return {"access_token": token, "token_type": "bearer"}

@app.get("/api/usuarios/{id}/dados")
async def dados(id: int, usuario_atual=Depends(get_usuario_atual)):
    # Autorização — só vê seus próprios dados ou se for admin
    if usuario_atual.id != id and not usuario_atual.eh_admin:
        raise HTTPException(403)

    with conn.cursor() as cur:
        cur.execute(
            # SELECT explícito, SEM senha_hash
            "SELECT id, email, nome, criado_em FROM usuarios WHERE id = %s",
            (id,),
        )
        row = cur.fetchone()

    if not row:
        raise HTTPException(404)
    return {"id": row[0], "email": row[1], "nome": row[2], "criado_em": row[3]}

Mudanças aplicadas: queries parametrizadas, senhas com Argon2, comparação tempo-constante implícita (ph.verify), JWT real com expiração, rate limiting, autorização explícita, SELECT campos específicos, log estruturado sem vazar PII.

26.12 Erros comuns

Erro 1 · Esquecer autorização "porque está autenticado"

Autenticação garante quem; autorização garante o quê. Endpoint com @login_required e nada mais permite usuário ver dados de qualquer outro. Check de ownership é obrigatório.

Erro 2 · Mensagem de erro vazando informação

"Email não cadastrado" vs "senha incorreta" permite enumerar emails. Mensagem genérica "credenciais inválidas" para ambos os casos.

Erro 3 · Criptografia caseira

"Vou XOR com a chave, é simples." Você acaba de criar vulnerabilidade. Use bibliotecas auditadas, sempre.

Erro 4 · Logs com PII ou secrets

logger.info(f"user logged in: {user.email}, token: {token}"). Logs vão para sistemas com acesso mais amplo; secrets ficam expostos. Sanitize antes de logar.

Erro 5 · Confiar em validação só no cliente

JavaScript valida formulário; backend confia. Atacante envia direto bypassando JS. Valide sempre no backend; cliente é experiência de usuário, não controle.

Erro 6 · Mass assignment

Endpoint PATCH /usuarios/{id} que faz user.update(**request.json). Atacante envia {"eh_admin": true} e vira admin. Allowlist explícita dos campos editáveis.

Verifique seu entendimento
"Sistema armazena senhas em SHA-256 com salt. Atacante vaza dump do banco. Qual o risco real?"

26.13 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Identificar vulnerabilidade

Para cada trecho, qual vulnerabilidade do OWASP Top 10?

  1. query = f"SELECT * FROM users WHERE name = '{name}'"
  2. Endpoint não checa se o usuário é dono do recurso antes de retornar
  3. Senha armazenada com md5(senha)
  4. Dependência Django 2.0 em produção (CVEs conhecidos)
  5. API key hardcoded no código fonte
  6. Logs sem alertas em tentativas falhas de login
  7. App pega URL do usuário e faz fetch sem validar destino
  1. A03 Injection (SQL injection).
  2. A01 Broken Access Control (IDOR).
  3. A02 Cryptographic Failures (MD5 é trivial de quebrar).
  4. A06 Vulnerable Components.
  5. A02 Cryptographic Failures + secrets management.
  6. A09 Security Logging and Monitoring Failures.
  7. A10 SSRF (Server-Side Request Forgery).
Médio
Exercício 2 · Hash de senha + verificação

Implemente um UserService com cadastrar(email, senha) e autenticar(email, senha). Use Argon2. Inclua: validação de força mínima (8+ caracteres), comparação tempo-constante implícita no verify, mensagem genérica em falha.

user_service.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from dataclasses import dataclass

class SenhaFraca(Exception): pass
class CredenciaisInvalidas(Exception): pass
class EmailJaCadastrado(Exception): pass

@dataclass
class Usuario:
    id: str
    email: str
    senha_hash: str

class UserService:
    DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$" \
                 "YWFhYWFhYWFhYWFhYWFhYQ$rmt0kQGzWMFn3hzCKLABcWQS"

    def __init__(self, repo):
        self._repo = repo
        self._ph = PasswordHasher()

    def cadastrar(self, email: str, senha: str) -> Usuario:
        if len(senha) < 8:
            raise SenhaFraca("mínimo 8 caracteres")
        if self._repo.buscar_por_email(email):
            raise EmailJaCadastrado()
        usuario = Usuario(
            id=gerar_id(),
            email=email,
            senha_hash=self._ph.hash(senha),
        )
        self._repo.salvar(usuario)
        return usuario

    def autenticar(self, email: str, senha: str) -> Usuario:
        usuario = self._repo.buscar_por_email(email)
        # Mesmo se usuário não existir, faz verify contra hash dummy
        # → tempo de resposta similar em ambos os casos
        hash_para_verificar = usuario.senha_hash if usuario else self.DUMMY_HASH
        try:
            self._ph.verify(hash_para_verificar, senha)
        except VerifyMismatchError:
            raise CredenciaisInvalidas()

        if not usuario:
            # Mesmo que verify passe contra dummy (não deve), sem usuário não autentica
            raise CredenciaisInvalidas()

        # Rehash se algoritmo mudou de parâmetros
        if self._ph.check_needs_rehash(usuario.senha_hash):
            usuario.senha_hash = self._ph.hash(senha)
            self._repo.salvar(usuario)

        return usuario
Médio
Exercício 3 · Decorator de autorização

Implemente decorator @requires_owner_or_admin(resource_param='id') para FastAPI: ele recebe id do path, busca o recurso, e verifica se usuário atual é dono ou admin. Caso contrário, 403.

auth_decorator.py
from functools import wraps
from fastapi import HTTPException

def requires_owner_or_admin(resource_param: str, repo_factory):
    """
    resource_param: nome do parâmetro do path com o ID do recurso
    repo_factory: callable que retorna o repository do recurso
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            usuario = kwargs.get("usuario_atual")
            if not usuario:
                raise HTTPException(401)

            resource_id = kwargs.get(resource_param)
            if not resource_id:
                raise ValueError(f"falta parâmetro {resource_param}")

            repo = repo_factory()
            recurso = repo.buscar(resource_id)
            if not recurso:
                raise HTTPException(404)

            if recurso.usuario_id != usuario.id and not usuario.eh_admin:
                logger.warning(
                    f"forbidden access user={usuario.id} resource={resource_id}"
                )
                raise HTTPException(403)

            return await func(*args, **kwargs)
        return wrapper
    return decorator

# Uso:
@app.get("/api/pedidos/{id}")
@requires_owner_or_admin(resource_param="id", repo_factory=lambda: repo_pedidos)
async def obter_pedido(id: str, usuario_atual=Depends(get_usuario_atual)):
    return repo_pedidos.buscar(id)
Difícil
Exercício 4 · Auditoria de endpoint completo

Analise o endpoint abaixo e liste TODAS as vulnerabilidades, classificando por severidade e categoria OWASP. Depois reescreva o endpoint sanando os problemas.

endpoint.py
@app.post("/api/admin/exec")
def exec_admin(req: dict, token: str = Header()):
    # decodifica token (base64)
    user_id = int(base64.b64decode(token).decode())

    # busca usuário
    cursor = conn.cursor()
    cursor.execute(f"SELECT eh_admin FROM users WHERE id={user_id}")
    row = cursor.fetchone()
    if not row or not row[0]:
        return {"error": "not admin"}, 403

    cmd = req["comando"]  # ex: "rebuild_index"
    resultado = subprocess.run(cmd, shell=True, capture_output=True)

    logger.info(f"admin {user_id} rodou: {cmd}")
    return {"output": resultado.stdout.decode()}

Vulnerabilidades:

#ProblemaOWASPSeveridade
1"Token" base64 trivialmente forjávelA07 AuthenticationCrítica
2SQL injection no user_id (via base64)A03 InjectionCrítica
3shell=True com input do usuário = RCEA03 InjectionCrítica
4Sem validação de pydantic; req é dict arbitrárioA04 Insecure DesignAlta
5Log do comando completo (pode vazar PII)A09Média
6Sem rate limitA04Média
7Sem auditoria robusta de ações adminA09Média
seguro.py
from enum import Enum
from pydantic import BaseModel

class ComandoAdmin(str, Enum):
    REBUILD_INDEX = "rebuild_index"
    CLEAR_CACHE = "clear_cache"
    VACUUM_DB = "vacuum_db"

# Mapa de comando para função Python — nada de shell
COMANDOS = {
    ComandoAdmin.REBUILD_INDEX: lambda: search_service.rebuild(),
    ComandoAdmin.CLEAR_CACHE: lambda: cache.clear(),
    ComandoAdmin.VACUUM_DB: lambda: db.vacuum(),
}

class ExecRequest(BaseModel):
    comando: ComandoAdmin

@app.post("/api/admin/exec")
@limiter.limit("10/hour")
async def exec_admin(
    req: ExecRequest,
    request: Request,
    usuario=Depends(get_usuario_atual),  # JWT real, não base64
):
    if not usuario.eh_admin:
        logger.warning(f"admin_attempt_denied user={usuario.id} ip={request.client.host}")
        raise HTTPException(403)

    # Auditoria: registrar ação admin em tabela dedicada (não logger)
    audit_log.registrar(
        usuario_id=usuario.id,
        acao="admin_exec",
        detalhes={"comando": req.comando.value},
        ip=request.client.host,
    )

    try:
        resultado = COMANDOS[req.comando]()  # enum garante apenas comandos válidos
        return {"status": "ok", "resultado": resultado}
    except Exception as e:
        logger.exception(f"admin_exec_failed user={usuario.id} comando={req.comando.value}")
        raise HTTPException(500, "erro interno")
Fim do capítulo 26
Próximo capítulo: observabilidade. Como saber o que está acontecendo dentro do sistema em produção — logs, métricas, traces, OpenTelemetry.
Parte VI · Capítulo 27 · Operação e equipe

Observabilidade:
saber o que
aconteceu.

Sistemas em produção falham de formas inesperadas. A pergunta crítica é: quando falham, você consegue descobrir por quê? Observabilidade é o que separa "investigação de 10 minutos" de "investigação de 10 dias".

A intuição comum é que observabilidade significa "ter logs". Errado. Logs são só uma das três pernas. Sistema bem instrumentado tem logs estruturados, métricas e traces, integrados via correlação. Quem domina isso responde a incidentes com confiança; quem não domina vira detetive de coisa óbvia que poderia estar visível.

27.1 A história — de syslog a OpenTelemetry

Contexto histórico

Nos anos 80, sistemas Unix tinham syslog — protocolo simples que enviava mensagens textuais para um servidor central. Cada linha um log. Texto puro, sem estrutura. Durante décadas foi o padrão; ainda é, em muitos lugares.

Métricas têm origem nos anos 90 com sistemas de monitoramento como Nagios (1999). Foco em saúde da infraestrutura: CPU, memória, disco. Aplicações ficavam de fora.

Em 2010, Etsy publicou StatsD, agente que recebia métricas das aplicações e agregava antes de enviar para storage. Métricas viraram parte da rotina de desenvolvimento.

Em 2012, surgiu Prometheus (SoundCloud) — formato de métricas pull-based, com linguagem de query (PromQL). Em 2015, Grafana tornou-se padrão para visualização. Stack Prometheus+Grafana virou referência open-source.

Em 2010, o Google publicou Dapper, paper sobre tracing distribuído em larga escala. Em 2014, ele inspirou Zipkin (Twitter) e Jaeger (Uber). Tracing tornou-se viável fora do Google.

Em 2019, projetos OpenTracing e OpenCensus se fundiram em OpenTelemetry (CNCF). Tornou-se padrão de fato para instrumentação de aplicações — vendor-neutral, suportado por todas as principais ferramentas. Hoje, instrumentar com OTel é o caminho default.

Em paralelo, Honeycomb, Datadog, New Relic popularizaram o termo "observabilidade" (diferente de "monitoramento"). A distinção: monitoramento é "estou observando coisas conhecidas"; observabilidade é "consigo investigar coisas desconhecidas".

27.2 Os três pilares — e por que se complementam

📝
Logs
Eventos discretos com contexto. Para investigar casos específicos. "O que aconteceu com o pedido X?"
📊
Métricas
Valores numéricos agregados ao longo do tempo. Para tendências e alertas. "Taxa de erro está subindo?"
🔗
Traces
Caminho de uma requisição por múltiplos serviços. Para entender fluxo distribuído. "Onde está a latência?"

Eles se complementam:

Um pilar sozinho deixa lacunas. Métricas sem traces: você sabe que erro subiu, não sabe onde. Logs sem métricas: você descobre o problema pelo cliente reclamando. Traces sem logs: você vê que o span demorou, não sabe o que rodou nele.

27.3 Logs estruturados — JSON, não strings

Por décadas, logs eram texto livre: "User 42 logged in at 2026-05-18". Filtrar virou problema de regex. Logs estruturados mudam isso: cada evento é JSON com campos tipados.

✗ Texto livre
texto.log
2026-05-18 10:42:17 INFO User 42 logged in
2026-05-18 10:42:18 WARN Slow query: 2.3s
2026-05-18 10:42:19 ERROR Payment failed for order 87: card declined

Para buscar "todas falhas de pagamento do user 42": parsing manual, regex frágil, sem agregação.

✓ JSON estruturado
estruturado.log
{"ts":"2026-05-18T10:42:17Z","level":"info","event":"user_login","user_id":"42"}
{"ts":"2026-05-18T10:42:18Z","level":"warn","event":"slow_query","duration_ms":2300,"query":"SELECT..."}
{"ts":"2026-05-18T10:42:19Z","level":"error","event":"payment_failed","user_id":"42","order_id":"87","reason":"card_declined"}

Query SQL: WHERE event='payment_failed' AND user_id='42'. Trivial.

Bibliotecas Python

structlog e loguru são as opções modernas. structlog integra bem com logging da stdlib e tem boa performance.

structlog_setup.py
import structlog
import logging

# Configuração padrão em produção
structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,      # contexto de async
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    context_class=dict,
)

log = structlog.get_logger()

# Uso
log.info("user_login", user_id="42", ip="1.2.3.4")
log.warning("slow_query", query="SELECT...", duration_ms=2300)
log.error("payment_failed", user_id="42", order_id="87", reason="card_declined")

# Contexto persistente em fluxo
log = log.bind(request_id="abc123", user_id="42")
log.info("order_started")        # inclui request_id + user_id
log.info("payment_processed")    # idem

O que logar (e o que não logar)

Logue:

Não logue:

Níveis de log

27.4 Métricas — agregações ao longo do tempo

Métricas são números medidos ao longo do tempo. Diferente de logs, são agregadas: você não tem "cada request"; tem "X requests/minuto", "p99 da latência", "taxa de erro".

Quatro tipos canônicos

prometheus_python.py
from prometheus_client import Counter, Gauge, Histogram, start_http_server

# Contador de requests por método e status
http_requests_total = Counter(
    "http_requests_total",
    "Total de requisições HTTP",
    ["method", "endpoint", "status"],
)

# Gauge de conexões ativas
active_connections = Gauge(
    "db_connections_active",
    "Conexões ativas no pool",
)

# Histogram de latência de requests
request_duration = Histogram(
    "http_request_duration_seconds",
    "Latência de requests HTTP",
    ["method", "endpoint"],
    buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 5.0],
)

# Middleware FastAPI
@app.middleware("http")
async def metrics_middleware(request, call_next):
    inicio = time.time()
    response = await call_next(request)
    duracao = time.time() - inicio

    endpoint = request.url.path
    method = request.method
    status = response.status_code

    http_requests_total.labels(method=method, endpoint=endpoint, status=status).inc()
    request_duration.labels(method=method, endpoint=endpoint).observe(duracao)

    return response

# Expor endpoint /metrics para Prometheus fazer scrape
start_http_server(8001)

RED e USE — frameworks de métricas

Dois modelos populares para decidir o que medir:

Aplicações: use RED. Infra (CPU, disco, rede): use USE. Cobertos os dois, você vê tanto saúde do código quanto saúde do hardware.

27.5 Distributed tracing — onde está a latência?

Em sistemas distribuídos, uma única requisição passa por vários serviços. Você vê: "API levou 5s". Onde? Foi o banco? Foi o cache? Foi outro serviço? Tracing mostra o caminho completo.

Conceitos:

trace_visualization.txt
trace_id=abc123

POST /pedidos                                  [████████████████████] 2.3s
  ├ load_user (cache)                          [█] 0.05s
  ├ load_user (postgres)                       [██] 0.12s
  ├ validate_inventory                         [███] 0.2s
  ├ charge_payment (stripe)                    [██████████████] 1.6s   <-- aqui!
  │   └ external HTTP call                     [█████████████] 1.5s
  ├ save_order                                 [██] 0.15s
  └ publish_event (kafka)                      [█] 0.08s

# Em uma visualização gráfica (Jaeger, Tempo, Honeycomb):
# fica claro que 70% do tempo é o Stripe.
# Mudou de Stripe — Cap diminuiu de 2.3s para 700ms.

27.6 OpenTelemetry — o padrão

OpenTelemetry (OTel) é o projeto unificado da CNCF para instrumentação de aplicações. Padrão de fato em 2026. Cobre logs, métricas e traces — uma única API para os três.

Vantagens:

otel_python.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

# Setup do tracer
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317"))
)

# Auto-instrument frameworks comuns
FastAPIInstrumentor.instrument_app(app)
Psycopg2Instrumentor().instrument()
RequestsInstrumentor().instrument()
# Isso sozinho já te dá traces de toda chamada HTTP, query SQL e
# requisição requests.* — sem mudar uma linha do app.

# Spans customizados quando faz sentido
tracer = trace.get_tracer(__name__)

def processar_pedido(pedido_id):
    with tracer.start_as_current_span("processar_pedido") as span:
        span.set_attribute("pedido.id", pedido_id)

        with tracer.start_as_current_span("validar_estoque"):
            estoque.validar(pedido_id)

        with tracer.start_as_current_span("cobrar_pagamento") as sp:
            tx = pagamento.cobrar(pedido_id)
            sp.set_attribute("pagamento.gateway", "stripe")
            sp.set_attribute("pagamento.tx_id", tx.id)

27.7 Correlação — amarrando os três pilares

O ganho real vem quando logs, métricas e traces se correlacionam. Métrica de erro pulou → você clica → vê traces dos erros → clica em um → vê logs daquele trace.

A chave é o trace_id. Inclua em todo log:

correlacao.py
from opentelemetry import trace
import structlog

def add_trace_id(logger, method_name, event_dict):
    span = trace.get_current_span()
    if span and span.get_span_context().is_valid:
        ctx = span.get_span_context()
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict

# Adicionar ao pipeline de structlog
structlog.configure(
    processors=[
        add_trace_id,                            # adiciona trace_id automaticamente
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
)

# Agora todo log inclui trace_id quando há span ativo:
# {"ts":"...","event":"payment_failed","trace_id":"abc123...","span_id":"def456"}

No Grafana/Datadog/Honeycomb: você seleciona uma métrica anômala → "exemplars" mostram traces representativos daquele bucket → você abre o trace → cada span tem logs vinculados.

27.8 SLOs e alertas — só onde dói

Métricas sem alertas são gráficos bonitos que ninguém olha. Mas alertas demais geram fadiga (alert fatigue) — engenheiros começam a ignorar.

Modelo moderno: SLO (Service Level Objective) — meta de qualidade que você se compromete a entregar.

Estrutura de SLO
SLI (Indicator): o que você mede. Ex: "% de requests bem-sucedidos em < 500ms".
SLO (Objective): a meta. Ex: "99.5% das requests".
Error budget: o que você pode "gastar". Se SLO é 99.5%, error budget é 0.5% (~3.6h/mês de falha aceita).

Alertas baseados em SLO:

Cada alerta acionado deveria ser actionable: o responsável sabe o que fazer. Se "alerta dispara, eu olho, é normal, ignoro" — alerta tem que sair ou ser ajustado. Confusão entre "warning" e "info" é fonte comum de fadiga.

27.9 Cardinalidade — o problema oculto

Cardinalidade em métricas refere-se ao número de combinações únicas de labels. Cada combinação é uma série temporal armazenada.

Erro comum: usar identificadores únicos (user_id, request_id) como label de métrica.

cardinalidade.py
# ❌ ERRADO — explode cardinalidade
http_requests_total = Counter(
    "http_requests_total",
    "requests",
    ["method", "endpoint", "user_id"],  # 10M usuários = 10M séries!
)

# ✓ CORRETO — labels com cardinalidade limitada
http_requests_total = Counter(
    "http_requests_total",
    "requests",
    ["method", "endpoint", "status_class"],  # 5xx, 4xx, 2xx
)
# Métodos: ~5. Endpoints: ~50. Status: ~3. Total: ~750 séries. Saudável.

Para análise por user_id específico, use logs e traces — onde dimensão alta não é problema. Métricas são para agregação. Confundir os papéis estoura o sistema de métricas.

27.10 Estudo de caso — investigando incidente

Da reclamação do cliente à causa raiz em 10 minutos

Cliente liga: "checkout está dando erro intermitente". Sem observabilidade, você passa horas chutando. Com os três pilares, é rotina.

1 · Olhar métricas (1 min)

Abrir dashboard "Checkout":

  • Taxa de erro: subiu de 0.2% para 4% nos últimos 30min.
  • Latência p99: subiu de 500ms para 8s.
  • Requests/s: estável.

Confirmado: problema real, intermitente, com timeout.

2 · Drill-down em traces (2 min)

Clique em "ver traces de erros desta janela". Vê 50 traces. Padrão visível:

  • 90% dos erros têm span charge_payment demorando 8s+ ou timeout.
  • Erro vem da chamada para api.stripe.com.
  • Outros spans (validação, persistência) normais.
3 · Confirmar com logs (1 min)

Filtrar logs com event=payment_failed no mesmo período. Veja causas:

  • reason="upstream_timeout" — 35 ocorrências.
  • reason="connection_refused" — 8 ocorrências.
  • reason="card_declined" — 7 ocorrências (normais, baseline).
4 · Confirmar com mundo externo (30s)

Abrir status.stripe.com. Sim: incidente reportado há 25 minutos. Latência elevada na região US-East.

5 · Mitigar (5 min)

Verificar runbook: existe fallback configurado? Sim — circuit breaker pode ser ativado manualmente para rotear para gateway secundário. Ativa. Métricas começam a normalizar em 2 minutos.

Post-mortem depois: por que circuit breaker não abriu automaticamente? Threshold estava em 50% de erro, mas atingimos 4% — circuit breaker não reagiu a degradação moderada. Ajuste configurado.

Tempo total: ~10 minutos para identificar causa e mitigar. Sem observabilidade integrada: provavelmente horas, com várias trocas de teoria entre engenheiros.

27.11 Erros comuns

Erro 1 · Achar que logs bastam

Sem métricas, você não vê tendências. Sem traces, não vê onde a latência mora. Logs sozinhos te fazem investigar caso a caso, perdendo o problema sistêmico.

Erro 2 · Cardinalidade explosiva

Métrica com user_id, order_id, request_id como labels. Sistema de métricas grita ou colapsa. Use logs e traces para dimensões altas.

Erro 3 · Alertas demais

Alertas em métricas que oscilam normalmente. Engenheiros ignoram. Alerta real passa despercebido. Menos alertas, mais úteis.

Erro 4 · Logs sem correlação

Logs em vários serviços, sem trace_id. Para entender um fluxo, junta na unha. Trace_id propagado entre serviços resolve.

Erro 5 · Instrumentar tudo no código manual

Adicionar tracer.start_span em cada função vira ruído. Use auto-instrumentação para boilerplate (HTTP, SQL); spans manuais só para lógica de negócio importante.

Erro 6 · Logar dados sensíveis

Senha, token, CPF, número de cartão. Logs acabam em sistemas com mais acesso que o app. Sanitize antes de logar.

Verifique seu entendimento
"Você quer rastrear latência da API para análise por usuário individual. Qual a melhor abordagem?"

27.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Pilar certo

Para cada necessidade, qual pilar usar (logs / métricas / traces)?

  1. Saber a taxa de erro da API por endpoint, ao longo do tempo
  2. Descobrir o que aconteceu com o pedido #42 que falhou
  3. Entender por que essa request específica demorou 8s
  4. Alertar quando latência p99 ultrapassar 1s
  5. Investigar como um único request percorre 5 microsserviços
  6. Auditar todas as ações administrativas dos últimos 6 meses
  7. Calcular SLO de disponibilidade do mês
  1. Métricas — agregação ao longo do tempo.
  2. Logs — caso específico com contexto.
  3. Traces — drill-down de uma requisição.
  4. Métricas — alertas baseados em agregação.
  5. Traces — fluxo entre serviços é o caso canônico.
  6. Logs — eventos discretos com histórico longo.
  7. Métricas — agregação de disponibilidade.
Médio
Exercício 2 · Logging estruturado

Refatore esse código para usar structlog com bind de contexto persistente em todo o fluxo:

antes.py
import logging
log = logging.getLogger(__name__)

def processar_pedido(pedido_id, usuario_id):
    log.info(f"processando pedido {pedido_id} do usuario {usuario_id}")
    try:
        validar(pedido_id)
        log.info(f"validado pedido {pedido_id}")
        cobrar(pedido_id, usuario_id)
        log.info(f"cobrado pedido {pedido_id}")
    except Exception as e:
        log.error(f"erro no pedido {pedido_id}: {e}")
        raise
depois.py
import structlog

def processar_pedido(pedido_id, usuario_id):
    log = structlog.get_logger().bind(
        pedido_id=pedido_id,
        usuario_id=usuario_id,
    )
    log.info("pedido_iniciado")
    try:
        validar(pedido_id)
        log.info("pedido_validado")
        cobrar(pedido_id, usuario_id)
        log.info("pedido_cobrado")
    except Exception as e:
        log.error("pedido_falhou", erro=str(e), exc_info=True)
        raise

# Resultado em JSON (assumindo configuração estruturada):
# {"event":"pedido_iniciado","pedido_id":"p1","usuario_id":"u1","ts":"..."}
# {"event":"pedido_validado","pedido_id":"p1","usuario_id":"u1","ts":"..."}
# {"event":"pedido_cobrado","pedido_id":"p1","usuario_id":"u1","ts":"..."}
# Query: event=pedido_falhou AND pedido_id=p1 → tudo trivial.
Médio
Exercício 3 · Métricas RED em FastAPI

Implemente middleware para FastAPI que registra métricas RED (Rate, Errors, Duration) usando prometheus_client. Inclua labels apropriados sem cardinalidade explosiva.

middleware_red.py
from prometheus_client import Counter, Histogram, make_asgi_app
from fastapi import Request
import time

# Rate + Errors (mesmo counter, agrupado por status)
http_requests = Counter(
    "http_requests_total",
    "Total HTTP requests",
    ["method", "route", "status_class"],  # status_class = 2xx, 4xx, 5xx
)

# Duration
http_duration = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration",
    ["method", "route"],
    buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
)

def _status_class(status: int) -> str:
    return f"{status // 100}xx"

@app.middleware("http")
async def prometheus_middleware(request: Request, call_next):
    inicio = time.perf_counter()
    try:
        response = await call_next(request)
        status = response.status_code
    except Exception:
        status = 500
        raise
    finally:
        duracao = time.perf_counter() - inicio
        # Use route template, não path com IDs! /users/{id} não /users/42
        route = request.scope.get("route")
        route_path = route.path if route else request.url.path

        http_requests.labels(
            method=request.method,
            route=route_path,
            status_class=_status_class(status),
        ).inc()
        http_duration.labels(
            method=request.method,
            route=route_path,
        ).observe(duracao)

    return response

# Expor endpoint /metrics
app.mount("/metrics", make_asgi_app())
Difícil
Exercício 4 · Definir SLO e alertas

Você é responsável pela API de pagamentos. Negócio diz que disponibilidade é crítica. Defina: (1) dois SLIs apropriados; (2) SLOs para 30 dias; (3) error budget resultante; (4) duas regras de alerta (uma crítica, uma warning); (5) o que cada alerta dispara.

SLI #1 — Disponibilidade:

Proporção de requests HTTP que retornam status 2xx ou 3xx (em relação a 2xx/3xx + 5xx). Erros 4xx são culpa do cliente, não contam.

SLI #2 — Latência:

Proporção de requests POST /pagamentos que retornam em < 1s.

SLOs (30 dias):

  • Disponibilidade: 99.9% (error budget: 0.1% ≈ 43min/mês de erro)
  • Latência: 99% < 1s (error budget: 1% das requests podem ser lentas)

Alertas:

  • Crítico (página on-call): burn rate 14× nos últimos 5min E últimos 1h. Significa que em ~2 dias o budget mensal acaba. Razão: incidente em andamento.
  • Warning (ticket no horário comercial): burn rate 6× nas últimas 6h. Degradação sustentada mas não catastrófica.

O que cada alerta dispara:

  • Crítico → PagerDuty para on-call, mensagem em canal #incidentes-pagamentos, link para runbook e dashboards.
  • Warning → Ticket Jira para o squad, marca como prioridade alta para o próximo dia útil.
Fim do capítulo 27
Próximo capítulo: Docker e containers. Como empacotar e rodar serviços de forma reproducível.
Parte VI · Capítulo 28 · Operação e equipe

Docker e
containers:
empacotar
direito.

Antes do Docker, "funcionou na minha máquina" era explicação aceita. Containers tornaram isso intolerável — se está dentro do container, funciona em qualquer máquina. A revolução não foi tecnológica; foi cultural.

Containers são onipresentes hoje. Toda equipe usa, todo provedor de nuvem aceita, todo framework moderno tem template Docker. O que separa usuários casuais de quem domina é entender o modelo (imagem, camadas, cache), saber escrever Dockerfile decente, e conhecer as armadilhas (imagens enormes, secrets vazando, container rodando como root). Esse capítulo cobre o necessário sem perder em detalhes raramente usados.

28.1 A história — chroot, LXC, Docker

Contexto histórico

A ideia de isolamento de processos é antiga. Em 1979, Unix V7 introduziu chroot — mudar o root do filesystem para um diretório, isolando processos dele. Era isolamento mínimo, fácil de escapar, mas conceito embrionário.

Em 2000, FreeBSD introduziu Jails — isolamento mais robusto, com rede e processos separados. Em 2005, Solaris lançou Zones. Linux ganhou cgroups em 2007 (Google contribuiu) e namespaces ao longo dos anos 2000.

Em 2008, LXC (Linux Containers) combinou cgroups + namespaces num produto utilizável. Mas a experiência era complicada — montar imagens, configurar redes, gerenciar volumes exigia engenharia significativa.

Em 2013, Solomon Hykes demonstrou Docker numa palestra de 5 minutos na PyCon. A inovação não foi o isolamento (LXC já fazia) — foi o modelo de imagens em camadas e a experiência de desenvolvedor. Comando docker run baixava imagem e rodava em segundos. Adoção explodiu.

Em 2014, Google liberou Kubernetes baseado em sua experiência interna (Borg). Em 2015, surgiu a OCI (Open Container Initiative) — padrão aberto para formato de imagem e runtime. Docker, hoje, segue OCI; outros runtimes (containerd, podman) implementam o mesmo padrão.

Em 2020, o Kubernetes removeu suporte direto ao Docker engine (continua suportando imagens OCI, que Docker produz). Em 2024-2025, Podman ganhou tração como alternativa daemonless, especialmente em empresas com requisitos de segurança mais altos. Mas Docker segue como padrão de desenvolvimento.

28.2 Container vs VM — o que muda

Máquina Virtual
  • Hypervisor + kernel próprio em cada VM.
  • Isolamento forte — VM "é uma máquina".
  • Boot: minutos.
  • Tamanho: gigabytes.
  • Overhead: alto.
  • Bom para: isolamento total, hosts compartilhados, OS diferente.
Container
  • Compartilha kernel do host; usa namespaces/cgroups.
  • Isolamento bom, mas mais fraco que VM.
  • Boot: segundos.
  • Tamanho: megabytes a poucos GB.
  • Overhead: baixíssimo.
  • Bom para: empacotar apps, micro-serviços, ambientes reproducíveis.

Containers não substituem VMs em todos os casos — eles complementam. Padrão moderno: VMs rodam o host de produção; containers rodam dentro das VMs. Cada um com seu papel.

28.3 Imagem vs container — o conceito central

Confusão comum: imagem e container são coisas diferentes.

A partir de uma imagem você pode criar zero, um ou milhares de containers. Cada um isolado, com filesystem próprio (a camada de escrita), mas compartilhando o conteúdo imutável da imagem (eficiente em disco e memória).

basicos.sh
# Baixar imagem
docker pull python:3.12-slim

# Listar imagens locais
docker images

# Rodar container a partir de imagem
docker run -it python:3.12-slim python -c "print('hello')"

# Rodar em background, expondo porta
docker run -d --name api -p 8000:8000 minha-app:1.0

# Listar containers rodando
docker ps

# Logs do container
docker logs -f api

# Entrar em container rodando
docker exec -it api bash

# Parar e remover
docker stop api && docker rm api

28.4 Dockerfile — receita da imagem

Dockerfile é o arquivo declarativo que define como construir a imagem. Cada linha cria uma camada — vamos falar sobre isso em breve.

Dockerfile
# Imagem base
FROM python:3.12-slim

# Diretório de trabalho dentro do container
WORKDIR /app

# Variáveis de ambiente
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Copia arquivo de dependências primeiro (cache!)
COPY requirements.txt .

# Instala dependências
RUN pip install --no-cache-dir -r requirements.txt

# Copia código (depois das deps — só invalida cache quando muda)
COPY . .

# Porta que o app escuta
EXPOSE 8000

# Comando padrão
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Comandos principais

.dockerignore

Análogo ao .gitignore. Lista o que o COPY . deve ignorar. Crucial para imagens pequenas e build rápido.

.dockerignore
__pycache__
*.pyc
.git
.venv
.env
.env.*
node_modules
.pytest_cache
.mypy_cache
.ruff_cache
*.log
tests/
docs/
README.md

Sem .dockerignore, COPY . copia .git de 500MB, node_modules que nem é usado, etc. Imagem incha sem propósito.

28.5 Multi-stage build — imagens pequenas

Você precisa de ferramentas de build (compiladores, etc) para construir o app, mas não para rodá-lo. Multi-stage build separa: um estágio constrói, outro só carrega o resultado final.

Dockerfile (multi-stage)
# Estágio 1: build (com ferramentas pesadas)
FROM python:3.12 AS builder

WORKDIR /app
COPY requirements.txt .

# Instala em ambiente virtual isolado para copiar depois
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt

# Estágio 2: runtime (mínimo)
FROM python:3.12-slim AS runtime

# Copia só o venv pronto
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

WORKDIR /app
COPY . .

# Usuário não-root
RUN useradd --create-home --shell /bin/bash app
USER app

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

Resultado: imagem final inclui só Python + venv com dependências + código. Sem compiladores, sem cache de pip, sem ferramentas de build. Tipicamente reduz imagem em 30-70%.

Imagens base — escolha com critério

BaseTamanhoQuando usar
python:3.12~1GBBuild stage; quando precisa de tudo (compiladores, etc)
python:3.12-slim~150MBRuntime padrão. Debian mínimo. Default recomendado.
python:3.12-alpine~60MBMenor, mas usa musl libc — bibliotecas C às vezes não compilam. Cuidado.
gcr.io/distroless/python3~50MBSem shell, sem package manager. Máximo de segurança, mínimo debug.

Recomendação geral: slim para runtime. Alpine só se imagem pequena for crítica e você confirmar que as bibliotecas funcionam.

28.6 Camadas e cache — entendendo o modelo

Cada instrução do Dockerfile cria uma camada. Camadas são imutáveis e cacheadas. Docker reusa camadas quando os inputs não mudaram — é por isso que ordem importa.

ruim.dockerfile
# RUIM: código copiado antes das deps
FROM python:3.12-slim
WORKDIR /app
COPY . .                                # muda em todo commit → invalida tudo
RUN pip install -r requirements.txt   # reinstala TUDO em cada build

# BOM: deps copiadas e instaladas antes do código
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .                # muda RARO → cache funciona
RUN pip install -r requirements.txt   # cacheado quando deps não mudam
COPY . .                                # muda muito → invalida só daqui pra frente

Sem cache: cada build do app puxa todas as dependências do PyPI (lento, fragil em rede). Com cache: build do código incremental fica em 5-10s.

Combinar comandos para reduzir camadas

camadas.dockerfile
# RUIM: três camadas, lixo persistido
RUN apt-get update
RUN apt-get install -y build-essential libpq-dev
RUN apt-get clean

# BOM: uma camada, lixo descartado
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        build-essential \
        libpq-dev \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

28.7 Docker Compose — múltiplos containers em dev

App típico em dev tem: aplicação + Postgres + Redis + talvez RabbitMQ. Cada um seria um docker run com várias flags. Compose define isso em YAML.

docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - "./src:/app/src"  # hot reload em dev

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    volumes:
      - "pg_data:/var/lib/postgresql/data"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - "redis_data:/data"

volumes:
  pg_data:
  redis_data:

Comandos do dia a dia:

Compose é para dev e teste, não produção. Em produção: Kubernetes, ECS, Cloud Run, ou orchestration similar.

28.8 Preparando para produção

Dockerfile que roda em dev frequentemente tem problemas em produção. Checklist do que mudar:

Usuário não-root

Por default, container roda como root. Se atacante escapa, vira root no host (em alguns runtimes/configs). Sempre defina USER.

user.dockerfile
RUN groupadd -r app && useradd -r -g app app
USER app
# Comandos seguintes rodam como user app, não root.

Health check

Container saudável != processo rodando. Pode estar travado, sem responder. Health check define como saber.

healthcheck.dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Orchestrator (k8s) usa para decidir reiniciar pod ou tirar de load balancer.

Sinais e graceful shutdown

Quando container recebe SIGTERM, ele deveria terminar requests em andamento e desligar limpo. Em Python:

Configuração via ambiente

Nunca hardcode configuração no Dockerfile. Use variáveis de ambiente lidas em runtime.

config.py
import os
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    log_level: str = "INFO"
    debug: bool = False

    class Config:
        env_file = ".env"

settings = Settings()
# pydantic-settings carrega de env vars automaticamente

Logs para stdout

Container deveria escrever logs em stdout/stderr. Plataforma (Docker, k8s) coleta dali e roteia para destino central. Não escreva em arquivo dentro do container — perde-se quando o container morre.

28.9 Segurança em containers

Checklist mínimo de segurança
  1. Imagem base oficial e atualizada. Não use FROM ubuntu:bionic de 2018.
  2. Não rode como root. Sempre USER não-root.
  3. Não embuta secrets na imagem. Nunca ENV API_KEY=.... Inject em runtime.
  4. Escaneie imagens. trivy, grype, Snyk. CVEs em camadas base aparecem aqui.
  5. Pinne versões. FROM python:3.12.5-slim, não python:latest. Builds reproducíveis.
  6. Read-only filesystem quando possível. --read-only no run; volumes para o que precisa de escrita.
  7. Drop capabilities. Containers têm capacidades Linux por default; drop o que não precisa.

Secrets em runtime

secrets.sh
# ❌ NUNCA — secret embutido na imagem (fica no histórico de camadas)
# Dockerfile:
#   ENV STRIPE_KEY=sk_live_abc123

# ✓ Em dev (compose) — env file (não commitado)
docker compose --env-file .env.local up

# ✓ Em prod — secrets do orchestrator
# Kubernetes: Secret objects, montados como env vars ou arquivos
# Docker Swarm: docker secret
# ECS: Secrets Manager integration
# Cloud Run: Secret Manager integration

28.10 Orquestração — uma nota

Containers sozinhos resolvem empacotamento. Para escalar, fazer rolling updates, autoscaling, service discovery, balanceamento, health-aware routing — você precisa de orquestrador.

Para projeto pequeno, Cloud Run ou ECS Fargate são frequentemente melhores que k8s. Adote k8s quando complexidade justifica — múltiplos serviços, time dedicado de plataforma.

28.11 Estudo de caso — Dockerfile de produção

Do quick-start ao pronto-para-prod

Você começou com Dockerfile rápido para testar. Agora vai pra produção. Vamos evoluir, mostrando o que muda.

Versão 1 · Quick-start (não usar em prod)
Dockerfile.v1
FROM python:latest
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD python -m uvicorn main:app

# Problemas:
# - "latest" não é reproducível
# - imagem cheia (1GB+), tudo do Python
# - roda como root
# - sem .dockerignore — pega .git, .env etc
# - sem cache de deps (COPY . . antes do pip install)
# - CMD em shell form (sinais não propagam)
# - sem health check
# - sem host 0.0.0.0 — não acessível de fora
Versão 2 · Decente (já melhor)
Dockerfile.v2
FROM python:3.12.5-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Deps primeiro — cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

# Melhorou:
# - versão pinada
# - imagem slim
# - cache de deps
# - CMD em exec form
# Ainda falta:
# - usuário não-root
# - multi-stage (libs de build no runtime)
# - health check
Versão 3 · Production-ready
Dockerfile
# ============================
# Estágio 1: build
# ============================
FROM python:3.12.5-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

# Libs de build apenas neste estágio
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        build-essential \
        libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Virtualenv para isolar dependências
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ============================
# Estágio 2: runtime
# ============================
FROM python:3.12.5-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

# Só libs runtime necessárias (libpq5 para psycopg2)
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        libpq5 \
        curl \
    && rm -rf /var/lib/apt/lists/*

# Usuário não-root
RUN groupadd -r app && useradd -r -g app -m -d /home/app app

# Copia venv pronto do builder
COPY --from=builder /opt/venv /opt/venv

WORKDIR /app
COPY --chown=app:app . .

USER app

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD curl -fsS http://localhost:8000/health || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Resultado final:

  • Imagem ~180MB (v1 era ~1.1GB).
  • Roda como usuário app.
  • Health check ativo — orchestrator sabe se está saudável.
  • Build cacheado — incrementos rodam em segundos.
  • Sem ferramentas de build no runtime (menos superfície de ataque).
  • Reproducível — versão pinada do Python.

28.12 Erros comuns

Erro 1 · FROM python:latest

Imagem muda no tempo. Build "funciona" hoje, quebra amanhã sem você mexer. Sempre pinne versão: python:3.12.5-slim.

Erro 2 · COPY antes das deps

Cada mudança no código invalida cache de install. Build de 30s vira 3min. Copie requirements primeiro, instale, depois copie código.

Erro 3 · Embutir secrets na imagem

ENV STRIPE_KEY=sk_live.... A imagem é distribuída; secret vaza. Use orchestrator secrets, env files locais, ou cofres.

Erro 4 · Rodar como root

Default. Atacante escapa do container → root no host (em algumas configs). Sempre USER não-root.

Erro 5 · Logs em arquivo dentro do container

Container morre, logs vão junto. Sempre stdout/stderr — orchestrator coleta de lá.

Erro 6 · Sem .dockerignore

.git de 500MB vai pra imagem. node_modules de 1GB também. Tempo de build longo, imagem inchada. Crie .dockerignore sempre.

Verifique seu entendimento
"Você adicionou uma nova dependência ao requirements.txt e fez build. Build levou 5min, mesmo o código não tendo mudado. O Dockerfile tem COPY . . antes de RUN pip install. Por quê?"

28.13 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Comandos básicos

Para cada situação, qual o comando Docker?

  1. Listar todos os containers (incluindo parados)
  2. Ver os últimos 100 logs de um container e seguir
  3. Entrar em um container rodando para inspecionar
  4. Remover todas as imagens não utilizadas
  5. Construir imagem com tag app:v1.2 a partir do Dockerfile no diretório atual
  6. Rodar container com nome web, expondo porta 8000 do container na 80 do host, em background
  1. docker ps -a
  2. docker logs --tail 100 -f <container>
  3. docker exec -it <container> bash (ou sh em alpine/slim sem bash)
  4. docker image prune -a
  5. docker build -t app:v1.2 .
  6. docker run -d --name web -p 80:8000 app:v1.2
Médio
Exercício 2 · Dockerfile decente

Escreva Dockerfile para uma aplicação FastAPI Python. Requisitos: imagem base slim com versão pinada; cache de dependências; usuário não-root; health check no endpoint /health; comando em exec form; expose da porta.

Dockerfile
FROM python:3.12.5-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Deps primeiro
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Código
COPY . .

# Usuário não-root
RUN useradd --create-home --shell /bin/bash app \
    && chown -R app:app /app
USER app

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -fsS http://localhost:8000/health || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Médio
Exercício 3 · Compose para dev

Escreva docker-compose.yml para ambiente de desenvolvimento com: app Python (build do Dockerfile local), PostgreSQL, Redis, RabbitMQ. App depende de todos os outros estarem saudáveis. Inclua volumes persistentes para banco e healthcheck do Postgres.

docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: "postgres://user:pass@db:5432/app"
      REDIS_URL: "redis://cache:6379/0"
      RABBITMQ_URL: "amqp://guest:guest@queue:5672/"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
      queue:
        condition: service_started
    volumes:
      - "./src:/app/src"

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    volumes:
      - "pg_data:/var/lib/postgresql/data"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d app"]
      interval: 5s
      timeout: 3s
      retries: 5

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - "redis_data:/data"

  queue:
    image: rabbitmq:3-management-alpine
    ports:
      - "15672:15672"  # UI de gestão
    volumes:
      - "rabbitmq_data:/var/lib/rabbitmq"

volumes:
  pg_data:
  redis_data:
  rabbitmq_data:
Difícil
Exercício 4 · Auditar Dockerfile

Analise o Dockerfile abaixo. Liste TODOS os problemas (security, performance, reprodutibilidade). Sugira correção para cada.

Dockerfile.ruim
FROM python:latest

RUN apt-get update
RUN apt-get install -y curl wget gcc
RUN apt-get install -y libpq-dev

COPY . /app
WORKDIR /app

RUN pip install -r requirements.txt

ENV DATABASE_URL=postgres://user:senha123@prod-db:5432/app
ENV API_KEY=sk_live_abc123def456

EXPOSE 8000
CMD python -m uvicorn main:app --host 0.0.0.0
#ProblemaCorreção
1python:latest não é reproducívelPinne: python:3.12.5-slim
2Imagem completa (1GB+) sem necessidadeUse slim ou multi-stage
3Três RUN apt-get install separados — três camadasCombine em um único RUN
4Sem apt-get clean nem rm -rf /var/lib/apt/lists/*Limpe no mesmo RUN
5COPY . /app antes de pip install — invalida cacheCopie requirements primeiro, instale, depois copie código
6Sem .dockerignore (presumível)Crie .dockerignore com .git, .env, __pycache__, etc
7Sem --no-cache-dir no pip — cache fica na imagemAdicione flag para pip
8Secrets na imagem! DATABASE_URL com senha + API_KEY embutidosInject em runtime via env do orchestrator
9Roda como rootCrie usuário, use USER
10Sem health checkAdicione HEALTHCHECK
11CMD em shell form — sinais não propagamUse exec form: CMD ["uvicorn", ...]
12Falta --port no comandoEspecifique a porta explicitamente
13Falta PYTHONUNBUFFERED=1Adicione para logs irem direto ao stdout
14gcc/libpq-dev ficam no runtime (libs de build)Multi-stage: build em estágio separado
Fim do capítulo 28
Próximo capítulo: Git e code review. Como times de software colaboram em código.
Parte VI · Capítulo 29 · Operação e equipe

Git e
code review:
código em
equipe.

Saber Git é trivial; usar Git bem é raro. Times entregam software de qualidade quando o histórico do repositório é claro, branches têm propósito, PRs são reviewáveis, e code review entrega aprendizado mútuo. Isso não vem por acaso — vem por disciplina.

Você já sabe git add, git commit, git push. Este capítulo é sobre o resto: como pensar em commits como unidade de comunicação, escolher estratégia de branch com critério, fazer e receber code review que agregue. As práticas aqui distinguem engenheiros que crescem em time de engenheiros que apenas entregam código.

29.1 A história — de SVN ao DAG distribuído

Contexto histórico

Antes do Git, controle de versão era centralizado. CVS (1990), Subversion (2000) — um servidor único, branches caros, merges dolorosos. Times tinham dia para "fazer merge da semana". Cultura de branch curto não existia.

Em 2005, conflito político fez o time do Linux perder acesso ao BitKeeper, o sistema proprietário que usavam. Linus Torvalds escreveu Git em duas semanas com objetivos claros: distribuído (cada clone é repositório completo), rápido (commits/branches instantâneos), íntegro (cada objeto identificado por hash SHA-1).

A revolução foi o modelo distribuído. Branches viraram baratos. Merge ficou viável diariamente. GitHub (2008) adicionou camada social — pull requests, code review como conversa pública, fork como mecânica de colaboração. Mudou a cultura tanto quanto a ferramenta mudou.

Em 2010-2015, Git venceu definitivamente. SVN sobrevive em legados; Mercurial perdeu mercado; CVS é arqueologia. Em 2018, GitHub anunciou substituição do SHA-1 por SHA-256 (em curso). Em 2020, o branch padrão passou de master para main em novos repos.

Hoje, Git é commodity. Mas estratégias de uso variam muito: Trunk-based Development (Google, Facebook), Git Flow (decadente), GitHub Flow (mais comum), Stacked Diffs (Phabricator, Graphite, Gerrit). Escolha depende do time, não da ferramenta.

29.2 Modelo mental do Git

Confusão comum: Git não armazena diffs. Armazena snapshots. Cada commit é uma foto completa do estado dos arquivos, identificada por hash.

Quatro conceitos centrais:

Cada commit aponta para parent(s) — formando um grafo acíclico direcionado (DAG). Branches são ponteiros para commits. main é apenas um nome apontando para o último commit dessa "linha".

dag.txt
             A---B---C---D  (main)
                  \
                   E---F  (feature/login)

# A, B, C, D, E, F são commits (hashes).
# main aponta para D. feature/login aponta para F.
# F tem parent E, que tem parent B.
# Merge de feature/login em main cria commit G com 2 parents (D e F).

Entender isso muda como você usa o Git. Comandos como rebase, cherry-pick, reflog ficam intuitivos quando você visualiza o DAG.

29.3 Commits que contam — o ofício esquecido

Commits são unidade de comunicação, não só de salvamento. Bom commit:

Conventional Commits

Convenção popular para mensagens:

conventional.txt
# Formato:
# <tipo>(escopo opcional): <descrição curta>
#
# <corpo opcional>
#
# <footer opcional>

feat(pagamentos): adiciona suporte a Pix

Implementa integração com Celcoin como provider de Pix.
Suporta QR Code estático e dinâmico. Tratamento de webhook
em /webhooks/pix.

Closes #234

# Tipos comuns:
# feat:     nova funcionalidade
# fix:      correção de bug
# refactor: refactor sem mudar comportamento
# perf:     melhoria de performance
# test:     testes
# docs:     documentação
# chore:    tooling, build, deps
# ci:       configuração de CI

Bom vs ruim — exemplos reais

✗ Mensagens ruins
ruim.log
fix
WIP
ajustes
asdf
update
correções
bug
final final v2
mudanças

Em 6 meses, ninguém entende nada do histórico. Investigar bug fica detetive.

✓ Mensagens úteis
bom.log
feat(checkout): adiciona Pix como método

fix(pedidos): trata timeout do Stripe sem duplicar pedido

refactor(usuarios): extrai validação de CPF para Value Object

perf(busca): adiciona índice GIN em produtos.descricao

docs(api): atualiza exemplos do endpoint POST /pedidos

chore: atualiza FastAPI de 0.110 para 0.115

Por que importa

git log é a memória institucional do projeto. Quando alguém procura "por que essa linha está assim" daqui a dois anos, vai chegar no commit. Mensagem boa responde; mensagem ruim deixa a pessoa desistir e tentar adivinhar.

29.4 Estratégias de branch

Git Flow — caiu em desuso

Vincent Driessen, 2010. Branches master, develop, feature/*, release/*, hotfix/*. Complexo. Era padrão para releases mensais com tooling rudimentar. Hoje é considerado overkill para a maioria dos times — burocracia sem ganho.

GitHub Flow — o mais comum

Branch main sempre deployável. Cada feature/fix em branch curto, PR pra main, deploy assim que merged. Simples, funciona pra maioria.

Fluxo:

  1. Crie branch a partir de main: git checkout -b feat/pix-checkout
  2. Trabalhe, commit, push.
  3. Abra PR pra main.
  4. CI roda, reviewers aprovam.
  5. Merge.
  6. Deploy (automático ou manual).

Trunk-Based Development — para times maduros

Todo mundo commita direto em main (ou faz PRs muito curtos, mergeados em horas). Branches longas são proibidas. Requer: feature flags, testes excelentes, CI rápido, deploy frequente.

Vantagens: nunca há "branch divergente"; integração contínua de verdade. Desvantagens: exige disciplina e infra séria.

Como escolher

Importante: escolha uma e seja consistente. Time fazendo "meio Git Flow, meio GitHub Flow" gera confusão maior que qualquer estratégia.

29.5 Merge, rebase, squash — três jeitos de integrar

Três formas de trazer mudanças de uma branch para outra. Cada uma com semântica diferente.

Merge — preserva o histórico

Cria commit de merge. Histórico mostra exatamente como o trabalho aconteceu, incluindo paralelismo.

merge.txt
# Antes:
#     A---B---C  (main)
#          \
#           D---E---F  (feature)

# git checkout main
# git merge feature

# Depois:
#     A---B---C---------M  (main)
#          \           /
#           D---E---F  (feature)
# M é commit de merge com 2 parents (C e F).

Rebase — reescreve para parecer linear

Pega seus commits e os "replanta" no topo da main atual. Histórico fica linear, sem merges.

rebase.txt
# Antes:
#     A---B---C  (main)
#          \
#           D---E---F  (feature)

# git checkout feature
# git rebase main

# Depois:
#     A---B---C---D'---E'---F'  (feature)
# (D, E, F viraram D', E', F' — novos commits com mesmo conteúdo)
# (a branch original foi reescrita; force-push é necessário)

Squash — compacta em um commit só

Pega todos os commits da branch e faz um. Histórico mostra "feature X" como ato único.

squash.txt
# Antes:
#     A---B---C  (main)
#          \
#           D---E---F  (feature, com WIP/fixup/etc)

# Merge com squash:
#     A---B---C---S  (main)
# S contém TODAS as mudanças de D+E+F como um único commit.
# Os commits originais ficaram perdidos (continuam na branch local até ser apagada).

Como escolher

EstratégiaQuando usar
Merge commitBranches longas e bem documentadas (Git Flow). Times que valorizam histórico fiel.
Rebase + merge fast-forwardTimes que querem histórico linear mas preservar commits individuais bem feitos.
Squash and mergeBranches com muitos commits "wip", fixup, etc — vira um commit limpo na main. Padrão mais usado em GitHub Flow.
A regra de ouro do rebase
Nunca rebase commits que já foram pushed e compartilhados. Rebase reescreve histórico — quem clonou antes vai ficar com história diferente, gerando conflitos absurdos. Rebase só na sua branch local antes do push, ou em branches que só você usa.

29.6 Resgates — quando algo dá errado

Git é difícil de quebrar de forma irrecuperável, se você sabe os comandos certos.

Desfazer commit local (não pushed)

desfazer.sh
# Mantém mudanças no working dir, "des-commita"
git reset --soft HEAD~1

# Mantém mudanças no working dir, sai do staging
git reset HEAD~1

# DESCARTA mudanças (cuidado!)
git reset --hard HEAD~1

Reverter commit já pushed (sem reescrever histórico)

revert.sh
# Cria NOVO commit que desfaz o anterior. Histórico preservado.
git revert abc1234

# Reverter merge:
git revert -m 1 <hash do merge>

Reflog — sua rede de segurança

git reflog registra todas as movimentações de HEAD nos últimos 90 dias (default). Mesmo se você "perdeu" um commit, ele provavelmente está lá.

reflog.sh
git reflog
# a3f1b2c HEAD@{0}: reset: moving to HEAD~1
# d4e5f6a HEAD@{1}: commit: feat: importante
# ...

# "Reset arrast..., agora quero d4e5f6a de volta":
git reset --hard d4e5f6a
# Commit recuperado.

Bisect — encontrando o commit que quebrou

bisect.sh
# Sistema funcionava 2 semanas atrás, hoje quebrou.
# Bisect faz busca binária no histórico.
git bisect start
git bisect bad                  # commit atual quebrado
git bisect good v1.2.0          # versão que funcionava

# Git checkout commit intermediário. Você testa.
git bisect good                 # OU git bisect bad

# Repete até achar o commit exato que introduziu o bug.
# Em 100 commits, ~7 iterações.
git bisect reset

Cherry-pick — pegar commit de outra branch

cherrypick.sh
# Aplicar commit específico na sua branch atual
git cherry-pick abc1234

# Útil para: hotfix que precisa ir pra produção e desenvolvimento;
# isolar mudança boa de uma branch ruim; experimentar.

29.7 Pull Requests — a unidade da colaboração

Em GitHub/GitLab/Bitbucket, a forma de incorporar trabalho no main é via Pull Request (PR) ou Merge Request (MR). Não é cerimônia — é checkpoint de qualidade.

O que faz um bom PR

  1. Tamanho razoável: 50-400 linhas de diff. Acima de 1000, reviewer perde foco. Quebre em PRs menores quando possível.
  2. Escopo único: uma mudança. Não misture refactor com nova feature com fix de bug.
  3. Descrição clara: o que mudou, por quê, como testar.
  4. Build verde: CI passa, testes rodam, lint sem erros.
  5. Self-review primeiro: antes de pedir review humano, você revisa seu próprio PR no GitHub. Acha 30% dos problemas você mesmo.

Template de PR

PULL_REQUEST_TEMPLATE.md
## O que muda

Descreva em 1-2 frases o que esta PR faz.

## Por quê

Contexto / motivação. Link para issue, RFC, ADR se houver.

## Como testar

Passos para validar localmente. Cenários cobertos pelos testes.

## Checklist

- [ ] Testes adicionados/atualizados
- [ ] Documentação atualizada (se necessário)
- [ ] Breaking changes documentadas
- [ ] Migração necessária (se aplicável)

## Screenshots / logs

(Se relevante)

Quando o PR é repetitivo (chore, dependabot), o template pode ser ignorado. Para mudanças de produto, ele força você a pensar antes de pedir review.

Drafts e rascunhos

GitHub e GitLab têm modo "draft" de PR. Use enquanto trabalha — sinaliza "ainda não pronto", mas permite CI rodar e colegas verem o trabalho em andamento. Tirar do draft significa "pronto para review".

29.8 Code review — o que separa times bons de excelentes

Code review entrega mais que "achar bugs". Entrega disseminação de conhecimento, padronização orgânica, mentoria, e qualidade. Mal feito, vira fonte de fricção, atraso, ressentimento.

O que reviewar (e em que ordem)

  1. Entender o objetivo antes do código. Leia descrição do PR. Não trate como "puzzle a resolver".
  2. Arquitetura: a abordagem está certa? Se tem problema aqui, comentários sobre vírgula são desperdício.
  3. Correção: a lógica está certa? Edge cases? Concorrência? Erros tratados?
  4. Testes: cobrem o que importa? Não só caminho feliz.
  5. Legibilidade: nomes, organização, simplicidade.
  6. Estilo: só se linter não pegou. Comentar style manualmente é desperdício de tempo humano.

Como comentar

Tom importa muito. Mesmas palavras, ditas de jeitos diferentes, têm efeitos opostos.

✗ Frustrante
  • "Isso está errado."
  • "Por que você fez assim?"
  • "Eu não faria desse jeito."
  • "-1, rejeitado."
  • "Quem te ensinou Python?"
✓ Construtivo
  • "Aqui pode ter um edge case quando X é vazio — você testou?"
  • "Existe uma forma alternativa usando Y que tem benefício Z. O que acha?"
  • "Acho que vale extrair isso em função própria para facilitar teste — opinião?"
  • "Discordo desta abordagem porque... [argumento técnico]. Mas aceito se você defender com razão."
  • "Nit: pequeno detalhe de estilo, não bloqueia."

Convenções úteis

Tempo de resposta

Reviews demoradas matam produtividade. Convenção saudável:

PR que fica 5 dias sem review apodrece — conflitos com main, autor esquece contexto. Times que tratam review como prioridade entregam mais que times que tratam como interrupção.

O que reviewer NÃO deveria fazer

29.9 Estudo de caso — code review em PR real

Como reviewar um PR de feature

Colega abre PR adicionando endpoint de cancelamento de pedido. Vamos passar por ele com olhos críticos e construtivos.

PR submetido
cancel.py (proposto)
@app.post("/pedidos/{id}/cancelar")
def cancelar(id: str, motivo: str):
    pedido = repo.get(id)
    pedido.status = "cancelado"
    pedido.motivo = motivo
    repo.salvar(pedido)
    return {"ok": True}
Review estruturada

Arquitetura:

  • Comentário sugestão: "O endpoint faz operação de domínio (cancelar pedido). Considera mover lógica para um CancelarPedidoUseCase? Facilita testes e segue o padrão dos outros endpoints (referência: CriarPedidoUseCase)."
  • Comentário sugestão: "URL /pedidos/{id}/cancelar tem verbo. Considera POST /pedidos/{id}/cancelamento seguindo a convenção REST adotada no projeto (ADR-0014)?"

Correção:

  • Comentário blocker: "Falta autenticação/autorização — qualquer um pode cancelar pedido de qualquer outro. Precisa do Depends(get_usuario_atual) e check de ownership."
  • Comentário blocker: "repo.get(id) pode retornar None — vai dar AttributeError em pedido.status. Trate 404."
  • Comentário blocker: "Pedido já entregue pode ser cancelado nesse fluxo? Domínio deveria recusar (regra de negócio). Sugiro chamar pedido.cancelar(motivo) que valida estado."
  • Comentário question: "Quando cancelar pedido pago, há reembolso ou estorno? Esse endpoint deveria disparar isso?"

Testes:

  • Comentário blocker: "Sem testes. Adicione pelo menos: sucesso, pedido inexistente (404), sem autenticação (401), pedido de outro usuário (403), pedido já entregue (409)."

Legibilidade:

  • Comentário nit: "motivo: str no parameter está bom, mas validar tamanho mínimo (5 chars) ajuda. Considera um Pydantic model?"
  • Comentário nit: "return {'ok': True} não traz informação útil. Status code 200 já basta — considere retornar dados do pedido cancelado ou 204 No Content."
Versão revisada (depois do diálogo)
cancel_v2.py
class CancelarPedidoRequest(BaseModel):
    motivo: str = Field(..., min_length=5, max_length=500)

class PedidoResponse(BaseModel):
    id: str
    status: str
    cancelado_em: datetime
    motivo: str

@app.post(
    "/pedidos/{id}/cancelamento",
    response_model=PedidoResponse,
    status_code=200,
)
async def cancelar_pedido(
    id: str,
    req: CancelarPedidoRequest,
    usuario=Depends(get_usuario_atual),
    uc: CancelarPedidoUseCase = Depends(),
):
    try:
        pedido = uc.executar(id=id, motivo=req.motivo, usuario_id=usuario.id)
    except PedidoNaoEncontrado:
        raise HTTPException(404)
    except SemPermissao:
        raise HTTPException(403)
    except PedidoJaEntregue:
        raise HTTPException(409, "pedido entregue não pode ser cancelado")

    return PedidoResponse.de_dominio(pedido)

Mais 5 testes adicionados cobrindo cenários. PR aprovado.

O que o autor levou: conhecimento sobre arquitetura do projeto, padrões adotados, casos de teste importantes. O que o reviewer levou: prática de articular feedback técnico de forma construtiva. Code review bem feito é ferramenta de crescimento dos dois lados.

29.10 Erros comuns

Erro 1 · Commits gigantes

"Feature X completa" em um commit de 5000 linhas. Impossível reviewar com cuidado. Quebre em commits que façam sentido isoladamente.

Erro 2 · Mensagem "ajustes"

Em 6 meses, ninguém vai entender. Investigue com curiosidade: o que esse commit faz? Por quê? Resposta na mensagem.

Erro 3 · Rebase em branch compartilhada

Você reescreve histórico de algo que outros têm. Eles entram em conflito impossível. Rebase só local ou em branch sua exclusiva.

Erro 4 · Force-push em main

Reescreve histórico do branch principal. Apaga commits dos outros. Configure proteção: GitHub permite "protect branch" desabilitando force-push.

Erro 5 · Code review como ego

Reviewer faz comentários para mostrar conhecimento, não para ajudar. Bloqueia PR sem motivo técnico. Cria fricção sem entregar valor.

Erro 6 · Aprovar sem ler

Rubber-stamp. Mata o propósito de code review e diluí confiança do time. Ou revise sério, ou se declare ocupado.

Erro 7 · Commitar dados sensíveis

.env, chaves API, senhas vão pro Git. Mesmo deletando depois, ficam no histórico. Use .gitignore e ferramentas de pre-commit (gitleaks) para prevenir.

Verifique seu entendimento
"Você fez 5 commits em sua branch local. Já pushed para o remote. Percebeu que o terceiro commit tem mensagem ruim. Qual a abordagem certa?"

29.11 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · Comandos Git

Para cada situação, qual o comando?

  1. Ver o que você modificou desde o último commit (sem fazer staging)
  2. Ver últimos 10 commits em formato resumido
  3. Criar branch nova chamada feat/pix e fazer checkout nela
  4. Descartar todas as mudanças locais não-commitadas em um arquivo
  5. Atualizar sua branch atual com mudanças da main, usando rebase
  6. Listar branches locais
  7. Apagar branch local que já foi mergeada
  1. git diff
  2. git log --oneline -10
  3. git checkout -b feat/pix (ou git switch -c feat/pix)
  4. git checkout -- <arquivo> (ou git restore <arquivo>)
  5. git pull --rebase origin main
  6. git branch
  7. git branch -d feat/pix (use -D para forçar se não mergeada)
Fácil
Exercício 2 · Mensagens de commit

Reescreva cada mensagem ruim em forma boa (Conventional Commits):

  1. fixes
  2. tests
  3. updated lib
  4. refactor
  5. WIP merge later

Exemplos de melhorias (depende do contexto real, claro):

  1. fix(pedidos): corrige cálculo de imposto quando frete é zero
  2. test(usuarios): adiciona casos de borda para validação de CPF
  3. chore: atualiza FastAPI de 0.110 para 0.115
  4. refactor(pagamentos): extrai cálculo de taxa para Value Object
  5. Não commite WIP em main. Em branch própria pode, mas com mensagem informativa: wip: rascunho da integração Pix (não mergear)
Médio
Exercício 3 · Reviewar um PR

Você é reviewer do PR abaixo. Liste comentários estruturados (arquitetura, correção, testes, legibilidade). Indique quais são blockers.

PR.py
@app.get("/usuarios")
def listar(filtro: str = None):
    cursor = conn.cursor()
    if filtro:
        cursor.execute(f"SELECT * FROM usuarios WHERE nome LIKE '%{filtro}%'")
    else:
        cursor.execute("SELECT * FROM usuarios")
    return cursor.fetchall()

Correção (blockers):

  • Blocker: SQL injection — concatenação direta de filtro. Use query parametrizada: cursor.execute("... WHERE nome LIKE %s", (f'%{filtro}%',)).
  • Blocker: SELECT * retorna senha_hash junto. Liste campos explícitos.
  • Blocker: Sem autenticação — qualquer um lista usuários do sistema.
  • Blocker: Sem paginação. 1M de usuários derruba o servidor.

Arquitetura:

  • Sugestão: usar repository pattern adotado no resto do projeto (referência: outros endpoints).
  • Sugestão: retorno como DTO Pydantic em vez de tuple do cursor.

Testes:

  • Blocker: Sem testes. Adicionar: sem filtro retorna lista; com filtro filtra; sem auth retorna 401; paginação funcionando.

Legibilidade:

  • Nit: nome filtro é vago. busca_nome ou q (convenção) é melhor.
  • Nit: response_model do Pydantic ajuda na documentação OpenAPI.
Difícil
Exercício 4 · Cenário de resgate

Você está em feat/checkout. Por engano, rodou git reset --hard HEAD~3 e perdeu 3 commits importantes que estavam só locais (não pushed). Descreva passo a passo como recuperar.

Passo a passo:

  1. Não entre em pânico e não rode mais comandos destrutivos. Os commits não foram deletados — só não há ponteiro pra eles.
  2. Veja o reflog:
    $
    git reflog
    # a3b4c5d HEAD@{0}: reset: moving to HEAD~3
    # d6e7f8a HEAD@{1}: commit: feat: validação de cartão
    # 9c0d1e2 HEAD@{2}: commit: feat: integração Stripe
    # 4f5a6b7 HEAD@{3}: commit: refactor: extrai PaymentService
    # ...
  3. Identifique o commit "topo" antes do reset — é o HEAD@{1} (ou onde estava antes do reset).
  4. Volte para ele:
    $
    git reset --hard d6e7f8a
    # Os 3 commits voltam. Working dir agora reflete o estado correto.
  5. Alternativa mais segura (se houvesse mudanças não-commitadas):
    $
    # Cria branch temporária no ponto recuperado, sem mexer no estado atual
    git branch backup d6e7f8a
    git checkout backup
    # Confere que está tudo lá; depois mergeia ou substitui sua branch
  6. Lição: --hard é perigoso. Em situações similares, prefira --mixed (mantém arquivos no working dir) ou faça git stash antes.

Reflog tem retenção padrão de 90 dias — recuperação é praticamente sempre possível dentro desse prazo.

Fim do capítulo 29
Próximo capítulo: CI/CD e deploy. Como entregar software em produção com confiança. O último capítulo do livro.
Parte VI · Capítulo 30 · Operação e equipe

CI/CD e
deploy:
entregar com
confiança.

Deploy era evento — anunciado, temido, feito sexta. CI/CD bem feito muda isso: deploy é rotina, várias vezes ao dia, com tanta confiança quanto fazer commit. Não é magia. É disciplina de automação acumulada.

Esse é o último capítulo do livro principal. Fecha a Parte VI e encerra a jornada que começou em variáveis e classes. Aqui você junta tudo: testes (Cap 11) que dão confiança, containers (Cap 28) que empacotam, Git (Cap 29) que organiza, observabilidade (Cap 27) que valida. CI/CD é a costura final — o que transforma código numa máquina de entrega contínua.

30.1 A história — de Makefile ao GitOps

Contexto histórico

Nos anos 70-80, build era tarefa manual. Engenheiro rodava compilador, copiava artefatos, instalava no servidor. Em 1976, Stuart Feldman criou o make nos Bell Labs — primeira tentativa séria de automatizar build. Dependências entre tarefas, regras de recompilação. Sobreviveu 50 anos.

Em 2001, surgiu CruiseControl, primeiro servidor de CI popular. Em 2005, Hudson (renomeado Jenkins em 2011 após disputa com Oracle) tomou o mercado. Configuração via UI, plugins, build em servidor dedicado. Dominou anos 2010.

Em 2007, Continuous Delivery (Jez Humble e David Farley) consolidou o vocabulário: CI é "integrar continuamente"; CD pode ser "Continuous Delivery" (pronto pra deploy a qualquer momento) ou "Continuous Deployment" (deploy automático após CI verde). Diferença sutil mas importante.

Em 2013, Travis CI popularizou CI como serviço — sem infra para manter, integração com GitHub. CircleCI, GitLab CI (2014) seguiram o modelo. GitHub Actions (2018) consolidou: CI integrado ao repositório, configuração via YAML versionado junto com o código. Hoje é padrão de fato para a maioria dos projetos.

Em paralelo: Kubernetes (2014) permitiu rolling updates nativos. ArgoCD (2018) e Flux trouxeram GitOps — estado desejado do cluster declarado em Git, deploy é "push para o repo" e o controlador converge. Em 2021, DORA Report formalizou métricas de elite: deploy múltiplas vezes ao dia, lead time em horas, recuperação em minutos.

Em 2024-2025, a tendência é deploy contínuo até em sistemas regulados, com auditoria automatizada (atestação SLSA, SBOM, assinatura Sigstore). A pergunta não é mais "se você deploya por pipeline"; é "que velocidade e confiança você consegue extrair dele".

30.2 CI, CD, CD — três siglas, três coisas diferentes

A confusão é constante. Vamos separar:

SiglaSignificadoO que entrega
CI Continuous Integration Toda mudança roda lint + testes + build automaticamente. Garante que main sempre funciona.
CD Continuous Delivery Artefato pronto para deploy a qualquer momento. Decisão de deployar continua manual (clique no botão).
CD Continuous Deployment Deploy automático para produção após CI verde. Sem botão. Sem pessoa no meio.

Times maduros começam com CI sério, depois Continuous Delivery, depois Continuous Deployment em alguns serviços. Sistemas críticos (saúde, financeiro regulado) frequentemente param em Continuous Delivery por boa razão. Sistemas internos podem ir para Continuous Deployment com tranquilidade.

DORA — métricas de referência
O DORA Report (DevOps Research and Assessment, Google Cloud) define quatro métricas-chave de performance de delivery:
  • Deployment frequency: quantos deploys por dia/semana?
  • Lead time: commit até produção, quanto tempo?
  • Change failure rate: % de deploys que causam incidente?
  • Mean time to recovery (MTTR): tempo para resolver incidente?

Times "Elite": deploys múltiplos por dia, lead time em horas, < 15% de change failure, recuperação em < 1h. Não é meta para todos — mas dá direção do que é possível.

30.3 Pipeline típico — o que deve estar lá

Pipeline é sequência de etapas. Cada etapa pode falhar; falha barra avanço. Etapas típicas, em ordem:

  1. Checkout do código.
  2. Setup de ambiente (Python, Node, etc).
  3. Cache de dependências (acelera muito).
  4. Install de dependências.
  5. Lint / format check (rápido, falha cedo).
  6. Type check (mypy, pyright).
  7. Testes unitários (segundos a minutos).
  8. Testes de integração (banco real, fila real — em containers).
  9. Security scan (dependências, SAST, secrets).
  10. Build da imagem Docker (multi-arch se necessário).
  11. Push da imagem para registry.
  12. Deploy (em ambiente de staging primeiro, depois produção).
  13. Smoke tests pós-deploy.
  14. Notificação em caso de falha.

Princípio crítico: falhe rápido. Lint roda em 5s; rodar antes dos testes de integração economiza minutos por build quebrado. Custo cai, feedback é mais rápido.

30.4 GitHub Actions na prática

Sintaxe YAML, arquivos em .github/workflows/. Exemplo de pipeline completo para app Python:

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  PYTHON_VERSION: "3.12"

jobs:
  lint-test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt -r requirements-dev.txt

      - name: Lint (ruff)
        run: ruff check .

      - name: Format check (ruff)
        run: ruff format --check .

      - name: Type check (mypy)
        run: mypy src/

      - name: Security audit
        run: |
          pip install pip-audit bandit
          pip-audit --requirement requirements.txt
          bandit -r src/

      - name: Tests
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test
        run: |
          pytest --cov=src --cov-report=xml --cov-fail-under=80

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

  build-and-push:
    needs: lint-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Deploy
        run: ./scripts/deploy.sh staging ${{ github.sha }}

Pontos a observar

30.5 Versionamento de artefatos

Toda build produz artefato (imagem Docker, geralmente). Como você nomeia esses artefatos importa muito para rastreabilidade e rollback.

Esquemas comuns

SemVer — versionamento semântico

semver.txt
MAJOR.MINOR.PATCH

MAJOR: mudança quebradora (API mudou; cliente precisa atualizar)
MINOR: nova funcionalidade compatível
PATCH: bug fix compatível

# Exemplo:
# 1.2.3  →  1.2.4  (bug fix)
# 1.2.3  →  1.3.0  (feature nova compatível)
# 1.2.3  →  2.0.0  (breaking change)

# Para serviços internos sem clientes externos, semver é opcional.
# Para bibliotecas, APIs públicas: semver é praticamente obrigatório.

Ferramentas como semantic-release automatizam: lê mensagens de commit Conventional, bump da versão, gera changelog, cria tag, dispara deploy. Time não decide "que versão é essa" — o histórico decide.

30.6 Estratégias de deploy

Várias formas de deployar nova versão. Cada uma com trade-off entre risco, custo e complexidade.

Big-bang — tudo de uma vez

Derruba versão antiga, sobe nova. Mais simples. Downtime garantido. Aceitável apenas para sistemas internos pequenos ou janelas de manutenção planejadas. Em prod sério, evite.

Rolling update — substituir aos poucos

Sobe nova versão em uma fração das instâncias; valida; expande para mais; até completar. Kubernetes faz por padrão. Sem downtime, mas durante o rollout, ambas as versões coexistem — APIs precisam ser compatíveis.

rolling.txt
# Versão antiga: v1 (5 pods)
# Deploy v2:

t=0    [v1] [v1] [v1] [v1] [v1]
t=1    [v2] [v1] [v1] [v1] [v1]  # sobe 1 v2, derruba 1 v1
t=2    [v2] [v2] [v1] [v1] [v1]
t=3    [v2] [v2] [v2] [v1] [v1]
t=4    [v2] [v2] [v2] [v2] [v1]
t=5    [v2] [v2] [v2] [v2] [v2]  # pronto

Blue-Green — dois ambientes

Mantém dois ambientes idênticos: blue (atual) e green (novo). Deploya no green, valida, troca tráfego do balanceador. Rollback é trocar de volta. Custo: 2x infra durante deploy.

Canary — testar com fatia do tráfego

Sobe nova versão; direciona 1% do tráfego para ela. Monitora métricas. Se OK, aumenta gradualmente (5%, 25%, 50%, 100%). Se algo cai, reduz e investiga. Mais seguro para mudanças arriscadas; exige infra capaz de roteamento por percentual.

canary.txt
t=0     v1: 100%   v2:   0%   # deploy v2 (sem tráfego ainda)
t=10m   v1:  99%   v2:   1%   # monitora métricas: erros? latência?
t=30m   v1:  95%   v2:   5%
t=1h    v1:  75%   v2:  25%
t=2h    v1:  50%   v2:  50%
t=4h    v1:   0%   v2: 100%   # promoção completa

# A qualquer momento, se algo der errado:
        v1: 100%   v2:   0%   # rollback de tráfego (não de infra)

Como escolher

EstratégiaQuando usar
Big-bangSistemas internos sem usuários ativos; janela de manutenção
Rolling updatePadrão de Kubernetes. Bom para 95% dos casos. Sem custo extra de infra.
Blue-GreenQuando rollback rápido é crítico e custo de 2x infra é aceitável.
CanaryMudanças arriscadas, alto volume, vontade de validar com tráfego real antes de promover.

30.7 Feature flags — desacoplando deploy de release

Princípio poderoso: deploy de código ≠ ativação da feature. Você merge e deploya código de uma feature nova com ela desligada por flag; ativa depois, quando quiser, para quem quiser.

feature_flags.py
from launchdarkly_server_sdk import LDClient   # ou unleash, flagsmith, etc

ld = LDClient(sdk_key=os.environ["LD_KEY"])

def processar_pagamento(pedido, usuario):
    # Avalia flag em runtime, com contexto do usuário
    if ld.variation("pix-checkout", user_context(usuario), False):
        return pix_service.cobrar(pedido)
    return stripe_service.cobrar(pedido)

# No painel da ferramenta, você liga/desliga sem deploy.
# Pode segmentar: ligar para 5% dos usuários, ou só usuários X.

Benefícios

Trade-offs

Ferramentas: LaunchDarkly (líder de mercado), Unleash (open-source), Flagsmith, Statsig. Em escala pequena, tabela no banco basta.

30.8 Rollback — voltar atrás sem drama

Deploy quebrou. Métricas alertam. O que fazer?

Tipos de rollback:

Rollback de banco é especial
Versão N+1 fez migração de schema. Rollback para N pode quebrar — código antigo não sabe lidar com schema novo. Regra geral: migrations devem ser sempre compatíveis com versão atual e anterior (expand/contract pattern). Schema novo aceita ambos; depois que tudo deploya, schema antigo é removido em deploy separado.

Padrão expand/contract

  1. Expand: migração adiciona nova coluna/tabela, mas não remove nem renomeia nada. Código antigo continua funcionando.
  2. Migrate code: deploys de código novo passam a usar a nova estrutura. Antiga ainda existe.
  3. Contract: só depois de TODOS os deploys do código novo estarem em prod e estáveis, migration remove a estrutura antiga.

Sem expand/contract, rollback de código quebra junto com banco. Com ele, rollback é seguro.

30.9 Observabilidade pós-deploy

Deploy não termina quando o pipeline está verde. Termina quando você confirma que a nova versão está saudável em produção.

O que monitorar após deploy

Annotation de deploys

Em Grafana/Datadog/qualquer ferramenta de dashboard, marque cada deploy como anotação. Ajuda muito a correlacionar: "essa subida de erro começou exatamente no deploy v2.3.1".

Smoke tests automatizados

Após deploy, pipeline pode rodar testes mínimos contra o ambiente real: "GET /health retorna 200", "POST /pedidos com dados de teste cria pedido". Se falhar, dispara rollback automático ou alerta.

30.10 Estudo de caso — pipeline real para uma API

Da prancheta ao deploy contínuo

API Python (FastAPI) que serve checkout de e-commerce. Time pequeno, 3 engenheiros. Vamos construir o pipeline em iterações.

Iteração 1 · CI básico

Antes: testes manuais, deploy via SSH no servidor. Risco alto.

Adicionado: workflow GitHub Actions com lint + testes em PR. Falha bloqueia merge.

Ganho: PR ruim para de chegar em main. Bugs simples são pegos antes.

Iteração 2 · Build de imagem na main

Adicionado: após CI verde em main, build de imagem Docker e push para GHCR com tag SHA.

Ganho: deploy fica trivial (rodar imagem versionada). Rollback é "rodar a imagem anterior".

Iteração 3 · Deploy automatizado em staging

Adicionado: após build, deploy automático em staging (Cloud Run). Smoke tests rodam contra staging.

Ganho: ambiente espelhado disponível em minutos após merge. Validação manual fica fácil.

Iteração 4 · Deploy em prod com aprovação manual

Adicionado: workflow de deploy de prod, com GitHub Environment exigindo aprovação manual (Continuous Delivery, não Deployment).

Ganho: deploy fácil (clique), mas controle humano permanece.

Iteração 5 · Feature flags + canary

Adicionado: integração com LaunchDarkly. Mudanças arriscadas vão atrás de flag, ligadas gradualmente. Canary em mudanças de infra.

Ganho: kill switch sem deploy. Risco baixou; velocidade subiu.

Iteração 6 · Continuous Deployment para serviços não-críticos

Adicionado: serviços internos (admin, BI) passam a deployar automaticamente após CI verde. APIs públicas seguem com aprovação manual.

Ganho: lead time desses serviços cai para minutos.

Resultado após ~6 meses:

  • Deploy frequency: de 1x/semana para 5-10x/dia.
  • Lead time: de dias para minutos/horas.
  • Change failure rate: de ~30% para < 10% (testes + canary + flags pegam mais).
  • MTTR: de horas para minutos (rollback por flag é instantâneo).

Lição: não tente construir tudo de uma vez. Cada iteração é pequena, valor entregue imediatamente. Maturidade de CI/CD é processo, não projeto.

30.11 Erros comuns

Erro 1 · Testes lentos = pipeline ignorado

CI demorando 30min faz time desligar a aba e voltar depois. Foco se perde. Mantenha pipeline em < 10min (idealmente < 5min). Paralelize, cache, separe testes lentos em jobs próprios.

Erro 2 · Pipelines que falham intermitentemente

Testes "flaky" derrubam confiança. Time aprende a "rerodar" — perde o sinal real. Investigue e corrija; ou quarantine + ticket para arrumar.

Erro 3 · Deploy sem rollback testado

"Tem rollback." Tem? Você testou? Faça um drill: deploya algo, simula problema, executa rollback. Se não funciona em drill, não vai funcionar em incidente.

Erro 4 · Imagem latest em produção

Cluster pega "latest" do registry. Build novo sobe sem ninguém saber. Rollback impossível (qual era o "latest" de ontem?). Tag SHA ou versão explícita, sempre.

Erro 5 · Migrations destrutivas sem expand/contract

Renomeou coluna, deploy quebra. Rollback quebra junto. Faça migrations em duas fases: expand (compatível) e contract (depois de tudo estabilizado).

Erro 6 · Feature flag esquecida no código

Feature rollada 100% há 6 meses. Flag ainda no código, alterando comportamento. Dívida silenciosa. Política: flag tem expiração; review periódica para remover.

Erro 7 · Sem observabilidade pós-deploy

Deploy, pipeline verde, vai pra casa. Bug em produção, descoberto pelo cliente. Monitore métricas após cada deploy. Annotation em dashboards. Alertas com baseline.

Verifique seu entendimento
"Time deploya 3x ao dia. Em um deploy, latência p99 subiu 40% e taxa de erro foi de 0.3% para 1.5%. Qual a ação correta?"

30.12 Exercícios

Pratique antes de seguir adiante
Fácil
Exercício 1 · CI ou CD?

Classifique cada prática como CI, Continuous Delivery (CDel), Continuous Deployment (CDep) ou nenhuma:

  1. Toda PR roda testes automaticamente
  2. Após merge em main, deploy vai para prod automaticamente
  3. Time se reúne sexta-feira para deployar manualmente o que está em main
  4. Imagem Docker é construída e pushed após merge, pronta para deploy
  5. Engenheiro roda ./deploy.sh manualmente quando quer
  6. Pipeline produz artefato versionado em todo commit em main
  1. CI
  2. Continuous Deployment
  3. Nenhuma (deploy semanal manual; antítese)
  4. Continuous Delivery (pronto, mas não automático)
  5. Nenhuma (deploy manual ad-hoc)
  6. Continuous Delivery (artefato sempre pronto)
Médio
Exercício 2 · Pipeline GitHub Actions

Escreva workflow GitHub Actions para projeto Python com: lint (ruff), type check (mypy), testes (pytest), e build de imagem Docker apenas em push para main. Use cache de pip e timeout de 10 min por job.

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: ruff check .
      - run: ruff format --check .
      - run: mypy src/
      - run: pytest

  build:
    needs: check
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Médio
Exercício 3 · Estratégia de deploy

Para cada situação, escolha a estratégia mais apropriada (big-bang, rolling, blue-green, canary) e justifique:

  1. Mudança no algoritmo de recomendação que pode afetar taxa de conversão
  2. Bug fix simples em endpoint de listagem
  3. Mudança que adiciona índice em tabela grande do banco
  4. Atualização de versão menor de framework (compatível)
  5. Ferramenta interna usada por 5 pessoas, manutenção fora de horário
  1. Canary. Mudança de algoritmo pode degradar métricas de negócio. Validar com 5% do tráfego antes de promover.
  2. Rolling update. Default seguro. Bug fix tem baixo risco; rolling com health check basta.
  3. Estratégia mista: migration de banco fora do horário de pico (manutenção planejada para o índice em si, mesmo com CREATE INDEX CONCURRENTLY se Postgres) + rolling update do código. Coordenado.
  4. Rolling update. Mudanças compatíveis são caso típico.
  5. Big-bang é aceitável. Sistema interno + manutenção planejada + poucos usuários. Não vale custo de blue-green.
Difícil
Exercício 4 · Migração com expand/contract

Você precisa renomear coluna nome para nome_completo na tabela usuarios em produção. Descreva o plano em fases, garantindo que rollback funcione em qualquer momento.

Plano em 4 deploys:

Deploy 1 — Expand (migration):

migration_1.sql
-- Adiciona coluna nova, mantém antiga
ALTER TABLE usuarios ADD COLUMN nome_completo TEXT;

-- Backfill em batches (evita lock longo)
UPDATE usuarios SET nome_completo = nome WHERE nome_completo IS NULL;

-- Trigger para manter sincronizado durante transição
CREATE TRIGGER sync_nome
BEFORE INSERT OR UPDATE ON usuarios
FOR EACH ROW EXECUTE FUNCTION sync_nome_columns();
-- (função copia nome para nome_completo e vice-versa)

Código continua usando nome. Rollback aqui é só dropar coluna nova. Seguro.

Deploy 2 — Código passa a escrever em ambos:

Código novo escreve em nome E nome_completo; lê de nome_completo. Trigger garante consistência mesmo se algum código antigo persistir. Rollback: código antigo ainda funciona (lê de nome).

Deploy 3 — Código só usa nome_completo:

Remove escrita em nome. Trigger ainda mantém sincronização para qualquer rollback. Lê e escreve só nome_completo.

Deploy 4 — Contract (após estabilização):

migration_2.sql
DROP TRIGGER sync_nome ON usuarios;
ALTER TABLE usuarios DROP COLUMN nome;

Após confirmar que nada usa nome (logs limpos, métricas estáveis por dias). A partir daqui, rollback não é mais possível para versão original — mas isso é OK porque a mudança já está completamente estável.

Princípio: entre cada deploy, observação por horas/dias antes do próximo. Tempo total: semana ou mais. Não há atalho seguro para renames em produção.

Fim do capítulo 30 · Fim do livro principal
Você chegou ao fim. Trinta capítulos cobrindo dos fundamentos de POO até deploy contínuo em produção. Daqui pra frente: prática. Pegue um projeto pessoal, aplique camadas (Cap 19), adicione testes (Cap 11), suba no GitHub com Actions (Cap 30), Docker (Cap 28), com observabilidade (Cap 27). Em meses de prática deliberada, você não vai reler esse livro — vai consultar pontos específicos quando o problema aparecer, que é como livros assim servem. Boa jornada.

Livro principal · completo.

Trinta capítulos. Seis partes. Dos fundamentos de POO ao deploy contínuo em produção. ~800 páginas equivalentes · 150+ exercícios graduados · 30 quizzes interativos · estudos de caso evolutivos em cada capítulo. Apêndices em construção: plano de 36 semanas de estudo, biblioteca essencial complementar, e projeto integrador que põe em prática o que foi cobrido. Peça "continua" para receber.