Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Event-Driven Microservices

Designing events that last: domain versus integration events, Avro and schema registry compatibility, event sourcing basics, ordering guarantees and schema evolution.

Advanced6 min readUpdated
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

Domain events are internal detail and may change freely. Integration events are published contracts and may not.

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.

EventTypes.java
// 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:

OrderPlaced.avsc
{
  "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 modeAllowsUse when
BACKWARDNew schema reads old dataConsumers upgrade first (default)
FORWARDOld schema reads new dataProducers upgrade first
FULLBothYou cannot control upgrade order
NONEAnythingNever, 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.

application.yml
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: true

auto.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:

Versioning.java
// 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?
A thin event with an id forces every consumer to call back, which recreates the coupling you were removing and hammers the producer. A fat event duplicates your model everywhere and makes every field change a breaking one. The workable middle is the id plus the few fields consumers demonstrably need, decided by asking them.
Do I need a schema registry?
Once more than one team consumes your events, yes. It turns "we changed the payload and three consumers broke" into a build failure at the producer. With JSON and no registry, the contract exists only in documentation and nobody notices when it drifts.
Is event sourcing required for event-driven architecture?
No, and conflating them causes a lot of unnecessary complexity. Event-driven means services communicate by publishing events. Event sourcing means the event log is your system of record instead of a state table. You can do the first without the second, and most systems should.

Related tutorials