Reliable Kafka Integration Between Bounded Contexts
Outbox, inbox, idempotency, ordering, and the reality of at-least-once delivery.
In the previous post, we looked at how to design integration events that age well.
The main idea was simple:
Integration events are public contracts.
They should be intentional, versioned, useful to consumers, and free from private implementation details.
But once we have a good integration event, the next question appears immediately:
How do we publish and consume it reliably?
This is where many Kafka-based systems become fragile. Not because Kafka is weak (it’s great as a durable, partitioned, replayable log), but because it does not automatically make a business transaction reliable across multiple bounded contexts.
For example, Kafka does not know that
a membership was activated.
Billing must issue an invoice.
Sending the same email twice is bad.
Kafka only transports records; your bounded contexts still need explicit reliability patterns around it.
The Basic Flow
The first step is to activate the membership and publish the integration event to Kafka.
@Transactional
public void activateMembership(UUID membershipId) {
membershipRepository.activate(membershipId);
kafkaTemplate.send(
"membership.integration-events.v1",
membershipId.toString(),
membershipActivatedJson
);
}
At first glance, this looks fine: we update the database, then we publish an event.
But there is a failure window: what happens if the database commit succeeds, but the application crashes before Kafka receives the event?
Membership is active in the database.
But no MembershipActivatedV1 event exists in Kafka.That means:
Billing never issues the invoice.
Notifications never send the welcome email.
Downstream projections are wrong.
The reverse order is not safe either: if we publish to Kafka first and then save to the database, consumers may react to an event for a state change that never actually committed. That creates a ghost event.
Both variants are broken because we are trying to commit one logical business action across two independent systems:
The service database
Kafka
Without a shared transaction, this is the dual-write problem.
Transactional Outbox
An often chosen solution to this problem is the transactional outbox pattern.
Instead of writing to the database and Kafka in the same use case, we write two things into the same database transaction:
The business state change
The intent to publish an integration event
So the transaction becomes:
Activate membership
Persist membership state
Insert outbox row for MembershipActivatedV1
CommitNow we have one atomic database transaction: either both are saved, or neither is saved. The outbox row is not “the event already published to Kafka.” It is the durable intent to publish. A separate relay process reads unpublished outbox rows, publishes them to Kafka, and marks them as published.
@Scheduled(fixedDelay = 500)
@Transactional
public void publishOutbox() {
var messages = outboxRepository.findNextBatch();
for (var message : messages) {
kafkaTemplate
.send(
message.topic(),
message.aggregateId().toString(),
message.payload()
)
.get();
message.markPublished();
}
}For aggregate-related events, a good default is:
Kafka key = aggregateIdThat keeps all events for the same aggregate on the same Kafka partition. And Kafka preserves ordering within a partition.
The Outbox Does Not Mean Exactly Once
The outbox removes the dangerous failure window between the database and Kafka, but it does not make the whole system exactly-once.
Consider this:
The relay publishes an event to Kafka.
Kafka acknowledges the write.
The relay crashes before marking the outbox row as published.
After restart, the relay sees the row as unpublished and sends it again. So the same event may be published more than once.
That means that even though we can reduce duplicate publishing on the producer side, we still need to ensure on the consumer side that we can handle duplicates.
This is where the inbox pattern comes in.
Inbox Pattern
Kafka consumers usually operate with at-least-once delivery.
That means:
An event will be delivered, but it may be delivered more than once.
A consumer may successfully write to its database, but crash before committing the Kafka offset. From Kafka’s perspective, the message was not fully processed. So Kafka redelivers it.
Example:
Billing consumes MembershipActivatedV1
Billing creates invoice
Billing commits DB transaction
Billing crashes before committing Kafka offset
Kafka redelivers MembershipActivatedV1Without protection, Billing may issue the same invoice again.
The inbox pattern solves this at the consumer boundary. Each bounded context stores the event IDs it has already processed.
create table inbox_message (
event_id uuid primary key,
received_at timestamptz not null
);Then the consumer handles the event inside a database transaction:
@KafkaListener(topics = "membership.integration-events.v1")
@Transactional
public void on(String payload) {
var event = parse(payload);
if (!inbox.tryRegister(event.eventId())) {
return;
}
billing.handle(new IssueInvoice(
event.membershipId(),
event.memberId(),
event.termId()
));
}
The first delivery inserts the event ID and continues. A duplicate delivery tries to insert the same event ID, sees that it already exists, and stops.
First delivery → process
Second delivery → skipTo summarise, a safe cross-bounded-context flow usually looks something like this:
Idempotency Has Layers
Inbox deduplication is necessary, but it is not the whole story. Reliable systems usually need idempotency at three levels:
Transport idempotency
Domain idempotency
Side-effect idempotency
Transport idempotency is the inbox. It prevents the same event from being handled twice by the same consumer pipeline.
Domain idempotency means the domain model still protects its own rules. Even if the inbox fails, or someone replays an event manually, the aggregate should not enter an invalid state. For example, Billing should prevent issuing two invoices for the same membership term. That can be enforced in the domain model, or with a database constraint such as:
unique (membership_id, term_id)Side-effect idempotency protects the outside world. Emails, payment calls, shipments, and external APIs need their own protection.
Sending the same email twice might be annoying - charging the same customer twice is much worse.
For external effects, use idempotency keys or sent logs.
paymentProvider.charge(
customerId,
amount,
idempotencyKey = invoiceId
)To summarise:
The inbox protects message handling.
The aggregate (or some business service) protects business correctness.
Side-effect idempotency protects the outside world.
Ordering Is a Separate Concern
The inbox answers this question:
Have I already processed this event?
It does not answer:
Did I process events in the correct order?
Ordering is separate.
Kafka guarantees ordering only within a partition. So the producer must choose the key carefully. For events related to one aggregate, a good default is:
Kafka key = aggregateIdThen events like these go to the same partition:
MembershipActivatedV1
MembershipSuspendedV1
MembershipReactivatedV1Kafka will deliver them in partition order. But this only works if the producer publishes them in the correct order. The outbox relay must not accidentally reorder events for the same aggregate through unordered batching or competing relay instances.
If ordering matters, add a per-aggregate sequence number:
{
"eventId": "7ce4...",
"type": "MembershipSuspendedV1",
"membershipId": "8c12...",
"sequence": 42,
"occurredAt": "2026-07-07T10:15:00Z"
}The consumer can then detect gaps or out-of-order delivery and decide what to do: retry, park the event, or send it to a DLQ.
Inbox handles duplicates - partitioning and sequencing handle ordering.
They are not the same problem.
Closing Thought
Kafka gives us durable transport.
The outbox gives us reliable publication intent.
The inbox gives us duplicate protection.
The domain model gives us business correctness.
Side-effect idempotency protects the outside world.
Ordering requires stable keys and producer discipline.
These are all different concerns.
So instead of saying:
Kafka is reliable.
It is better to say:
Kafka can reliably store and deliver records, but reliable business integration requires explicit patterns around it.
So:
Kafka is not a distributed transaction manager.
Kafka is not your domain model.
Kafka is not your idempotency strategy.
But:
Kafka is the log.
Your bounded contexts still own their consistency.
Want to apply this to your architecture?
If your team already uses Kafka, event-driven architecture, or moduliths / micro services, the hard part is often no longer the infrastructure. It is deciding which facts deserve to become public contracts, where bounded contexts begin and end, and how teams can collaborate without recreating a shared database in a new form.
Codeartify helps software teams work through exactly these questions in practical, hands-on formats:
A 15 mins free call to discuss where we could be of service in your Kafka-based system: codeartify.com/booking.
Not sure whether DDD fits your Kafka-based system? We offer a 1 - 3 hour decision workshop on exactly that topic: codeartify.com/decision-workshops.
Request a tailored in-house workshop for Kafka, DDD, EventStorming, bounded contexts, or event-driven architecture: codeartify.com/custom-workshop.
We also offer a ready-made yet customisable workshop including Kafka, Kotlin/Java, DDD, and Axon: codeartify.com/event-sourcing
Download practical architecture resources, including cheat sheets on event-sourced DDD systems and AI Agent Skills: codeartify.com/downloads
Want to learn the basics of EventStorming and DDD? Checkout our O’Reilly e-learning course: codeartify.com/elearning
Kafka can connect bounded contexts. The design work is making sure it does not accidentally replace them. That’s why Domain-Driven Design is a necessity.


