Back to Blog
KafkaSpring BootEvent SourcingCQRSFinTech

Kafka + Spring Boot: Event Sourcing & CQRS Patterns for Financial Systems

18 min read

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());
    }
}
Events ReplayedWithout SnapshotWith Snapshot (every 100) 100100 reads0-100 reads 10001000 reads0-100 reads 1000010000 reads0-100 reads

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.