Event-Driven Microservices
Designing events that last: domain versus integration events, Avro and schema registry compatibility, event sourcing basics, ordering guarantees and schema evolution.
On this page
Event-driven architecture decouples services in time and in knowledge: a producer does not wait for consumers and does not know they exist. That freedom is real, and it moves the difficulty into schema design and debugging.
Key Takeaways
- Domain events stay inside a bounded context; integration events are a published contract.
- An integration event is an API — version it, document it, and never break it casually.
- A schema registry turns an incompatible change into a build failure instead of a production one.
- Ordering is per partition key. Choose the key as deliberately as you would a primary key.
- Event-driven and event-sourced are different decisions.
Two kinds of event
The distinction has real consequences. A domain event is internal — it can carry your entity types, change shape in the same commit as its handler, and needs no versioning. An integration event crosses a boundary, so it needs its own type, a stable schema, documentation, and the same change discipline as a REST endpoint.
Publishing domain events directly is a common and expensive mistake: your internal model becomes every consumer's dependency, and refactoring an entity breaks three other teams.
// Internal — free to change alongside its handlers.
record OrderLineAdded(Order order, OrderLine line) { }
// Published — a contract. Primitives and value objects only, never entities.
record OrderPlaced(
String eventId,
String orderId,
String customerId,
long totalMinorUnits,
String currency,
List<Line> lines,
Instant occurredAt) {
record Line(String sku, int quantity, long unitPriceMinorUnits) { }
}Schema and compatibility
Avro with a schema registry gives compact binary encoding plus enforced compatibility:
{
"type": "record",
"name": "OrderPlaced",
"namespace": "com.acme.orders.events.v1",
"fields": [
{ "name": "eventId", "type": "string" },
{ "name": "orderId", "type": "string" },
{ "name": "customerId", "type": "string" },
{ "name": "totalMinorUnits", "type": "long" },
{ "name": "currency", "type": "string" },
{ "name": "occurredAt", "type": { "type": "long", "logicalType": "timestamp-millis" } },
{ "name": "channel", "type": ["null", "string"], "default": null }
]
}That last field shows the rule for safe evolution: a new field must be nullable with a default. A consumer on the old schema ignores it; a consumer on the new schema reading an old message gets the default.
| Compatibility mode | Allows | Use when |
|---|---|---|
BACKWARD | New schema reads old data | Consumers upgrade first (default) |
FORWARD | Old schema reads new data | Producers upgrade first |
FULL | Both | You cannot control upgrade order |
NONE | Anything | Never, in production |
Set the registry to FULL for events crossing team boundaries. It costs a little design discipline
and removes an entire category of incident.
spring:
kafka:
producer:
value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
properties:
schema.registry.url: http://schema-registry:8081
auto.register.schemas: false # register via CI, not from production code
consumer:
value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
properties:
specific.avro.reader: trueauto.register.schemas: false is worth setting. With it on, a service can register an incompatible
schema at runtime the first time it publishes — which means the compatibility check happens in
production instead of in CI.
Ordering
Kafka orders per partition, and the key decides the partition. Keying by orderId means every event
for one order is processed in sequence while different orders proceed in parallel — almost always the
right granularity.
Two things break this and are worth knowing. Adding partitions changes the key-to-partition
mapping, so events for an existing key can land in a different partition and arrive out of order
relative to their history. And retrying to a separate topic, as @RetryableTopic does, means a
retried event lands after events that came behind it.
Where global ordering genuinely matters, a single partition is the only answer and parallelism is gone. Before accepting that, check whether per-entity ordering is actually sufficient — it usually is.
Designing events that last
Four properties make an event survivable across years of change.
Past tense, business language. OrderPlaced, not OrderTableUpdated. The name should mean
something to a domain expert, because the event is a business fact, not a database trigger.
A unique id and a timestamp. The id enables deduplication; the timestamp enables late-arrival detection and ordering checks.
Primitives at the boundary. No entity types, no framework classes, no enums whose values you might reorder. Money as minor units plus a currency code, never a floating-point number.
Self-contained enough to be useful. A consumer that must call back for every field has gained nothing from the event. One that receives your whole model is coupled to it. Ask consumers what they need and include that.
Breaking changes
When a change cannot be made compatibly, do not modify the existing event. Publish a new type alongside it:
// v1 still published for existing consumers.
outbox.publish(new OrderPlacedV1(orderId, customerId, totalMinorUnits));
// v2 published in parallel with the new shape.
outbox.publish(new OrderPlacedV2(orderId, customerId, money, channel, lines));Run both, track consumption per version, contact the remaining v1 consumers, and retire v1 on an announced date. It is exactly the HTTP API deprecation process, and for the same reasons.
Event sourcing, briefly
Event sourcing goes further: instead of storing current state, you store the sequence of events and derive state by replaying them. Every past state is reconstructible, the audit trail is complete by construction, and temporal queries become natural.
The costs are substantial and often underestimated. Querying requires projections you build and maintain. Schema evolution now applies to events stored forever, not just in flight. Fixing a bug means either a compensating event or a rewrite of history, both awkward. And most developers have not worked this way, so the whole team pays a learning cost.
Use it where the history genuinely is the product — ledgers, audit systems, anything regulated. For an ordinary service, publishing events from a state-based model with an outbox gives you most of the integration benefit at a fraction of the cost.
What to take away
Separate internal domain events from published integration events, and treat the published ones as
APIs. Use a schema registry with FULL compatibility so incompatible changes fail in CI. Key by the
entity whose ordering matters. And keep event-driven and event-sourced as separate decisions — you
probably want the first without the second.
Frequently Asked Questions
Should events carry full state or just an identifier?
Do I need a schema registry?
Is event sourcing required for event-driven architecture?
Related tutorials
- Distributed Transactions & Saga PatternsWhy two-phase commit fails in microservices, choreography versus orchestration sagas, compensating transactions, the transactional outbox, and idempotent consumers.
- Microservices Testing StrategiesA testing strategy for distributed systems: where the pyramid changes shape, consumer-driven contract testing, component tests with Testcontainers, and why end-to-end tests fail you.
- Distributed Tracing & ObservabilityDistributed tracing that actually helps: spans and trace context, W3C propagation across HTTP and messaging, sampling strategies, and correlating traces with logs and metrics.
- Containerizing Spring Boot with DockerBuilding small, fast, secure Spring Boot images: multi-stage builds, BuildKit cache mounts, layered jars, JVM container awareness, distroless bases and vulnerability scanning.