Event design: contracts, naming, and schema evolution 🧩
This is the guide you want to have before publishing your first event called “UserUpdated” and discovering that you just created an eternal contract.
Events as contracts (yes, like APIs) 📜
flowchart LR
subgraph Producer
S[Service]
end
S --> E["Event (contract)"]
E --> B[(Broker / Stream)]
B --> C1[Consumer A]
B --> C2[Consumer B]
B --> C3[Consumer C]
In EDA, an event is a contract between systems.
- If you change the payload without thinking about compatibility, you are not breaking a feature: you are breaking an ecosystem.
- If you do not document the event, you are handing the future a very nice puzzle (spoiler: you will solve it at night).
Naming: choose the domain, not the database 🧠
The event name is its most visible contract. Even before the payload, the name tells consumers what happened in the domain. Getting it wrong is expensive: once an event type is in production with real consumers, renaming it is a breaking change.
Prefer names that represent business facts, in the past tense, in English:
- ✅
OrderCreated,EmailChanged,PaymentRejected,InventoryReserved - ❌
OrderRowInserted,UpdateUserTable,SyncCRM,DoPayment
Useful conventions:
- Past tense: the event tells you something that already happened, not a command.
OrderCreatedis a fact;CreateOrderis an intention and belongs to a command, not an event. - Domain.Entity.Fact (optional): in large systems, a domain prefix helps routing and readability in catalogs, such as
payments.PaymentRejectedorinventory.StockDepleted. - Avoid generic names:
UserUpdatedsays nothing useful.UserEmailVerified,UserAddressChanged, orUserAccountSuspendeddescribe precise facts with clear semantics for consumers. - Avoid technical names:
DBSyncEvent,CacheInvalidated,RowDeletedare implementation details wearing an event costume. If they become public, you couple all consumers to your storage.
Practical rule: if the name reveals implementation details or cannot be understood without reading the code, change it before going to production.
Internal vs external events 🔐
flowchart LR
INT["Internal event<br/>(optimized for the domain)"] --> ACL["Anti-corruption layer<br/>(translation/normalization)"]
ACL --> EXT["External/Public event<br/>(stable, documented)"]
Not all events are equal in terms of audience and expected stability:
- Internal events: optimized for a bounded context; they may change more often, reflect the internal model, and do not need to be understandable outside the team. An
payments.InternalLedgerEntryCreatedcan evolve freely as long as it stays inside the payments domain. - External/public events: designed for consumers outside the bounded context or outside the organization; they should be treated like public APIs, with the same rigor, retro-compatibility care, and documentation you would give a public REST endpoint.
The Anti-Corruption Layer (ACL) is the translation layer that separates the two worlds: it takes an internal event, normalizes it, and publishes an external event with a stable and well-defined shape. This allows you to evolve the internal model without breaking external consumers.
Practical advice: even if you are the only consumer today, treat the public channel as if ten teams are already listening. Adding an ACL later, when consumers are already coupled to the internal format, is expensive and painful. Better to separate the layers from the start, even with a simple mapping.
Event types (the ones that actually appear) 🧩
There are no “only events”; there are events with different intentions and trade-offs. Naming these types is not academia: it is a way to avoid infinite discussions about payloads, stability, and coupling.
- Domain/Internal events: they speak the language of the bounded context and may evolve faster. Examples:
PaymentLedgerUpdated,InternalRiskScoreComputed. No one outside the context should depend on them. - Integration/Public events: designed for integrations between bounded contexts or external consumers; stability and documentation come first. Examples:
PaymentCompleted,OrderShipped. Changing them requires an explicit deprecation process. - Delta events: they carry what changed (a diff) instead of repeating the whole state. Useful in change data capture scenarios or when the full payload is too large or too sensitive. The main challenge is that the consumer must reconstruct state by applying deltas in order, which complicates replay and recovery.
- CDC events: derived directly from database changes (binlog, WAL), often via tools such as Debezium. They have the advantage of being generated automatically and atomically with the DB transaction, but they are tightly coupled to the storage schema. If they leave the bounded context without translation, you couple consumers to your internal data model — almost always a mistake.
Notification vs ECST: minimum or rich payload? 📦
The choice here is not between “good” and “bad”; it is between a simpler contract with more callbacks and a richer contract with more responsibilities. The important thing is to make explicit what coupling you are accepting and why.
- Notification: the event carries only a reference such as
{ "orderId": "abc-123" }. The consumer needing details makes a synchronous call to the producer. Advantage: the event contract is small and stable; downside: it reintroduces synchronous dependency and can cause a thundering herd of callbacks at high volume. - Event-Carried State Transfer (ECST): the event carries all relevant state in the payload (for example
{ "orderId": "abc-123", "customerId": 7, "items": [...], "total": 89.90 }). The consumer is autonomous at runtime; downside: every field becomes part of the public contract, schema evolution is more complex, and the payload can become large.
There is no universally correct answer. The real choice is a trade-off between synchronous coupling and contract complexity. The choice should be documented explicitly rather than left to the moment’s intuition.
Explicit vs implicit events 🧭
An event is explicit when it clearly tells the business fact (PaymentRejected, UserEmailVerified); it is implicit when it leaves the consumer guessing (UserUpdated, OrderChanged).
Implicit events seem convenient to produce because you can listen to any change and publish it, but they force consumers into fragile inference: why was the user updated? What changed in the order? Every consumer responds differently, and the event semantics drift silently over time.
Practical rule: if two teams can interpret the same event in two different ways, the name is not explicit enough. Better to have more events with precise names than a single generic event that tries to cover everything.
Schema evolution: how to change without causing damage 🧬
Objective: backward and/or forward compatibility.
Most disasters come from small changes made quickly: renames, type changes, semantics that slowly slip. The guidelines here are intentionally conservative: they aim to preserve compatibility and reduce surprises for consumers.
Conservative guidance:
- add fields as optional (do not change the meaning of existing ones);
- do not change a field’s type (if needed, add a new field);
- avoid “creative” renames (they are disguised breaking changes);
- plan a deprecation strategy (overlap period).
Versioning: events “v1, v2…”? 🏷️
Versioning is useful, but overusing it is an elegant way to have 17 variants in production.
A sustainable approach often looks like this:
- try compatibility first (optional fields);
- use explicit versioning only for unavoidable breaking changes;
- clearly document the support window.
Minimal metadata (which saves you in debugging) 🧵
flowchart TB
ENV[Event envelope] --> META[Metadata<br />- event_id<br />- event_type<br />- occurred_at<br />- producer<br />- correlation_id<br />- causation_id]
ENV --> PAY["Payload<br />(domain data)"]
Practical suggestion: define an internal metadata standard.
Typical fields:
You do not need to invent 40 headers “just because.” A few consistent fields are enough to trace the flow and debug issues.
event_id(unique)event_typeoccurred_atproducercorrelation_idcausation_id
If you want an interoperability standard, consider CloudEvents as a common envelope. It does not solve domain problems, but it avoids everyone reinventing the wheel.
Beware of exposing too much 🕵️
Events that are too rich can expose internal details, PII, or lock consumers to your current data model. It is better to design a clean public event (with ACL/translation when needed) than to publish your table directly as JSON.
Checklist before publishing a new event ✅
This checklist is the “before pressing deploy” version of the whole chapter. If you skip it, it still works… until the first real consumer arrives (or the first privacy audit).
- Name: it must be a clear business fact even for someone who has not read the code; if needed, ask a product owner for a sentence explaining it.
- Internal vs external: identify the target consumers and decide whether a translation/anti-corruption layer is needed before exposing the event.
- Payload (Notification vs ECST): document the choice and the reasons (latency vs consumer autonomy) to avoid “gut feeling” rethinking.
- Contract and versioning: ensure the schema is defined in a traceable place and that the compatibility strategy is clear (optional vs breaking).
- Metadata: confirm which headers are mandatory (for example
correlation_id) and how they are generated/propagated. - Privacy: check PII, minimize data in the payload, and consider masking or redaction for public events.
- Documentation: indicate where the schema lives, who maintains it, and how to open a change request.
Next steps 🚀
If you designed the contract well, the next point is to make it survive reality: duplicates, retries, DLQ, and people replaying events when you are not looking.
- For idempotency, retries, and DLQ: see the operational guide.
- For governance and the event catalog: see the governance guide.