ADR-008: Audit + outbox event live in the same DB transaction as the state change
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering
- Relates to: ADR-011
Context
Three things must always agree:
- Business state — the row changed (payroll run status, EWA request status, …).
- Audit row — the record of what changed, when, by whom.
- Outbox event — the message subscribers receive ("payroll.run_approved.v1").
If they don't all happen atomically, downstream views diverge from authoritative state and audit history develops holes. This is a class of bug that does not surface until weeks later, during a dispute, when nobody can explain what happened.
Decision
In every code path that changes financial or financially-adjacent state:
await prisma.$transaction(async (tx) => {
// 1. business state change — a status transition, NOT a balance mutation.
// Money truth lives in the ledger (ADR-006); no domain aggregate carries
// a `balance` column, so the state change is always a status/field change.
const after = await tx.payrollRun.update({
where: { id: runId },
data: { status: 'APPROVED', approvedBy: actorId },
});
// 2. audit row — SAME tx
await audit.record({ tenantId, actorId, action: 'PayrollRunApproved',
entityType: 'PayrollRun', entityId: after.id, before, after }, tx);
// 3. outbox event — SAME tx
await outbox.append({ tenantId, type: 'payroll.run_approved.v1',
payload, occurredAt: new Date() }, tx);
});
All three either commit or all three roll back. The outbox publisher is a separate process that drains the outbox table and ships to Kafka; it commits publishedAt independently of the original tx.
Alternatives considered
- Best-effort audit/outbox (try-catch, log on failure). Rejected. Silent loss of audit rows is what we are explicitly trying to prevent.
- CDC / debezium (read the WAL, derive events). Reasonable long-term, but operationally heavy now. Easier to add later by replacing the publisher loop.
- Synchronous Kafka publish after commit. Rejected — partial failure (DB commit succeeded, publish failed) recreates the divergence we're avoiding.
Consequences
Positive
- One guarantee for every reader of the codebase: state, audit, and event are atomic.
- Reconciliation: comparing outbox event count to ledger entry count to audit row count is a meaningful invariant.
Negative
- The outbox publisher is a separate process. Adds operational surface. Mitigated by it being simple enough to fit in one Go file (~200 LOC).
- DB transactions are slightly larger. Negligible at our throughput.
Follow-ups
packages/shared/auditprovides the emitter contract; Prisma-backed impl lives inapps/api.packages/shared/eventsprovides the outbox contract; Prisma-backed impl lives inapps/api, Kafka publisher lives inservices/notifications(or its own tiny service when extracted).- Add an integration test in each domain's
tests/that asserts a state-change PR also writes audit + outbox in the same tx (mocking Kafka, asserting outbox row).