Why Financial Systems Need Event-Driven Architecture
In a traditional request-response architecture, a deposit request hits your API, updates the database, calls the PSP, updates the balance, triggers a notification, and returns a response — all synchronously. When any step in that chain fails, you're left with partially completed operations and an inconsistent state.
Financial systems cannot tolerate inconsistency. A deposit that was charged to the client's card but never credited to their trading account is a support ticket, a chargeback risk, and a regulatory violation all at once.
Event-driven architecture solves this by decomposing operations into discrete events that flow through your system asynchronously. Each service reacts to events it cares about, and the event log becomes your single source of truth. Over the past several years building transaction processing systems for forex brokerages and payment platforms, I've found that event-driven patterns are not optional for financial systems — they're foundational.
Event Sourcing: The Ledger Pattern
Event sourcing means storing every state change as an immutable event, rather than overwriting the current state. This is naturally aligned with financial systems because accounting has always worked this way. A bank ledger doesn't erase entries — it appends new ones.
// Event base class
abstract class DomainEvent
{
public readonly string $eventId;
public readonly string $aggregateId;
public readonly string $eventType;
public readonly \DateTimeImmutable $occurredAt;
public readonly array $payload;
public readonly int $version;
public function __construct(
string $aggregateId,
array $payload,
int $version,
) {
$this->eventId = (string) Str::uuid();
$this->aggregateId = $aggregateId;
$this->eventType = static::class;
$this->occurredAt = new \DateTimeImmutable();
$this->payload = $payload;
$this->version = $version;
}
}
// Concrete events for a trading account
class AccountOpened extends DomainEvent {}
class FundsDeposited extends DomainEvent {}
class FundsWithdrawn extends DomainEvent {}
class TradeExecuted extends DomainEvent {}
class CommissionCharged extends DomainEvent {}
class AccountSuspended extends DomainEvent {}
The event store is append-only. No updates, no deletes.
CREATE TABLE event_store (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
aggregate_id UUID NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
version INTEGER NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Optimistic concurrency: no two events for the same aggregate
-- can have the same version
CONSTRAINT unique_aggregate_version
UNIQUE (aggregate_id, version)
);
CREATE INDEX idx_event_store_aggregate
ON event_store (aggregate_id, version);
CREATE INDEX idx_event_store_type
ON event_store (event_type, occurred_at);
The UNIQUE (aggregate_id, version) constraint is critical. It provides optimistic concurrency control — if two processes try to append an event with the same version number, the second one fails, which prevents lost updates.
The Account Aggregate
The aggregate rebuilds its current state by replaying events:
class TradingAccount
{
private string $accountId;
private string $status = 'pending';
private float $balance = 0.0;
private float $equity = 0.0;
private string $currency;
private array $pendingEvents = [];
private int $version = 0;
public static function open(
string $accountId,
string $ownerId,
string $currency,
): self {
$account = new self();
$account->apply(new AccountOpened($accountId, [
'owner_id' => $ownerId,
'currency' => $currency,
], 1));
return $account;
}
public function deposit(float $amount, string $reference): void
{
if ($this->status !== 'active') {
throw new \DomainException('Cannot deposit to inactive account');
}
if ($amount <= 0) {
throw new \DomainException('Deposit amount must be positive');
}
$this->apply(new FundsDeposited($this->accountId, [
'amount' => $amount,
'reference' => $reference,
'balance_before' => $this->balance,
'balance_after' => $this->balance + $amount,
], $this->version + 1));
}
public function withdraw(float $amount, string $reference): void
{
if ($this->status !== 'active') {
throw new \DomainException('Cannot withdraw from inactive account');
}
if ($amount > $this->balance) {
throw new \DomainException('Insufficient funds');
}
$this->apply(new FundsWithdrawn($this->accountId, [
'amount' => $amount,
'reference' => $reference,
'balance_before' => $this->balance,
'balance_after' => $this->balance - $amount,
], $this->version + 1));
}
private function apply(DomainEvent $event): void
{
$this->applyEvent($event);
$this->pendingEvents[] = $event;
}
private function applyEvent(DomainEvent $event): void
{
match ($event::class) {
AccountOpened::class => $this->onAccountOpened($event),
FundsDeposited::class => $this->onFundsDeposited($event),
FundsWithdrawn::class => $this->onFundsWithdrawn($event),
AccountSuspended::class => $this->onAccountSuspended($event),
};
$this->version = $event->version;
}
private function onAccountOpened(DomainEvent $event): void
{
$this->accountId = $event->aggregateId;
$this->currency = $event->payload['currency'];
$this->status = 'active';
}
private function onFundsDeposited(DomainEvent $event): void
{
$this->balance = $event->payload['balance_after'];
}
private function onFundsWithdrawn(DomainEvent $event): void
{
$this->balance = $event->payload['balance_after'];
}
private function onAccountSuspended(DomainEvent $event): void
{
$this->status = 'suspended';
}
// Reconstitute from event history
public static function fromHistory(array $events): self
{
$account = new self();
foreach ($events as $event) {
$account->applyEvent($event);
}
return $account;
}
}
CQRS: Separating Reads and Writes
Event sourcing naturally leads to CQRS (Command Query Responsibility Segregation). Your write model processes commands and emits events. Your read model consumes those events and builds optimized query projections.
// Command side — handles the write
class DepositFundsHandler
{
public function __construct(
private EventStore $eventStore,
private EventBus $eventBus,
) {}
public function handle(DepositFundsCommand $command): void
{
// Load the aggregate from event history
$events = $this->eventStore->loadStream($command->accountId);
$account = TradingAccount::fromHistory($events);
// Execute the business logic
$account->deposit($command->amount, $command->reference);
// Persist new events
$this->eventStore->append(
$command->accountId,
$account->getPendingEvents()
);
// Publish events to the bus
foreach ($account->getPendingEvents() as $event) {
$this->eventBus->publish($event);
}
}
}
// Query side — builds a read-optimized projection
class AccountBalanceProjector
{
public function onFundsDeposited(FundsDeposited $event): void
{
DB::table('account_balances')->updateOrInsert(
['account_id' => $event->aggregateId],
[
'balance' => $event->payload['balance_after'],
'last_deposit_at' => $event->occurredAt->format('Y-m-d H:i:s'),
'updated_at' => now(),
]
);
}
public function onFundsWithdrawn(FundsWithdrawn $event): void
{
DB::table('account_balances')->updateOrInsert(
['account_id' => $event->aggregateId],
[
'balance' => $event->payload['balance_after'],
'last_withdrawal_at' => $event->occurredAt->format('Y-m-d H:i:s'),
'updated_at' => now(),
]
);
}
}
The beauty of this separation is that you can build multiple projections from the same event stream. One projection optimized for the client dashboard, another for the back-office admin panel, another for regulatory reporting — all derived from the same source of truth.
The Saga Pattern for Multi-Step Transactions
A withdrawal involves multiple services: validate the request, check compliance rules, debit the trading account, call the PSP, send a notification. If the PSP call fails after the account has been debited, you need a compensating action. This is the saga pattern.
class WithdrawalSaga
{
private string $sagaId;
private string $state = 'initiated';
private array $completedSteps = [];
public function __construct(
private EventBus $eventBus,
private WithdrawalRepository $withdrawals,
) {
$this->sagaId = (string) Str::uuid();
}
public function handle(WithdrawalRequested $event): void
{
$this->state = 'validating';
try {
// Step 1: Compliance check
$this->validateCompliance($event);
$this->completedSteps[] = 'compliance';
// Step 2: Reserve funds (soft hold on the balance)
$this->reserveFunds($event);
$this->completedSteps[] = 'funds_reserved';
// Step 3: Call PSP
$pspResult = $this->processPspWithdrawal($event);
$this->completedSteps[] = 'psp_processed';
// Step 4: Confirm debit
$this->confirmDebit($event, $pspResult);
$this->completedSteps[] = 'debit_confirmed';
// Step 5: Notify client
$this->eventBus->publish(new WithdrawalCompleted(
$event->aggregateId,
[
'withdrawal_id' => $event->payload['withdrawal_id'],
'amount' => $event->payload['amount'],
'psp_reference' => $pspResult['reference'],
],
$event->version + 1,
));
$this->state = 'completed';
} catch (ComplianceRejectedException $e) {
$this->compensate($event, 'compliance_rejected', $e->getMessage());
} catch (InsufficientFundsException $e) {
$this->compensate($event, 'insufficient_funds', $e->getMessage());
} catch (PspException $e) {
$this->compensate($event, 'psp_failed', $e->getMessage());
}
}
private function compensate(
WithdrawalRequested $event,
string $reason,
string $message,
): void {
$this->state = 'compensating';
// Reverse completed steps in reverse order
$steps = array_reverse($this->completedSteps);
foreach ($steps as $step) {
match ($step) {
'debit_confirmed' => $this->reverseDebit($event),
'psp_processed' => $this->reversePspWithdrawal($event),
'funds_reserved' => $this->releaseFunds($event),
'compliance' => null, // No compensation needed
};
}
$this->eventBus->publish(new WithdrawalFailed(
$event->aggregateId,
[
'withdrawal_id' => $event->payload['withdrawal_id'],
'reason' => $reason,
'message' => $message,
'saga_id' => $this->sagaId,
],
$event->version + 1,
));
$this->state = 'compensated';
}
}
Each step in the saga has a corresponding compensating action. If Step 3 (PSP call) fails, the saga automatically releases the reserved funds (reverse of Step 2). The order of compensation matters — you always reverse in the opposite order of execution.
Message Broker Configuration
I use RabbitMQ for event distribution in most fintech systems. It provides durable queues, dead-letter exchanges, and message acknowledgment that you need for financial events.
// RabbitMQ event publisher with retry and dead-letter configuration
class RabbitMqEventPublisher
{
public function __construct(
private AMQPChannel $channel,
) {
$this->declareTopology();
}
private function declareTopology(): void
{
// Dead letter exchange for failed messages
$this->channel->exchange_declare(
'events.dlx', 'topic', false, true, false
);
// Main event exchange
$this->channel->exchange_declare(
'events', 'topic', false, true, false
);
// Declare queues with dead-letter routing
$queues = [
'balance.projector' => 'account.funds.*',
'notification.service' => 'account.#',
'compliance.audit' => '#', // Receives ALL events
'commission.calculator' => 'trade.executed',
];
foreach ($queues as $queueName => $routingKey) {
$this->channel->queue_declare($queueName, false, true, false, false, false, [
'x-dead-letter-exchange' => ['S', 'events.dlx'],
'x-dead-letter-routing-key' => ['S', $queueName],
'x-message-ttl' => ['I', 86400000], // 24h TTL
]);
$this->channel->queue_bind($queueName, 'events', $routingKey);
}
}
public function publish(DomainEvent $event): void
{
$routingKey = $this->resolveRoutingKey($event);
$message = new AMQPMessage(json_encode([
'event_id' => $event->eventId,
'event_type' => $event->eventType,
'aggregate_id' => $event->aggregateId,
'payload' => $event->payload,
'occurred_at' => $event->occurredAt->format('c'),
'version' => $event->version,
]), [
'content_type' => 'application/json',
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
'message_id' => $event->eventId,
'timestamp' => $event->occurredAt->getTimestamp(),
]);
$this->channel->basic_publish($message, 'events', $routingKey);
}
private function resolveRoutingKey(DomainEvent $event): string
{
return match ($event::class) {
FundsDeposited::class => 'account.funds.deposited',
FundsWithdrawn::class => 'account.funds.withdrawn',
TradeExecuted::class => 'trade.executed',
AccountSuspended::class => 'account.suspended',
default => 'unknown',
};
}
}
Note the compliance.audit queue with the # routing key — it receives every single event in the system. This gives your compliance team a complete, immutable audit trail of every action that occurred on the platform. When regulators ask "show me every state change for account X between these dates," you replay the event store and hand them a chronological report.
Handling Idempotency
In a distributed event-driven system, messages can be delivered more than once. Every event handler must be idempotent.
class IdempotentEventHandler
{
public function __construct(
private Redis $redis,
) {}
public function handleOnce(DomainEvent $event, callable $handler): void
{
$key = "processed_events:{$event->eventId}";
// SET NX — only succeeds if the key doesn't exist
$isNew = $this->redis->set($key, '1', ['NX', 'EX' => 86400]);
if (!$isNew) {
Log::info('Duplicate event skipped', [
'event_id' => $event->eventId,
'event_type' => $event->eventType,
]);
return;
}
try {
$handler($event);
} catch (\Exception $e) {
// Remove the idempotency key so the event can be retried
$this->redis->del($key);
throw $e;
}
}
}
This pattern ensures that even if RabbitMQ delivers the same event twice (which it will, under network partitions or consumer restarts), your handlers only process it once. The 24-hour TTL on the idempotency key prevents unbounded memory growth while covering the realistic window for redelivery.
Eventual Consistency: Communicating It to Users
The hardest part of event-driven architecture isn't the technology — it's the UX. When a user makes a deposit, the event might take 200 milliseconds to propagate through the system and update the balance projection. If the user immediately refreshes their dashboard, they might see their old balance.
I handle this with a simple pattern: return the expected state in the command response, and let the projection catch up asynchronously.
class DepositController
{
public function store(DepositRequest $request): JsonResponse
{
$command = new DepositFundsCommand(
accountId: $request->account_id,
amount: $request->amount,
reference: Str::uuid(),
);
$this->commandBus->dispatch($command);
// Return the expected balance immediately
// The projection will catch up within milliseconds
return response()->json([
'status' => 'processing',
'expected_balance' => $request->current_balance + $request->amount,
'reference' => $command->reference,
'message' => 'Deposit is being processed.',
], 202); // 202 Accepted, not 200 OK
}
}
The 202 Accepted status code is semantically correct — you've accepted the request, but processing isn't complete. The frontend can optimistically update the UI with the expected balance while subscribing to a WebSocket channel for the confirmed update.
Key Takeaways
- Event sourcing gives you a built-in audit trail. In financial systems, the ability to replay every state change for any account is not a luxury — it's a regulatory requirement. Event sourcing provides this by design.
- Use optimistic concurrency control on the event store. The unique constraint on
(aggregate_id, version)prevents two processes from simultaneously modifying the same aggregate, eliminating lost updates without pessimistic locking. - CQRS lets you build multiple read models from one event stream. Client dashboards, admin panels, and regulatory reports all need different views of the same data — projections make this clean and maintainable.
- Sagas with compensating actions replace distributed transactions. Two-phase commits don't work reliably across service boundaries. Sagas give you eventual consistency with explicit compensation logic for every step that can fail.
- Every event handler must be idempotent. Message brokers guarantee at-least-once delivery, not exactly-once. Use an idempotency key in Redis to ensure duplicate messages are harmlessly discarded.
- Return 202 Accepted for commands and let projections catch up. Eventual consistency is a technical reality — communicate it honestly to the frontend, and use optimistic UI updates to keep the user experience smooth.
