A catraca do metrô
A catraca só conhece dois estados: travada e liberada. Moeda na travada → libera; passar na liberada → trava de novo. Empurrar a catraca travada não a abre 'um pouco' — simplesmente não acontece. Ninguém escreveu if (empurrou && !pagou) recusar(): a física da catraca torna o estado inválido inalcançável. É exatamente o que a tabela de transições faz pelo seu domínio.
O problema: o status que aceita qualquer coisa
status: string nasce inocente. Seis meses depois há cinco valores possíveis, quatro lugares que fazem UPDATE, e as regras de ordem — 'só cancela se ainda não pagou' — existem como ifs repetidos, cada um com uma versão levemente diferente.
O bug típico não é um estado errado: é uma transição errada. O job de expiração roda atrasado e expira uma cobrança que foi paga há dez minutos. Cada estado isoladamente é válido; o caminho entre eles é que era proibido — e ninguém tinha escrito a proibição num lugar só.
A versão formal vem da computação teórica: autômatos finitos, com estados, alfabeto de eventos e função de transição. Você não precisa do formalismo — precisa da disciplina que ele impõe: enumerar estados (fechados, não strings livres), enumerar eventos, e tornar a função de transição o único caminho de mudança.
Relação com o padrão State: a máquina de estados é o modelo (quais estados, quais transições); o padrão State é uma forma de implementá-la em OO, com uma classe por estado, útil quando cada estado carrega comportamento complexo. Para a maioria dos ciclos de vida, a tabela declarativa basta — e é mais fácil de ler, testar e desenhar.
Em banco, a transição vira UPDATE condicional: SET estado = novo WHERE id = X AND estado = esperado. De brinde, isso fecha a corrida entre dois processos disputando a mesma transição — o segundo altera zero linhas.
- Criada
- publicarAtiva
- pagarPaga
- conciliarConciliada
As três partes da máquina
Camada 1/3Tabela de transições
use ← →Camada 1
Tabela de transições
a especificação executável do ciclo de vida
Estados fechados (enum, não string), eventos fechados, e o mapa estado+evento → próximo. É pequena o bastante para caber numa tela, ser revisada pelo time de produto e virar o desenho da documentação — porque ela É a regra, não uma descrição dela.
No código
ativa: { pagar: "paga", expirar: "expirada" }Se violar: regras de transição espalhadas em ifs voltam a divergir entre si — a tabela existe, mas na cabeça de cada dev, em versões diferentes.
Código
// A máquina inteira cabe numa tabela: estado atual + evento -> próximo estado.
// Tudo que NÃO está na tabela é impossível por construção.
type Estado = "criada" | "ativa" | "paga" | "expirada" | "cancelada";
type Evento = "publicar" | "pagar" | "expirar" | "cancelar";
const TRANSICOES: Record<Estado, Partial<Record<Evento, Estado>>> = {
criada: { publicar: "ativa", cancelar: "cancelada" },
ativa: { pagar: "paga", expirar: "expirada", cancelar: "cancelada" },
paga: {}, // estado terminal: cobrança paga não expira nem cancela
expirada: {},
cancelada: {},
};
function aplicar(atual: Estado, evento: Evento): Estado {
const proximo = TRANSICOES[atual][evento];
if (!proximo) {
throw new Error(`transição inválida: ${atual} + ${evento}`);
}
return proximo;
}
aplicar("ativa", "pagar"); // "paga"
aplicar("paga", "expirar"); // Error — o bug do job de expiração morre AQUI,
// não no extrato do cliente três dias depois.
// No banco, a mesma ideia vira UPDATE condicional (e mata a corrida junto):
// UPDATE cobrancas SET estado = 'paga'
// WHERE id = $1 AND estado = 'ativa' -- 0 linhas = transição inválida# estado atual + evento -> proximo estado; fora da tabela = impossivel
TRANSICOES: dict[str, dict[str, str]] = {
"criada": {"publicar": "ativa", "cancelar": "cancelada"},
"ativa": {"pagar": "paga", "expirar": "expirada", "cancelar": "cancelada"},
"paga": {}, # terminal: paga nao expira nem cancela
"expirada": {},
"cancelada": {},
}
def aplicar(atual: str, evento: str) -> str:
proximo = TRANSICOES[atual].get(evento)
if proximo is None:
raise ValueError(f"transicao invalida: {atual} + {evento}")
return proximo
aplicar("ativa", "pagar") # "paga"
aplicar("paga", "expirar") # ValueError — o bug morre na fronteiraCasos de uso
Ciclo de vida de uma cobrança PIX
Cenário
Uma cobrança nasce, fica ativa aguardando pagamento, e pode ser paga, expirar ou ser cancelada. O job de expiração roda em lote e o webhook de pagamento chega quando quer — às vezes os dois no mesmo segundo.
Como o padrão entra
A tabela declara: ativa → paga|expirada|cancelada; paga, expirada e cancelada são terminais. A transição roda como UPDATE condicional, então quando o webhook e o job disputam, o segundo altera zero linhas e desiste — a cobrança paga jamais expira, por construção.
Trade-off: Todo caminho novo de negócio ('reativar cobrança expirada') exige mexer na tabela e pensar nas consequências — o que é mais lento que 'só mudar a string', e é exatamente o ponto.
Onboarding com análise de documentos
Cenário
Cadastro passa por: dados enviados → em análise → aprovado ou reprovado, com reenvio de documentos permitido só após reprovação. Analistas humanos e validação automática atuam no mesmo cadastro.
Como o padrão entra
A máquina impede o analista de aprovar um cadastro que voltou para 'aguardando documentos' e impede o robô de reprovar quem já foi aprovado. Cada transição registra ator e timestamp — o histórico de transições vira a trilha de auditoria do compliance.
Trade-off: Estados demais (análise-fase-1, fase-2, fase-3…) explodem a tabela; agrupar em estados de negócio e guardar o detalhe como dado do estado mantém a máquina legível.
Armadilhas
Estado implícito em booleans combinados
isPaid + isCanceled + isExpired = oito combinações, cinco impossíveis — e nada impede paid=true com canceled=true. Booleans que descrevem ciclo de vida são uma máquina de estados desmontada: junte-os num enum e as combinações absurdas deixam de existir.
A máquina que existe só na cabeça
O time 'sabe' as regras, mas cada regra vive num if diferente, e o UPDATE direto no banco não passa por nenhum deles. Se a tabela de transições não é código executado por toda mudança, ela não é a regra — é folclore.
Esquecer as saídas de erro e de tempo
Todo estado não-terminal precisa responder: e se der errado? e se ninguém agir? Cobrança 'ativa' sem transição de expiração fica ativa para sempre; análise sem timeout prende o cadastro. Os caminhos infelizes fazem parte da máquina — geralmente são a maioria dela.
Quando usar × quando evitar
Use quando
3- Entidades com ciclo de vida e regras de ordem — cobrança, pedido, contrato, entrega.
- Quando dois atores (jobs, webhooks, humanos) mudam o mesmo status concorrentemente.
- Quando o histórico de transições tem valor de auditoria.
Evite quando
3- Flags simples sem regra de ordem — ativo/inativo não precisa de máquina.
- Fluxos que mudam toda semana em fase de descoberta — formalizar cedo demais engessa.
- Modelar como estado o que é dado (o valor da cobrança não é um estado).
Testar o que ficou
3 armadilhas de Máquina de estados viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.
Onde isto aparece de verdade
Você já usa isto, talvez sem saber o nome.
XStateA biblioteca de máquinas de estado do front: você declara estados e transições, e o que não está na tabela simplesmente não acontece.PaymentIntent do StripeO status de uma cobrança (requires_payment_method, processing, succeeded) é uma máquina: cada webhook é um evento que só move para os estados permitidos.useReducer do ReactA assinatura (estado, ação) => próximoEstado é exatamente uma função de transição — o reducer é a tabela da máquina escrita em código.
O padrão em uma linha
Sem nome de domínio, sem cerimônia. Só a ideia.
// Transições nomeadas; estados ilegais viram erro.
pedido.transicionar("pago"); // só de 'aguardando'O que ele cobra
Nenhum salto a mais entre a chamada e o efeito.
- A tabela de transições precisa ser mantida em dia conforme o fluxo ganha estados e eventos
- Estados e transições demais tornam a tabela grande e difícil de abarcar de uma vez
Não vale se: o fluxo tem um ou dois estados sem transições inválidas plausíveis — aí a máquina explícita é só cerimônia.
Onde isto já custou caro
Incidentes reais que este conceito explica.
Onde isto aparece
Continue por aqui
- StateTransforma cada estado de um objeto em uma classe própria: o comportamento muda conforme o estado atual, e é o próprio estado que decide a próxima transição.
- Race conditionDuas execuções leem o mesmo estado, decidem com base nele e gravam — e o resultado passa a depender de quem chega primeiro. É o bug que nunca aparece no teste e vende o mesmo ingresso duas vezes na sexta à noite.
- IdempotênciaExecutar a mesma operação duas vezes tem o mesmo efeito de executar uma: é o que torna o retry seguro. Em rede, onde 'não sei se foi' é resposta comum, é a diferença entre reenviar e cobrar em dobro.