Event Sourcing Patterns for Audit-Heavy Applications
Event sourcing for audit-heavy applications where every state change must be traceable: append-only stores, projections and snapshot strategies.

Traditional CRUD applications overwrite state. When someone updates a record, the previous value is gone unless you've added audit logging as an afterthought. In domains where every change must be traceable—financial services, healthcare, legal tech—this creates a fundamental tension between how the application works and what regulators demand.
Event sourcing flips the data model: instead of storing current state, you store the sequence of events that produced that state. Current state becomes a derivation, and the complete history exists by default. For audit-heavy applications, this isn't a fancy architecture pattern—it's the natural fit.
Events as the Source of Truth
In event sourcing, your data store is an append-only log of domain events. Each event captures what happened, when, and the relevant details. Current state is computed by replaying these events.
// Domain events for an account management system
interface DomainEvent {
eventId: string;
aggregateId: string;
eventType: string;
timestamp: Date;
version: number;
data: Record<string, unknown>;
metadata: {
userId: string;
correlationId: string;
causationId: string;
ipAddress: string;
};
}
// ❌ CRUD approach: audit is an afterthought
class AccountRepository {
async updateBalance(accountId: string, newBalance: number) {
// Previous balance is gone forever
await db.query(
'UPDATE accounts SET balance = $1 WHERE id = $2',
[newBalance, accountId]
);
// Audit log added later, often incomplete
await db.query(
'INSERT INTO audit_log (entity, action, timestamp) VALUES ($1, $2, NOW())',
[accountId, 'balance_updated']
);
}
}
// ✅ Event sourcing: audit is inherent
class AccountEventStore {
async append(event: DomainEvent): Promise<void> {
await db.query(
`INSERT INTO events (event_id, aggregate_id, event_type,
timestamp, version, data, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
event.eventId,
event.aggregateId,
event.eventType,
event.timestamp,
event.version,
JSON.stringify(event.data),
JSON.stringify(event.metadata),
]
);
}
async getEvents(aggregateId: string): Promise<DomainEvent[]> {
const result = await db.query(
`SELECT * FROM events
WHERE aggregate_id = $1
ORDER BY version ASC`,
[aggregateId]
);
return result.rows;
}
}The metadata field is critical for auditing. Every event captures who triggered it, the correlation chain linking related operations, and contextual information like IP addresses that auditors need.
Building Read Models with Projections
Storing events solves the audit problem but creates a query problem—you can't efficiently query "all accounts with balance over $10,000" by replaying millions of events every time. Projections solve this by maintaining denormalized read models.
// Projection that builds a read model from events
class AccountBalanceProjection {
async handle(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case 'AccountOpened':
await db.query(
`INSERT INTO account_balances
(account_id, balance, owner_name, opened_at)
VALUES ($1, $2, $3, $4)`,
[
event.aggregateId,
event.data.initialDeposit,
event.data.ownerName,
event.timestamp,
]
);
break;
case 'FundsDeposited':
await db.query(
`UPDATE account_balances
SET balance = balance + $1, updated_at = $2
WHERE account_id = $3`,
[event.data.amount, event.timestamp, event.aggregateId]
);
break;
case 'FundsWithdrawn':
await db.query(
`UPDATE account_balances
SET balance = balance - $1, updated_at = $2
WHERE account_id = $3`,
[event.data.amount, event.timestamp, event.aggregateId]
);
break;
}
}
}
// Separate audit-specific projection
class AuditTrailProjection {
async handle(event: DomainEvent): Promise<void> {
await db.query(
`INSERT INTO audit_trail
(event_id, aggregate_id, event_type, actor_id,
ip_address, correlation_id, timestamp, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
event.eventId,
event.aggregateId,
event.eventType,
event.metadata.userId,
event.metadata.ipAddress,
event.metadata.correlationId,
event.timestamp,
JSON.stringify(event.data),
]
);
}
}Projections are disposable. If an auditor needs a new report format, you build a new projection and replay events through it. The source data never changes.
Snapshot Strategies for Performance
Replaying thousands of events to rebuild an aggregate gets slow. Snapshots periodically capture the computed state so you only replay events after the snapshot.
class SnapshotStore {
private readonly SNAPSHOT_INTERVAL = 100;
async getAggregate(aggregateId: string): Promise<Account> {
const snapshot = await this.getLatestSnapshot(aggregateId);
const startVersion = snapshot ? snapshot.version + 1 : 0;
const events = await this.eventStore.getEventsAfterVersion(
aggregateId,
startVersion
);
let account = snapshot
? Account.fromSnapshot(snapshot.state)
: Account.empty(aggregateId);
for (const event of events) {
account = account.apply(event);
}
// Create snapshot if enough events have accumulated
if (events.length >= this.SNAPSHOT_INTERVAL) {
await this.saveSnapshot({
aggregateId,
version: account.version,
state: account.toSnapshot(),
createdAt: new Date(),
});
}
return account;
}
private async getLatestSnapshot(
aggregateId: string
): Promise<Snapshot | null> {
const result = await db.query(
`SELECT * FROM snapshots
WHERE aggregate_id = $1
ORDER BY version DESC LIMIT 1`,
[aggregateId]
);
return result.rows[0] || null;
}
private async saveSnapshot(snapshot: Snapshot): Promise<void> {
await db.query(
`INSERT INTO snapshots
(aggregate_id, version, state, created_at)
VALUES ($1, $2, $3, $4)`,
[
snapshot.aggregateId,
snapshot.version,
JSON.stringify(snapshot.state),
snapshot.createdAt,
]
);
}
}Snapshots are optimization, not source data. You can always delete all snapshots and rebuild from events—the system remains correct, just temporarily slower.
Handling Event Schema Evolution
Events are immutable, but your domain understanding evolves. You'll need to deal with events that were written with an older schema than your current code expects.
// Event upcasting: transform old event shapes to new ones
class EventUpcaster {
private upcasters = new Map<string, UpcastFunction[]>();
register(eventType: string, fromVersion: number, upcaster: UpcastFunction) {
const key = `${eventType}:${fromVersion}`;
const chain = this.upcasters.get(key) || [];
chain.push(upcaster);
this.upcasters.set(key, chain);
}
upcast(event: StoredEvent): DomainEvent {
let current = event;
while (current.schemaVersion < this.getCurrentVersion(current.eventType)) {
const key = `${current.eventType}:${current.schemaVersion}`;
const upcaster = this.upcasters.get(key);
if (!upcaster) {
throw new Error(
`No upcaster for ${current.eventType} v${current.schemaVersion}`
);
}
current = upcaster[0](current);
}
return current as DomainEvent;
}
}
// Example: FundsDeposited event evolved over time
// v1: { amount: number }
// v2: { amount: number, currency: string }
// v3: { amount: number, currency: string, channel: string }
const upcaster = new EventUpcaster();
upcaster.register('FundsDeposited', 1, (event) => ({
...event,
data: { ...event.data, currency: 'USD' },
schemaVersion: 2,
}));
upcaster.register('FundsDeposited', 2, (event) => ({
...event,
data: { ...event.data, channel: 'unknown' },
schemaVersion: 3,
}));Compliance Queries: The Audit Payoff
With event sourcing, compliance queries that would require complex audit log joins in a CRUD system become straightforward event stream queries.
class ComplianceQueryService {
// "Show me every change to this account, by whom, and when"
async getAccountHistory(accountId: string): Promise<AuditEntry[]> {
const events = await this.eventStore.getEvents(accountId);
return events.map((event) => ({
timestamp: event.timestamp,
action: event.eventType,
actor: event.metadata.userId,
ipAddress: event.metadata.ipAddress,
details: event.data,
correlationId: event.metadata.correlationId,
}));
}
// "What did the account look like at this specific point in time?"
async getStateAtTime(
accountId: string,
pointInTime: Date
): Promise<Account> {
const events = await this.eventStore.getEventsUntil(
accountId,
pointInTime
);
let account = Account.empty(accountId);
for (const event of events) {
account = account.apply(event);
}
return account;
}
// "Who accessed accounts with balances over $50k last quarter?"
async getHighValueAccessPatterns(
threshold: number,
startDate: Date,
endDate: Date
): Promise<AccessPattern[]> {
// This query runs against a projection built
// specifically for compliance reporting
return db.query(
`SELECT actor_id, account_id, event_type, timestamp
FROM compliance_access_log
WHERE balance_at_time > $1
AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp DESC`,
[threshold, startDate, endDate]
);
}
}The "state at a point in time" query is the killer feature for auditing. In a CRUD system, reconstructing historical state requires extensive audit log correlation. In event sourcing, it's replaying events up to a timestamp.
Key Takeaways
Event sourcing makes audit trails a natural consequence of the data model rather than a bolted-on afterthought—every state change is an immutable event capturing not just what changed but who triggered it, when, and why. Projections solve the query problem by maintaining disposable read models: build new projections whenever auditors need a different view, replay the event history through them, and never worry about data loss because the source events are immutable. Snapshots are a performance optimization that lets you skip replaying ancient events—they're disposable summaries, not source data, and you should only introduce them when replay performance actually becomes a problem, not preemptively. Schema evolution through upcasting lets your domain model grow without rewriting stored events—transform old event shapes to current schemas at read time, maintaining backward compatibility with your complete event history.


