Skip to main content

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:

  1. Business state — the row changed (payroll run status, EWA request status, …).
  2. Audit row — the record of what changed, when, by whom.
  3. 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/audit provides the emitter contract; Prisma-backed impl lives in apps/api.
  • packages/shared/events provides the outbox contract; Prisma-backed impl lives in apps/api, Kafka publisher lives in services/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).