Everyone states the requirement for a pricing and billing platform the same way: calculate the right price. That is not the hard part. The hard part is that the number has to be reproducible in front of an auditor eleven months later, the platform has to keep pace with a core banking system that will not slow down for you, and the pricing rules themselves are being edited by product teams while both of those things are happening.
I architected the Corporate Pricing Engine at Zafin against those three constraints — an event-driven pricing and billing platform on Spring Boot, Kafka, PostgreSQL and domain-driven design, processing 2M+ transactions a day at sub-second latency. What follows is where the decisions were less obvious than the architecture diagram makes them look.
Why event-driven, and not a pricing service you call
The obvious design is synchronous. Core banking posts a transaction, calls POST /price, waits, gets a number, writes it down. It is easier to reason about and for a low-volume internal tool I would still build it that way.
It fails here on two counts. The first is availability coupling: if pricing is a synchronous dependency of transaction posting, then every pricing deployment, every slow query and every long GC pause is a core banking incident. The second is subtler and matters more in a regulated domain — a synchronous call leaves you with a result and no durable record of the question. When someone asks in March why a corporate client was charged a particular fee in the previous fiscal year, “we called the pricing service and it returned 4.5 basis points” is not an answer anybody can act on.
Publishing the transaction as an event onto a Kafka topic addresses both. Kafka's log is an ordered, retained, replayable record of what was asked: a partition is an immutable, append-only sequence and the unit of both ordering and parallelism, and the broker retains a record independently of whether any consumer has processed it. The pricing engine becomes a consumer rather than a dependency. If it stops for ten minutes, consumer offsets stay where they are, the log holds the backlog, and the engine catches up. Core banking never notices.
The command side and the query side want different databases
Pricing writes and pricing reads have almost nothing in common. A write is one transaction against one agreement, guarded by invariants — is this product actually in this client's arrangement, is the tier still valid on this date, does the discount breach the floor. A read is “show me every fee this relationship accrued this quarter, grouped by product, with the rule version that produced each one.” Normalising a schema for the first makes the second a nine-way join; denormalising for the second makes every write a fan-out.
CQRS is the standard answer and it earns its complexity here, because the split is genuine rather than imposed: the write model holds aggregates and enforces invariants, and a projector builds a separate read model shaped for the questions the UI and the reporting APIs actually ask. The event log between them is what makes the read model disposable — if a projection is wrong, you fix the projector and replay, rather than writing a migration that tries to repair derived state in place.
The outbox row and the aggregate change go in the same database transaction. That detail is what stops the classic dual-write bug where the row commits and the Kafka publish fails, or the publish succeeds and the transaction rolls back. A separate relay reads committed outbox rows and publishes them, so the only thing the business transaction has to be atomic over is one PostgreSQL commit — which it already is, under PostgreSQL's isolation levels.
Domain-driven design earns its keep when the rules change weekly
Pricing logic is the kind of domain where an anaemic model rots fast. Tiered rates, relationship-level discounts, product bundles, floors and caps, effective-dated rule versions — express that as service methods reading flat rows and within two quarters you have the same eligibility check implemented four slightly different ways in four services, and nobody can tell you which is authoritative.
Putting the invariants inside aggregates, with bounded contexts drawn around agreement management, rate calculation and invoicing, is what keeps that from happening. The boundaries also gave us the deployment seams: three contexts, three services, three release cadences, and a product team that can ship a change to invoicing without a regression run across rate calculation.
Sub-second at 2M+ a day is a partitioning problem, not a tuning problem
2M+ transactions a day averages out to roughly 25 a second, which sounds trivial and is not, because the load is not average. Corporate billing arrives in bursts tied to cycle boundaries, and the latency target has to hold during the burst rather than on the daily mean.
Partition key choice is most of the answer. Keying the commands topic by customer identifier gives strict ordering per customer — two transactions on the same agreement can never be priced out of sequence — while letting throughput scale with partition count, since Kafka parallelises consumption across partitions and not within one. It also makes the hot-key problem explicit rather than emergent: a single very large corporate client concentrates on one partition, so you find out at design time that you need a secondary key component for the largest relationships, instead of finding out during a cycle run.
On the correctness side, the pricing consumer has to be idempotent regardless of what the producer does, because Kafka's default delivery guarantee is at-least-once and a rebalance can redeliver a batch. We leaned on the idempotent producer to remove duplicates introduced by producer retries, and on a natural dedupe key on the consumer side for everything else — because the delivery semantics documentation is clear that end-to-end exactly-once is a property of the whole pipeline, not a flag you turn on at one end.
The batch half nobody puts in the diagram
Pricing platforms in banking always have a batch side: reference data, rate cards, client hierarchies and balance snapshots arriving as files on a schedule. Ours was reloading far more than it needed to, because a full reload is the easy thing to write and the expensive thing to run.
Rebuilding it as delta processing — compare against the last loaded state, emit only what actually changed, partition the resulting work across Kafka so the load parallelises instead of running as one long single-threaded job — cut data-load time by 60%. Spring Batch's chunk-oriented processing is a good fit for the shape of this: read a chunk, process it, write it in one transaction, commit, repeat, with restartability that matters a great deal the first time a four-hour load dies at hour three.
The non-obvious win was not the runtime. It was that a delta load makes the change set inspectable. “Nine hundred rate rows changed in this load, here they are” is a reviewable artefact; “we reloaded everything” is not.
Going live at a bank is a different discipline from building
I was technical SPOC for the first production go-live of Deal Manager for ING Bank, which held 99.9% uptime on day one. Very little of what made that work was architecture. It was having one named person who owned the end-to-end picture across the client's teams and ours, a rollback that had actually been executed in a rehearsal rather than documented, and dashboards agreed with the client beforehand so that “is it healthy?” had a single answer both sides were looking at.
The same period is where the engineering-practice work paid off. Mentoring 10+ engineers through consistent SOLID and CQRS adoption cut defect leakage by 40% — not because the principles are magic, but because a team that agrees on where behaviour belongs stops producing the category of bug where two services disagree about who owns a rule.
What an AI copilot adds once the event log exists
The last piece built on top of the platform was an AI-powered pricing and billing copilot, which cut L1 resolution time by 50%. The interesting part is not the chatbot. It is that most L1 pricing tickets are the same question — “why was this client charged this amount?” — and an event-sourced platform can actually answer it, because the command that arrived, the rule version in effect, and the resulting billing event are all durably recorded. A copilot over a system with no event log would have to guess. A copilot over this one retrieves.
That is the throughline for the whole design: the event log was adopted for auditability and availability, and it kept paying for itself in places that were not on the original requirements list — replayable projections, inspectable batch deltas, and eventually a support tool that works because the history is already there.