State e Command pertencem à família dos padrões comportamentais, mas resolvem problemas distintos. State delega comportamento conforme a fase atual do ciclo de vida; Command encapsula uma solicitação para separar o solicitante do executor da operação [1][2]. Nenhum padrão garante persistência, segurança entre threads, autorização ou mensagens confiáveis. Essas propriedades dependem de contratos e mecanismos separados. Vamos derivar ambos num fluxo de aprovação de documentos e localizar a fronteira onde uma operação repetida deixa de ser segura.
Especifique o fluxo antes de escolher classes#
Um documento começa em rascunho, entra em revisão e pode ser publicado. Publicar antes da revisão é proibido. Na hipótese deste capítulo, documento publicado é imutável. Existem duas operações: enviar para revisão e publicar. Erros não devem alterar o estado. A versão começa em zero e aumenta uma unidade por transição aceita. Retirada, papéis de moderação e edições concorrentes externas estão fora do modelo até que sejam definidos.
Uma transição é uma função parcial T(estado, operação) → novo_estado. Parcial significa que algumas combinações são inválidas. Com três fases e duas operações existem seis pares; apenas rascunho+enviar e revisão+publicar são permitidos. Enumerar esses casos antes das classes impede que a hierarquia esconda lacunas. O capítulo de Low-Level Design mostra máquinas de estado sem necessariamente criar uma classe para cada estado.
Quando State justifica polimorfismo#
Uma tabela ou condição simples costuma ser suficiente para poucas fases e ações. Classes de estado passam a fazer sentido quando cada fase acumula comportamento diferente, as regras evoluem independentemente ou blocos condicionais grandes repetem lógica. Com State, o documento mantém uma referência ao objeto de estado atual e delega a ele a ação; o objeto determina a próxima fase permitida [1]. Não confunda com Strategy: a Strategy é escolhida para variar algoritmos intercambiáveis, enquanto o ciclo de vida impõe restrições sobre as operações válidas.
O exemplo é intencionalmente local. Seu invariante é: ação aceita incrementa a versão exatamente uma vez; ação rejeitada não altera versão nem fase. Os objetos de estado não têm identidade durável própria. Somente fase e versão precisariam ser reconstruídas a partir de persistência adequada.
class Draft:
name = "draft"
def submit(self):
return Review()
def publish(self):
raise ValueError("review required")
class Review:
name = "review"
def submit(self):
raise ValueError("already in review")
def publish(self):
return Published()
class Published:
name = "published"
def submit(self):
raise ValueError("immutable document")
def publish(self):
raise ValueError("already published")
class Document:
def __init__(self):
self.state = Draft()
self.version = 0
def apply(self, action):
if action not in ("submit", "publish"):
raise ValueError("unknown action")
next_state = getattr(self.state, action)()
self.state = next_state
self.version += 1
return self.state.name
d = Document()
assert (d.state.name, d.version) == ("draft", 0)
try:
d.apply("publish")
except ValueError:
pass
else:
raise AssertionError("draft was published")
assert (d.state.name, d.version) == ("draft", 0)
assert d.apply("submit") == "review"
assert d.apply("publish") == "published"
assert (d.state.name, d.version) == ("published", 2)
for invalid in ("publish", "submit", "delete"):
try:
d.apply(invalid)
except ValueError:
pass
else:
raise AssertionError("invalid action accepted")
assert d.version == 2
Esse código não representa uma obrigação de usar polimorfismo em todo fluxo. Acrescentar fase exige revisar a interface e possíveis transições de outras classes. O getattr dinâmico só está restrito porque a entrada passa por uma lista fechada de ações. Sem essa validação, seria possível acessar membros que nunca deveriam ser comandos públicos.
Command separa intenção e execução#
Command contém a operação desejada e seus argumentos; um invocador decide quando chamá-la no receptor [2]. É útil para handlers HTTP, agendamento, histórico e filas. A existência do objeto Command representa uma solicitação, não comprova que o efeito tenha sido confirmado. Repetir a chamada após timeout pode duplicar efeitos, a menos que o receptor defina deduplicação estável.
O exemplo utiliza a versão esperada como pré-condição otimista. Comandos com versão desatualizada falham em vez de sobrescrever silenciosamente uma atualização nova. Construir o comando não altera o documento.
from dataclasses import dataclass
@dataclass(frozen=True)
class TransitionCommand:
action: str
expected_version: int
def execute(self, receiver):
if not isinstance(self.expected_version, int) or self.expected_version < 0:
raise ValueError("invalid version")
if receiver.version != self.expected_version:
raise ValueError("version conflict")
return receiver.apply(self.action)
doc = Document()
cmd = TransitionCommand("submit", 0)
assert doc.version == 0
assert cmd.execute(doc) == "review"
try:
cmd.execute(doc)
except ValueError:
pass
else:
raise AssertionError("stale command replayed")
assert (doc.state.name, doc.version) == ("review", 1)
assert TransitionCommand("publish", 1).execute(doc) == "published"
Rejeitar execução duplicada não equivale a sucesso idempotente. Cliente que repete submit com versão zero recebe conflito, não a resposta anterior. Um contrato de idempotência pode exigir armazenar de forma durável (solicitante, ID da operação, hash do payload, resultado concluído), na mesma fronteira transacional da mudança. A mesma chave com corpo diferente precisa de regra explícita. A Builders Library discute a importância da identidade estável da requisição durante retentativas [3].
Complexidade e limites de consistência#
Cada transição local realiza quantidade fixa de acessos e atribuições; por isso, tempo e memória adicional são O(1) para ações de tamanho fixo neste modelo em memória. Não se deve transferir a conclusão para o banco: índice, bloqueio, rede, log de transações e contenção têm custos próprios. A checagem ingênua de versão também falha entre processos se leitura e escrita forem operações separadas.
Uma implementação autoritativa pode usar UPDATE documents SET state=?, version=version+1 WHERE id=? AND version=? AND state=?. Quando o banco avalia a condição e atualiza atomicamente, no máximo uma requisição vence para uma dada versão. Verifique linhas afetadas e diferencie versão obsoleta, estado inválido e documento inexistente segundo o contrato. Essa consulta não autoriza o solicitante, não publica notificações e não fornece semântica exatamente uma vez para efeitos externos.
Falhas, eventos e contraexemplos#
Se publicar também envia uma notificação externa, notificar antes do commit pode anunciar publicação que sofreu rollback. Notificar depois do commit pode perder a mensagem se o processo cair. Uma outbox ou protocolo equivalente define a ligação entre commit e entrega. Mesmo assim, um relay pode entregar duplicatas; consumidores precisam de IDs de evento e política de repetição [3].
Uma fila de objetos Command não é event sourcing automaticamente. Comando descreve o que foi solicitado, enquanto evento de domínio descreve o que aconteceu. Um comando rejeitado não deve reaparecer no histórico como publicação confirmada. Undo também não é propriedade automática: desfazer pagamento ou publicação pode exigir compensação nova e autorizada, não restauração de objeto em memória.
Escolha orientada pelo problema#
Use tabela para transições pequenas e estáveis; State para comportamento substancial que varia por fase; Command para invocação desacoplada ou adiada; Strategy para escolher entre algoritmos. Ao introduzir persistência e fila, projete antes o contrato de retry e deduplicação. Não crie uma classe de comando para cada função de uma linha só para reproduzir um diagrama. Evite concentrar permissões, regras de estado, transmissão e retentativas num objeto monolítico.
Revise o código pelos efeitos observáveis, não pelos nomes dos padrões. Mudança de versão, ações inválidas, identidade do ator, durabilidade e recuperação são promessas que precisam de verificações independentes. Padrões localizam responsabilidades; não substituem testes.
Exercícios e verificação#
- Enumere seis combinações de fase e ação. Identifique sucesso ou erro e versão final; justifique por que erro mantém estado.
- Adicione operação withdraw, válida apenas durante revisão. Atualize o modelo e os testes e discuta o custo de acrescentar método a todos os estados.
- Faça duas requisições com expected_version=1 sobre documento durável. Especifique UPDATE atômico e resultado esperado para a requisição perdedora.
- Compare contratos de repetição: devolver conflito ou retornar resposta armazenada. Indique os dados duráveis necessários à segunda opção.
- Diferencie Command, evento de domínio, Strategy e State em autorização de pagamento. Explique por que histórico em memória não constitui livro-razão confiável.
Leituras relacionadas: famílias de padrões, refatoração e padrões e confiabilidade de API.