Event-Driven Architecture Patterns
Mental models for building event-driven systems. Understanding event sourcing, CQRS, eventual consistency, and when to use them in fintech contexts.
Why Events Matter in Financial Systems
Financial systems are inherently event-driven. A payment succeeds, a loan defaults, a borrower updates their address. These aren’t just state changes, they’re business events with meaning.
Traditional CRUD thinking treats these as database updates. Event-driven thinking treats them as first-class domain events that trigger downstream processes. The difference matters when you’re coordinating across multiple systems.
Events vs Commands vs State Changes
Not everything should be an event. Here’s how to think about it:
Commands are requests for action. “ProcessPayment”, “ApproveLoan”, “SendNotification”. They can fail. They express intent.
Events are facts about what happened. “PaymentProcessed”, “LoanApproved”, “NotificationSent”. They can’t fail because they already happened. They express reality.
State changes are the mutations to your database. They’re the implementation detail, not the interface.
In an event-driven architecture, commands trigger business logic that produces events. Events trigger state changes and downstream processes. This separation makes the system easier to reason about and test.
Event Sourcing: When and Why
Event sourcing means storing events as your source of truth rather than current state. Your balance isn’t a number in a database, it’s the sum of all deposit and withdrawal events.
This sounds elegant until you try to query it. “Show me all loans with a balance over $10k” becomes expensive when you have to replay events for every loan.
Use event sourcing for:
- Audit trails (financial systems need complete history)
- Complex state machines (loan lifecycle, collections workflows)
- Temporal queries (“what was the state on this date?”)
Don’t use it for:
- Simple CRUD entities
- Data that gets queried more than it changes
- High-volume, low-value events
CQRS: Read and Write Separation
Command Query Responsibility Segregation means your write model and read model are different.
You write events to an append-only log. You read from projections (materialized views) optimized for queries. The projection can be relational, document, search index. Whatever fits the read pattern.
For lending systems, this works well:
- Write: Loan events (originated, payment made, defaulted)
- Read projections: Current loan state, borrower dashboard, collections queue
The projections are eventually consistent. That’s fine for most reads. For the few that need strong consistency, query the write model directly.
Handling Eventual Consistency
Eventual consistency breaks intuitions. A borrower makes a payment but the balance doesn’t update immediately. How do you handle that in the UI?
Options:
- Optimistic UI: Show the expected state immediately, with a spinner.
- Lag indicator: “Balance as of 2 minutes ago. Updating…”
- Synchronous path: For critical operations, wait for the projection to update.
We use different strategies for different operations. Payment confirmation? Optimistic. Account dashboard? Lag indicator. Loan approval? Synchronous.
When Not to Go Event-Driven
Event-driven architecture adds complexity. You’re trading simple database transactions for distributed systems problems. Only worth it if:
- You need to coordinate across multiple services
- You need complete audit logs
- You have complex workflows with many decision points
- Your domain is naturally event-driven (it is in fintech)
If you’re building a simple CRUD app, stick with CRUD. Don’t add events just because they’re fashionable.
Deep Dive: Events in Financial Systems
This detailed version expands on the overview with implementation specifics, code examples, and production considerations.
Event Structure and Schema
Every event should contain:
interface DomainEvent {
// Unique identifier for this event
eventId: string;
// Type discriminator for event routing
eventType: string;
// Aggregate/entity this event belongs to
aggregateId: string;
aggregateType: string;
// Ordering within the aggregate
sequenceNumber: number;
// When it happened (business time)
occurredAt: Date;
// When it was recorded (system time)
recordedAt: Date;
// Who/what caused it
causationId?: string;
correlationId: string;
// The actual event data
payload: Record<string, unknown>;
// Schema version for evolution
version: number;
}
Event Naming Conventions
Events should be named as past-tense facts:
| ❌ Bad | ✅ Good |
|---|---|
| CreateLoan | LoanCreated |
| ProcessPayment | PaymentProcessed |
| UpdateAddress | AddressUpdated |
| SendNotification | NotificationSent |
The name should be domain-specific, not technical:
| ❌ Technical | ✅ Domain |
|---|---|
| RecordInserted | LoanOriginated |
| StatusChanged | LoanDefaulted |
| MessagePublished | StatementGenerated |
Event Sourcing Implementation
Here’s a minimal event-sourced aggregate:
abstract class EventSourcedAggregate {
private uncommittedEvents: DomainEvent[] = [];
private version: number = 0;
protected apply(event: DomainEvent): void {
// Apply the event to update state
this.when(event);
this.version++;
this.uncommittedEvents.push(event);
}
// Override in subclass to handle specific events
protected abstract when(event: DomainEvent): void;
public getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedEvents];
}
public loadFromHistory(events: DomainEvent[]): void {
for (const event of events) {
this.when(event);
this.version++;
}
}
}
Example Loan aggregate:
class Loan extends EventSourcedAggregate {
private status: LoanStatus = 'draft';
private balance: number = 0;
private borrowerId: string = '';
static originate(
loanId: string,
borrowerId: string,
amount: number
): Loan {
const loan = new Loan();
loan.apply({
eventType: 'LoanOriginated',
aggregateId: loanId,
aggregateType: 'Loan',
payload: { borrowerId, amount },
// ... other fields
});
return loan;
}
makePayment(amount: number): void {
if (this.status !== 'active') {
throw new Error('Cannot pay inactive loan');
}
this.apply({
eventType: 'PaymentMade',
aggregateId: this.id,
aggregateType: 'Loan',
payload: { amount, previousBalance: this.balance },
// ...
});
if (this.balance <= 0) {
this.apply({
eventType: 'LoanPaidOff',
aggregateId: this.id,
aggregateType: 'Loan',
payload: {},
// ...
});
}
}
protected when(event: DomainEvent): void {
switch (event.eventType) {
case 'LoanOriginated':
this.borrowerId = event.payload.borrowerId;
this.balance = event.payload.amount;
this.status = 'active';
break;
case 'PaymentMade':
this.balance -= event.payload.amount;
break;
case 'LoanPaidOff':
this.status = 'paid_off';
break;
case 'LoanDefaulted':
this.status = 'defaulted';
break;
}
}
}
CQRS Projection Patterns
Projections transform events into queryable read models:
interface ProjectionHandler<TEvent, TReadModel> {
eventTypes: string[];
handle(event: TEvent, current: TReadModel | null): TReadModel;
}
// Example: Loan Dashboard Projection
const loanDashboardProjection: ProjectionHandler<DomainEvent, LoanDashboard> = {
eventTypes: ['LoanOriginated', 'PaymentMade', 'LoanDefaulted', 'LoanPaidOff'],
handle(event: DomainEvent, current: LoanDashboard | null): LoanDashboard {
const dashboard = current || {
loanId: event.aggregateId,
status: 'unknown',
balance: 0,
paymentCount: 0,
lastPaymentDate: null,
};
switch (event.eventType) {
case 'LoanOriginated':
return {
...dashboard,
status: 'active',
balance: event.payload.amount,
originatedAt: event.occurredAt,
};
case 'PaymentMade':
return {
...dashboard,
balance: dashboard.balance - event.payload.amount,
paymentCount: dashboard.paymentCount + 1,
lastPaymentDate: event.occurredAt,
};
case 'LoanPaidOff':
return { ...dashboard, status: 'paid_off', balance: 0 };
case 'LoanDefaulted':
return { ...dashboard, status: 'defaulted' };
default:
return dashboard;
}
}
};
Eventual Consistency Strategies
1. Optimistic UI with Confirmation Polling
async function processPaymentWithOptimisticUI(
loanId: string,
amount: number
): Promise<void> {
// 1. Submit the command
const { correlationId } = await submitPaymentCommand(loanId, amount);
// 2. Immediately show optimistic UI
updateUIOptimistically(loanId, -amount);
// 3. Poll for confirmation (with backoff)
const confirmed = await pollForProjectionUpdate(
loanId,
correlationId,
{ maxAttempts: 10, backoffMs: 500 }
);
// 4. If not confirmed, show warning or refresh
if (!confirmed) {
showStaleDataWarning();
await refreshLoanData(loanId);
}
}
2. Read-Your-Writes Pattern
class ReadYourWritesClient {
private lastKnownVersion: Map<string, number> = new Map();
async submitCommand(command: Command): Promise<void> {
const result = await this.commandHandler.handle(command);
// Store the version after our write
this.lastKnownVersion.set(result.aggregateId, result.newVersion);
}
async query(aggregateId: string): Promise<ReadModel> {
const minVersion = this.lastKnownVersion.get(aggregateId) || 0;
// Wait for projection to catch up to our write
return await this.queryService.queryWithMinVersion(
aggregateId,
minVersion,
{ timeoutMs: 5000 }
);
}
}
Event Store Considerations
Key requirements for a financial event store:
- Immutability: Events are append-only, never modified
- Ordering: Strict ordering within an aggregate
- Idempotency: Duplicate writes should be safe
- Optimistic Concurrency: Check expected version on write
interface EventStore {
// Append events with optimistic concurrency
append(
aggregateId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<{ newVersion: number }>;
// Read events for replay
getEvents(
aggregateId: string,
fromVersion?: number
): AsyncIterable<DomainEvent>;
// Subscribe to new events (for projections)
subscribe(
fromPosition: string,
handler: (event: DomainEvent) => Promise<void>
): Subscription;
}
Production Checklist
Before going to production with event-driven architecture:
- Schema Registry: Track event schemas and versions
- Dead Letter Queue: Handle events that can’t be processed
- Idempotent Consumers: Handle duplicate event delivery
- Monitoring: Track projection lag, event throughput
- Replay Tools: Ability to rebuild projections from events
- Event Versioning Strategy: How to evolve schemas over time
- Backup & Recovery: Event store backup strategy
- GDPR Compliance: How to handle data deletion requests
Common Pitfalls
-
Too Many Events: Not every change needs an event. Start with domain events.
-
Anemic Events: Events with just IDs requiring additional lookups. Include relevant data.
-
Coupling Through Events: Services depending on internal event structure. Use published language.
-
Missing Correlation: Can’t trace a request through multiple events. Always include correlation IDs.
-
Projection Sprawl: Too many specialized projections. Start with general-purpose, optimize when needed.
Further Reading
- Martin Fowler: Event Sourcing
- Greg Young: CQRS Documents
- Udi Dahan: Clarified CQRS