Event Mesh Design for Financial Services

Prerequisite: Event Streaming

Your ledger service publishes a payment.completed event. Two consumers process it — one credits the merchant’s balance, the other triggers the settlement file. A Kafka broker restarts during a rebalance, and the merchant balance consumer re-reads an offset it already processed. The credit posts twice. Your ledger now shows $14,200 in the merchant account instead of $7,100, and because the event carried enough authority for the consumer to act, the damage is done before your reconciliation job catches it four hours later.

Event-driven architecture in fintech is not a pattern choice — it is the nervous system connecting every ledger write, compliance check, and settlement action. The failure modes are denominated in dollars.

Topic Granularity

The first design decision is how many topics you need and what each one represents. Too few topics and consumers waste cycles filtering events they don’t care about. Too many and operational overhead — monitoring, ACLs, retention policies — grows linearly with topic count.

The financial event domain has a natural structure: each bounded context owns a small set of topics representing its state transitions.

Domain Topics Events per topic
Payments payments.commands, payments.events initiated, authorized, captured, failed, refunded
Ledger ledger.entries entry.posted, entry.voided
Compliance compliance.events kyc.completed, kyc.failed, sanctions.cleared, sanctions.hit
Settlement settlement.events batch.opened, batch.closed, file.submitted, file.acknowledged

Each topic carries events for one domain, and each event type is distinguished by a type field in the payload — not by a separate topic. A payments.events topic with five event types is operationally manageable. Fifty topics with one event type each is not.

Naming convention

Use a three-segment naming pattern: {domain}.{category}.{version}.

payments.events.v1
ledger.entries.v1
compliance.screening.v1
settlement.batches.v1

The version suffix is not optional. When you need a breaking schema change, you publish to payments.events.v2 and run both topics in parallel during migration. Without a version in the topic name, you are forced into backward-compatible-only schema evolution forever — which works until it doesn’t.

Partitioning Strategy

Kafka guarantees ordering within a partition but not across partitions. For financial events, ordering per entity is the minimum guarantee your consumers need. A payment’s authorized event must arrive before its captured event — at least within the same consumer instance.

Partition by entity ID: account ID for ledger events, payment ID for payment events, customer ID for compliance events.

partition_key = event.account_id   // ledger events
partition_key = event.payment_id   // payment events
partition_key = event.customer_id  // compliance events

This guarantees that all events for a single payment, account, or customer are processed in order by a single consumer instance. Cross-entity ordering is not guaranteed — and should not be. If your consumer requires ordering across entities, your domain model has a missing aggregate.

The partition count determines your maximum consumer parallelism. For a payment events topic processing 50,000 events per second, 64 partitions yield roughly 780 events/sec per partition — well within a single consumer’s capacity. Over-partitioning (512 partitions for a 1,000 events/sec topic) wastes broker resources and increases rebalance time.

Events/sec Recommended partitions Events/partition/sec
1,000 8–16 62–125
10,000 32–64 156–312
50,000 64–128 390–780
200,000 128–256 780–1,562

Changing the partition count after creation redistributes keys across partitions. Events for the same account may land on a different partition than their predecessors, breaking ordering for in-flight processing. Increase partitions only during a maintenance window with all consumers drained.

Schema Evolution

Every financial event is a contract. Downstream consumers — ledger writers, compliance checkers, settlement generators — depend on the shape of the payload. A field rename breaks them. A removed field breaks them. Even a field type change from string to int breaks them.

Use a schema registry (Confluent Schema Registry, AWS Glue, or Apicurio) with Avro or Protobuf schemas. JSON Schema works but lacks the compact wire format that matters at high throughput.

The evolution rules for financial events are stricter than for general-purpose systems:

Change Allowed? Why
Add optional field Yes Existing consumers ignore it
Add required field No Breaks all existing consumers
Remove field No Consumers relying on it fail
Rename field No Semantically identical to remove + add
Change field type No Deserialization fails
Add enum value Yes (backward) Only if consumers have a default case

AsyncAPI specifications document your event contracts. Each topic gets an AsyncAPI channel definition with the schema, the partition key, and the semantic meaning of the event.

channels:
  payments/events/v1:
    subscribe:
      message:
        schemaFormat: 'application/vnd.apache.avro+json;version=1.9.0'
        payload:
          type: record
          name: PaymentEvent
          fields:
            - name: payment_id
              type: string
            - name: type
              type:
                type: enum
                symbols: [initiated, authorized, captured, failed, refunded]
            - name: amount
              type: { type: fixed, size: 16, logicalType: decimal, precision: 18, scale: 2 }
            - name: currency
              type: string
            - name: occurred_at
              type: { type: long, logicalType: timestamp-millis }

When you need a breaking change, create a new version of the topic, deploy consumers that read both, and drain the old topic. Never mutate a live schema in a way that violates the evolution rules above.

Dead Letter Queues and Replay

A consumer fails to process an event. The schema is valid, the message deserialized correctly, but the downstream database rejected the write — a foreign key violation, a constraint check, a transient network failure. The consumer has three choices: retry in place (blocks the partition), skip and lose the event, or route to a dead letter queue (DLQ).

For financial events, the DLQ is the only acceptable option. Lost events mean lost money or broken audit trails.

payments.events.v1          →  consumer  →  success
                                         →  payments.events.v1.dlq (on failure)

Each DLQ event carries the original payload, the failure reason, the attempt count, and the timestamp of the last attempt.

The replay strategy matters. Replaying a failed payment.captured event is not the same as processing a new capture — the payment may have already been partially settled, the merchant may have already been credited by a compensation process, or the event may represent a state that no longer exists. Every DLQ replay must be idempotent, and the consumer must check current state before acting on a replayed event.

DLQ depth is a monitoring metric. A DLQ with zero messages is healthy. A DLQ accumulating 50 messages per hour indicates a systemic issue — a bad deployment, a schema mismatch, a downstream outage. Alert when the DLQ growth rate exceeds your baseline, not when it reaches an absolute count.

See Idempotency at Scale. Every event consumer that writes to a ledger or triggers a payment must be idempotent. The DLQ replay path is the most common source of duplicate processing — and the easiest to miss in testing.

Choreography vs Orchestration

A multi-step financial workflow — customer pays, merchant is credited, settlement file is generated, bank is notified — can be coordinated in two ways.

Choreography lets each service react to events from the previous step. The payment service publishes payment.captured. The ledger service consumes it, posts entries, and publishes entry.posted. The settlement service consumes entry.posted and adds it to the current batch. No central coordinator exists.

Orchestration uses a central workflow engine (Temporal, AWS Step Functions, a custom state machine) that explicitly calls each step in sequence, handles retries, and manages compensation.

Dimension Choreography Orchestration
Visibility Distributed — requires correlation IDs to trace Centralized — the orchestrator holds the full workflow state
Failure handling Each service handles its own retries and DLQ Orchestrator manages retries, timeouts, and compensation
Coupling Services only know about events, not about each other Orchestrator knows the full workflow graph
Latency Lower — no coordinator hop Higher — each step routes through the orchestrator
Debugging Hard — reconstruct the flow from multiple topic logs Easy — query the orchestrator for workflow state

For financial systems, the pattern is: choreography for fan-out after money has moved, orchestration for the critical path where money is moving.

The payment capture → ledger entry → balance update path is orchestrated. One coordinator owns the sequence, handles failure at any step, and runs compensation if step 3 fails after step 2 succeeded. You cannot afford to discover that the ledger entry posted but the balance update didn’t by correlating events across three topics.

The post-settlement fan-out — notify the merchant, send the receipt, update the dashboard, trigger the reconciliation — is choreographed. Each consumer acts independently on the settlement.completed event. If the receipt email fails, the merchant’s balance is unaffected.

Exactly-Once Semantics

Kafka’s exactly-once semantics (EOS), introduced in KIP-98 and available since Kafka 0.11, guarantees that a produce-consume-produce cycle executes exactly once. The producer writes to a topic, the consumer reads from that topic and writes to another topic, and EOS ensures the read and write are atomic — if the consumer crashes after the write but before committing the offset, Kafka rolls back the write on recovery.

This works when both sides of the transaction are Kafka topics. It does not work when the consumer’s side effect is a database write, an API call, or a ledger entry.

When your consumer reads a payment.captured event and credits a merchant’s ledger balance, the side effect is a Postgres transaction. Kafka’s EOS guarantees the offset commit and the downstream topic write are atomic. It says nothing about the Postgres write. If the consumer crashes after the Postgres commit but before the Kafka offset commit, the next consumer instance re-reads the event and credits the merchant again.

The solution is not Kafka-level EOS. The solution is consumer-level idempotency.

BEGIN;

INSERT INTO processed_events (event_id, topic, partition, offset_val, processed_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (event_id) DO NOTHING;

-- Only proceed if the insert succeeded (event not already processed)
INSERT INTO entries (account_id, amount, event_id, ...)
SELECT $5, $6, $1, ...
WHERE EXISTS (
  SELECT 1 FROM processed_events
  WHERE event_id = $1 AND processed_at = now()
);

COMMIT;

The processed_events table is the idempotency store. The event ID is the natural deduplication key. Both the deduplication check and the ledger write happen in the same database transaction. If the consumer crashes after this transaction commits, the re-read event hits the ON CONFLICT clause and the ledger write is skipped.

This pattern — database-level idempotency wrapping the side effect — is the only reliable exactly-once guarantee when the side effect leaves Kafka’s transactional boundary. Kafka’s EOS is a building block, not a complete solution.

Guarantee level What it covers What it doesn’t cover
At-most-once Consumer commits offset before processing Lost events on crash
At-least-once Consumer commits offset after processing Duplicate processing on crash
Kafka EOS Atomic offset commit + downstream topic write Side effects outside Kafka (DB, API, ledger)
Consumer idempotency Deduplication at the side-effect boundary Requires per-consumer implementation

“We use Kafka exactly-once” is not a complete answer to “can a payment event credit a merchant twice?” Kafka’s EOS covers the offset-to-topic path. The offset-to-database path requires your own idempotency layer. Every financial event consumer must implement both.

Retention and Compaction

Financial event topics carry data that may be subject to regulatory retention requirements. A payment.captured event is part of the transaction audit trail. Deleting it after Kafka’s default 7-day retention may violate recordkeeping obligations.

Two strategies:

Long retention. Set retention to match your regulatory requirement — 5 years for BSA/AML, 7 years for SOX. At 50,000 events/day averaging 1KB each, 7 years is roughly 128GB per topic. Kafka handles this, but your consumers must handle reprocessing from arbitrary offsets without re-executing side effects.

Event archival. Consume events into a durable store (S3 with Object Lock, a data warehouse) and keep Kafka retention at 30–90 days for operational replay. The archive is the regulatory record; Kafka is the operational bus.

Log compaction — Kafka’s strategy of keeping only the latest value per key — is appropriate for state topics (current account balance, current KYC status) but never for event topics. Compacting a payment events topic deletes intermediate states (authorized, captured) and keeps only the latest (settled), destroying the audit trail.

Operational Metrics

Monitor four signals per topic:

Metric Healthy Investigate Alert
Consumer lag (messages behind) < 1,000 1,000–10,000 > 10,000
DLQ growth rate 0/hour 1–10/hour > 10/hour
End-to-end latency (produce to consume) < 500ms p99 500ms–2s p99 > 2s p99
Schema validation failures 0 1–5/day > 5/day

Consumer lag is the leading indicator. A consumer falling behind on a payment events topic means payment processing is backing up — which surfaces to customers as delayed transactions before any downstream system raises an alarm.