Domain events: design and schema evolution
How to shape the events services publish, and how to change them without breaking consumers.
key idea / coordinatorservice / good pathin progress / watch outproblem / failureneutral
Events sized for their consumers
How much should an event carry? Three common answers, shown with an order whose street number changed.
A town crier event forces the inventory service to ask the order service for the rest of the address. That is a synchronous dependency, and it may even read stale data. Two ways out:
| Option | What happens | Cost |
|---|---|---|
| Bee events (event-carried state transfer) | Consumers keep their own copy of the data from every event | Storage, and copies spread. A new service can rebuild by replaying the stream |
| Goldilocks event | Producer adds the extra fields the consumers need | Producer must know its consumers; do not add data from another domain |
| Documents on a compacted topic | Latest document per key stays on the log | Consumers lose the meaning of the change |
From Practical Event-Driven Microservices Architecture (ch. 3 and ch. 8).
How big should an event be?
Event size is a design choice with real trade-offs. The section above introduces it; here are the four named patterns. From Practical Event-Driven Microservices Architecture, ch. 8.
| Pattern | Idea | Trade-off |
|---|---|---|
| Town crier | Small notification, consumer fetches the rest. | Easy and quick, but a synchronous dependency returns. |
| Bee | Consumers store the event data locally. | More aligned with events; each service keeps a view. |
| Goldilocks | Event carries exactly what the consumer needs. | Best fit when you control the schema. |
| Denormalized | A process manager merges streams into one larger event. | Consumer sees one stream instead of several. |
Event metadata and translation
Every event has a payload about the business fact. Information that every event shares, such as ids and times, belongs in headers or an envelope, like HTTP headers beside a body.
Headers are optional and flexible. An envelope is a wrapper with a fixed shape, so it can force fields to be present, but changing it hurts every event. CloudEvents is a spec that standardizes this kind of data.
Between contexts, an anti-corruption layer translates events so one context's concepts do not leak into another. It keeps only what the inside needs.
From Practical Event-Driven Microservices Architecture (ch. 3 and ch. 8).
Headers, envelopes and contextual information
Events need metadata so consumers can trace, order and route them without opening the payload. You can carry it in broker headers or wrap the payload in an envelope. From Practical Event-Driven Microservices Architecture, ch. 8.
| Metadata | What it is for |
|---|---|
| Event id | Unique per event. Lets a consumer detect duplicates (lesson 06). |
| Correlation id | Ties together every event caused by one request, for tracing across services. |
| Source and timestamp | Who published it and when, which helps debugging and ordering checks. |
| Version | Tells consumers which schema the body follows. |
| Priority and user id | Priority routes urgent events to a faster queue. User id supports auditing and erasure. |
Evolving schemas without breaking consumers
Events live in the broker for a long time, so every schema change must respect old consumers and old events. Lesson 06 shows the basics; this is the compatibility vocabulary. From Practical Event-Driven Microservices Architecture, ch. 8.
| Compatibility | Meaning | Upgrade first |
|---|---|---|
| Backward | New schema can read data written with the old one. | Consumers |
| Forward | Old schema can read data written with the new one. | Producers |
| Full | Both directions work. | Any order |
| None | Neither works. Needs a coordinated cutover. | Both together |
Choosing and evolving the event schema
Events live a long time, so their schema is as hard to change as an API. Four compatibility types describe a change:
| Type | Meaning | Example change |
|---|---|---|
| Backward | New schema reads old events | Remove UserName |
| Forward | Old schema reads new events | Add a country field |
| Full | Both directions | Add description with a default value |
| None | Neither can read the other | Change UserId to a UUID |
Full compatibility needs default values, which Avro and Protobuf support and plain JSON does not. A schema registry can enforce these rules.
An incompatible change can be split into two safe ones. To turn an address string into a structured object:
For a deep redesign, publish each schema version on its own stream. Producers write v1 and v2 side by side, consumers move when ready, and old events are copied into the new stream. The cost is making sure both streams get the same events.
A downscaler is a separate component that turns new events back into the old shape. The domain service stays clean, and you switch the downscaler off once every consumer has moved. An upscaler does the reverse.
From Practical Event-Driven Microservices Architecture (ch. 8, Schema Evolution).
Recap
- Size events for their consumers: town crier, Goldilocks or document. Avoid forcing consumers to call back.
- Put shared metadata (ids, version, time) in headers, and use an anti-corruption layer between contexts.
- Put id, correlation id, source, timestamp and version in headers or an envelope. CloudEvents is the standard to follow.
- Choose event size on purpose: town crier, bee, Goldilocks or denormalized. Prefer bee over town crier when you cannot change the event.
- Evolve schemas with compatibility rules, two-step changes and, if needed, upscalers and downscalers.
- Plan schema change: know backward, forward and full compatibility, split breaking changes in two, and use versioned streams or a downscaler.
Quick check
Show answer
Have the order service publish a richer OrderAddressChanged event with the whole address, or let the inventory service keep its own copy of the address built from every order event (a bee event).
Show answer
It now depends synchronously on the order service and fails with it. Options: enrich the event (Goldilocks), keep a local copy (bee), or merge streams with a process manager.
Show answer
Not directly. Add the replacement first, publish both versions, move consumers over, then drop the old field.
Show answer
Add the new structured field next to the old one (a forward compatible change), let consumers migrate, then remove the old field (a backward compatible change) after an agreed deadline.