When building a core financial ledger, the single most dangerous line of code you can write looks completely harmless:
@Transactional
public TransferResponse postTransfer(TransferRequest request) {
// 1. Update balances & create ledger entries in Postgres
updateBalances(request);
createLedgerEntries(request);
// 2. Publish event to Kafka
kafkaTemplate.send("transfers.posted", new TransferEvent(request)); // ❌ DISASTER
return response;
}
It looks clean. It passes unit tests. And in production, it will inevitably corrupt your financial system.
Here is the story of how I encountered the classic Dual-Write Problem while designing LedgerX - a double-entry ledger engine - and how implementing the Transactional Outbox Pattern gave me verifiable, crash-resilient exactly-once semantics.
The Dual-Write Trap
In a distributed financial system, you need two things to happen when money moves:
- Mutate state in PostgreSQL (balances, immutable journal entries, audit log).
- Broadcast an event to Apache Kafka so downstream notification services, fraud analysis, and data warehouses react.
The fatal trap is that PostgreSQL transactions and Kafka publishes cannot share an atomic ACID transaction.
Consider the failure modes:
-
Scenario A: The Kafka broker hangs or throws a network timeout. The
@Transactionalblock catches the exception and rolls back the database. But what if Kafka actually received the bytes right before the network dropped? Your event was published, but the money never moved. -
Scenario B: You publish to Kafka at the end of the method. The Kafka send succeeds, but when Hibernate issues the final
COMMITto PostgreSQL, a database constraint fails or the connection drops. You just broadcasted a payment that does not exist in the ledger. - Scenario C: Kafka is temporarily down for maintenance. Your entire payment API returns HTTP 500, rejecting transfers even though your database is completely healthy.
Kafka is never called inside the transfer transaction.
The Solution: The Transactional Outbox Pattern
The Transactional Outbox Pattern decouples your database commit from message delivery by storing the message directly inside PostgreSQL as an append-only table.
Because the outbox row is written inside the same database transaction as the financial transfers and ledger entries, it is protected by the database's ACID guarantees. Either everything commits together, or nothing does.
sequenceDiagram
autonumber
actor Client
participant API as LedgerX API
participant DB as PostgreSQL (ACID)
participant Relay as Outbox Relay Worker
participant Kafka as Apache Kafka Broker
participant Consumer as Audit Consumer
Client->>API: POST /api/v1/transfers (Idempotency-Key)
activate API
API->>DB: BEGIN TRANSACTION
API->>DB: Lock accounts (ORDER BY id FOR UPDATE)
API->>DB: Insert Transfer & Balanced Entries
API->>DB: Insert outbox row (published_at = NULL)
API->>DB: COMMIT TRANSACTION
API-->>Client: 201 Created (Transfer Posted)
deactivate API
loop Every 500ms
activate Relay
Relay->>DB: SELECT * FROM outbox FOR UPDATE SKIP LOCKED
Relay->>Kafka: Produce event (TransferPosted.v1)
Kafka-->>Relay: Broker ACK
Relay->>DB: UPDATE outbox SET published_at = NOW()
deactivate Relay
end
Kafka->>Consumer: Consume event
activate Consumer
Consumer->>DB: INSERT INTO processed_events ON CONFLICT DO NOTHING
Consumer->>DB: Append immutable audit record
deactivate Consumer
3 Critical Engineering Decisions in LedgerX
Implementing the pattern in production requires more than a naive table and polling loop. Here are three architectural choices that made LedgerX bulletproof:
1. Concurrency with FOR UPDATE SKIP LOCKED
If you run multiple instances of your application, multiple outbox pollers will fight over the same pending rows.
Instead of external distributed locks (like Redis/Redlock), LedgerX uses PostgreSQL's native row locking:
SELECT id, aggregate_type, aggregate_id, event_type, payload, headers, created_at, published_at, attempts, last_error, next_attempt_at
FROM outbox
WHERE published_at IS NULL
AND next_attempt_at <= NOW()
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;
SKIP LOCKED allows instance A to lock rows 1 to 50 while instance B concurrently processes rows 51 to 100 without blocking or waiting.
2. Durable Exponential Backoff in SQL
If Kafka goes down, an unconstrained relay will spin in a tight CPU loop, flooding logs with connection errors.
LedgerX stores retry metadata directly in the outbox table (V8__outbox_next_attempt_at.sql):
ALTER TABLE outbox
ADD COLUMN attempts INT NOT NULL DEFAULT 0,
ADD COLUMN last_error TEXT,
ADD COLUMN next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
When a publish fails, the delay expands exponentially: $\text{delay} = \min(300, \text{attempts}^2)$ seconds. The poller query simply excludes rows where next_attempt_at > NOW(), letting failing rows rest while fresh transactions continue flowing.
3. Delivery Guarantees: At-Least-Once Outbox and Exactly-Once Effect
The outbox pattern guarantees at-least-once publishing. Network partitions or restarts during broker acknowledgment can cause a message to be published more than once. Kafka is never called inside the transfer transaction.
To achieve an end-to-end exactly-once processing effect, consumers track processed message IDs using an atomic deduplication table:
INSERT INTO processed_events (event_id, consumer_group, processed_at)
VALUES (?, 'audit-consumer', NOW())
ON CONFLICT (event_id, consumer_group) DO NOTHING;
If the row already exists, the consumer immediately ACKs Kafka and skips side effects.
Conclusion
By moving event publication out of the HTTP transaction and into a durable database-backed outbox:
- Payment requests succeed even when Kafka is completely offline.
- Data never goes out of sync between PostgreSQL and event streams.
- The system achieves ironclad consistency without complex distributed transaction protocols.
You can inspect the full Java 21 implementation, Flyway migrations, and Testcontainers test suite on GitHub: github.com/Akinmoldun/LedgerX.
Top comments (0)