I'm building OrderHub, an event-driven e-commerce backend, one service at a time. Day 26 added the first consumer — inventory-service reserves stock and stops. Day 27 closes the loop: payment-service is the first service that is fully event-driven both ways. It subscribes to order-placed, decides whether to charge, and then publishes a new event of its own, PaymentProcessed, to a brand-new payment-events topic. "Consume a fact, answer with a fact" is the atom of a choreography saga, and here's how it's wired in Spring Boot.
A new service that changes nothing about the old ones
payment-service is a whole new Boot app in the monorepo, standing beside order-service and inventory-service — its own module in the reactor pom, its own main class, its own port. But the point is what didn't change: the two existing services don't call it, don't import it, don't know it exists. payment-service joins the system purely by subscribing to a topic that already exists and publishing to a new one. That's the promise of event-driven architecture made concrete — you extend the system by adding a reactor, not by editing the actors already in it.
Its own consumer group — a second copy of every event
payment-service subscribes to the same order-placed topic inventory-service already consumes. Both services receive every event because of consumer groups: Kafka gives each group its own independent cursor over the topic, so a message is delivered once per group, not once total. Inventory is in group inventory-service; payment is in group payment-service. They never steal messages from each other — each gets the full stream and tracks its own offsets. This is exactly why events beat point-to-point calls for fan-out: order-service published one fact, and now two services react to it, each on its own schedule, with the producer none the wiser.
@KafkaListener(
topics = "${payment.events.order-placed-topic:order-placed}",
groupId = "${payment.events.consumer-group-id:payment-service}", // its OWN group
containerFactory = "orderEventListenerContainerFactory",
autoStartup = "${payment.events.enabled:true}")
Idempotency — for money, exactly-once processing is not optional
Kafka guarantees at-least-once delivery, never exactly-once: after a rebalance, a retry, or a crash between processing a record and committing its offset, the same OrderPlaced can arrive again. For a stock reservation a double-process over-commits inventory; for a payment it charges the customer twice — the single worst bug this system could ship. So payment-service keys on the stable business id (the order id) and claims it atomically with putIfAbsent — true only for the first caller, false for every redelivery — before any charge is made and before any result is emitted. A second, contradictory PaymentProcessed would be almost as bad as the double charge itself.
public boolean claim(String orderId){
return claimed.putIfAbsent(orderId, TRUE) == null; // true only the FIRST time
}
// in the listener, BEFORE charging:
if (!ledger.claim(event.orderId())) {
log.info("Duplicate OrderPlaced for {} — already charged, skipping", event.orderId());
return; // redelivery -> no charge, no second result
}
A decision that stays decided
PaymentService is where a real system would call Stripe. I use a deterministic simulation on purpose — a real gateway is non-deterministic and needs network and secrets, which would make the round-trip test flaky. The rules are pure functions of the order: a test-card customer is always declined, an amount over the threshold is declined as over-limit, everything else is approved. And the Payment domain object enforces its own invariant — it starts PENDING and transitions a single time; approve() and decline() refuse to run on an already-decided payment. Even if a redelivery somehow slipped past the idempotency guard, the object itself would refuse to re-flip the decision. Money fields are BigDecimal throughout — never double for a charge.
public Payment process(String orderId, String customer, BigDecimal amount) {
Payment payment = Payment.pending(orderId, customer, amount);
if (isTestCard(customer)) payment.decline("TEST_CARD_DECLINED");
else if (amount.compareTo(props.declineThreshold()) > 0)
payment.decline("AMOUNT_OVER_LIMIT");
else payment.approve("APPROVED");
return payment; // decided, never PENDING
}
Answer with an event
This is what makes payment-service a full participant rather than a dead-end consumer: after deciding, it publishes a PaymentProcessed result to the new payment-events topic via a typed KafkaTemplate. The event carries the whole outcome plus causedByEventId — the OrderPlaced eventId that triggered it — so the two facts can be correlated end to end. Crucially, publishing runs inside the consumer, so it must never crash the listener: the payment is already recorded, and a hiccup talking to Kafka must not turn that into a redelivered, re-charged order. So the publisher swallows and logs every failure, sync and async, and the consumer's offset commits regardless.
public void publish(PaymentProcessedEvent event){
if (!props.enabled()) return; // tests / broker-less: skip
try {
kafkaTemplate.send(props.paymentEventsTopic(), event.orderId(), event)
.whenComplete((res, ex) -> { // async failure -> log, never rethrow
if (ex != null) log.warn("publish failed for {}: {}", event.orderId(), ex);
});
} catch (Exception ex) { // sync failure (broker down) -> swallow
log.warn("could not publish for {} (decision still recorded): {}", event.orderId(), ex);
}
}
Prove it without a broker
The round-trip test is hermetic — no Docker. @EmbeddedKafka stands up a throwaway in-JVM broker, and the same producer and consumer code point at it. The test publishes an OrderPlaced and asserts both halves: a Payment is recorded on the ledger with the right status, and a matching PaymentProcessed lands on payment-events, read back by a test consumer. Four cases cover it — approved, over-limit declined, test-card declined, and a duplicate charged exactly once.
Zoom out and the backend now has a genuine event mesh: order-service publishes, inventory and payment both consume (their own groups), and payment publishes a new fact that further services can react to. Every service is decoupled — a new reaction is a new subscriber, not an edit to anyone. That "consume a fact → publish a fact" shape is precisely the setup for the next step, where these independent reactions get chained into a saga: stock-reserved plus payment-approved trigger ship the order, and a decline becomes a compensating cancel — all with no central coordinator.
The interactive walkthrough — publish an order and watch the decision and the emitted event, plus flip idempotency on and off:
https://dev48v.infy.uk/orderhub.php
Top comments (0)