Do requisito ao modelo: um roteiro de decisão
Modelar com Orientação a Objetos é transformar um conjunto de necessidades (requisitos) em um conjunto de tipos (classes) com responsabilidades claras e uma API previsível. O objetivo não é “criar muitas classes”, e sim reduzir ambiguidades: quem faz o quê, onde ficam as regras, como o sistema evolui sem quebrar.
Um processo prático e repetível costuma seguir estas etapas:
- Levantar requisitos: o que o sistema precisa fazer, quais regras não podem ser violadas, quais cenários são críticos.
- Identificar entidades e valores: “coisas” com identidade (entidades) e “dados” sem identidade própria (value objects).
- Definir responsabilidades: cada classe deve ter um motivo principal para mudar.
- Desenhar relacionamentos: composição (tem-um), associação (usa-um) e herança (é-um) quando houver especialização real.
- Decidir onde ficam regras de negócio: invariantes e validações perto dos dados; orquestração em serviços/casos de uso; persistência fora do domínio.
- Criar uma API consistente: nomes, retornos, erros, e fluxo de uso previsível.
- Refatorar guiado por extensibilidade: reduzir duplicação, isolar variações e estabilizar contratos.
Checklist rápido para evitar modelos frágeis
- Evite “classes anêmicas”: classes que só guardam dados e deixam toda regra espalhada em funções externas.
- Evite “classes Deus”: uma classe que sabe e faz tudo (alta complexidade e acoplamento).
- Prefira composição para montar comportamentos e herança apenas quando a substituição for segura.
- Regras importantes devem ser testáveis sem depender de banco, rede ou interface.
Passo a passo de modelagem (com um template reutilizável)
1) Levantamento de requisitos: transforme frases em cenários
Comece com 5 a 10 cenários em linguagem natural. Exemplo (biblioteca):
- Um usuário pode pegar emprestado um exemplar disponível.
- Um empréstimo tem data de início e data prevista de devolução.
- Não é possível emprestar um exemplar já emprestado.
- Ao devolver, o exemplar volta a ficar disponível.
- Um usuário não pode ter mais que N empréstimos ativos.
Em seguida, separe:
- Regras (invariantes): “não emprestar exemplar indisponível”, “limite de empréstimos”.
- Eventos: “emprestar”, “devolver”.
- Consultas: “listar empréstimos ativos”, “ver disponibilidade”.
2) Identificação de entidades, value objects e serviços
Uma heurística simples:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- Entidade: tem identidade e muda ao longo do tempo (Ex.: Usuário, Exemplar, Empréstimo).
- Value object: representa um conceito imutável/sem identidade (Ex.: ISBN, Período, Dinheiro).
- Serviço de domínio/caso de uso: coordena ações entre entidades (Ex.: Serviço de Empréstimo).
Evite criar entidade para tudo. “Título do livro” pode ser apenas um atributo; “ISBN” pode ser um value object se houver validação e uso recorrente.
3) Responsabilidades: escreva “verbo + objeto” por classe
Para cada candidato a classe, escreva 3 a 6 responsabilidades. Exemplo:
- Exemplar: marcar como emprestado; marcar como devolvido; informar disponibilidade.
- Usuário: registrar empréstimo ativo; remover empréstimo; verificar limite.
- Empréstimo: registrar devolução; verificar se está ativo; calcular atraso (se existir regra).
Se uma classe tiver responsabilidades demais, divida. Se tiver responsabilidades vagas (“gerenciar sistema”), recomece.
4) Relacionamentos: composição vs herança
Use composição quando um objeto “tem” outro como parte (Carrinho tem Itens). Use herança quando há especialização substituível (PagamentoCartao é um Pagamento) e quando a API do tipo base faz sentido para todos os tipos.
| Sinal | Prefira | Exemplo |
|---|---|---|
| Variação de comportamento | Composição/estratégia | Frete calculado por regra pluggável |
| Hierarquia natural e substituível | Herança | NotificaçãoEmail/NotificaçãoSMS |
| Parte-todo | Composição | Biblioteca possui Catálogo |
5) Onde ficam as regras de negócio
Uma divisão prática:
- Entidades/value objects: invariantes e validações locais (ex.: não permitir estado inválido).
- Serviços/casos de uso: orquestração entre objetos (ex.: emprestar envolve usuário + exemplar + criação de empréstimo).
- Infra: persistência, IO, integrações (não misturar com regra).
Isso ajuda a manter o domínio testável e independente.
6) API consistente: nomes, retornos e erros
Defina convenções antes de codar:
- Métodos de comando (mudam estado):
emprestar(),devolver(),adicionar_item(). - Métodos de consulta (não mudam estado):
esta_disponivel(),total(),itens(). - Erros previsíveis: use exceções específicas do domínio (ex.:
ExemplarIndisponivel).
Caso 1: Biblioteca de empréstimos (do requisito ao código)
Requisitos mínimos (recorte)
- Emprestar um exemplar disponível para um usuário.
- Impedir empréstimo se o usuário atingiu o limite.
- Devolver um exemplar e encerrar o empréstimo.
Modelo inicial (classes e relações)
- Usuario (entidade): mantém empréstimos ativos (composição).
- Exemplar (entidade): estado de disponibilidade.
- Emprestimo (entidade): liga Usuario e Exemplar, com datas.
- ServicoEmprestimo (caso de uso): coordena a operação emprestar/devolver.
Implementação enxuta com regras no lugar certo
from dataclasses import dataclass, field
from datetime import date, timedelta
class RegraDeNegocioError(Exception):
pass
class ExemplarIndisponivel(RegraDeNegocioError):
pass
class LimiteDeEmprestimosAtingido(RegraDeNegocioError):
pass
@dataclass
class Exemplar:
id: str
titulo: str
_emprestado: bool = False
def esta_disponivel(self) -> bool:
return not self._emprestado
def marcar_emprestado(self) -> None:
if self._emprestado:
raise ExemplarIndisponivel(f"Exemplar {self.id} indisponível")
self._emprestado = True
def marcar_devolvido(self) -> None:
self._emprestado = False
@dataclass
class Emprestimo:
id: str
usuario_id: str
exemplar_id: str
inicio: date
previsto_para: date
devolvido_em: date | None = None
def esta_ativo(self) -> bool:
return self.devolvido_em is None
def registrar_devolucao(self, quando: date) -> None:
if not self.esta_ativo():
return
self.devolvido_em = quando
@dataclass
class Usuario:
id: str
nome: str
limite_emprestimos: int = 3
emprestimos: list[Emprestimo] = field(default_factory=list)
def emprestimos_ativos(self) -> list[Emprestimo]:
return [e for e in self.emprestimos if e.esta_ativo()]
def pode_emprestar(self) -> bool:
return len(self.emprestimos_ativos()) < self.limite_emprestimos
def adicionar_emprestimo(self, emprestimo: Emprestimo) -> None:
if not self.pode_emprestar():
raise LimiteDeEmprestimosAtingido(
f"Usuário {self.id} atingiu o limite"
)
self.emprestimos.append(emprestimo)
class ServicoEmprestimo:
def emprestar(self, *, usuario: Usuario, exemplar: Exemplar, dias: int = 7) -> Emprestimo:
if not usuario.pode_emprestar():
raise LimiteDeEmprestimosAtingido(f"Usuário {usuario.id} atingiu o limite")
exemplar.marcar_emprestado()
hoje = date.today()
emprestimo = Emprestimo(
id=f"E-{usuario.id}-{exemplar.id}-{hoje.isoformat()}",
usuario_id=usuario.id,
exemplar_id=exemplar.id,
inicio=hoje,
previsto_para=hoje + timedelta(days=dias),
)
usuario.adicionar_emprestimo(emprestimo)
return emprestimo
def devolver(self, *, usuario: Usuario, exemplar: Exemplar, emprestimo: Emprestimo) -> None:
emprestimo.registrar_devolucao(date.today())
exemplar.marcar_devolvido()Note a separação: Exemplar protege sua disponibilidade; Usuario protege o limite; o serviço coordena a sequência e cria o empréstimo.
Teste de comportamento (focado em regra)
def test_nao_empresta_exemplar_indisponivel():
usuario = Usuario(id="U1", nome="Ana")
exemplar = Exemplar(id="X1", titulo="Python")
svc = ServicoEmprestimo()
svc.emprestar(usuario=usuario, exemplar=exemplar)
try:
svc.emprestar(usuario=usuario, exemplar=exemplar)
assert False, "Deveria falhar"
except ExemplarIndisponivel:
assert True
Caso 2: Carrinho de compras (modelagem e API consistente)
Requisitos mínimos (recorte)
- Adicionar/remover itens com quantidade.
- Calcular subtotal, desconto e total.
- Aplicar cupom com regras (ex.: percentual, valor fixo, mínimo de compra).
Decisões de modelagem
- Produto como entidade simples (id, nome, preço).
- ItemCarrinho como composição (produto + quantidade).
- Carrinho como agregador de itens e regras de cálculo.
- Cupom como objeto de regra (política de desconto). Em vez de herança obrigatória, podemos usar composição com um “aplicador” (estratégia).
Implementação com estratégia de desconto (composição)
from dataclasses import dataclass, field
class CupomInvalido(Exception):
pass
@dataclass(frozen=True)
class Produto:
id: str
nome: str
preco: float
@dataclass
class ItemCarrinho:
produto: Produto
quantidade: int
def subtotal(self) -> float:
return self.produto.preco * self.quantidade
class RegraDesconto:
def calcular(self, subtotal: float) -> float:
return 0.0
@dataclass(frozen=True)
class DescontoPercentual(RegraDesconto):
percentual: float # 0.10 = 10%
def calcular(self, subtotal: float) -> float:
return max(0.0, subtotal * self.percentual)
@dataclass(frozen=True)
class DescontoValorFixo(RegraDesconto):
valor: float
def calcular(self, subtotal: float) -> float:
return min(subtotal, max(0.0, self.valor))
@dataclass(frozen=True)
class Cupom:
codigo: str
regra: RegraDesconto
minimo: float = 0.0
def desconto(self, subtotal: float) -> float:
if subtotal < self.minimo:
return 0.0
return self.regra.calcular(subtotal)
@dataclass
class Carrinho:
itens: list[ItemCarrinho] = field(default_factory=list)
cupom: Cupom | None = None
def adicionar(self, produto: Produto, quantidade: int = 1) -> None:
if quantidade <= 0:
raise ValueError("quantidade deve ser > 0")
for item in self.itens:
if item.produto.id == produto.id:
item.quantidade += quantidade
return
self.itens.append(ItemCarrinho(produto=produto, quantidade=quantidade))
def remover(self, produto_id: str) -> None:
self.itens = [i for i in self.itens if i.produto.id != produto_id]
def subtotal(self) -> float:
return sum(i.subtotal() for i in self.itens)
def desconto(self) -> float:
if not self.cupom:
return 0.0
return self.cupom.desconto(self.subtotal())
def total(self) -> float:
return self.subtotal() - self.desconto()
def aplicar_cupom(self, cupom: Cupom) -> None:
self.cupom = cupom
Observe como a variação (tipos de desconto) foi isolada em RegraDesconto. O carrinho não precisa conhecer detalhes de cada desconto, apenas chamar calcular.
Caso 3: Agenda de tarefas (regras, estados e extensibilidade)
Requisitos mínimos (recorte)
- Criar tarefas com título e prazo opcional.
- Marcar como concluída e reabrir.
- Listar tarefas por status e por vencimento.
- Regra: não permitir concluir tarefa já concluída (ou tornar idempotente).
Modelagem sugerida
- Tarefa: entidade com estado (aberta/concluída), datas e operações.
- ListaTarefas: coleção com operações de consulta e organização.
- Filtro/Ordenação: pode ser função ou objeto (comece simples e extraia se crescer).
Implementação com API previsível
from dataclasses import dataclass, field
from datetime import date
@dataclass
class Tarefa:
id: str
titulo: str
prazo: date | None = None
concluida_em: date | None = None
def esta_concluida(self) -> bool:
return self.concluida_em is not None
def concluir(self, quando: date | None = None) -> None:
if self.esta_concluida():
return
self.concluida_em = quando or date.today()
def reabrir(self) -> None:
self.concluida_em = None
def esta_atrasada(self, hoje: date | None = None) -> bool:
hoje = hoje or date.today()
return (self.prazo is not None) and (not self.esta_concluida()) and (self.prazo < hoje)
@dataclass
class ListaTarefas:
tarefas: list[Tarefa] = field(default_factory=list)
def adicionar(self, tarefa: Tarefa) -> None:
self.tarefas.append(tarefa)
def abertas(self) -> list[Tarefa]:
return [t for t in self.tarefas if not t.esta_concluida()]
def concluidas(self) -> list[Tarefa]:
return [t for t in self.tarefas if t.esta_concluida()]
def atrasadas(self, hoje: date | None = None) -> list[Tarefa]:
return [t for t in self.tarefas if t.esta_atrasada(hoje=hoje)]
Essa modelagem deixa claro onde estão as regras de estado (na Tarefa) e onde estão as consultas de coleção (na ListaTarefas).
Refatorações guiadas para melhorar extensibilidade
Refatoração 1: “if” crescendo demais → isolar variação
Sintoma: um método começa a ter muitos if tipo == ... para tratar regras diferentes (ex.: descontos, tipos de notificação, cálculo de multa). Ação: extrair para estratégia (composição) ou para classes especializadas.
Exemplo de sintoma no carrinho (evitar):
def desconto(self):
if self.cupom.tipo == "percentual":
...
elif self.cupom.tipo == "fixo":
...
Melhoria: Cupom delega para regra.calcular() (como no exemplo).
Refatoração 2: regras duplicadas em vários lugares → mover para o dono do dado
Sintoma: a mesma validação aparece em serviço, controller e entidade. Ação: a regra deve ficar onde o estado é alterado. Ex.: disponibilidade do exemplar deve ser protegida pelo próprio Exemplar.
Refatoração 3: acoplamento com infraestrutura → criar portas (interfaces) simples
Sintoma: o serviço de empréstimo já “salva no banco” dentro do método. Ação: introduzir um repositório como dependência (porta), mantendo o domínio testável. Mesmo sem entrar em detalhes de arquitetura, você pode definir um contrato mínimo e usar um fake em testes.
class EmprestimoRepo:
def salvar(self, emprestimo: Emprestimo) -> None:
raise NotImplementedError
class ServicoEmprestimo:
def __init__(self, repo: EmprestimoRepo):
self.repo = repo
def emprestar(self, *, usuario: Usuario, exemplar: Exemplar, dias: int = 7) -> Emprestimo:
...
self.repo.salvar(emprestimo)
return emprestimo
Refatoração 4: API inconsistente → padronizar comandos e consultas
Sintoma: métodos com nomes ambíguos (processar, gerenciar), retornos imprevisíveis e efeitos colaterais escondidos. Ação: padronizar:
- Comandos retornam
Noneou o objeto criado (ex.:emprestarretornaEmprestimo). - Consultas não alteram estado.
- Exceções do domínio para violações de regra.
Exercícios de modelagem (com critérios de avaliação)
Exercício 1: Biblioteca com reserva
Requisitos:
- Se um exemplar estiver indisponível, o usuário pode criar uma reserva.
- Quando o exemplar for devolvido, a primeira reserva da fila tem prioridade por 24h.
- Um usuário não pode reservar o mesmo exemplar duas vezes.
Tarefas:
- Liste entidades e value objects.
- Desenhe relações (quem compõe quem).
- Defina a API mínima: métodos e exceções.
- Escreva 3 testes de comportamento (cenários).
Critérios de avaliação:
- Clareza: nomes e responsabilidades evidentes; regras localizadas.
- Reutilização: fila de reservas não acoplada ao empréstimo; regras isoladas.
- Testes de comportamento: cobrem prioridade de 24h, duplicidade e ordem da fila.
Exercício 2: Carrinho com frete e impostos
Requisitos:
- Frete varia por região e peso total.
- Imposto varia por categoria do produto.
- O total final = subtotal + frete + impostos − descontos.
Tarefas:
- Modele
CalculadoraFreteeCalculadoraImpostocomo estratégias (composição). - Garanta que o carrinho não precise de
ifpor região/categoria. - Crie testes para duas regiões e duas categorias.
Critérios de avaliação:
- Clareza: carrinho com API simples (
total()), dependências explícitas. - Reutilização: estratégias plugáveis; fácil adicionar nova região/categoria.
- Testes de comportamento: validam cálculo final e regras de variação.
Exercício 3: Agenda com recorrência
Requisitos:
- Tarefas podem ser recorrentes (diária, semanal, mensal).
- Ao concluir uma tarefa recorrente, a próxima ocorrência é criada automaticamente.
- Deve ser possível pausar a recorrência.
Tarefas:
- Decida se recorrência é herança (
TarefaRecorrente) ou composição (tarefa + regra de recorrência). Justifique. - Defina como a criação da próxima ocorrência acontece (na tarefa? em um serviço?).
- Escreva testes para: concluir gera próxima; pausar impede geração.
Critérios de avaliação:
- Clareza: regra de recorrência explícita e isolada.
- Reutilização: fácil adicionar novo tipo de recorrência.
- Testes de comportamento: cobrem geração, pausa e datas.