Reference architecture

Integration platform

A message bus carries thin reference events — an identifier plus the few attributes subscribers filter on. Every subscriber reads the full record back from the system of record, then updates its own store. The payload never touches the bus.

Event-driven Azure Event Grid CloudEvents 1.0 Eventual consistency Last updated August 2026

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

Integration platform: thin reference events with read-back Systems of record publish thin events to an event bus. Subscriptions filter on event type and attributes. Each subscriber then reads the full record back from the source system's API and updates its own store. 1 Systems of record 2 Publish thin event 3 Filter & fan out 4 Subscribers ERP orders · invoices CRM accounts · contacts PIM products · assets Change publisher outbox · CDC · webhook builds the thin event Event bus Azure Event Grid topic analytics all types webshop product.* finance order.* · NL Data platform lakehouse · reporting Webshop catalog · pricing Finance invoicing · dunning 5 Subscriber reads the full record from the source API, then updates its store. The bus carries identifiers only — never the payload.
Thin events fan out through the bus; the actual data always travels on a direct read from the system of record.

Five steps, and the fifth is the one that defines the pattern:

  1. A record changes in a system of record — the one system allowed to own that entity.
  2. 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.
  3. The bus filters and fans out. Each subscription declares the events it wants; nobody receives traffic they did not ask for.
  4. Each subscriber receives the reference.
  5. The subscriber reads the full record back from the source system's API and updates its own store.
Why the read-back

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.

FieldWhy it is there
entityType + entityIdWhat changed. The subscriber's primary key for deduplication and lookup.
operationcreate / update / delete. Deletes are special — see below.
sequenceMonotonic per entity, assigned by the source. Lets subscribers discard stale deliveries without trusting arrival order or clocks.
selfThe resolvable URL of the record. Subscribers should not hard-code URL templates for systems they do not own.
idUnique per event. The idempotency key for at-least-once delivery.
traceparentW3C trace context, propagated from the originating transaction.
routing keysOnly the attributes some subscription genuinely filters on. Every field you add here is a field you can never remove.
Keep off the bus

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.

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:

Delivery guarantees you must design for

RealityWhat it meansYour countermeasure
At-least-once deliveryThe same event will arrive twice, eventuallyIdempotent handlers keyed on id; the read-back makes a repeat harmless anyway
No global orderingEvent 43 can land before event 42Compare sequence against what you already stored and drop the older one
Eventual consistencySubscribers lag the source by seconds to minutesMake the lag explicit in SLAs and UIs; never read your own write through the bus
The source can be downThe read-back fails after the event was deliveredRetry with backoff, then dead-letter. Never acknowledge an event you could not apply
Publish and commit are separateA crash between them loses the eventTransactional 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

Observability

Three things make this operable, and all three are cheap if you build them in from the start:

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) eventsFat (state-carried) events
On the busIdentifiers + routing keysThe full entity
Coupling pointThe source's read APIThe event schema
Out-of-order eventsSelf-correcting — always reads latestMust be resolved explicitly
Load on the sourceHigh: read amplification per subscriberNone after publish
Source availabilityRequired to apply an eventNot required
Replay semanticsGives current stateGives point-in-time state
Personal data on the busNoneYes — with retention and access scope
LatencyExtra round tripSingle 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.

ConcernAzureAWSGoogle CloudKafka
BusEvent Grid topic + subscriptionsEventBridge bus + rulesPub/Sub topic + subscriptionsTopic + consumer groups
Server-side filteringAdvanced filters on data.*Event patterns on the payloadFilters on attributes only — put routing keys thereNone; filter in the consumer or split topics
Retry + DLQRetry policy + dead-letter to BlobRetry policy + SQS DLQRetry policy + dead-letter topicConsumer-managed + DLQ topic
DeliveryAt-least-onceAt-least-onceAt-least-onceAt-least-once
OrderingNoneNoneOptional ordering keysPer partition
ReplayNot built in — re-publish from the outboxArchive + replaySeek to a timestampNative, by offset

Implementation checklist

  1. Name the system of record for every entity type. One owner each; write this down before writing code.
  2. Agree the event envelope and the naming convention (<entity>.<verb>.v<n>). Version from day one.
  3. Decide the routing keys per entity type — and defend the list. It only ever grows.
  4. Implement publishing as a transactional outbox, not a best-effort call after commit.
  5. Expose a read endpoint per entity type with ETag support and a batch variant.
  6. Give every subscription its own dead-letter destination, retry policy and alert.
  7. Make handlers idempotent and sequence-aware before the first one goes live, not after the first incident.
  8. Propagate traceparent everywhere.
  9. Ship the reconciliation job with the first subscriber, not with the fifth.
  10. Publish the event catalogue where consumers can find it — a schema registry, or at minimum a versioned repository of the contracts.
Build it with a skill

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.

← All reference architectures