Domain Events Are Not Integration Events
How bounded contexts communicate through infrastructure like Kafka without leaking their internal models to the world
In the previous post, I argued that Kafka is not a global variable. Kafka should not become the place where every service publishes every internal event “just in case someone might need it”, because that creates a kind of shared database.
But that leaves an important question still open:
If we should not publish everything, what should a bounded context actually publish?
The answer starts with a distinction that is easy to miss:
Domain events and integration events are not the same thing.
They are related. But they have different audiences, different lifecycles, and different design constraints.
Domain events are internal business facts
A domain event is something meaningful that happened inside a bounded context.
For example, in a membership context:
MembershipActivated
MembershipSuspended
MembershipRenewed
MembershipCancelledIn a billing context:
InvoiceIssued
InvoicePaid
InvoiceDueDateExpiredThese events are not database updates. They describe facts in the language of the domain:
MembershipActivatednot:
MembershipUpdated { status: "ACTIVE" }That difference matters.
MembershipUpdated says a row changed.
MembershipActivated says something happened in the business.
A domain event should help the team to reason about the model. It should explain what happened, not just which field changed.
Domain events belong to a bounded context
A bounded context owns its language. The same word can mean different things in different contexts.
A Member in Membership may not be the same concept as a Customer in Billing.
A Plan in Membership may not be the same concept as a Product in Sales.
An Active membership may not mean the same thing as an Active invoice.
That is the point of bounded contexts.
Each context owns:
its own (ubiquitous) language
its own model
its own lifecycles
its own consistency rules
So a domain event is not automatically a public message. It may be useful only inside the context that produced it.
For example, inside Membership you may have events like:
MembershipEligibilityChecked
MembershipTermCalculated
MembershipActivationApproved
MembershipActivated
WelcomePolicyTriggeredSome of these may be useful internally. Most of them should probably not become public Kafka events. Because once another bounded context starts depending on them, they are no longer just internal design choices. They become contracts. And contracts are harder to change.
Integration events are public contracts
An integration event is different. It is a fact one bounded context intentionally publishes for other bounded contexts to consume and react to.
For example:
MembershipActivatedV1
InvoicePaidV1
MembershipSuspendedV1The important part is not the V1 suffix. The important part is that this event is now public:
Other teams may build code against it.
Other services may store it in projections.
Other bounded contexts may use it to trigger their own workflows.
That means an integration event needs a different level of discipline.
It should be:
intentionally published
stable enough for consumers
versioned
owned by the producing context
meaningful without exposing private internals
A domain event says:
This happened inside my model.
An integration event says:
This is a fact I am willing to share with the outside world.
That is a very different promise.
Do not serialise your domain model to Kafka
A common shortcut is to take an internal domain event and publish it directly:
kafkaTemplate.send(
"membership-events",
objectMapper.writeValueAsString(domainEvent)
);This looks simple, but it creates a problem: your internal model has now become your public API.
Every class name, field name, enum value, and modeling decision can become something consumers depend on. That makes the producer harder to change and consumers too aware of the producer’s internal design. That is exactly the kind of coupling bounded contexts are supposed to avoid.
Kafka did not cause the problem, it only made it easy to spread the problem.
Map domain events to integration events
A better approach is to make the boundary explicit: inside the Membership bounded context, the aggregate may emit a domain event:
record MembershipActivated(
MembershipId membershipId,
MemberId memberId,
Term term,
Instant occurredAt
) implements DomainEvent {}This is internal:
It can use domain-specific types.
It can reflect the internal model.
It can change when the model changes.
But the event published to Kafka should be an integration event:
record MembershipActivatedV1(
UUID eventId,
UUID membershipId,
UUID memberId,
UUID termId,
LocalDate termStartDate,
LocalDate termEndDate,
String planId,
Instant occurredAt
) implements IntegrationEvent {}This event is designed for consumers:
It uses a stable shape.
It contains the data other contexts need.
It does not expose the full internal object model.
The mapping step is the architectural boundary:
class MembershipIntegrationEventMapper {
Optional<IntegrationEvent> toIntegrationEvent(DomainEvent event) {
if (event instanceof MembershipActivated e) {
return Optional.of(new MembershipActivatedV1(
UUID.randomUUID(),
e.membershipId().value(),
e.memberId().value(),
e.term().id().value(),
e.term().startDate(),
e.term().endDate(),
e.term().planId().value(),
e.occurredAt()
));
}
return Optional.empty();
}
}This mapper decides what becomes public. Not every domain event needs a corresponding integration event. And not every field from the domain model belongs in the public event.
That decision is part of the design.
Example: Membership activates, Billing reacts
Imagine we have two bounded contexts:
Membership
BillingMembership owns the membership lifecycle, Billing owns invoices, payment state, and billing rules. When a membership is activated, Billing may need to issue an invoice.
A naive approach would be:
Membership calls Billing directlyThat works, but it creates runtime coupling:
Membership now needs Billing to be available at the right moment.
Billing failures can affect Membership activation.
The two contexts become tied together through a synchronous workflow.
With Kafka, we can decouple the two contexts:
MembershipActivatedV1 published
Billing consumes it
Billing issues invoice in its own modelBut the important part is not Kafka, it’s the boundary: Membership does not tell Billing how to model invoices. Billing does not reach into the Membership database.
Membership simply publishes a public fact:
MembershipActivatedV1Billing translates that fact into its own command:
IssueInvoiceThat keeps the models separate.
Kafka replay is useful but only with good events
Kafka’s replayability is one of its most useful properties. A new bounded context can start consuming historical integration events and build its own projection:
A broken read model can be rebuilt.
A consumer can reprocess events after a bug fix.
That is powerful. But replay only helps if the events are worth replaying.
If the topic contains:
Accidental internal details, replay spreads accidental coupling.
Database patches, consumers must infer business meaning.
Unstable internal events, every replay depends on old implementation choices.
So the question is not only:
Can we replay the events?The better question is:
Are these events intentionally designed public facts?Kafka gives you the log. DDD helps you decide what belongs in it.
A practical rule of thumb
When a domain event is emitted, ask:
Is this fact meaningful outside this bounded context?If no, keep it internal.
If yes, ask:
What is the stable integration event we want to publish?Then design that event as a contract. Not as a class dump, database diff, or convenience for the producer.
But as a fact that other contexts can safely depend on.
The main distinction
Domain events are how a bounded context talks to itself. They can be rich, internal, and model-specific
Integration events are how it talks to the outside world. They should be stable, intentional, and public.
Kafka should transport integration events between bounded contexts. It should not expose every internal domain event. That is how we keep the benefits of event-driven architecture without turning Kafka into a global variable again.
Want to apply this to your architecture?
If your team is already using Kafka, event-driven architecture, or microservices, 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 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.

