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.
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.
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.
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.
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.
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.
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.
OOP se apoia em quatro pilares:
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".
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.
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.
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
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.
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.
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.
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.
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()
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.
Há casos legítimos:
View, Model) ou de bibliotecas de teste oferecem comportamento fundamental que você quer estender.Exception customizada herdando de Exception faz sentido.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.
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.
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.
Abstração é a arte de decidir o que mostrar e o que esconder. Uma boa abstração:
RepositorioPedidos > PostgreSQLPedidoDAO.
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.
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.
Função de 80 linhas, sem classes. Está tudo "funcionando", mas é frágil:
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.
Primeiro, transformamos os "dicts" em objetos com identidade clara:
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
Frete vai variar (transportadora diferente, regras especiais). Não queremos misturar com o pedido:
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
Cupons mudam constantemente — promoções de fim de ano, programa de fidelidade, parceiros. Cada um vira uma estratégia:
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") )
Finalmente, o orquestrador junta tudo. Cada parte é testável isoladamente:
@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.
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.
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
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.
"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".
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.
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).
Há situações em que OOP atrapalha mais do que ajuda:
dict ou dataclass simples basta.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.
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.
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
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 ==.
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
Refatore a função abaixo. Regra: adicionar formato novo (CSV, XML) não deve exigir alterar o código existente.
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")
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)
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.
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.
Modele um Carrinho com:
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")
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.
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.
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ção | Nome | Exemplo | Crescimento |
|---|---|---|---|
O(1) | Constante | Acessar lista[5] | Independente do tamanho |
O(log n) | Logarítmico | Busca binária | Cresce devagar |
O(n) | Linear | Buscar item em lista | Proporcional ao tamanho |
O(n log n) | Linearítmico | Bons algoritmos de ordenação | Quase linear |
O(n²) | Quadrático | Loop dentro de loop | Cresce rápido |
O(2ⁿ) | Exponencial | Força bruta combinatória | Inviá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.
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.
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ção | Complexidade | Observaçã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 lista | O(n) | Varre a lista inteira |
lista.sort() | O(n log n) | Timsort, ótimo na prática |
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.
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á.
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.
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.
set também é tabela hash, mas só guarda chaves (sem valores). Use quando precisar de:
set(lista) remove duplicatas.a & b (interseção), a | b (união), a - b (diferença).# 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) ...
Tupla é como lista, mas imutável. Uma vez criada, não muda. Use para:
dataclass se forem 3+ campos com significado).vendas[("2024-Q1", "SP")] = ...
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.
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)
Árvores são estruturas hierárquicas. Em Python pure não temos uma "árvore de busca" pronta no stdlib, mas vários casos importantes:
heapq — sempre te dá o menor (ou maior) elemento em O(log n). Use para: scheduling de tarefas, top-K elementos, algoritmo de Dijkstra.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
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.
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.
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.
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.
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).
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.
for x in lista: if cond: lista.remove(x) — comportamento imprevisível. Crie nova lista com list comprehension, ou itere sobre cópia.
"Lista de IDs únicos" — se a unicidade importa e ordem não, é set. Se ordem importa, é dict.fromkeys() ou list(dict.fromkeys(items)).
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:
Para o resto, código claro > código teoricamente ótimo. Primeiro perfil, depois otimização.
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).
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)
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.
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).
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.
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)
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).
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.
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.
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).
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.
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.
"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".
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.
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
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.
"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:
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.
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")
"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:
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.
isinstance() para tratar subtipo diferente — sinal claro."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.
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.
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.
"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).
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.
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.
Vamos pegar uma classe "típica" que viola vários princípios e refatorar passo a passo.
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).
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:
ServicoCadastro depende de abstrações, não de psycopg2/smtplib/requests.RepositorioUsuario precisa honrar o contrato dos métodos.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.
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.
Esses nomes são sinal de SRP violado. Se você não conseguiu nomear especificamente o que a classe faz, ela faz coisas demais.
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.
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ê.
SOLID tem custo: mais classes, mais arquivos, mais cerimônia. Esse custo se paga em sistemas que evoluem por anos. Em código que:
— 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.
Veiculo com método ligar(). Agora vai criar BicicletaEletrica, que tem motor mas não tem ignição como um carro. Como tratar?"Para cada item abaixo, identifique qual princípio é violado e por quê:
UtilStrings com 30 métodos: upper, split, format_email, parse_json, send_sms, connect_db.Quadrado(Retangulo) que sobrescreve setters para manter lados iguais.processar(tipo: str) com if/elif para 8 tipos diferentes.psycopg2 diretamente.Repositorio.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.
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")
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 })
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?
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.
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.
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.
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.
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.
Existe uma distinção fundamental que muito código mistura: erros esperados vs erros excepcionais.
Exceções em Python são poderosas, mas quase sempre mal usadas. Cinco regras práticas:
Exception genéricotry: 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.
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).
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.
finally e context managers para limpeza# 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.
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
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.
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.
# 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)
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.
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.
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".
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:
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.
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:
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), )
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.
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
Vamos construir, passo a passo, um serviço de pagamento que combina validação local, retry, circuit breaker e hierarquia de exceções.
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): ...
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"])
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.
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.
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.
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".
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.
None ou Optional. Não é exceção.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.
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
Modele exceções para um sistema de biblioteca: erro base ErroBiblioteca, categorias ErroEmprestimo e ErroAcervo, e específicos: LivroIndisponivel, LeitorBloqueado, LivroNaoCatalogado.
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")
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.
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}")
Crie decorator @retry(tentativas, exceptions, base=1.0, fator=2.0) com backoff exponencial e jitter. Aplique em uma função que faz HTTP.
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()
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.
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, )
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.
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.
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.
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: ...
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.
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":
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.
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.
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.
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
Python 3.12 introduziu sintaxe mais limpa para generics, sem necessidade de declarar TypeVar:
# 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.
Você precisa tipar uma resposta de API que vem como dict? Usar dict[str, Any] perde informação. TypedDict dá a forma exata:
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 ...
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:
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.
NewType cria um "apelido com tipo próprio" para um tipo existente. Útil quando você usa o mesmo tipo primitivo para coisas semanticamente diferentes:
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".
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):
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"
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.
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:
[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.
Vamos tipar um repositório genérico, com Protocol, Generics, NewType e exhaustividade.
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.
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).
def f(x: list) sem parâmetro vira list[Any]. Sempre escreva list[T]. mypy strict pega.
Em Python < 3.6 e em configs antigas, def f(x: int = None) aceitava None silenciosamente. Sempre escreva x: int | None = None explicitamente.
# 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.
Tipar só assinaturas públicas e deixar o interior "selvagem". A coerência é o ganho — tipe variáveis locais complexas também.
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.
read() que retorna bytes — incluindo arquivos, BytesIO, classes customizadas. Como tipar?"Adicione type hints completos a esta função:
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
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]]: ...
Crie TypedDict que representa uma resposta como esta:
{
"id": 123,
"tipo": "premium", // "premium" | "free" | "trial"
"email": "x@y.com",
"tags": ["a", "b"],
"metadata": {"x": "y"} // opcional
}
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"
Você tem funções que recebem int para "ID de usuário", "idade", "código postal". Use NewType para impedir que sejam misturadas acidentalmente.
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
Crie Protocol Storage com métodos get(key) -> bytes | None, put(key, value), delete(key). Implemente StorageMemoria sem herdar do Protocol — só satisfazendo estruturalmente.
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
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.
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.
Soluções nominadas para problemas que se repetem. Aprenda quando aplicar — e, igualmente importante, quando o padrão é cargo cult disfarçado.
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ê.
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.
Um padrão de design tem quatro elementos essenciais:
O que padrão não é:
MetodoPagamento com várias implementações? Strategy. FormatadorRelatorio? Strategy. Endereco imutável? Value Object. O nome só formaliza o que você já fazia intuitivamente.
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).
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.
"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.
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.
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.
Você usa padrões sem perceber. Vamos identificar três que estão em código Python que você já chamou.
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.
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.
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.
"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?
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.
"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.
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.
if simples ainda compete bem com Strategy.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.
Para cada cenário, diga qual padrão (mesmo que você ainda não saiba o nome formal — descreva a estrutura):
render(formato) e internamente chama render_html(), render_pdf() ou render_csv() dependendo do argumento.Pedido com 12 campos opcionais sem ter construtor com 12 argumentos.Pedido.builder().cliente(x).item(y).cupom(z).build().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.
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:
requests.Session.mount(adapter) permite plugar HTTPAdapter customizado — adapta protocolos diferentes para a mesma API.requests.Request() seguido de .prepare() constrói a requisição em etapas antes de enviar.Em sqlalchemy:
session acumula mudanças e faz commit em bloco.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:
# 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.
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.
| Padrão | Resolve | Use quando |
|---|---|---|
| Factory Method | Decidir qual classe instanciar com base em parâmetros | Lógica de criação é não-trivial |
| Abstract Factory | Criar famílias de objetos relacionados | Múltiplas variantes consistentes (tema dark/light, ambiente prod/test) |
| Builder | Construir objeto complexo passo a passo | Muitos campos opcionais, validação durante construção |
| Prototype | Criar copiando objeto existente | Construção cara, configuração reutilizada |
| Singleton | Garantir instância única | Quase nunca — leia a seção dedicada |
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.
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)
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.
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).
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.
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.
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())
@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.
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.
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"
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".
# 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
# 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.
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.
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): ...
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"]))
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).
Aplicar Singleton em qualquer "serviço". Acoplamento global escondido, testes complicados. Use módulo Python ou DI.
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.
Builder que constrói qualquer estado, mesmo inválido. O build() precisa validar antes de retornar — é o ponto único para garantir invariantes.
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.
__init__ direto.Conexao que seja única em todo o sistema. Qual abordagem?"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).
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)
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.
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())
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.
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(), )
Construa ContratoBuilder que monta um Contrato com regras:
Cada método do builder valida o que pode; build() valida regras cruzadas.
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, )
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.
| Padrão | Resolve | Use quando |
|---|---|---|
| Adapter | Encaixar interfaces incompatíveis | Integrar código legado ou library externa |
| Decorator | Adicionar comportamento sem alterar classe | Logging, cache, autenticação, retry |
| Facade | Simplificar interface complexa | Subsistema com muitas peças expostas |
| Proxy | Substituto que controla acesso | Lazy loading, controle, cache, remoto |
| Composite | Tratar parte e todo uniformemente | Árvores: arquivos, organograma, AST |
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.
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".
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).
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.
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))
@ é 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.
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.
# 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.
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.
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
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.
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
Vamos combinar Adapter + Decorator para construir camada robusta sobre uma API externa flaky.
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"], )
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
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")
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.
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.
Se o método original retorna lista e seu decorator retorna dict, você quebrou a interface. Decorator preserva contrato, adiciona comportamento.
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.
RemoteProxy que parece chamada local mas faz HTTP. Clientes assumem ser barata, e isso vira gargalo silencioso. Documente claramente — ou exponha como assíncrono.
RepositorioPedido existente, sem alterá-lo. Qual padrão?"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.
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)
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.
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"}
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.
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()
Empilhe sobre um ServicoUsuario real:
Cada decorator deve preservar a interface do ServicoUsuario Protocol.
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, )
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.
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.
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.
Um padrão de design tem três características essenciais:
Padrão não é:
O GoF organizou os 23 padrões em três categorias, baseadas em propósito:
Vamos cobrir os mais úteis hoje em dia — não todos. Cada um nos próximos três capítulos.
Todo padrão bem descrito tem partes claras. Quando ler descrição de qualquer padrão, procure por:
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.
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:
Todo padrão tem custo. Você o paga em:
PaymentProcessorFactoryBuilderDecorator existe. Sério.Não aplique padrão quando:
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.
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.
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.
Começa simples — só ICMS de São Paulo:
def calcular_imposto(valor: Decimal) -> Decimal: return valor * Decimal("0.18")
Avaliação: sem necessidade de padrão. Função pura, clara, direta.
Loja vende para outro estado. Aliquota diferente:
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.
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:
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:
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.
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.
"Tenho que usar algum padrão aqui." Não. Tem que resolver o problema. Se a solução natural já é boa, não force.
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.
"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.
Em cada descrição abaixo, qual padrão (mesmo sem saber o catálogo todo) está sendo descrito? Confie na intuição.
Para cada cenário, decida se aplicar o padrão sugerido faz sentido ou seria overengineering. Justifique.
Abra um projeto seu (no trabalho ou pessoal). Procure por:
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.
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.
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.
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.
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()
A função acima ainda viola OCP (adicionar formato exige editar a função). Versão melhor: registro:
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.
Tipo() diretamente.if simples basta.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.
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.
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.
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.
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.
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())
__init__ com kwargs basta.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.
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.
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.
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"
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.
Talvez o padrão mais polêmico do catálogo. Vamos cobrir o que é, como implementar — e por que quase sempre você deveria evitar.
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.
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
O Singleton ganhou má fama merecida. Os problemas são reais e graves:
class ServicoPedido: def criar(self, p): config = ConfigGlobal() # dependência oculta timeout = config.timeout ... # Teste: precisa hackear ConfigGlobal._instance
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.
logging.getLogger em Python é Singleton por nome).Em sistemas profissionais maiores, prefira injeção de dependência ou container de objetos do framework (Django settings, FastAPI Depends, etc).
Python oferece atalhos que tornam vários padrões criacionais menos necessários:
Datetime.fromisoformat(), Path.cwd(), dict.fromkeys(). Padrão idiomático e legível.config = Config()) e importe.partial(criar, tipo="x"), não classe Factory.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.
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).
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, )
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)
# 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.
Cargo cult clássico: FactoryFactory.createFactory().create(). Se a fábrica precisa de fábrica, provavelmente você está abstraindo o que ainda nem existe.
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".
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.
"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.
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.
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
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.
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() )
Refatore o código abaixo, que usa Singleton, para usar injeção de dependência. Mostre como o teste fica mais simples.
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)
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
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.
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 == {}
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.
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.
Crie uma classe adaptadora que implementa a interface esperada (B), mas internamente chama a interface real (A) fazendo a tradução necessária.
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.
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.
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.
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.
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:
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.
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.
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.
# 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.
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.
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.
Crie um "stand-in" — um objeto que implementa a mesma interface, mas internamente gerencia o acesso ao objeto real (que pode nem existir ainda).
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
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.
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.
Defina uma interface comum. Cada folha (objeto individual) implementa diretamente. Cada composto (grupo) implementa delegando para seus filhos.
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
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".
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: ...
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, )
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
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.
API externa já tem interface boa? Use direto. Adapter desnecessário é só indireção sem ganho.
Empilhar 8 decorators numa operação simples — debugar fica pesadelo. Cada decorator adicional precisa de justificativa concreta.
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.
Tecnicamente são parecidos; conceitualmente diferentes. O nome importa para comunicação: Decorator adiciona comportamento; Proxy controla acesso.
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.
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")
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.
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
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.
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))
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".
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})
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.
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.
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.
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"), ]
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.
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.
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.
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.
Você quer tratar "uma ação a ser executada" como objeto: para enfileirar, registrar em log, desfazer, agendar, repetir.
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.
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"
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.
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:
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)
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.
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.
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.
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.
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.
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>"
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.
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).
Cada handler tem referência ao próximo. Recebe requisição, decide se trata, se não passa adiante. É a forma estruturada de middleware.
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.
Não vamos cobrir em detalhe, mas vale conhecer pelo nome:
singledispatch, a forma idiomática é mais simples que o Visitor clássico.
Ambos têm aplicação real, mas em situações específicas. Reconheça quando aparecerem; não force.
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.
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")
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}")
# 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.
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.
Observer A dispara evento que dispara Observer B que dispara evento que dispara Observer C... debugar isso é pesadelo. Limite a profundidade; documente.
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.
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.
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).
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
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.
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"))
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.
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:]
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.
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
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.
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.
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.
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.
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.
@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"
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)
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 é.
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.
"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.
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.
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.
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.
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
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.
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.
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.
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".
"Premature optimization is the root of all evil" — Donald Knuth, 1974.
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".
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.
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.
"Para quem só tem martelo, todo problema parece prego."
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.
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?
"You Aren't Gonna Need It" — XP, anos 90.
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.
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.
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.
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.
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
Agrupe os métodos por "motivo de mudança":
| Grupo | Métodos | Motivo de mudança |
|---|---|---|
| Validação | validar_cliente, validar_estoque | Regras de negócio |
| Frete | calcular_frete_* | Mudança em transportadoras |
| Cupons | aplicar_cupom_* | Campanhas de marketing |
| Gateway | enviar_para_gateway, processar_resposta | Troca de gateway |
| Notificação | notificar_* | Canais de notificação |
| Persistência | salvar_em_* | Esquema de banco |
| Imposto | calcular_imposto | Legislação fiscal |
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.
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.
Comece pelo grupo mais isolado. Frete parece bom — não depende de muita coisa interna:
# 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.
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.
# Antes servico = ServicoPedido() frete = servico.calcular_frete_correios(2.5, "01310-000") # Depois frete_calc = CalculadoraFrete() frete = frete_calc.correios(2.5, "01310-000")
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.
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.
"Vou consertar essa classe rapidinho." Sem testes, você não sabe o que quebrou. Sempre teste antes de mexer.
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.
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.
Para cada situação, identifique o anti-padrão dominante:
UtilManager com 1.500 linhas que faz validação, cálculo de imposto, envio de e-mail e geração de PDF.if codigo == 7: aplicar_taxa(valor * 0.18) espalhada em 12 arquivos.Cliente com 30 atributos públicos e nenhum método de comportamento; todas as regras estão em ClienteService.Pegue o código abaixo e elimine Magic Strings/Numbers. Use Enum e constantes nomeadas.
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"
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
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.
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")
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.
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)
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.
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 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.
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.
Vamos ser explícitos. Testes servem para:
Testes não servem para:
Mike Cohn, em Succeeding with Agile (2009), popularizou a metáfora da pirâmide para distribuir tipos de teste:
Todo teste bem escrito tem três fases visíveis:
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).
Cada teste verifica uma propriedade. Quando você precisa de 5 asserts no mesmo teste, ou:
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:
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")
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:
@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
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:
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)
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.
Quando você quer testar a mesma lógica com vários inputs, copiar-colar 12 funções é ruim. Pytest tem parametrização:
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.
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.
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.
Test-Driven Development tem ciclo de três passos:
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.
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:
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.
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
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 })
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"
@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 == []
@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.
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.
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.
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.
assert resultado que só checa truthiness. assert True em catch. Testes que rodam sem realmente verificar nada. Cobertura sobe; valor é zero.
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.
Mas atenção: "código que vai pra produção" raramente cai nessas categorias. A tentação de pular testes é grande; o arrependimento, garantido.
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.
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")
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.
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)
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.
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")
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).
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)
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.
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"]
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.
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.
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:
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:
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.
Fowler catalogou ~70 refatorações. Você não precisa decorar. Mas vale dominar bem as 10-15 mais comuns. Vamos cobrir as essenciais.
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.
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}")
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}")
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:
data, info, obj, tmp — não dizem nada.processar, handle, do_something — vagos demais.get_user() que também salva no banco.usr_mgmt_proc().# 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.
Uma classe está fazendo trabalho de duas. Conjunto de campos e métodos relacionados pode ser tirado para classe própria.
# 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
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).
# 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()
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:
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.
# 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.
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.
Regra de Chesterton: antes de remover uma cerca, descubra por que ela foi colocada lá. Códigos estranhos frequentemente têm razões esquecidas.
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.
# 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.
Função real, simplificada: cálculo de tarifa de remessa. Cresceu organicamente. Tem testes (vamos garantir que continuem passando).
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
Primeiro, três cálculos viram funções:
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.
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)
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:
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.
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.
Se a área não tem testes, escreva testes de caracterização primeiro. Refatoração sem rede é só sorte.
"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.
"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.
"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.
Aplique Extract Method na função abaixo, identificando 3 sub-responsabilidades. Cada uma vira função com nome descritivo.
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
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}")
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.
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")
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.
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.
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 ""
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.
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.
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
# 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.
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 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).
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.
Mike Cohn, no livro Succeeding with Agile (2009), popularizou a "test pyramid" — a ideia de que testes devem se distribuir em uma pirâmide:
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.
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.
Todo teste tem três fases. Quando você as separa explicitamente, o teste vira documentação executável:
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)
Nome de teste deve descrever o que está sendo verificado, não como. Compare:
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.
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.
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.
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)
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
[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", ]
Fixtures evitam duplicação de setup. Você declara o "estado preparado" uma vez; testes recebem por parâmetro.
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()
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
Por padrão, fixture é recriada para cada teste. Você pode alterar o escopo:
scope="function" (padrão): novo para cada teste. Mais isolado.scope="class": compartilhado entre testes da mesma classe.scope="module": por arquivo.scope="session": uma vez por execução. Bom para conexões caras.
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:
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.
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:
| Tipo | Propósito | Verifica? |
|---|---|---|
| Dummy | Só preenche parâmetro, não é usado de fato | Não |
| Stub | Retorna valores pré-definidos (responde sem lógica real) | Não |
| Fake | Implementação funcional simplificada (ex: dict no lugar de DB) | Não |
| Mock | Stub + verifica que foi chamado corretamente | Sim |
| Spy | Objeto real + grava chamadas para inspeção posterior | Sim |
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
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.
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.
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.
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.
TDD prescreve o ciclo Red → Green → Refactor:
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.
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.
$ 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%
"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.
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.
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.
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.
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.
Teste B passa se A rodar antes. Sintoma: rodar testes em ordem aleatória quebra coisas. Cada teste deve preparar seu próprio estado.
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.
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.
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.
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?"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.
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")
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.
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)
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 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
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.
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.
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.
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.
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.
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.
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.
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.
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}")
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}")
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).
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:
data, process, handle, manager, util.buscarComSQL em vez de buscarUsuario.get_user() que na verdade cria se não existe.def process(d): return [x for x in d if x["v"] > 0] manager = DataManager() result = manager.handle(stuff)
def filtrar_lancamentos_positivos(lancamentos): return [l for l in lancamentos if l["valor"] > 0] repositorio = RepositorioVendas() vendas_aprovadas = repositorio.listar_aprovadas(data)
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.
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.
# 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:
eh_email_corporativo() > "@" in s and not s.endswith("@gmail.com")).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.
# 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()
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.
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.
# 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.
Quando função tem 5+ parâmetros, especialmente se vários sempre aparecem juntos, agrupe em objeto:
# 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]: ...
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.
Bons momentos:
Vamos pegar uma função complexa real e refatorar passo a passo, mantendo testes verdes a cada passo.
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)
Antes de mexer, capturamos comportamento atual:
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...
Trecho de cálculo de imposto vira função:
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.
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.
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() }
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)
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.
Reviewer não consegue distinguir "isso mudou comportamento" de "isso só foi movido". Bugs passam, ou PR fica preso para sempre. Separe.
"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.
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.
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.
Refatore esta função em 3 funções menores com nomes descritivos:
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
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
Renomeie tudo neste código para nomes que descrevem intenção:
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")
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")
Refatore este código que usa if/elif sobre tipo, para Strategy via dict de classes:
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)
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)
Esta função tem assinatura grande. Crie 2-3 objetos para agrupar parâmetros relacionados.
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, ): ...
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, ): ...
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.
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:
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:
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:
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 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.
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.
Existe uma confusão recorrente sobre o propósito de testes. Vamos ser específicos:
Testes servem para:
Testes NÃO servem para:
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).
┌─────────┐
│ 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.
"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.
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
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.
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")
Bons nomes: test_ + condição + resultado esperado. Quando o teste falha, o nome já te diz o que quebrou:
test_pedido_vazio_nao_pode_ser_fechadotest_cartao_recusado_nao_dispara_retrytest_1, test_pedido, test_fecharTestes 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.
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).
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.
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.
Terminologia confusa. Vamos esclarecer:
get(x), retorne 42." Não verifica chamadas.save()? Com que argumentos? Quantas vezes?"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"
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.
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.
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.
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:
if tem dois caminhos, ambos foram testados?mutmut alteram operadores (+ vira -, > vira >=) e checam se algum teste falha. Se nenhum falha, seus testes são cegos àquela parte."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.
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.
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))
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
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.
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.
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).
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.
Teste tautológico: obj.x = 5; assert obj.x == 5. Não pega bug, só sobe cobertura. Teste comportamento, não estrutura interna.
Para o resto — código com lógica, com integração, com regras de negócio, com persistência — testes pagam. Faça.
Escreva, com @pytest.mark.parametrize, testes para função eh_par(n) cobrindo: positivo par, positivo ímpar, zero, negativo par, negativo ímpar.
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
Implemente RepoUsuariosMemoria que satisfaz Protocol RepoUsuarios com métodos salvar(u), buscar(id), existe_email(email). Use para testar ServicoCadastro sem tocar em banco.
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")
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).
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)
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.
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
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.
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.
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.
Qualquer refatoração disciplinada segue mais ou menos esse fluxo:
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).
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}")
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}")
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.
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.
# 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.
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.
# 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).
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.
# 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()
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).
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
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.
Função recebendo 5+ parâmetros, vários sempre passados juntos. Esses parâmetros formam um conceito que merece nome próprio.
# 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).
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.
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.
# 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.
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:
# 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.
Vamos refatorar uma função realmente ruim, passo a passo, comitando cada um. O objetivo é mostrar a disciplina, não só o resultado final.
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
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).
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.
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.
@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).
Agora os cupons estão isolados em _calcular_desconto. Quando o terceiro cupom aparecer ("BLACK_FRIDAY30"), refatoramos para Strategy:
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.
"É 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.
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.
"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.
"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.
Refatore a função abaixo extraindo métodos com nomes que descrevem intenção. Não mude comportamento.
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")
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")
Renomeie variáveis e função para nomes que descrevem domínio. Não mude estrutura.
def proc(x, y, z): a = x * 0.0825 b = y + a c = b - z return c if c > 0 else 0
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"))
Refatore para polimorfismo (Strategy via Protocol). Adicionar novo tipo de assinatura deve exigir apenas nova classe.
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
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()
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.
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"
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.
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.
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.
Cheiro é indício, não prova. Não é "código ruim"; é "código que merece um olhar". Três características:
Vamos cobrir os cheiros mais úteis, agrupados por natureza.
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.
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.
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.
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.
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:
Manager, Handler, Service) sem qualificação específica.Extract Class. Identifique grupos coesos de atributos+métodos e mude para classe separada. Frequentemente Move Method para outra classe existente.
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.
def enviar_email( de_nome, de_email, para_nome, para_email, assunto, corpo, html, anexos, prioridade, smtp_host, smtp_port, smtp_user, smtp_pass, ): ...
@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): ...
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.
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.
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.
# 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.
Split Class. Identifique os grupos de mudança e separe em classes responsáveis por cada um. Frequentemente uma God Object emergindo.
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.
Métrica simples: tamanho mediano dos PRs. Se PRs "pequenos" (do ponto de vista de funcionalidade) tocam consistentemente 8+ arquivos, há shotgun surgery.
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.
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.
# 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()
Move Method para a classe que tem os dados. Frequentemente um sintoma de Anemic Domain Model — método deveria ser do objeto invejado.
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.
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.
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.
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.
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: ...
@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.
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.
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
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.
Cadeias de chamadas longas: pedido.cliente.endereco.cidade.estado.codigo. Cada ponto te acopla a uma estrutura interna. Mudar a estrutura quebra os clientes.
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() ✗.
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.
Inverso do anterior. Uma classe tem 80% de métodos que só delegam para outra. Ela existe sem agregar nada — pura indireção.
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?
Remove Middle Man. Clientes falam direto com a classe alvo. A intermediária some.
À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.
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 é.
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.
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
| Cheiro | Evidência | Refatoração |
|---|---|---|
| Long Method | gerar com 250 linhas | Extract Method em sub-passos |
| Large Class | orquestra dados, formatação, e-mail, S3, analytics | Extract Class por responsabilidade |
| Long Parameter List | 13 parâmetros em gerar | Introduce Parameter Object (Cliente, Periodo, Localizacao) |
| Data Clumps | cliente_nome/email/cep/cidade/estado andam juntos | Extract Class Cliente |
| Data Clumps | ano/mes/dia/hora/fuso andam juntos | Use datetime nativo ou dataclass |
| Temporary Field | dados_atual, total_atual | Variável local, não atributo |
| Message Chain | p.cliente.endereco.cidade.estado.codigo | Hide Delegate em Pedido |
| Feature Envy | _formatar só mexe em d | Move Method para classe de d |
| Divergent Change | muda por motivo de imposto, e-mail, S3... | Split Class por responsabilidade |
gerar (3-5 cenários cobrindo combinações principais)._formatar para a classe correta (low risk, alto sinal).gerar (cálculo, formatação, e-mail, S3, analytics).Cliente, Periodo, FormatoSaida.p.estado_entrega().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.
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.
Refatorar Long Method em 8 Tiny Methods (cada um chamado de um lugar só) é trocar inchaço por fragmentação. Equilíbrio.
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.
"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.
FormatadorPedido e o método formatar_endereco acessa 6 atributos de Endereco e nenhum de FormatadorPedido. Qual é o cheiro principal?"Para cada código, identifique todos os cheiros que você consegue ver:
processar(d) de 180 linhas com 9 parâmetros.Pedido com método calcular_distancia_da_loja() que só acessa atributos de self.loja.rua, numero, complemento, cidade, cep.ServiceFacade onde 90% dos métodos só delegam para self._inner.cadastrar(nome_str, cpf_str, email_str, telefone_str, renda_float).Loja.Endereco não modelado.CPF, Email, Dinheiro deveriam ser tipos próprios.Refatore a função abaixo eliminando obsessão por primitivos. Crie tipos próprios para conceitos do domínio.
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() # ...
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. ...
Refatore os usos abaixo eliminando as cadeias de mensagens. Adicione métodos delegadores onde fizer sentido.
# 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": ...
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.
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.
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}"
processar tem 320 linhas → Extract Method em sub-passos.processar com 7 parâmetros e __init__ com 5 → Parameter Object para Cliente, Pagamento, SmtpConfig.ultimo_pedido, ultimo_total → variável local ou Resultado retornado.itens[0].produto.categoria.imposto.aliquota → Hide Delegate ou ler aliquota direto via método de Item._formatar_endereco só acessa atributos de end → Move Method para classe Endereco.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.
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".
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.
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.
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.
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.
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.
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") ...
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.
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.
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.
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.
# 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()
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.
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.
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.
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:
# Calcula o imposto antes de bloco que faz exatamente isso. Esse comentário é candidato a virar função _calcular_imposto().x = 0 # contador de tentativas → renomeie para tentativas = 0.# gambiarra horrível, depois refatoro. Provavelmente nunca refatorou.Comentário legítimo:
# usamos sleep porque API X tem rate limit não documentado.# implementa RFC 7519, seção 4.1.2.# NÃO mude para list comprehension — gera problema de memória em datasets grandes.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.
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.
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.
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.
# 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.
Classe que só repassa chamadas para outra classe. Cada método é uma linha: def x(self): return self._real.x(). Existe sem agregar valor.
# 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.
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).
Não toda cadeia de if/elif é cheiro. Ela vira cheiro quando:
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.
Cheiros se detectam de duas formas: leitura humana atenta e ferramentas automáticas.
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.
[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
Você abre processamento_pedido.py num projeto que herdou. Em 5 minutos de leitura, anote os cheiros. Vamos fazer junto.
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:
processar() tem 14 parâmetros.cep, rua, numero, complemento, cidade, estado sempre juntos = pede Endereço.valor como número primitivo, forma_pgto e prioridade provavelmente como strings.processar.1.18 sem nome.i.cliente.endereco.cidade.estado.codigo — cadeia de 5.# Calcula o subtotal é candidato a extrair função.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:
aplicar_desconto_fidelidade acessa 3 atributos de cliente, nenhum próprio. Move para Cliente.desconto_fidelidade().pedido.cliente.email_secundario + .email_principal + .nome — conhece estrutura interna de cliente. Cliente devia expor método email_de_contato().self.ultimo_erro, self.contador — populados ocasionalmente, esquecidos depois.Com o diagnóstico em mãos, priorize. Não tente atacar tudo:
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.
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.
"Método com 51 linhas, REFATORA AGORA". Cheiro é gatilho, não veredito. Às vezes 60 linhas legítimas. Olhe; decida; siga.
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.
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.
Registrar cheiros não resolvidos como dívida técnica explícita (issue no repo, comentário com # TODO: ... + data) é melhor que ignorar silenciosamente.
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?"Para cada trecho, identifique o cheiro principal:
(nome, sobrenome, email, telefone, rua, numero, cep, cidade, estado) em vários lugares do código.Carrinho que usa principalmente atributos de Cliente.Funcionario com 25 atributos públicos e nenhum método de comportamento.Pedido com atributo self.imposto_recalculado_em que só é preenchido depois de chamar recalcular_imposto().pedido.fatura.parcelas[0].vencimento.formatado().NotificadorMockTeste herda de Notificador mas todos os métodos lançam NotImplementedError exceto um.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.
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") ...
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.
Refatore movendo cada método para a classe certa.
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)
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()
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).
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"
| Cheiro | Onde | Refatoração | Prioridade |
|---|---|---|---|
| Long Parameter List | gerar() com 9 parâmetros | Introduce Parameter Object: ConfigRelatorio | Alta |
| Long Method | gerar() faz 5 coisas | Extract Method: buscar, calcular, formatar, enviar | Alta |
| Divergent Change | Classe muda por: SQL, regra de imposto, formato, e-mail | Extract Class: Repositório, Calculadora, Formatador, Notificador | Alta |
| Magic Numbers | 1.18, 1.05 | Constantes nomeadas: ALIQUOTA_VENDA, ALIQUOTA_SERVICO | Média |
| Magic Strings | "venda", "servico", "html", "pdf", "excel", "premium" | Enum / Literal | Média |
| Temporary Field | self.html_gerado, self.pdf_path, self.email_enviado, self.ultimo_erro | Remover atributos; retornar valores | Alta |
| Primitive Obsession | Mes/ano como int, formato como string | Value Objects: Periodo, FormatoRelatorio | Média |
| Inappropriate Intimacy | cliente.preferencias.formato + cliente.plano.tipo | Move Method para Cliente: quer_excel() | Média |
| Feature Envy | cliente_quer_relatorio_em_excel | Move para Cliente | Alta |
| SQL injection | f-string em query | Parâmetros bound (não cheiro, é bug crítico) | Crítica |
| Conexão hardcoded | psycopg2.connect("...") | DIP: injetar repositório | Alta |
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.
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.
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:
calcular_subtotal() não precisa de comentário "calcula o subtotal".Daniele Procida observou que toda documentação técnica útil se encaixa em quatro categorias com propósitos diferentes. Misturar é o erro mais comum.
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.
O README é a porta de entrada. Leitor chega ali sem contexto. Se confundir, ele sai. Estrutura testada na prática:
# 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.
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:
_underscore) com escopo pequeno.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.
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. """ ...
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.
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):
# 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:
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:
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.
Mesmo com Swagger automático, você ainda precisa de documento humano para coisas que o OpenAPI não captura:
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.
# 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).
Diagrama bonito feito no Figma envelhece em semanas. Diagrama em texto, versionado junto com o código, sobrevive anos.
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.
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.
```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.
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.
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
# 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".
# 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.
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.
"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.
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.
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.
calcular_subtotal que calcula subtotal. Não documente o óbvio.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.
Para cada item, classifique como Tutorial, How-to, Referência ou Explicação (Diátaxis):
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.
# 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.
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.
# 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).
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.
## 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.
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.
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.
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.
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.
NULL abre porta para 3 estados (presente / ausente / desconhecido); use apenas quando esses três estados forem semanticamente distintos.
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.
Cada célula contém um único valor atômico. Sem listas separadas por vírgula, sem dicionários serializados em string.
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.
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) );
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.
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".
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) );
Toda tabela precisa de chave primária — a identidade canônica do registro. A escolha é mais sutil do que parece.
BIGSERIAL (auto-incremento), UUID, ou identificador opaco. Recomendação padrão para a maioria dos casos.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.
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.
-- 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 );
Um pedido tem vários itens; cada item pertence a um pedido. A chave estrangeira fica do lado "muitos" (em pedido_itens.pedido_id).
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:
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.
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.
Categorias com subcategorias, comentários com respostas, organogramas. Uma coluna na tabela referencia outra linha da mesma tabela.
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.
Tempo é o tipo de dado que mais causa bugs em sistemas. Cinco regras práticas:
TIMESTAMP sem zona — vai te morder quando seu servidor mudar de fuso ou quando expandir para múltiplos países.DATE — não tem fuso, é só "dia 12 de maio", independente de onde a pessoa está.INTERVAL ou guarde números inteiros (segundos, milissegundos). Não invente formatos próprios.criado_em e atualizado_em. Custa pouco, salva muito.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();
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.
"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.
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;
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.
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();
Desnormalizar é introduzir redundância de propósito. Aumenta complexidade (precisa manter cópias sincronizadas) em troca de performance. Faça apenas com motivo medido.
COUNT caro em toda leitura.-- 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 );
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.
CONCURRENTLY (não bloqueia escrita).Para mudanças que parecem "atômicas" mas exigem deploy gradual:
-- 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;
Vamos modelar um e-commerce pequeno: usuários, produtos, pedidos com itens, pagamentos. Cada decisão tem justificativa.
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;
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) );
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) );
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:
confirmado_em, pago_em, etc) — facilita auditoria e queries temporais.total gerada — calculada pelo banco, nunca dessincroniza.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.
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.
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.
"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.
"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".
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.
A tabela abaixo viola 3FN. Quebre em tabelas adequadas, mantendo integridade.
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
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.
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.
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'));
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.
-- ============================================ -- 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;
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.
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() );
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.
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.
Antes de qualquer técnica, o modelo mental certo: o banco, ao receber sua query, faz três coisas:
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:
A primeira ação ao investigar query lenta é rodar EXPLAIN ANALYZE. Ele mostra o plano + tempos reais de cada operação.
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:
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.
Í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.
(a, b) serve queries filtrando por a, ou por a AND b. Não serve query só de b.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
Quando você só consulta um subconjunto pequeno da tabela, índice parcial economiza espaço e melhora performance:
-- 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.
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.
-- 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.
B-tree é default e cobre 90% dos casos, mas existem outros tipos com casos específicos.
-- 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');
Útil quando dados são inseridos em ordem (logs, métricas, eventos por timestamp). Muito menor que B-tree, ideal para tabelas gigantes.
-- 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.
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.
# 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.
# 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()
Use django-debug-toolbar em dev, ou em testes assert no número máximo de queries:
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)
-- 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
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.
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.
-- 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.
Common Table Expressions (WITH) tornam queries complexas legíveis. Mas tem uma nuance importante de performance.
-- 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.
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.
-- ❌ 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.
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.
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ó.
# ❌ 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, )
-- 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 $$;
Dashboard de vendas mensal funcionou bem por meses. Hoje, leva 28 segundos para carregar. Vamos diagnosticar e resolver.
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.
-- Í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.
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.
Dashboard é acessado por dezenas de gestores várias vezes por dia. Mesmo 700ms multiplica. Solução: tabela materializada atualizada a cada 5 minutos.
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.
SELECT * em todos os lugaresTrazer 30 colunas quando só usa 3 desperdiça memória, rede e impede Index Only Scan. Liste só as colunas que importam.
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').
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.
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".
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.
/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?"Para cada query, identifique o problema principal de performance:
SELECT * FROM pedidos WHERE LOWER(email) = 'a@b.com';SELECT * FROM pedidos ORDER BY criado_em DESC LIMIT 50 OFFSET 50000;SELECT * FROM pedidos p, clientes c WHERE p.cliente_id = c.id; (sem índice em pedido.cliente_id)SELECT *, (SELECT COUNT(*) FROM itens WHERE pedido_id = p.id) FROM pedidos p;SELECT * FROM pedidos WHERE total > 100 OR cliente_id = 42; (índices separados em total e cliente_id)pedidos.cliente_id.Dado:
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:
WHERE cliente_id = 42WHERE cliente_id = 42 AND criado_em > '2026-01-01'WHERE cliente_id = 42 AND status = 'pago'WHERE criado_em > '2026-01-01'WHERE cliente_id = 42 ORDER BY criado_em DESCReescreva a query usando JOIN com agregação:
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;
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.
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.
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;
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.
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.
EXPLAIN (ANALYZE, BUFFERS) /* query */; -- Identificar: Seq Scan? Hash Join enorme? Sort caro?
Etapa 2 · índices.
-- 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.
-- 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.
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.
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.
ACID é o que torna um banco "transacional". Vamos com nuance — não como bullet point de prova.
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.
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.
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.
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.
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.
O padrão SQL define quatro fenômenos que podem (ou não) acontecer dependendo do nível de isolamento:
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".
Transação A lê uma linha duas vezes na mesma transação e recebe valores diferentes (porque B commitou no meio).
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).
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.
Cada nível permite ou previne os fenômenos acima:
| Nível | Dirty Read | Non-Repeatable | Phantom | Serialization |
|---|---|---|---|---|
| Read Uncommitted | Possível | Possível | Possível | Possível |
| Read Committed | Previne | Possível | Possível | Possível |
| Repeatable Read | Previne | Previne | Possível* | Possível |
| Serializable | Previne | Previne | Previne | Previne |
* Em Postgres, Repeatable Read também previne phantoms; é mais forte que o padrão.
-- 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.
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;
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.
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.
Isolamento por snapshot funciona para leituras, mas operações que dependem do estado atual para decidir frequentemente precisam de lock explícito.
Trava as linhas lidas até o fim da transação. Outras transações que tentarem ler com FOR UPDATE essas linhas vão esperar.
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.
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.
Variação útil para filas de trabalho: pega N itens disponíveis e pula os que já estão travados por outros workers.
-- 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;
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".
-- 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
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.
-- 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
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))
Duas estratégias para concorrência:
Assuma que vai haver conflito; trave antes de ler. Garante consistência mas reduz paralelismo. SELECT FOR UPDATE é pessimista.
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.
-- 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.
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:
Quando o gargalo é leitura (analytics, dashboards, busca), uma estratégia comum é replicar o banco e direcionar leituras para réplicas. Eis as nuances:
# 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:
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.
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.
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.
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.
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.
-- 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.
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.
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).
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.
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.
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.
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.
Para cada cenário, identifique o fenômeno de concorrência:
SELECT * FROM pedidos WHERE total > 100 duas vezes. Recebe 50 linhas, depois 53 — porque B inseriu pedidos no meio.Escreva SQL para incrementar contador de visualizações de um post, com proteção contra race condition. Não use FOR UPDATE.
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.
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.
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
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.
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.
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.
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);
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?".
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.
"NoSQL" é guarda-chuva para coisas muito diferentes. Cinco categorias principais, cada uma com modelo de dados próprio:
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.
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.
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.
// 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);
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.
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")
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.
-- 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.
Para a grande maioria dos casos, Postgres particionado resolve. Cassandra é ferramenta pesada — só justifica em escala onde Postgres claramente não daria conta.
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".
// 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;
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.
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.
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;
Antes de adotar qualquer banco não-relacional, responda honestamente:
Antes de adotar outro banco, vale conhecer o que Postgres faz nos territórios "NoSQL":
-- 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.
"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.
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.
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.
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.
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 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.
"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.
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.
Cassandra ou DynamoDB com "transações multi-partição garantidas". Não existem (ou existem com asterisco). Quando se descobre, é tarde.
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.
Redis em memória, sem persistência adequada. Crash do servidor = dados perdidos. Use para cache; source of truth fica em banco persistente.
Stack com Postgres + MongoDB + Redis + Elasticsearch para sistema com 1000 usuários. Cinco runtimes para operar é cinco fontes potenciais de problema. Mantenha simples.
Para cada caso, sugira a família de banco mais adequada (e justifique):
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).
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})")
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: 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.
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.
| Componente | Banco | Justificativa |
|---|---|---|
| Cursos, aulas, inscrições, faturas | PostgreSQL | Nú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âneos | Append-only, range queries por usuário/aula, retention curta. Manter no ecossistema Postgres simplifica operação. |
| Busca de cursos e aulas | Postgres FTS inicialmente; Elasticsearch quando precisar de relevância sofisticada | Postgres 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 produto | Pre-agregar reduz custo de queries de dashboard. Continuous aggregates do TimescaleDB são ideais. |
| Sessões e cache | Redis | Sessões com TTL, cache de páginas de cursos populares, rate limiting de API. |
| Arquivos de vídeo | S3 (ou MinIO on-prem) com CDN | Arquivos 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.
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 é 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.
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.
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:
O modelo mais comum, suficiente para a maioria dos sistemas:
# --- 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))
Para sistemas maiores, a separação fica:
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.
# --- 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)
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:
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] 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.
Pergunta recorrente: cada camada deve ter seus tipos, ou as entidades de domínio podem trafegar até a borda?
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.
Pedido circula em todas as camadas.Prós: simples, menos código. Contras: mudança no dominio pode mudar contrato HTTP acidentalmente; expor campos internos no JSON; acoplamento das camadas.
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.
# --- 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)
Duas escolas de organização de arquivos:
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.
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.
Sistema legado típico: tudo no controller. Vamos refatorar para três camadas, em passos seguros.
@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.
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: ...
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
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:
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.
Service retorna objetos do ORM (Django QuerySet, SQLAlchemy session). Controller chama .save() em entidade. Persistência infectou tudo. Use repositórios como fronteira.
from fastapi import ... dentro de entidade de domínio. Framework deve ser invisível para regras de negócio. Se aparece, há erro arquitetural.
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.
Princípio: camadas pagam quando há lógica de negócio para isolar e código vai evoluir. Para o resto, simplicidade primeiro.
Para cada trecho, identifique a qual camada pertence (domínio, aplicação, infraestrutura, apresentação):
def cobrar(self, valor): r = requests.post("https://stripe.com/...", ...)def confirmar(self): if not self.itens: raise PedidoVazio()def criar_e_pagar(self, dados): pedido = Pedido.novo(); self._repo.salvar(pedido); self._gateway.cobrar(...)@app.post("/pedidos") def criar(req: CriarPedidoRequest): ...cur.execute("SELECT ... FROM pedidos WHERE id = %s", (id,))class PedidoResponse(BaseModel): id: str; total: DecimalEm cada caso, qual a violação arquitetural?
from sqlalchemy import Column no arquivo domain/pedido.pypsycopg2.connect()QuerySet do DjangoPedido com método renderizar_html()Pegue esse controller "fat" e refatore em 3 camadas (apresentação, aplicação/serviço, infraestrutura/repository) + domínio. Mantenha comportamento.
@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
# --- 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")
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.
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
[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
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.
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.
Para entender o ganho, vejamos o problema que aparece em arquitetura em camadas "naïve":
# 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:
"Porta" = interface que o núcleo define. Duas direções:
"Adaptador" = implementação concreta de uma porta. Duas direções correspondentes:
┌──────────────┐ ┌──────────────┐ │ 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.
Clean Architecture organiza o sistema em quatro anéis, com a mesma regra:
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.
| Aspecto | Camadas tradicionais | Hexagonal / 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 |
Vamos construir um exemplo completo: serviço de pedidos com hexagonal architecture. Note como o núcleo nunca importa infraestrutura.
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"))
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: ...
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.
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 ...
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), )
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))
A grande vitória da arquitetura: testes do use case sem subir banco, sem mocks elaborados, sem patches. Apenas fakes que implementam os protocolos.
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.
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.
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.
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.
domain/.application/ — use cases não conhecem banco.presentation/.Apenas: novo adaptador em infrastructure/ + linha do bootstrap.
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"]], )
# 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.
Script de 200 linhas com hexagonal completa. Você tem mais arquivos de infraestrutura/ports do que código real. Camadas simples bastariam.
from pydantic import BaseModel em domain/. Pydantic é detalhe de serialização — não pertence ao domínio. Use dataclasses puras.
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.
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.
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.
RepoPedidos (Protocol) e onde vive a classe RepoPedidosPostgres?"Para cada item, classifique como porta/adaptador driving (entrada) ou driven (saída):
POST /pedidosCriarPedidoUseCaseEscreva 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.
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]: ...
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.
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
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")
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.
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
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: ...
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
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.
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.
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.
DDD tem duas metades, frequentemente confundidas:
Pergunta: "como dividir o sistema, e que linguagem usar em cada parte?"
Onde paga: sempre que há domínio complexo, mesmo em sistemas pequenos.
Pergunta: "como modelar dentro de cada parte?"
Onde paga: sistemas com regras de negócio complexas em cada contexto.
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:
Policy no código.insurance_data.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.
Apolice, não Policy. Variável data_emissao, não issued_at. Sim, frequentemente em português — a linguagem é do negócio.# 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.
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.
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 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.
Sinais que indicam que você está cruzando uma fronteira de contexto:
Bounded contexts não vivem isolados — eles se relacionam. Mapear como eles conversam é parte essencial de DDD estratégico. Os padrões mais comuns:
Moeda, CPF). Mudanças exigem coordenação.# 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.
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:
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
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:
id.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"
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.
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"
Vaughn Vernon (no livro vermelho) destilou em quatro regras úteis:
cliente_cpf, não tendo self.cliente: Cliente.Evento de domínio é fato passado registrado pelo domínio: "PedidoConfirmado", "PagamentoAprovado", "EstoqueReservado". Verbo no passado, sempre. Imutável (já aconteceu).
Eventos servem para:
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
À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 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)
Fintech com cinco anos, sistema monolítico onde "tudo conversa com tudo". Vamos mapear os contextos e mostrar como isolar.
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.
Após workshops com cada time, identificamos:
| Bounded Context | Conceito de cliente | Time dono |
|---|---|---|
| Onboarding | Solicitante (em validação KYC) | Compliance |
| Conta | Correntista (com saldo, cartão, PIX) | Conta digital |
| Crédito | Mutuário (score, limite, empréstimos) | Crédito |
| Investimentos | Investidor (perfil, posição) | Wealth |
| Suporte | Usuário (tickets, nível) | CX |
| Marketing | Pessoa (segmentos, jornada) | Growth |
# 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
# 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
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.
# 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.
Criar pastas entities/, value_objects/, aggregates/ sem nunca ter conversado com o negócio para descobrir linguagem e contextos. É DDD apenas no nome.
Cadastro simples (entidade com 5 campos sem regras complexas) virando agregado com aggregate root e eventos. Cerimônia sem ganho. CRUD pode ser CRUD.
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.
"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.
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.
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.
Para cada conceito, classifique como Entity, Value Object, Aggregate Root, ou Domain Event:
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.
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
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.
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
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:
Livro (ISBN, autor, editora). Cada Livro tem Exemplares como value objects internos com código de identificação e disponibilidade.Emprestimo (usuario_id, exemplar_id, data inicial, prazo, devolução). Multa é cálculo encapsulado.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.
"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.
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.
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:
/pedidos, /usuarios, /produtos./pedidos/{id}./pedidos/{id}/itens, /pedidos/{id}/pagamentos.Convenções que pagam:
/pedidos, não /pedido. Consistência reduz fricção./pedidos/{id}/cancelar (POST), prefira modelar como mudança de estado: PATCH /pedidos/{id} com {"status": "cancelado"}. Quando ação não cabe em CRUD, abra exceção pragmática./ordens-de-compra, não /ordensDeCompra./pedidos/42, não /pedidos?id=42.Cada verbo tem semântica. Quem desenha API bem usa cada um para o que ele foi feito:
| Verbo | Significado | Seguro? | Idempotente? |
|---|---|---|---|
| GET | Ler recurso(s) | Sim | Sim |
| POST | Criar recurso (ou ação não-CRUD) | Não | Não (por padrão) |
| PUT | Substituir recurso completo | Não | Sim |
| PATCH | Atualizar parcialmente | Não | Não (depende) |
| DELETE | Remover recurso | Não | Sim |
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 exige enviar o recurso completo: campos não enviados são interpretados como null. PATCH envia apenas o que muda.
# 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).
HTTP define dezenas. Os essenciais:
Location com URL do recurso novo é boa prática.Retry-After.Retry-After ajuda cliente."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.
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 adicionais são livres — coloque o que ajude o cliente a entender e agir.
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"}, )
APIs sempre evoluem. Versionamento define como mudanças quebradoras são gerenciadas.
Três estratégias principais:
| Estratégia | Exemplo | Comentá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.
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.
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:
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).
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.
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.
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!
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.
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.
{
"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.
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.
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)
Vamos modelar uma API de pedidos para um e-commerce. Cada decisão tem justificativa.
/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.# 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)
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 } }
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:
/v1/) — visível, simples.POST /createOrder, POST /getUserById. Volta para RPC, perde semântica de HTTP. Use POST /pedidos, GET /usuarios/{id}.
Retornar 200 OK com {"erro": "..."}. Cliente HTTP não consegue distinguir; ferramentas de monitoramento perdem visibilidade. Use o código apropriado.
{"error": "invalid request"} sem dizer o que está inválido. Cliente vira detetive. RFC 7807 com detail descritivo é o mínimo.
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.
"Vai ter no máximo umas centenas." Em dois anos tem 50 mil; cliente trava ao listar. Sempre pagine, mesmo "se não precisar".
Remover campo em produção "porque ninguém usa". Sempre tem cliente que usa. Adicione novo, marque antigo como deprecated, remova em V2.
Para cada situação, qual verbo e qual status apropriados?
GET /usuarios/{id} → 200 (ou 404 se não existe).POST /usuarios → 201 Created + header Location.PATCH /usuarios/{id} → 200 OK ou 204 No Content.DELETE /usuarios/{id} → 204 No Content.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.
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" }
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.
# 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.
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:
/v2/usuarios/{id} com novo formato.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" }
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.
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.
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.
Três problemas concretos que aparecem em APIs REST sob certas condições:
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.
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.
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.
# 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.
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.
# 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:
! para tornar obrigatório. Pense em cada campo: pode ser null? Se sim, deixe; se não, marque com !.GraphQL distingue:
# 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 } }
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.
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.
A flexibilidade do GraphQL traz problema clássico: clientes podem montar queries que disparam N+1 problemas em cascata. Considere:
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.
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.
GraphQL tem modelo de erro próprio. Resposta sempre tem campos data e (opcionalmente) errors:
{
"data": {
"pedido": null
},
"errors": [
{
"message": "Pedido não encontrado",
"path": ["pedido"],
"extensions": {
"code": "NOT_FOUND",
"pedidoId": "p_abc"
}
}
]
}
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.
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.
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.
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.
| Aspecto | REST | GraphQL |
|---|---|---|
| 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 |
Casos onde GraphQL claramente paga o investimento:
Casos onde GraphQL não vale (e REST é mais simples):
Empresa tem API REST com 60+ endpoints. Time mobile reclama de latência: cada tela exige 4-6 chamadas. Avaliam migrar para GraphQL.
Time mediu. Resultado:
Conclusão: apenas duas telas têm o problema. Migrar API inteira para GraphQL é overkill.
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.
┌─────────────┐ ┌──────────────┐
│ App Mobile │───→│ GraphQL │──┐
└─────────────┘ └──────────────┘ │
↓
┌──────────────┐
┌─────────────┐ ┌──────────────┐ │ Use Cases │
│ Web app │───→│ REST API │──→│ (compartil- │
└─────────────┘ └──────────────┘ │ hados) │
│ │
┌─────────────┐ ┌──────────────┐ └──────┬───────┘
│ Integrações │───→│ REST Webhooks│ │
└─────────────┘ └──────────────┘ ↓
┌──────────────┐
│ Domínio + │
│ Persistência │
└──────────────┘
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 }
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.
Implementação funcional, mas com N+1 em toda relação. Performance degrada drasticamente em queries aninhadas. DataLoader é requisito, não opcional.
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.
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.
"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.
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.
Princípio: GraphQL paga onde over/under-fetching é dor real e medida. Sem essa dor, é só complexidade adicional.
Para cada cenário, justifique se GraphQL vale ou não:
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.
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! }
Implemente resolver de Tarefa.responsavel usando DataLoader (Strawberry). Mostre a função de batch e como configurar.
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).
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.
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.
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.
Toda integração entre sistemas pode ser síncrona (RPC, REST) ou assíncrona (mensageria). Não é "moderno vs antigo" — é trade-off.
Sistemas reais misturam os dois. A questão certa é "qual usar onde", não "qual é melhor".
Duas semânticas fundamentais, com implementações diferentes:
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.
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.
RabbitMQ implementa AMQP 0.9.1. Três conceitos centrais:
Tipos de exchange definem como mensagens vão para queues:
pedidos.*.confirmado).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()
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.
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:
pedidos.confirmado).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
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
| Aspecto | RabbitMQ | Kafka |
|---|---|---|
| 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:
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.
{
"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}
]
}
}
pedido.confirmado, cliente.cadastrado). Verbo no passado é convenção.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.
Sistemas de mensageria oferecem semânticas diferentes. Saiba qual você está usando.
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.
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.
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.
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.
Problema clássico: você precisa atualizar o banco e publicar evento. Tentação:
# 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.
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;
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.
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:
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.
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
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.
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.
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:
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.
Sistema de pedidos que originalmente chamava 4 serviços sincronamente. Quando um deles ficava lento, todo o checkout travava. Vamos reestruturar.
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
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
# 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.
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.
Como vimos: banco commit + publicar Kafka separadamente = caminho para inconsistência. Use outbox sempre que precisar atomicidade entre banco e fila.
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.
At-least-once é o normal. Sem dedup, mesma mensagem processada duas vezes vira pedido duplicado, e-mail duplo, cobrança dupla. Sempre idempotente.
DLQ enche silenciosamente. Em dias, milhares de mensagens críticas falhadas, ninguém vê. Alerta no tamanho, dashboard, ownership claro.
"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.
Time produz {"pedido_id": "...", "total": "..."}. Em 6 meses, adiciona moeda. Consumidores quebram. Use versionamento desde o início.
Princípio: mensageria não é "moderno", é "específico". Use onde dor real (acoplamento, picos, latência percebida) justifica a complexidade adicional.
Para cada situação, decida: síncrono ou assíncrono, e justifique.
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.
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)
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).
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")
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.
| Passo | Evento em sucesso | Evento em falha | Compensaçã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:
Implementação: Temporal, AWS Step Functions, ou orquestrador caseiro se complexidade não justificar ferramental dedicado.
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.
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.
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.
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:
| Modelo | Tipo de trabalho | Paralelismo real? | Overhead |
|---|---|---|---|
asyncio | I/O-bound | Não (mas concorrência sim) | Baixo |
threading | I/O-bound | Não (GIL bloqueia) | Médio |
multiprocessing | CPU-bound | Sim | Alto |
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:
time.sleep().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.
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.
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.
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
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()
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.
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))
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
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.
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.
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.
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
multiprocessing.asyncio.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.
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).
# 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).
Dois threads, dois locks. A pega lock_1 e quer lock_2. B pega lock_2 e quer lock_1. Ambos travam para sempre.
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.
FastAPI recebe request; para responder, precisa consultar 5 microsserviços diferentes. Sequencial: 100ms × 5 = 500ms. Quer baixar.
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).
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.
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).
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.
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".
"Vou paralelizar com threads." GIL serializa. Performance igual ou pior. Para CPU-bound: multiprocessing.
time.sleep(), requests.get(), query síncrona dentro de função async. Trava o servidor inteiro. Use versão async ou asyncio.to_thread.
Variável global incrementada por múltiplas threads. Race condition silenciosa, dados perdidos. Use Lock, Queue ou estruturas thread-safe.
multiprocessing com 1000 processos. Cada um custa memória e startup. Use Pool com tamanho proporcional aos cores.
Threads que nunca terminam. ProcessPool fora de with. Conexões em pool não devolvidas. Use context managers e try/finally.
Classifique cada trabalho:
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).
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))
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.
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
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.
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.
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".
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.
Antes de "como me proteger", a pergunta certa é "do que estou me protegendo?". Modelagem de ameaças é o exercício de pensar sistematicamente:
Framework popular: STRIDE (Microsoft). Para cada componente, considere:
Lista de 2021, ainda referência. A ordem importa — refletem o que mais aparece em incidentes reais:
O resto do capítulo aprofunda os mais relevantes para o dia a dia de quem desenvolve aplicações.
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.
# 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.
# 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.
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).
# ❌ 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.
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.
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)
secrets.compare_digest em vez de == para tokens; evita timing attacks.authlib), não implemente do zero.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.
# 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
Espalhar permissões por controllers é caminho para esquecer um. Centralize:
@requires_role("admin").Depois de autenticar, o usuário precisa de uma "credencial" para próximas requisições. Duas estratégias dominantes:
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.
JWT (JSON Web Token) é payload assinado contendo claims (id, exp, etc). Servidor valida assinatura sem precisar consultar banco. Stateless, escalável. Mas:
jwt.decode(token, key, algorithms=["RS256"]).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")
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.
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): ...
Fernet da biblioteca cryptography é a API "para humanos": AES-128-CBC + HMAC-SHA256. Não exige você escolher modo, IV, padding.
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()
Para integridade (não senhas): SHA-256 ou SHA-512. hashlib da stdlib basta.
Nunca:
.env commitados.Onde colocar:
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).
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.
requirements.txt com versões exatas, ou poetry.lock / pdm.lock. Builds reproducíveis.pip-audit, safety, GitHub Dependabot, Snyk, Trivy.requests vs request — alguém publicou request malicioso.# 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
Você herda esse endpoint e precisa avaliar antes de promover a produção. Vamos achar os problemas.
@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()
| # | Problema | OWASP | Severidade |
|---|---|---|---|
| 1 | SQL injection na query do email | A03 Injection | Crítica |
| 2 | Senha em texto plano no banco | A02 Cryptographic Failures | Crítica |
| 3 | Senha comparada com == (timing attack) | A02 | Média |
| 4 | "Token" é só base64 do ID — qualquer um adivinha | A07 Authentication | Crítica |
| 5 | Sem rate limit em login (brute force livre) | A07 | Alta |
| 6 | SQL injection na busca de dados ({id}) | A03 | Crítica |
| 7 | Sem check de autorização (qualquer usuário vê qualquer outro) | A01 Broken Access Control | Crítica |
| 8 | SELECT * retorna campo senha junto | A02 | Crítica |
| 9 | Mensagem de erro genérica (bom!) mas sem log estruturado | A09 Logging | Baixa |
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.
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.
"Email não cadastrado" vs "senha incorreta" permite enumerar emails. Mensagem genérica "credenciais inválidas" para ambos os casos.
"Vou XOR com a chave, é simples." Você acaba de criar vulnerabilidade. Use bibliotecas auditadas, sempre.
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.
JavaScript valida formulário; backend confia. Atacante envia direto bypassando JS. Valide sempre no backend; cliente é experiência de usuário, não controle.
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.
Para cada trecho, qual vulnerabilidade do OWASP Top 10?
query = f"SELECT * FROM users WHERE name = '{name}'"md5(senha)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.
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
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.
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)
Analise o endpoint abaixo e liste TODAS as vulnerabilidades, classificando por severidade e categoria OWASP. Depois reescreva o endpoint sanando os problemas.
@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:
| # | Problema | OWASP | Severidade |
|---|---|---|---|
| 1 | "Token" base64 trivialmente forjável | A07 Authentication | Crítica |
| 2 | SQL injection no user_id (via base64) | A03 Injection | Crítica |
| 3 | shell=True com input do usuário = RCE | A03 Injection | Crítica |
| 4 | Sem validação de pydantic; req é dict arbitrário | A04 Insecure Design | Alta |
| 5 | Log do comando completo (pode vazar PII) | A09 | Média |
| 6 | Sem rate limit | A04 | Média |
| 7 | Sem auditoria robusta de ações admin | A09 | Média |
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")
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.
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".
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.
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.
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.
{"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.
structlog e loguru são as opções modernas. structlog integra bem com logging da stdlib e tem boa performance.
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
Logue:
Não logue:
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".
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)
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.
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_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.
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:
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)
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:
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.
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.
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.
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.
# ❌ 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.
Cliente liga: "checkout está dando erro intermitente". Sem observabilidade, você passa horas chutando. Com os três pilares, é rotina.
Abrir dashboard "Checkout":
Confirmado: problema real, intermitente, com timeout.
Clique em "ver traces de erros desta janela". Vê 50 traces. Padrão visível:
charge_payment demorando 8s+ ou timeout.api.stripe.com.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).Abrir status.stripe.com. Sim: incidente reportado há 25 minutos. Latência elevada na região US-East.
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.
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.
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.
Alertas em métricas que oscilam normalmente. Engenheiros ignoram. Alerta real passa despercebido. Menos alertas, mais úteis.
Logs em vários serviços, sem trace_id. Para entender um fluxo, junta na unha. Trace_id propagado entre serviços resolve.
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.
Senha, token, CPF, número de cartão. Logs acabam em sistemas com mais acesso que o app. Sanitize antes de logar.
Para cada necessidade, qual pilar usar (logs / métricas / traces)?
Refatore esse código para usar structlog com bind de contexto persistente em todo o fluxo:
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
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.
Implemente middleware para FastAPI que registra métricas RED (Rate, Errors, Duration) usando prometheus_client. Inclua labels apropriados sem cardinalidade explosiva.
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())
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):
Alertas:
O que cada alerta dispara:
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.
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.
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.
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).
# 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
Dockerfile é o arquivo declarativo que define como construir a imagem. Cada linha cria uma camada — vamos falar sobre isso em breve.
# 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"]
FROM: imagem base. Sempre o primeiro comando.WORKDIR: define diretório atual. Subsequentes COPY, RUN, CMD são relativos a ele.COPY / ADD: copia arquivos do host para a imagem. Prefira COPY; ADD tem comportamentos extras (auto-extrair tar, baixar URLs) raramente úteis.RUN: executa comando durante build. Cria camada.ENV: variável de ambiente persistente.ARG: variável apenas durante build.EXPOSE: documenta qual porta o container usa. Não publica (isso é -p no run).CMD: comando padrão. Pode ser sobrescrito no docker run.ENTRYPOINT: ponto de entrada fixo. CMD vira argumento para ele.USER: usuário que executa. Importante (vamos cobrir).
Análogo ao .gitignore. Lista o que o COPY . deve ignorar. Crucial para imagens pequenas e build rápido.
__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.
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.
# 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%.
| Base | Tamanho | Quando usar |
|---|---|---|
python:3.12 | ~1GB | Build stage; quando precisa de tudo (compiladores, etc) |
python:3.12-slim | ~150MB | Runtime padrão. Debian mínimo. Default recomendado. |
python:3.12-alpine | ~60MB | Menor, mas usa musl libc — bibliotecas C às vezes não compilam. Cuidado. |
gcr.io/distroless/python3 | ~50MB | Sem 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.
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: 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.
# 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/*
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.
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:
docker compose up -d: sobe tudo em background.docker compose down: derruba tudo.docker compose logs -f app: segue logs de um serviço.docker compose exec app bash: entra no container do app.docker compose down -v: derruba E remove volumes (cuidado: apaga dados).Compose é para dev e teste, não produção. Em produção: Kubernetes, ECS, Cloud Run, ou orchestration similar.
Dockerfile que roda em dev frequentemente tem problemas em produção. Checklist do que mudar:
Por default, container roda como root. Se atacante escapa, vira root no host (em alguns runtimes/configs). Sempre defina USER.
RUN groupadd -r app && useradd -r -g app app USER app # Comandos seguintes rodam como user app, não root.
Container saudável != processo rodando. Pode estar travado, sem responder. Health check define como saber.
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.
Quando container recebe SIGTERM, ele deveria terminar requests em andamento e desligar limpo. Em Python:
CMD em formato shell (CMD uvicorn ...) — sinais não propagam direito. Use lista (CMD ["uvicorn", ...]) ou um init como tini.Nunca hardcode configuração no Dockerfile. Use variáveis de ambiente lidas em runtime.
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
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.
FROM ubuntu:bionic de 2018.USER não-root.ENV API_KEY=.... Inject em runtime.trivy, grype, Snyk. CVEs em camadas base aparecem aqui.FROM python:3.12.5-slim, não python:latest. Builds reproducíveis.--read-only no run; volumes para o que precisa de escrita.# ❌ 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
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.
Você começou com Dockerfile rápido para testar. Agora vai pra produção. Vamos evoluir, mostrando o que muda.
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
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
# ============================ # 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:
FROM python:latestImagem muda no tempo. Build "funciona" hoje, quebra amanhã sem você mexer. Sempre pinne versão: python:3.12.5-slim.
Cada mudança no código invalida cache de install. Build de 30s vira 3min. Copie requirements primeiro, instale, depois copie código.
ENV STRIPE_KEY=sk_live.... A imagem é distribuída; secret vaza. Use orchestrator secrets, env files locais, ou cofres.
Default. Atacante escapa do container → root no host (em algumas configs). Sempre USER não-root.
Container morre, logs vão junto. Sempre stdout/stderr — orchestrator coleta de lá.
.dockerignore.git de 500MB vai pra imagem. node_modules de 1GB também. Tempo de build longo, imagem inchada. Crie .dockerignore sempre.
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ê?"Para cada situação, qual o comando Docker?
app:v1.2 a partir do Dockerfile no diretório atualweb, expondo porta 8000 do container na 80 do host, em backgrounddocker ps -adocker logs --tail 100 -f <container>docker exec -it <container> bash (ou sh em alpine/slim sem bash)docker image prune -adocker build -t app:v1.2 .docker run -d --name web -p 80:8000 app:v1.2Escreva 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.
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"]
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.
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:
Analise o Dockerfile abaixo. Liste TODOS os problemas (security, performance, reprodutibilidade). Sugira correção para cada.
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
| # | Problema | Correção |
|---|---|---|
| 1 | python:latest não é reproducível | Pinne: python:3.12.5-slim |
| 2 | Imagem completa (1GB+) sem necessidade | Use slim ou multi-stage |
| 3 | Três RUN apt-get install separados — três camadas | Combine em um único RUN |
| 4 | Sem apt-get clean nem rm -rf /var/lib/apt/lists/* | Limpe no mesmo RUN |
| 5 | COPY . /app antes de pip install — invalida cache | Copie requirements primeiro, instale, depois copie código |
| 6 | Sem .dockerignore (presumível) | Crie .dockerignore com .git, .env, __pycache__, etc |
| 7 | Sem --no-cache-dir no pip — cache fica na imagem | Adicione flag para pip |
| 8 | Secrets na imagem! DATABASE_URL com senha + API_KEY embutidos | Inject em runtime via env do orchestrator |
| 9 | Roda como root | Crie usuário, use USER |
| 10 | Sem health check | Adicione HEALTHCHECK |
| 11 | CMD em shell form — sinais não propagam | Use exec form: CMD ["uvicorn", ...] |
| 12 | Falta --port no comando | Especifique a porta explicitamente |
| 13 | Falta PYTHONUNBUFFERED=1 | Adicione para logs irem direto ao stdout |
| 14 | gcc/libpq-dev ficam no runtime (libs de build) | Multi-stage: build em estágio separado |
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.
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.
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:
git add coloca aqui.git commit grava do staging para cá.git push sincroniza.
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".
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.
Commits são unidade de comunicação, não só de salvamento. Bom commit:
git revert resolve sem efeitos colaterais inesperados.Convenção popular para mensagens:
# 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
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.
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
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.
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.
Branch main sempre deployável. Cada feature/fix em branch curto, PR pra main, deploy assim que merged. Simples, funciona pra maioria.
Fluxo:
git checkout -b feat/pix-checkoutTodo 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.
Importante: escolha uma e seja consistente. Time fazendo "meio Git Flow, meio GitHub Flow" gera confusão maior que qualquer estratégia.
Três formas de trazer mudanças de uma branch para outra. Cada uma com semântica diferente.
Cria commit de merge. Histórico mostra exatamente como o trabalho aconteceu, incluindo paralelismo.
# 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).
Pega seus commits e os "replanta" no topo da main atual. Histórico fica linear, sem merges.
# 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)
Pega todos os commits da branch e faz um. Histórico mostra "feature X" como ato único.
# 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).
| Estratégia | Quando usar |
|---|---|
| Merge commit | Branches longas e bem documentadas (Git Flow). Times que valorizam histórico fiel. |
| Rebase + merge fast-forward | Times que querem histórico linear mas preservar commits individuais bem feitos. |
| Squash and merge | Branches com muitos commits "wip", fixup, etc — vira um commit limpo na main. Padrão mais usado em GitHub Flow. |
Git é difícil de quebrar de forma irrecuperável, se você sabe os comandos certos.
# 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
# Cria NOVO commit que desfaz o anterior. Histórico preservado. git revert abc1234 # Reverter merge: git revert -m 1 <hash do merge>
git reflog registra todas as movimentações de HEAD nos últimos 90 dias (default). Mesmo se você "perdeu" um commit, ele provavelmente está lá.
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.
# 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
# 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.
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 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.
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".
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.
Tom importa muito. Mesmas palavras, ditas de jeitos diferentes, têm efeitos opostos.
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.
Colega abre PR adicionando endpoint de cancelamento de pedido. Vamos passar por ele com olhos críticos e construtivos.
@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}
Arquitetura:
CancelarPedidoUseCase? Facilita testes e segue o padrão dos outros endpoints (referência: CriarPedidoUseCase)."/pedidos/{id}/cancelar tem verbo. Considera POST /pedidos/{id}/cancelamento seguindo a convenção REST adotada no projeto (ADR-0014)?"Correção:
Depends(get_usuario_atual) e check de ownership."repo.get(id) pode retornar None — vai dar AttributeError em pedido.status. Trate 404."pedido.cancelar(motivo) que valida estado."Testes:
Legibilidade:
motivo: str no parameter está bom, mas validar tamanho mínimo (5 chars) ajuda. Considera um Pydantic model?"return {'ok': True} não traz informação útil. Status code 200 já basta — considere retornar dados do pedido cancelado ou 204 No Content."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.
"Feature X completa" em um commit de 5000 linhas. Impossível reviewar com cuidado. Quebre em commits que façam sentido isoladamente.
Em 6 meses, ninguém vai entender. Investigue com curiosidade: o que esse commit faz? Por quê? Resposta na mensagem.
Você reescreve histórico de algo que outros têm. Eles entram em conflito impossível. Rebase só local ou em branch sua exclusiva.
Reescreve histórico do branch principal. Apaga commits dos outros. Configure proteção: GitHub permite "protect branch" desabilitando force-push.
Reviewer faz comentários para mostrar conhecimento, não para ajudar. Bloqueia PR sem motivo técnico. Cria fricção sem entregar valor.
Rubber-stamp. Mata o propósito de code review e diluí confiança do time. Ou revise sério, ou se declare ocupado.
.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.
Para cada situação, qual o comando?
feat/pix e fazer checkout nelagit diffgit log --oneline -10git checkout -b feat/pix (ou git switch -c feat/pix)git checkout -- <arquivo> (ou git restore <arquivo>)git pull --rebase origin maingit branchgit branch -d feat/pix (use -D para forçar se não mergeada)Reescreva cada mensagem ruim em forma boa (Conventional Commits):
fixestestsupdated librefactorWIP merge laterExemplos de melhorias (depende do contexto real, claro):
fix(pedidos): corrige cálculo de imposto quando frete é zerotest(usuarios): adiciona casos de borda para validação de CPFchore: atualiza FastAPI de 0.110 para 0.115refactor(pagamentos): extrai cálculo de taxa para Value Objectwip: rascunho da integração Pix (não mergear)Você é reviewer do PR abaixo. Liste comentários estruturados (arquitetura, correção, testes, legibilidade). Indique quais são blockers.
@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):
filtro. Use query parametrizada: cursor.execute("... WHERE nome LIKE %s", (f'%{filtro}%',)).SELECT * retorna senha_hash junto. Liste campos explícitos.Arquitetura:
Testes:
Legibilidade:
filtro é vago. busca_nome ou q (convenção) é melhor.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:
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
# ...
git reset --hard d6e7f8a
# Os 3 commits voltam. Working dir agora reflete o estado correto.
# 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
--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.
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.
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".
A confusão é constante. Vamos separar:
| Sigla | Significado | O 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.
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.
Pipeline é sequência de etapas. Cada etapa pode falhar; falha barra avanço. Etapas típicas, em ordem:
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.
Sintaxe YAML, arquivos em .github/workflows/. Exemplo de pipeline completo para app Python:
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 }}
--cov-fail-under=80 bloqueia PR se cobertura cair.if: github.ref == 'refs/heads/main' — PRs validam, só main constrói imagem.Toda build produz artefato (imagem Docker, geralmente). Como você nomeia esses artefatos importa muito para rastreabilidade e rollback.
app:a3f1b2c. Imutável, único, mas não-legível. Recomendado para deploy.app:v1.2.3. Legível, mas exige tag manual ou automática.app:latest. Anti-padrão para produção — não-determinístico, dificulta rollback.app:2026-05-20-a3f1b2c. Misto.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.
Várias formas de deployar nova versão. Cada uma com trade-off entre risco, custo e complexidade.
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.
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.
# 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
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.
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.
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)
| Estratégia | Quando usar |
|---|---|
| Big-bang | Sistemas internos sem usuários ativos; janela de manutenção |
| Rolling update | Padrão de Kubernetes. Bom para 95% dos casos. Sem custo extra de infra. |
| Blue-Green | Quando rollback rápido é crítico e custo de 2x infra é aceitável. |
| Canary | Mudanças arriscadas, alto volume, vontade de validar com tráfego real antes de promover. |
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.
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.
Ferramentas: LaunchDarkly (líder de mercado), Unleash (open-source), Flagsmith, Statsig. Em escala pequena, tabela no banco basta.
Deploy quebrou. Métricas alertam. O que fazer?
Tipos de rollback:
kubectl rollout undo; ECS/Cloud Run permitem reverter para revisão anterior).Sem expand/contract, rollback de código quebra junto com banco. Com ele, rollback é seguro.
Deploy não termina quando o pipeline está verde. Termina quando você confirma que a nova versão está saudável em produção.
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".
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.
API Python (FastAPI) que serve checkout de e-commerce. Time pequeno, 3 engenheiros. Vamos construir o pipeline em iterações.
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.
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".
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.
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.
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.
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:
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.
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.
Testes "flaky" derrubam confiança. Time aprende a "rerodar" — perde o sinal real. Investigue e corrija; ou quarantine + ticket para arrumar.
"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.
latest em produçãoCluster 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.
Renomeou coluna, deploy quebra. Rollback quebra junto. Faça migrations em duas fases: expand (compatível) e contract (depois de tudo estabilizado).
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.
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.
Classifique cada prática como CI, Continuous Delivery (CDel), Continuous Deployment (CDep) ou nenhuma:
./deploy.sh manualmente quando querEscreva 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.
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
Para cada situação, escolha a estratégia mais apropriada (big-bang, rolling, blue-green, canary) e justifique:
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):
-- 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):
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.
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.