Cache invalidation fails in the seams between layers. The classic case is a database write that bypasses the application, so the ORM has no way of knowing a cached entity went stale and the cache keeps serving old data until someone clears it by hand the Debezium cache invalidation writeup. Change data capture fixes that by reading the database's own log rather than querying tables, so every insert, update and delete becomes an event with a before state, an after state and metadata the Debezium CDC explainer.
Once that stream exists, three consumers want it at once. Your edge gate holds rendered responses keyed by tags. Your L1 in-process cache holds objects keyed by content id. Your L2 shared cache holds the same objects but across many processes. If each layer listens to a different signal, you get the failure mode where L1 clears and L2 does not, or the gate keeps serving a page for a tag that was already purged downstream.
The fix is a single contract: one event shape, published once per change, with fields that let each consumer act without asking questions. That is the whole design. The rest of this article is the field list, the semantics, and the failure handling.
A comparison shows why the split matters:
| Layer | What it stores | Purge trigger | Needs version? | Needs tag? |
|---|---|---|---|---|
| Edge gate | Rendered responses | Tag scope events | No | Yes |
| L1 in-process | Objects per process | Key scope events | No (process evicts on write) | No |
| L2 shared | Objects across processes | Key plus version check | Yes | No |
| Reconciliation job | Version index | Compares stored vs. source | Yes | Optional |
If you collapse the contract to one generic "invalidate" message with only an id, the gate cannot purge by tag without a lookup, and L2 cannot reject a stale event that arrives after a newer one. Two extra fields, scope and version, carry almost all of the coordination value.



