ADR-003: Event Sourcing para o modelo de transações
| Campo | Valor |
|---|---|
| Status | Aceito |
| Data | 2024 |
| Autor | Caio Fiori Martins |
Contexto
O Ouroboros lida com dinheiro fictício, mas com consequências reais para os participantes do evento. Um saldo exibido errado, uma transação perdida ou um débito aplicado duas vezes são falhas graves para o contexto de um evento escolar. A organização precisa ter capacidade de auditar qualquer transação ocorrida.
A abordagem mais comum — armazenar o saldo diretamente — apresenta riscos específicos que motivaram uma escolha diferente.
O problema com CRUD convencional
Em um modelo CRUD padrão, a tabela de saldos seria algo como:
Cada transação executaria um UPDATE:
Esse modelo tem falhas críticas para o contexto:
1. Perda de histórico
Após o UPDATE, não há registro de que a transação aconteceu, quando, em qual loja, ou de quanto foi. O saldo existe. A transação, não.
2. Sem auditoria
Se um participante questionar o saldo, não há como reconstruir o histórico. A resposta é "o sistema diz que é X" — sem evidência.
3. Inconsistência em falha
Se o servidor cair no meio de um UPDATE, o estado fica parcialmente modificado. Com commits atômicos do SQLite isso é mitigado, mas a natureza mutável do dado ainda é um risco.
4. Impossibilidade de replay
Não é possível recalcular o estado a partir de outro ponto no tempo.
Decisão
O Ouroboros usa Event Sourcing para todas as transações financeiras.
O saldo nunca é armazenado diretamente. Cada operação de crédito ou débito é persistida como um evento imutável. O saldo atual é sempre derivado pela agregação do histórico de eventos.
Como funciona
A tabela de eventos
CREATE TABLE events (
id TEXT PRIMARY KEY, -- UUID v4
type TEXT NOT NULL, -- 'credit' | 'debit'
comanda_id TEXT NOT NULL,
store_id TEXT, -- NULL para operações admin
amount INTEGER NOT NULL, -- em centavos fictícios, sempre positivo
note TEXT, -- descrição opcional
created_at TEXT NOT NULL, -- ISO 8601
synced INTEGER NOT NULL DEFAULT 0 -- flag de sync com Firebase
);
Consultar saldo
SELECT
comanda_id,
SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) AS balance
FROM events
WHERE comanda_id = ?
GROUP BY comanda_id;
O saldo é calculado na hora. Não existe um campo balance em lugar algum.
Registrar débito
async def debit(comanda_id: str, amount: int, store_id: str) -> Event:
# 1. Calcula saldo atual
current_balance = await get_balance(comanda_id)
# 2. Valida saldo suficiente
if current_balance < amount:
raise InsufficientBalanceError()
# 3. Persiste o evento (INSERT, nunca UPDATE)
event = Event(
id=uuid4(),
type="debit",
comanda_id=comanda_id,
store_id=store_id,
amount=amount,
created_at=now()
)
await db.insert(event)
return event
Benefícios concretos
Auditoria completa
Todo histórico de transações de qualquer comanda pode ser reconstruído a qualquer momento. Quem comprou o quê, quando e em qual loja.
Imutabilidade
Eventos não são atualizados ou deletados — apenas inseridos. Um erro de transação é corrigido com um novo evento compensatório (crédito de estorno), não alterando o evento original.
Consistência garantida
A operação de débito é atômica: ou o evento é inserido, ou não é. Não existe "saldo parcialmente debitado".
Replay e debugging
É possível recalcular o estado do sistema em qualquer ponto no tempo filtrando eventos até aquele timestamp. Útil para debugging e auditoria pós-evento.
Sync natural com Firebase
O event store é a fila de sync. Eventos com synced = 0 são enviados ao Firebase. A flag vira 1 após confirmação. Se o sync falhar, o evento permanece na fila e será tentado novamente — sem perda de dados.
Alternativas consideradas
CRUD com tabela de saldo + tabela de histórico
Mantém saldo direto mas também registra cada transação numa tabela separada.
Descartado: duplicação de estado. O saldo pode divergir do histórico em caso de bug. Qual é a fonte da verdade? Esse é o problema que o event sourcing elimina por design.
CRUD puro (sem histórico)
Descartado: sem auditoria, sem capacidade de reconstruir estado, sem debugging post-hoc.
Consequências
Positivas:
- Auditoria completa e gratuita
- Zero estado inconsistente possível
- Sync com Firebase é consequência natural do modelo
- Debugging trivial
Negativas e trade-offs aceitos:
- Consulta de saldo requer uma agregação em vez de um
SELECTsimples — para o volume esperado, a diferença de performance é negligenciável (< 1ms) - O event store cresce ao longo do tempo — com 800 participantes e 10 transações médias cada, são 8.000 registros. Tamanho negligenciável
Event Sourcing ≠ overengineering aqui
Event sourcing tem fama de ser complexidade desnecessária para CRUDs simples. Nesse caso é a escolha natural: o domínio é inerentemente transacional, a auditoria é um requisito real, e a implementação é mais simples do que parece — é basicamente uma tabela append-only com uma view agregada.