Your event store is already your audit log
Almost every SaaS I've worked on ends up with an audit_log table. Someone files a compliance ticket — "we need to know who changed what and when" — and a new table appears next to the domain tables. Then the real work starts: writing to it on every mutating endpoint, keeping it in sync, and quietly discovering six months later that three endpoints forgot to log.
That table is a second source of truth. And second sources of truth drift.
What an audit log actually needs
Strip the compliance language away and an audit entry is five fields:
- who did it
- when they did it
- what they touched (which entity)
- which action it was
- the delta — what actually changed
Plus, for a multi-tenant app: whose data it was, so tenant A can never read tenant B's history.
Now look at what an event in an event-sourced system carries. Every state change is an appended event with createdBy, createdAt, tenantId, aggregateType + aggregateId, type, and a payload holding the delta.
That's the same five fields. The event log already is the audit trail — append-only, ordered, and impossible to forget to write, because writing the event is how state changes in the first place. There's no code path that mutates data without producing an event.
So don't build the table. Query the log.
If the audit trail is already there, the whole "audit feature" collapses into one privileged read over the events table:
export const listQuery = defineQueryHandler({
name: "list",
schema: z.object({
aggregateType: z.string().optional(),
aggregateId: z.uuid().optional(),
eventType: z.string().optional(),
userId: z.string().optional(),
from: z.iso.datetime().optional(),
to: z.iso.datetime().optional(),
limit: z.number().int().min(1).max(100).default(50),
before: z.string().optional(), // cursor
}),
access: { roles: ["Admin", "SystemAdmin"] },
handler: async (query, ctx) => {
const p = query.payload;
const where = { tenantId: query.user.tenantId }; // tenant-isolated at the WHERE
if (p.aggregateType) where.aggregateType = p.aggregateType;
if (p.aggregateId) where.aggregateId = p.aggregateId;
if (p.eventType) where.type = p.eventType;
if (p.userId) where.createdBy = p.userId;
// ...time range + cursor omitted for brevity
return selectMany(ctx.db, eventsTable, where, {
orderBy: { col: "id", direction: "desc" },
limit: p.limit,
});
},
});
No table, no projection, no write path, no sync job. The filter surface an audit UI wants — by entity, by actor, by action, by time — is just WHERE clauses over columns the events already have.
The two things you still owe
Reusing the event log doesn't come completely free. Two concerns are real:
1. Access control. The event log is the most sensitive read in the system — it's literally everything that ever happened. Gate it hard (Admin / SystemAdmin above) and pin tenant isolation into the WHERE clause itself, not into application logic that a future refactor can bypass. Cross-tenant peeking should be structurally impossible, not politely discouraged.
2. PII. If you dump raw event payloads into an audit view, you'll surface fields you didn't mean to. The clean fix is to strip sensitive values at append time — mark them in the entity definition and never let them into the stored event. Then the audit read physically cannot leak them, because they were never written. Doing it at read time is a filter you'll eventually forget on some new field; doing it at write time is a guarantee.
When this doesn't apply
Honesty: this only works if you're actually event-sourced. If your system does in-place UPDATEs, there's no historical record to query — you genuinely need to start capturing one, and a dedicated table (or CDC/logical decoding off the WAL) is the pragmatic path. This isn't an argument to adopt event sourcing for audit; it's an argument that if you already have it, the second table is redundant.
One caveat even when it fits: event schemas evolve, so your audit reader sees heterogeneous historical payloads. For an audit log that's a feature — you want the exact shape as it was written — but don't mistake it for a clean queryable projection.
The point
An audit log isn't a thing you build. It's a view onto history you're already keeping. If you're appending events, you've been sitting on a complete, tamper-evident audit trail the whole time — the only missing piece was a gated query with the right WHERE clauses.
This is exactly how the audit feature works in Kumiko, a Bun/TypeScript framework where multi-tenancy, GDPR, and audit are bundled features rather than boilerplate you rewrite per project — one ~40-line query handler, no separate table.
Top comments (0)