Kafka + Spring Boot: Event Sourcing & CQRS Patterns for Financial Systems
Why Event Sourcing for Financial Systems
Financial systems have non-negotiable requirements: complete audit trails, point-in-time reconstruction, and immutable history. Traditional CRUD approaches fail here because they only store the current state — past states are lost forever. Event sourcing solves this by storing every state-changing event as an immutable fact.
Core Concepts
Event Store
The event store is the single source of truth. Every event is appended to an append-only log:
AccountCreated → { id: "acc_001", owner: "John", balance: 0 }
FundsDeposited → { amount: 50000, newBalance: 50000 }
FundsWithdrawn → { amount: 10000, newBalance: 40000 }
InterestApplied → { rate: 0.04, amount: 1600, newBalance: 41600 }
To get the current balance, replay all events: 0 + 50000 - 10000 + 1600 = 41600
Kafka as Event Store
Apache Kafka's log-based architecture is a natural fit for event sourcing:
@Configuration
public class KafkaEventStoreConfig {
@Bean
public NewTopic eventTopic() {
return TopicBuilder.name("account.events")
.partitions(6)
.replicas(3)
.config(TopicConfig.CLEANUP_POLICY_CONFIG,
TopicConfig.CLEANUP_POLICY_COMPACT)
.build();
}
}
Using compacted topics ensures that the latest state for each key is retained while older events are cleaned up, giving us both the full event log and efficient state reconstruction.
CQRS Separation
CQRS (Command Query Responsibility Segregation) separates write operations from read operations:
┌──────────────────┐
│ Client App │
└──┬──────────┬────┘
│ │
┌──────▼──┐ ┌───▼────────┐
│ Command │ │ Query │
│ Side │ │ Side │
└──────┬──┘ └───┬────────┘
│ │
┌──────▼──┐ ┌───▼────────┐
│ Kafka │ │ Read │
│ (Write)│ │ Model │
└─────────┘ └────────────┘
Command Side (Write)
@Service
@Transactional
public class AccountCommandService {
private final KafkaTemplate<String, Object> kafka;
public void depositFunds(DepositFundsCommand cmd) {
var event = FundsDepositedEvent.builder()
.accountId(cmd.accountId())
.amount(cmd.amount())
.transactionId(UUID.randomUUID().toString())
.timestamp(Instant.now())
.build();
// Validate before persisting
validateDeposit(cmd);
kafka.send("account.events", cmd.accountId(), event);
}
public void withdrawFunds(WithdrawFundsCommand cmd) {
// Load events to check balance
var balance = replayEvents(cmd.accountId());
if (balance < cmd.amount()) {
throw new InsufficientFundsException();
}
var event = FundsWithdrawnEvent.builder()
.accountId(cmd.accountId())
.amount(cmd.amount())
.transactionId(UUID.randomUUID().toString())
.timestamp(Instant.now())
.build();
kafka.send("account.events", cmd.accountId(), event);
}
}
Query Side (Read)
The read side subscribes to events and builds denormalized projections:
@Component
public class AccountProjection {
private final AccountReadRepository repository;
@KafkaListener(topics = "account.events")
public void handleEvent(Object event) {
if (event instanceof FundsDepositedEvent e) {
repository.updateBalance(
e.getAccountId(),
e.getAmount(),
Operation.ADD
);
} else if (event instanceof FundsWithdrawnEvent e) {
repository.updateBalance(
e.getAccountId(),
e.getAmount(),
Operation.SUBTRACT
);
}
}
}
Snapshot Strategy
Replaying thousands of events to rebuild state is slow. Snapshots provide checkpoints:
public class AccountSnapshotter {
private static final int SNAPSHOT_INTERVAL = 100;
public Snapshot takeSnapshot(String accountId) {
var events = eventStore.getEvents(accountId);
if (events.size() % SNAPSHOT_INTERVAL != 0) return null;
var balance = replayEvents(events);
return new Snapshot(accountId, balance, events.size());
}
}
Replay Mechanism
For audit or rebuilding read models, event replay is essential:
@Service
public class EventReplayService {
private final KafkaConsumer<String, Object> consumer;
public void replayFrom(long timestamp, String... projections) {
consumer.assign(getAllPartitions());
consumer.seekToBeginning(getAllPartitions());
while (true) {
var records = consumer.poll(Duration.ofSeconds(1));
for (var record : records) {
if (record.timestamp() < timestamp) continue;
// Route to registered projections
notifyProjections(projections, record.value());
}
}
}
}
Production Considerations
1. Event Versioning — Schemas evolve. Use Avro or Protobuf with schema registry to handle backward/forward compatibility 2. Idempotent Projections — Exactly-once semantics for read models prevent double-counting 3. Tombstone Events — For GDPR compliance, compacted topics need tombstone records to delete data 4. Monitoring Consumer Lag — Critical for detecting projection drift
Conclusion
Event sourcing with Kafka and CQRS isn't just architectural purity — it's a practical necessity for financial systems that require audit trails, point-in-time recovery, and immutable history. The initial complexity investment pays dividends when regulators ask for seven-year transaction histories or when debugging production incidents requires exact state reconstruction.