Ask a visitor what an exhibition is and they will describe one thing: a title, an image, the artist, the room it is in, when it is on. Ask the platform where that page comes from and the honest answer is four systems that have never met. The description lives in Sanity. The artist and the object provenance live in the museum's collection system. The room and its configuration live in the venue system. The hero image lives in a digital asset store. A single content screen is a small act of diplomacy between databases that do not speak to each other.
Every visitor-facing content screen in the Powerhouse DXP reads through one layer we call the Content API: exhibitions, events, the live On Now feed, recommendations. It is the highest-volume read path in the whole platform, hit on almost every screen of every session, on both the mobile app and the website. Which makes the design question unusually consequential. Get the busiest, most-depended-on layer wrong and you have not made one screen slow, you have made all of them slow at once. This is the story of the wrong way to build that layer, and the small reframing that fixed it.
01 · THE PROBLEMA page that belongs to no single system
Two facts sit under everything here. The first is that content is genuinely distributed, and that is not an accident to be engineered away. Sanity is the right home for editorial copy. The collection management system, EMu, is the right home for object records and provenance. Momentus is the right home for venue and room configuration. The asset store is the right home for high-resolution media. Each of those systems is authoritative for its own slice, maintained by different people on different clocks. No amount of architecture makes it correct to move all of that into one place.
The second is that the read has to be fast, and it has to be fast every single time. A visitor standing in the building, opening On Now, is not interested in the fact that their exhibition card is a join across four vendors. They tap, and the screen owes them an answer in the time it takes to glance down. There is no loading spinner budget on the busiest screen in the app.
So the shape of the problem is a page assembled from four independent sources that must nonetheless render instantly, on demand, for every visitor, on the most-travelled path in the system. The tension is between where content correctly lives and how quickly it must be read. Everything that follows is about which side of that tension you resolve at request time, and which side you resolve in advance.
02 · THE WRONG TURNFetch everything the moment you are asked
The obvious first design is synchronous hydration. A visitor opens an exhibition. The Content API receives the request, calls Sanity for the copy, then calls EMu for the artist and object details, then calls Momentus for the room, then calls the asset store for the image, stitches the four responses into one, and returns it. One request in, four upstream calls out, assembled live.
It demos beautifully with one exhibition and a warm cache. It falls apart under a museum. Now the visitor's content screen is only as fast as the slowest of four vendor systems, and only as available as the least available of them. EMu's production API being slow during a bulk sync is no longer EMu's problem, it is every exhibition card's problem. The asset store having a bad minute becomes a bad minute for the whole On Now feed. You have taken four independent failure modes and wired them, in series, directly underneath the busiest screen you own.
Decision register · the misclassification
There was a sharper wrong turn hiding inside this one, and a reviewer caught it. An early solution design listed Tessitura booking data as Content API content. It is not. Tessitura is transactional system-of-record data — orders, constituents, live capacity — read through the Tessitura REST API or a TNEW view, and it must always be fresh. Content is editorial, published, and safely cacheable for a minute at a time. Filing a booking behind a content cache is not a small labelling error. It is two completely different data lifecycles collapsed into one, and it would have surfaced as a visitor seeing a ticket that was already sold.
The second failure the naive design invites is structural. If the Content API is one large service, sitting on its own across a network boundary from the rest of the platform, then it is a single point of failure for the entire visitor content experience, reached by a network hop on every read. When we looked hard at that shape, the direction was clear enough to write down as a position: split the content layer by bounded context so that no one component going down darkens every screen. A monolith on the hottest path is a bet that it will never have a bad day.
03 · THE KEY INSIGHTAssemble the page when it changes, not when it is read
The unlock is a change of tense. Synchronous hydration assembles the exhibition in the present tense, at the exact moment a visitor asks, which is the worst possible moment to be calling four other systems. But content does not change when it is read. It changes when an editor publishes. Those are radically different frequencies: an exhibition is published or amended a handful of times, and then read tens of thousands of times without changing at all.
So we move the work to the rare event and away from the common one. The instant a content editor publishes in Sanity, a webhook fires. A Content service catches it, does the expensive part exactly once — fetch the full document, hydrate the references from EMu, flatten the object graph — and writes a single, denormalised, read-ready document to Cosmos DB. From that point the read path has nothing to assemble. It fetches one pre-built document by key and returns it. The four systems are entirely absent from the hot path.
POST https://{ingest}.azurewebsites.net/api/events // Sanity GROQ webhook → Event Grid sanity-signature: <HMAC-SHA256 of body, secret from Key Vault> { "type": "Exhibition.Updated", "topic": "museum.content", "id": "exhibition-8815", "filter": "!(_id in path('drafts.**'))", // drafts never leave Sanity "_updatedAt": "2026-07-24T02:14:06Z" // Last-Write-Wins key }
The webhook is filtered at the source, signed, and carries the change — not the content. The Content service fetches the full document itself, so the payload cannot be trusted into the database.
Read what that buys. The busiest screen in the platform no longer depends on Sanity being up, or EMu being quick, or the asset store having a good minute. It depends on one Cosmos DB read, which is the one thing on that path we can make fast and keep fast. Publishing got a little more expensive and reading got dramatically cheaper, and reading is the thing that happens ten thousand times to one.
The visitor is never waiting on the museum's back office. By the time they ask, the answer was assembled hours ago and is sitting in one document with their name on the key.
04 · THE ARCHITECTUREA slow write side and a fast read side
Here is the shape of it, and the important read is the seam down the middle. On the left, a write side that is allowed to be slow, event-driven, and occasionally retried, because nobody is waiting on it. On the right, a read side that does exactly one thing quickly. The two never touch at runtime. They meet only in Cosmos DB, where the write side leaves a finished document and the read side picks it up. This is the same discipline as separating a command path from a query path: the thing that changes state and the thing that serves state are different systems with different rules.
The three moving parts that carry the content layer
Strip the diagram back and there are three things to stand up. That is the entire mechanism.
05 · THE FLOWOne publish, every screen that shows it
Put it in motion. A content editor publishes an exhibition, and this is the sequence that ends with it appearing on the On Now screen of every phone in the building. The editor sees none of it past the save button. The visitor sees none of it at all — they just find, next time they open the app, that the world has quietly updated.
Written out, the Content service is doing the one job the read path refuses to do. Its whole reason to exist is to absorb the messiness of four systems so that nothing downstream ever has to.
// Content service (abridged) — the only code that knows content is distributed module.exports = async (event) => { // 1. the webhook told us WHAT changed, not the content itself. fetch it ourselves. const doc = await sanity.getDocument(event.id); // 2. do the expensive joins here, once, off the read path const objects = await emu.hydrate(doc.linkedObjectRefs); // artist, year, image // 3. flatten four systems into one document the app can read with no joins const gold = flatten({ ...doc, objects }); // 4. upsert. Last-Write-Wins on _updatedAt — a late duplicate cannot overwrite newer content await cosmos.upsert("content", gold, { key: `${gold.siteId}/content/${gold.id}` }); };
Every hard dependency on an upstream system lives inside this function. The read side never imports any of it.
06 · THE DETAIL THAT SEPARATES REAL FROM DEMOWhat must be true before the document is trusted
A read model is only as good as the guarantees on what is allowed to enter it. Because the Gold document is served to visitors with no further checking, everything protective has to happen upstream, at write time, before the record ever lands. Three guarantees carry the weight, and each is structural rather than a matter of remembering to be careful.
The first is that drafts must never reach production. Editors work in progress inside Sanity all day. The guard is not a flag the read side checks; it is a GROQ filter on the webhook itself, !(_id in path('drafts.**')), so a draft simply never generates an event. The content the read side cannot see is content it can never accidentally serve. The second is that the pipeline only accepts changes it can prove came from Sanity. The ingestion function computes an HMAC-SHA256 of the request body against a secret held in Key Vault and rejects anything that fails with a 401. Without that, anyone who found the endpoint could inject content straight onto the museum's screens.
The third is idempotency, because delivery is at-least-once. The same publish event can arrive twice, and a retried older event can overlap a newer one. The write is keyed and resolved Last-Write-Wins on _updatedAt: a duplicate is a harmless re-write of the same document, and a late-arriving stale event cannot clobber fresher content because its timestamp is older. The record converges on the truth no matter what order the events turn up in.
Read hits a fresh document NORMAL
The common path. App asks for an exhibition, the Gold document is in Cosmos, the gateway returns it, cached 60 seconds by request hash. No upstream system is touched.
gateway: read key · serve · cache 60s
Upstream is down at publish time SAFE
The path people forget. EMu is unavailable when an editor publishes. The event retries with backoff and dead-letters if it must; the previously served document stays live and readable the whole time. A failed hydrate delays new content, it never blanks existing content.
bus: retry → dead-letter · old doc still served
A draft or forged event arrives REJECTED
The path that protects the visitor. A draft never fires the webhook; a request with a bad signature is refused at ingestion with a 401. Neither ever becomes a document, so neither can ever be read.
ingest: filtered at source / 401 · never stored
The shared move across all three is the same one that made the whole design work: resolve it before the read, not during it. A read model earns its speed by being strict about its front door, so that by the time a visitor arrives there is nothing left to check.
The Gold document is a contract, not a copy
A read model is only as decoupled as the shape of what it stores. If the Content service wrote raw Sanity and EMu records into Cosmos, every consumer would quietly be coupled to those upstream schemas, and the day either one shifts, every screen breaks at once. So the flattened document is stated in our own vocabulary, not the vendors': an exhibition has a title, an image URL, an artist name, a room, an On Now status. What went into producing those fields — which system, which reference, which join — is the Content service's private business and no one else's.
Why it matters
A denormalised document that mirrors its sources turns your read layer into a distributed copy of four systems' internals. A denormalised document stated as your own content model turns it into a stable contract that outlives any one of those systems being swapped out. When the asset store moves from Fotoware to Mediaflux, the write side changes and the document does not. Design the content, not the copy.
07 · THE BOUNDARYContent is cacheable, a booking is not
The misclassification from section 02 was worth catching because it points at the single most important line in the whole design: the boundary between content and transaction. They look similar from the app's side — both are just data on a screen — but they have opposite requirements, and putting them behind the same layer is how you get a fast platform that occasionally lies to people.
Content is published, changes rarely, and is safely served from a sixty-second cache. A booking is transactional, changes the instant a seat sells, and must be read live from Tessitura every time. So the boundary is drawn hard: the Content API serves Sanity-driven content only, out of the Gold document. Bookings, orders, and live capacity are read straight from the Tessitura REST API through the gateway and are never cached. On the On Now screen those two paths sit side by side — the exhibition description comes from the content read model, the remaining capacity comes direct from the source — and keeping them on separate paths is exactly what lets one be fast without the other ever being wrong.
The fastest thing you can do with a booking is not cache it. Speed on the content path is only safe because the transaction path refused to join it.
08 · WHY NOT JUST ORCHESTRATE THE READRight-sizing the layer
A fair challenge is why pre-hydrate at all, rather than let the gateway orchestrate the four calls and cache the result. For a single-system read that is the right answer, and we use it: fetching a profile or a booking is a synchronous call to one place, and there is nothing to pre-assemble. The pre-hydration earns its keep specifically where a record is a join across several systems on the busiest path, which is exactly the content case and almost nothing else.
The deciding factor is where the failure lands. Orchestrate-and-cache still means that the first request after any change, and every cache miss, pays the full four-system tax in front of a waiting visitor, and inherits the availability of the least reliable upstream at that moment. Pre-hydration moves that tax to a background event nobody is waiting on, and makes a cache miss cost one Cosmos read instead of four vendor calls. We kept the layer no heavier than that: an event, a function, and a document. No orchestration engine to operate, no second copy of the truth to reconcile, because Cosmos holds a read model and never claims to be the system of record.
09 · THE PROOFWhat holds, and what is honestly still open
None of this is a whiteboard claim. The content flow was traced end to end against the real systems, and the guarantees below are the ones the design actually stands on, checkable in the integration design rather than asserted.
- An editor publishing in Sanity firing a
GROQ-filtered webhook, with drafts excluded via!(_id in path('drafts.**'))before any event is emitted - The ingestion function validating
HMAC-SHA256against the Key Vault secret and rejecting an unsigned request with401 - An
Exhibition.Updatedevent on themuseum.contenttopic driving the Content service, not a synchronous call from the app - The Content service hydrating EMu object references and writing one flattened document to the Cosmos
contentcollection - A duplicate or late event resolving safely via Last-Write-Wins on
_updatedAt, keyed{siteId}/content/{exhibitionId} - The On Now screen reading that one document on a 60-second poll, touching no upstream system
- Bookings and live capacity read direct from Tessitura REST v16, deliberately outside the content cache
And the parts that are not settled, because a design is more trustworthy when it names its own edges:
- The latency added by routing content reads across the landing-zone boundary is still to be measured, not estimated — if the network hop is meaningful, caching at the boundary has to compensate
- The API contract and versioning strategy between the Content API and the rest of the platform needs to be explicit, so a breaking change to the read model cannot silently take down screens that were never redeployed
- EMu's production API stability for bulk hydration is not yet confirmed in writing by the vendor — an open dependency the write side has to be resilient to, not assume away
The last content line is the one that settles the shape, though. Once the busiest screen in the platform reads a single pre-assembled document and depends on none of the systems that produced it, the argument for hydrating on demand is over.