The problem
Organisations rarely have one system. They have an ERP that owns orders, a CRM that owns customers, a PIM that owns products, and a growing set of tools that all need to know when any of those change. The default answer is point-to-point: the ERP calls the webshop, the webshop calls the data platform, someone writes a nightly job to patch the gaps.
That works until roughly the fourth system. After that, every new consumer means changing the producer, every schema change breaks something downstream, and nobody can answer "where did this value come from?" without reading code.
The pattern below breaks that coupling with one deliberate constraint: the bus announces that something changed, it does not describe what changed.
How it works
Five steps, and the fifth is the one that defines the pattern:
- A record changes in a system of record — the one system allowed to own that entity.
- A change publisher turns that into a thin event: what changed, where to find it, and the handful of attributes subscribers route on. This is a transactional outbox, a CDC stream, or a native webhook — whatever the source can offer reliably.
- The bus filters and fans out. Each subscription declares the events it wants; nobody receives traffic they did not ask for.
- Each subscriber receives the reference.
- The subscriber reads the full record back from the source system's API and updates its own store.
Because the subscriber always fetches the current state, duplicate deliveries and out-of-order deliveries stop being correctness bugs — they become wasted reads. That is a performance problem, and performance problems are much cheaper to fix than data-corruption problems.
What goes in the event
The envelope is CloudEvents 1.0 — a spec Event Grid, EventBridge and Pub/Sub all understand, which keeps the pattern portable.
{
"specversion": "1.0",
"type": "customer.updated.v1",
"source": "/erp/prod/customers",
"id": "0f3a9f2c-8b21-4d0e-9e07-2b1f5a6c7d10",
"subject": "customer/NL-100482",
"time": "2026-08-11T09:14:22.117Z",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"datacontenttype": "application/json",
"data": {
"entityType": "customer",
"entityId": "NL-100482",
"operation": "update",
"sequence": 42,
"self": "https://api.contoso.com/erp/v1/customers/NL-100482",
"country": "NL",
"segment": "enterprise"
}
}
Everything above the blank line is structural. Everything below it is a routing key — and those are the only business fields allowed on the bus.
| Field | Why it is there |
|---|---|
entityType + entityId | What changed. The subscriber's primary key for deduplication and lookup. |
operation | create / update / delete. Deletes are special — see below. |
sequence | Monotonic per entity, assigned by the source. Lets subscribers discard stale deliveries without trusting arrival order or clocks. |
self | The resolvable URL of the record. Subscribers should not hard-code URL templates for systems they do not own. |
id | Unique per event. The idempotency key for at-least-once delivery. |
traceparent | W3C trace context, propagated from the originating transaction. |
| routing keys | Only the attributes some subscription genuinely filters on. Every field you add here is a field you can never remove. |
Full entity payloads, personal data, amounts, prices, free text, anything with a retention obligation. The moment a subscriber can satisfy its use case from the event body alone, someone will — and the envelope becomes a schema that can never change again.
Filtering and fan-out
Filtering belongs in the bus, not in subscriber code. A subscriber that receives everything and drops 98% of it is paying for compute, egress and on-call attention it does not need — and every new event type becomes a deployment for every consumer.
{
"filter": {
"includedEventTypes": ["customer.created.v1", "customer.updated.v1"],
"advancedFilters": [
{ "operatorType": "StringIn", "key": "data.country", "values": ["NL", "BE"] },
{ "operatorType": "StringIn", "key": "data.segment", "values": ["enterprise"] }
]
},
"retryPolicy": { "maxDeliveryAttempts": 30, "eventTimeToLiveInMinutes": 1440 },
"deadLetterDestination": { "endpointType": "StorageBlob", "...": "..." }
}
This is also why event types carry a version suffix (customer.updated.v1). Adding v2 alongside v1 lets subscribers migrate one at a time instead of all at once on a date somebody picked in a meeting.
Reading back from the source
The read-back is the part that gets under-engineered. A naive subscriber that issues one GET per event will turn a bulk update of 50,000 products into a denial-of-service attack on the PIM.
- Conditional reads. Store the
ETagper entity and sendIf-None-Match. A304costs the source almost nothing. - Coalesce. Buffer events per entity over a short window (5–30s) and read once. Ten updates to one order become one read — correct, because you only ever wanted the latest state.
- Batch endpoints.
GET /customers?ids=a,b,cbeats n round trips. - Read replica or data product. Point subscribers at a read-optimised copy rather than the transactional database.
- Back off. Honour
429andRetry-After; let the queue absorb the delay. The bus is your buffer — use it.
A subscriber handler, in full:
def handle(event):
ref = event["data"]
key = (ref["entityType"], ref["entityId"])
if ref["operation"] == "delete":
store.soft_delete(key, ref["sequence"])
return
# Read the current state from the system of record.
record = source_api.get(ref["self"], if_none_match=store.etag(key))
if record is None: # 304 Not Modified — nothing to do
return
# Last writer wins on the *source's* sequence, not on arrival order.
if record["sequence"] <= store.sequence_of(key):
return # stale or duplicate delivery
store.upsert(key, record)
Deletes and the missing record
Deletes break the read-back, because the whole point of a delete is that there is nothing left to read. Handle it explicitly:
- The
deleteevent is a tombstone — it must be actionable on its own, fromentityIdandsequencealone. - A read that returns
404is not an error. It means the entity is gone or the subscriber is not authorised to see it; either way, remove it locally. - Prefer soft deletes in the source.
GETreturning{"status": "deleted"}keeps one code path instead of two.
Delivery guarantees you must design for
| Reality | What it means | Your countermeasure |
|---|---|---|
| At-least-once delivery | The same event will arrive twice, eventually | Idempotent handlers keyed on id; the read-back makes a repeat harmless anyway |
| No global ordering | Event 43 can land before event 42 | Compare sequence against what you already stored and drop the older one |
| Eventual consistency | Subscribers lag the source by seconds to minutes | Make the lag explicit in SLAs and UIs; never read your own write through the bus |
| The source can be down | The read-back fails after the event was delivered | Retry with backoff, then dead-letter. Never acknowledge an event you could not apply |
| Publish and commit are separate | A crash between them loses the event | Transactional outbox: write the event in the same transaction as the change, publish from the outbox |
Failure handling and replay
Every subscription gets a dead-letter destination and an alert on its depth — a silently filling DLQ is the most common way this architecture fails in production. Because events are thin, replaying a DLQ is cheap and safe: re-delivering an old reference simply causes a fresh read of the current record.
That same property is also this pattern's sharpest edge. Replaying an event from last Tuesday gives you today's data, not Tuesday's. The bus is a change notification log, not an audit log. If you need point-in-time reconstruction, that is a job for the source system's history tables or a versioned store in the data platform — not for the bus.
Security and data protection
- The bus holds no personal data. Access requests, retention rules and breach scope stay with the systems of record — an enormous simplification under GDPR.
- Authorisation stays at the source. Each subscriber authenticates to the source API with its own identity and sees only its own scope. A subscription grants notification, not access.
- Managed identity, not keys. Publishers and handlers authenticate with workload identity; no connection strings in config.
- Private networking for the read path where the source is internal.
- Validate the event source. Handlers verify signature or issuer — an endpoint that trusts any POST is an open write path into your systems.
Observability
Three things make this operable, and all three are cheap if you build them in from the start:
- Trace context end to end. Propagate
traceparentfrom the source transaction through the event, the handler and the read-back, so one trace spans all five steps. - Lag per subscription. Measure
handled_at − occurred_at. This is the number that tells you whether the platform is healthy; queue depth alone does not. - Reconciliation. A periodic job comparing counts and checksums per entity type between source and subscriber. Events will be missed. Reconciliation is how you find out before your users do.
Thin events vs fat events
The alternative — putting the full entity in the event — is a legitimate pattern with a different set of costs. The choice is real, so make it deliberately.
| Thin (reference) events | Fat (state-carried) events | |
|---|---|---|
| On the bus | Identifiers + routing keys | The full entity |
| Coupling point | The source's read API | The event schema |
| Out-of-order events | Self-correcting — always reads latest | Must be resolved explicitly |
| Load on the source | High: read amplification per subscriber | None after publish |
| Source availability | Required to apply an event | Not required |
| Replay semantics | Gives current state | Gives point-in-time state |
| Personal data on the bus | None | Yes — with retention and access scope |
| Latency | Extra round trip | Single hop |
A common middle ground is worth knowing: keep the bus thin, but let the publisher write a full snapshot to object storage and put the blob URL in self (the claim-check pattern). Subscribers keep one read-back code path, and the source no longer takes the read load.
When to use it — and when not to
Good fit
- Several systems must react to the same change
- The source already exposes, or can expose, a decent read API
- Seconds-to-minutes of lag is acceptable
- Entities carry personal or regulated data you would rather not copy onto a bus
- Entities are large, or their shape changes often
- Consumers are added faster than producers change
Poor fit
- Subscribers need the before-state or the exact delta
- You need to reconstruct history by replaying the log
- The source cannot take the read load and cannot be fronted by a cache or replica
- Very high volume of very small changes — read amplification dominates
- Sub-100ms propagation requirements
- The source has no API at all, only a database
Platform mapping
Nothing here is Azure-specific. The pattern maps onto every major bus, with one caveat per platform.
| Concern | Azure | AWS | Google Cloud | Kafka |
|---|---|---|---|---|
| Bus | Event Grid topic + subscriptions | EventBridge bus + rules | Pub/Sub topic + subscriptions | Topic + consumer groups |
| Server-side filtering | Advanced filters on data.* | Event patterns on the payload | Filters on attributes only — put routing keys there | None; filter in the consumer or split topics |
| Retry + DLQ | Retry policy + dead-letter to Blob | Retry policy + SQS DLQ | Retry policy + dead-letter topic | Consumer-managed + DLQ topic |
| Delivery | At-least-once | At-least-once | At-least-once | At-least-once |
| Ordering | None | None | Optional ordering keys | Per partition |
| Replay | Not built in — re-publish from the outbox | Archive + replay | Seek to a timestamp | Native, by offset |
Implementation checklist
- Name the system of record for every entity type. One owner each; write this down before writing code.
- Agree the event envelope and the naming convention (
<entity>.<verb>.v<n>). Version from day one. - Decide the routing keys per entity type — and defend the list. It only ever grows.
- Implement publishing as a transactional outbox, not a best-effort call after commit.
- Expose a read endpoint per entity type with
ETagsupport and a batch variant. - Give every subscription its own dead-letter destination, retry policy and alert.
- Make handlers idempotent and sequence-aware before the first one goes live, not after the first incident.
- Propagate
traceparenteverywhere. - Ship the reconciliation job with the first subscriber, not with the fifth.
- Publish the event catalogue where consumers can find it — a schema registry, or at minimum a versioned repository of the contracts.
This checklist exists as a skill — event-contracts designs the envelopes and subscription filters, writes idempotent sequence-aware handlers, and flags payloads trying to sneak onto the bus.