Designing Integration Events That Age Well
Payloads, versioning, schemas, and the difference between useful facts and accidental coupling
In the previous post, we looked at the difference between domain events and integration events:
A domain event belongs inside one bounded context.
An integration event is a public contract. Other bounded contexts may consume it, store it, replay it, and build their own local models around it.
That means the next question is not only:
Which events should we publish?
The next question is:
How do we design integration events so they remain useful over time?
Because once an event is public, it is no longer just a piece of implementation detail. It becomes something other teams can depend on.
Integration events are APIs
Most teams understand that HTTP APIs need design discipline.
We think about:
endpoints
request and response shapes
status codes
versioning
backward compatibility
ownership
But with Kafka, teams sometimes forget that events are also APIs: the shape of an integration event is a contract.
If Billing consumes MembershipActivatedV1, builds a projection from it, and uses it to issue invoices, then the Membership context cannot casually rename fields, change meanings, remove data, or publish a different structure tomorrow.
The event may be asynchronous, but the contract is still real.
I’d argue integration events need much more discipline than synchronous APIs. An HTTP API usually has a visible caller. If you change it, you often know who will break.
With Kafka, consumers may be less visible. Some may only consume the event later. Some may replay old events. Some may build projections that depend on the event history. That makes event design a long-term responsibility.
The wrong default: “just publish the object”
A common shortcut is to publish whatever object already exists inside the producer:
record MembershipActivated(
MembershipId membershipId,
MemberId memberId,
Term term,
Instant occurredAt
) implements DomainEvent {}
This may be a good internal domain event, but it is not automatically a good integration event, because it exposes the internal model:
MembershipIdMemberIdTerminternal value objects
internal lifecycle decisions
internal naming
That may be perfectly fine inside the Membership bounded context, but the moment you publish this directly to Kafka, these internal choices become outside knowledge: other contexts start to depend on them. And once they do, you cannot change your internal model anymore freely.
A better question: what do consumers need to know?
Instead of asking:
What object do we already have?
Ask:
What stable business fact do we want to share with others?
For example, after a membership has been activated, Billing may need enough information to issue an invoice, so it may ask for:
the membership id
the member id
the term id
the term start date
the term end date
the plan id
when the activation happened
So the integration event could look like this:
record MembershipActivatedV1(
UUID eventId,
UUID membershipId,
UUID memberId,
UUID termId,
LocalDate termStartDate,
LocalDate termEndDate,
String planId,
Instant occurredAt
) {}
This is not just a serialised domain object. It is a designed contract. It contains enough information for another bounded context to react without directly having to reach back into the producer’s database or internal model. That is the point.
Notification events vs event-carried state transfer
There are two common styles of integration events.
The first is a notification event. It says:
Something happened. Call me if you need more details.
Example:
{
"type": "MembershipActivatedNotificationV1",
"eventId": "2f3e...",
"membershipId": "7a91...",
"occurredAt": "2026-06-19T10:15:00Z"
}
The consumer receives the event and then calls the Membership API to fetch the current membership details. This can be useful, but it creates runtime coupling:
the producer API must be available
the consumer depends on the producer’s API contract
the consumer may receive newer state than the event actually referred to
a replay may not reconstruct the same result if the API now returns different data
So notification events are simple, but they often shift the coupling somewhere else.
The second style is event-carried state transfer.
It says:
Something happened, and here is the relevant state you need to act on it.
Example:
{
"type": "MembershipActivatedV1",
"eventId": "2f3e...",
"membershipId": "7a91...",
"memberId": "42b8...",
"termId": "c91a...",
"termStartDate": "2026-06-01",
"termEndDate": "2027-05-31",
"planId": "premium",
"occurredAt": "2026-06-19T10:15:00Z"
}
Now Billing can consume the event and build its own local projection. It does not need to call Membership just to understand what happened, which improves autonomy.
Of course, the trade-off is that the event payload is larger. But in many bounded-context integrations, that is a good trade.
The question is not:
How do we make the event as small as possible?
The better question is:
Does this event contain enough stable information for consumers to act independently?
Do not publish private details
An integration event should contain enough information to be useful without exposing everything. There is a difference between useful business facts and private implementation details.
Useful:
{
"membershipId": "...",
"memberId": "...",
"termStartDate": "2026-06-01",
"termEndDate": "2027-05-31",
"planId": "premium"
}
Suspicious:
{
"membershipJpaEntityVersion": 12,
"internalStatusCode": 7,
"activationPolicyClassName": "DefaultActivationPolicy",
"hibernateLazyLoadedTerm": true
}
Consumers should not need to know how the producer stores the data, which ORM it uses, which internal status code it chose, or which internal policy class was involved. That information may change when the producer refactors its model. And a refactoring inside one bounded context should not break other bounded contexts.
That’s why we have these boundaries in the first place.
Event names should express business meaning
A good integration event name should tell consumers what happened in the business.
Prefer:
MembershipActivatedV1
MembershipSuspendedV1
MembershipRenewedV1
InvoiceIssuedV1
InvoicePaidV1Avoid:
MembershipUpdatedV1
MembershipChangedV1
StatusChangedV1
EntityModifiedV1The vague names are often symptoms of technical event design. They say that data changed, but they do not explain why. For consumers, that difference matters.
If Billing receives:
MembershipUpdatedV1 { status: "ACTIVE" }it has to infer what happened:
Was the membership newly activated?
Was it reactivated after suspension?
Was it corrected by an admin?
Was it migrated from another system?
Those are all different business facts that may lead to different reactions. A good event should reduce interpretation, not force every consumer to reverse-engineer intent.
Versioning is part of the contract
Integration events should be versioned deliberately.
A simple convention is to include the version in the event type:
MembershipActivatedV1
MembershipActivatedV2The exact naming convention matters less than the discipline behind it.
The important rules are:
prefer additive changes
do not remove fields consumers may depend on
do not silently change the meaning of a field
do not reuse the same event type for a different semantic contract
keep old versions alive long enough for consumers to migrate
For example, adding an optional field is usually safe:
{
"type": "MembershipActivatedV1",
"membershipId": "...",
"memberId": "...",
"planId": "premium",
"salesChannel": "online"
}But changing the meaning of an existing field is dangerous: If planId used to mean “contractual plan selected by the member” and now means “internal product package used for billing calculation,” that is not a harmless change. Even if the field name stayed the same, the contract changed, so consumers may now behave incorrectly.
Schema Registry helps, but it does not design the event
In Kafka ecosystems, Schema Registry is often used with Avro, Protobuf, or JSON Schema.
That is useful because it can:
enforce compatibility rules.
prevent certain breaking changes.
make event structures explicit.
But Schema Registry does not decide whether an event is meaningful.
It can
tell you whether a field was removed.
validate a schema
But it cannot
tell you whether
MembershipUpdatedV1is too vague.validate your bounded context boundaries.
While tooling helps to enforce discipline, it does not replace design.
Own your events like you own your APIs
Every integration event should have a clear owner. Usually, the owner is the bounded context that publishes the event. That owner is responsible for:
the meaning of the event
the schema
the versioning strategy
the topic
the compatibility rules
deprecation and migration
documentation for consumers
This does not mean the producer should design events in isolation. Good integration events are often designed through conversation. While the producer owns the contract, consumers help reveal what information is actually needed.
That collaboration is important.
Otherwise, producers either publish too little:
Something happened. Good luck.Or too much:
Here is my entire internal object graph. We now depend on each other tightly. Deal with it. A good integration event sits in the middle: it is intentionally designed for cross-context use.
A practical checklist
Before publishing an integration event, ask:
Is this fact meaningful outside this bounded context?If no, keep it internal.
If yes, ask:
Which consumers need this fact?
What do they need to know to act autonomously?
Can they use this event without calling back into the producer?
Does the event expose private implementation details?
Is the name a business fact or a technical update?
Can we evolve this event without breaking existing consumers?
Who owns this contract?This checklist is not bureaucracy. It is how we avoid turning Kafka into another shared database.
The main takeaway
Integration events are APIs. They are public contracts between bounded contexts.
A good integration event is:
meaningful
stable
versioned
owned
useful without exposing internals
rich enough for consumers to act independently
Kafka can move the event. Schema Registry can validate the structure. But the design responsibility stays with us.
Because event-driven architecture is not created by publishing more messages. It is created by publishing the right facts, with the right boundaries, in a form other contexts can safely depend on.
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.

