There is a class of system that will never tell you when something happened. It holds the truth, it will answer if you ask, but it will not raise its hand. Tessitura is one of those. It is the constituent database and the ticketing engine at the centre of the museum, and when a patron buys a ticket it records the fact perfectly and mentions it to no one.
For Phase 1 we solved identity: one Auth0 login, handed cleanly into TNEW ticketing. Phase 2 asks a harder question. When that patron completes a purchase, four or five other systems would like to know, immediately, so they can act. A scoring model wants to re-rank them. A register wants to update. Later, a shop and a hotel booking system will want the same signal. The tempting answer is to wire each of those to the purchase directly. This is the story of why we did not, and what we built instead.
01 · THE PROBLEMThe source that never speaks
Two facts sit under everything here, and neither of them is negotiable. The first is that Tessitura emits no events. It is a system of record, not a publisher. There is no webhook, no outbound message, no stream you can subscribe to. If you want to know a purchase happened, you have to be standing in exactly the right place at the right moment, or you have to keep asking.
The second is that Prospect 2, the constituent scoring module, is a batch consumer. It pulls from the Tessitura database on a schedule. It has no inbound event listener of its own. You cannot push a purchase into it and watch it react. On its own clock it will notice, hours later. For a patron who just bought a ticket and expects the site to treat them accordingly, hours later is the same as never.
So the shape of the problem is a source that will not announce, feeding a consumer that only listens on a timer. Between them sits the thing we actually control: the moment the purchase completes in TNEW, where, for one redirect, we are told what happened. That single moment is the whole opportunity.
02 · THE WRONG TURNWiring every system straight to the purchase
The obvious first design is point-to-point. The purchase completes, the DXP catches the redirect, and from there it calls Prospect 2's trigger, then updates the register, then, when Phase 2 lands, calls Shopify, then Mews, then the membership system. One producer, a growing list of direct calls fanned out by hand.
It works in a demo with two consumers. It rots the moment there are five. Every new system that wants the purchase signal means editing the one piece of code that catches the redirect. That code now knows about Shopify's API, Mews' quirks, the membership system's downtime. If Mews is slow, the patron waits on it. If the register call throws, does the Prospect 2 sync still fire, or did we already return? The producer has become responsible for the health, the ordering and the retries of every consumer it never should have known about.
Architecture decision · ADR-011
Point-to-point fan-out from the purchase handler was rejected, and so was the reflex to reach straight for heavy middleware. The failure mode of point-to-point is coupling: the producer grows a dependency on every consumer. The failure mode of premature middleware is a MuleSoft-shaped bill for a problem a topic and three subscriptions can solve. We wanted the decoupling of a bus without the weight of an integration platform.
There was a second wrong turn worth naming: polling. If Tessitura will not tell us, we could ask it every minute. But polling is a tax you pay forever, it is always either too slow or too chatty, and it still leaves you writing the same fan-out by hand once the data arrives. Polling answers did something happen. It does not answer let everyone who cares respond. Those are different problems.
03 · THE KEY INSIGHTManufacture the event the source refuses to send
The unlock is a small reframing. We were treating "Tessitura emits no events" as a wall. It is not a wall. It means only that Tessitura will not be the publisher. Nothing says the event has to originate inside the source. It has to originate at the one place we are reliably told the truth, and for a purchase that place is the TNEW post-purchase redirect.
So we stop waiting for an event and start minting one. The instant the redirect lands, a small publisher builds a single, well-formed message and pushes it onto an Azure Event Grid topic. From that point on, the fact that Tessitura is silent is irrelevant. There is now a real event in the system, and everything downstream is an event-driven design like any other.
POST https://dxp-events.australiaeast-1.eventgrid.azure.net/api/events aeg-sas-key: <from Key Vault, never in source> [{ "specversion": "1.0", "type": "com.powerhouse.dxp.purchase.completed", "source": "/dxp/tnew", "subject": "constituent/8815520", "id": "a1c9…order-scoped-uuid", "data": { "constituentId": 8815520, "orderId": 4471902 } }]
A CloudEvents 1.0 envelope. The subject carries the constituent, the id makes the event idempotent, the type lets subscribers filter.
Read what that buys us. The publisher knows one thing and one thing only: a purchase completed, for this constituent. It does not know Prospect 2 exists. It does not know Shopify is coming in Phase 2. It announces the fact and it is done. Every system that cares subscribes to the topic on its own terms, retries on its own failures, and can be added or removed without the publisher ever changing.
The producer stops calling systems and starts stating facts. Who listens, and what they do about it, is no longer its concern.
04 · THE ARCHITECTUREOne topic, many subscribers
Here is the shape of it. The important read is the seam down the middle. On the left, one publisher whose entire job is to notice a purchase and state that it happened. On the right, a set of subscribers that each do one thing in response, in isolation from one another. Event Grid sits between them as the topic, holding no business logic at all — it routes, retries, and dead-letters, and nothing more. CosmosDB stays underneath as the register that remembers who a patron is across systems.
The three moving parts that carry the whole bus
Strip the diagram back and there are three things to stand up. That is the entire mechanism.
05 · THE FLOWOne purchase, everyone who cares
Put it in motion and this is the sequence a completed purchase sets off. The patron sees none of it past the first redirect. The publisher's part is over in a single POST; everything after that is the topic fanning the fact out to whoever subscribed, each on their own path.
Written out, the publisher is almost embarrassingly small. That is the point — its smallness is the decoupling made literal.
// Purchase publisher (abridged) — the only code that knows a sale happened exports.onPurchaseReturn = async (req) => { // 1. the constituent was bound at login (Phase 1); read it off the session const constituentId = req.session.tessitura_constituent_id; const orderId = req.query.orderId; // 2. state the fact once, as a CloudEvent, onto the topic await grid.publish("dxp-events", [{ type: "com.powerhouse.dxp.purchase.completed", subject: `constituent/${constituentId}`, id: orderId, // order-scoped → idempotency key data: { constituentId, orderId } }]); // 3. that's it. no idea who is listening, and that is the design. };
Retries, ordering and downstream health belong to the subscribers and the topic, not here.
06 · THE DETAIL THAT SEPARATES REAL FROM DEMOAt-least-once means idempotent or nothing
Here is the single fact about Event Grid that decides whether your bus survives contact with production. Delivery is at-least-once, not exactly-once. A subscriber can, and eventually will, receive the same event twice: a network blip on the acknowledgement, a retry that overlaps a slow success. If your handler is not built for that, the bus that was meant to make things reliable will instead double-charge, double-count, or double-write.
So the rule for every subscriber is absolute: process the event by its id, and make repeat delivery a no-op. The event carries an order-scoped id precisely so consumers have a stable key to deduplicate on. Re-scoring a constituent is naturally idempotent. Stamping a register is idempotent if you write, not increment. The one thing you must never do is treat a second delivery as a second event.
Delivered once, handled once NORMAL
The common path. Subscriber receives the event, does its work, returns 200. Event Grid marks it delivered and moves on.
handler: do work · ack 200
Delivered twice EXPECTED
The path people forget. Same event id arrives again after a retry. The handler recognises the id, does nothing, and still returns 200. Correctness comes from the consumer, not the bus.
handler: seen id → no-op · ack 200
Not delivered yet RETRY
Subscriber is down or erroring. Event Grid retries with exponential backoff over its retention window, then dead-letters to blob storage. The event waits to be replayed; it is never silently lost.
bus: backoff → retry → dead-letter
The event contract, and why it never leaks the source's internals
A bus is only as decoupled as the shape of what travels on it. If the event carried a raw Tessitura row, every subscriber would be quietly coupled to Tessitura's schema, and the day that schema shifts, five systems break at once. So the envelope is deliberately thin and stated in our language, not Tessitura's: a purchase completed, for this constituent, on this order. Constituent id and order id, and nothing a consumer does not need.
The type field, com.powerhouse.dxp.purchase.completed, is namespaced and versionable. When Phase 2 wants a richer purchase event, it becomes .v2 alongside the original, and old subscribers keep receiving the shape they were built for. The contract is the thing we promise to keep stable, and keeping it small is what keeps that promise cheap.
Why it matters
A fat event that mirrors the source turns your bus into a distributed copy of one system's internals. A thin, well-named event turns it into a shared vocabulary that outlives any single system on either end. Design the event, not the payload.
07 · WHY EVENT GRID, AND NOT THE HEAVIER OPTIONSRight-sizing the bus
A fair challenge at this point is why Event Grid rather than a full message broker. We looked hard at Service Bus, which offers strict ordering, sessions and transactional guarantees, and at spinning up an integration platform. Both are the correct answer to a different problem than ours.
Our events are discrete facts, not an ordered stream. A completed purchase does not need to arrive before or after another patron's completed purchase; each is independent and keyed to its own constituent. What we need is cheap, push-based fan-out to a handful of HTTP and function subscribers, with retries and a dead-letter safety net. That is precisely the shape Event Grid is built for, billed per event rather than per always-on broker. We reserved the right to move a specific subscription onto Service Bus later if one consumer ever needs strict ordering — the publisher would not change, because it only ever talks to the topic.
Choosing the bus is choosing your coupling. We wanted systems coupled to a shared fact, not to each other, and not to a broker heavier than the fact deserves.
08 · THE REGISTERWhere a fact becomes memory
One decision carried over from Phase 1, and the bus makes it pay off. Tessitura stays the system of record for who a patron is and what they bought. The event bus does not try to become a second database of truth; it carries facts, it does not store them. What remembers a patron across systems is the same small CosmosDB register from Phase 1, phm-identity, now with one of the subscribers keeping it current.
When a purchase event lands, the register subscriber stamps the constituent's record — last purchase, last active — against the Auth0 user id and Tessitura constituent id already held there. The fields still sitting in reserve for the Shopify customer id, the Mews guest id and the member id are exactly where Phase 2's new subscribers will write, each reacting to the same event without the publisher or any sibling ever knowing they arrived.
The discipline is the same one that held the identity design together. The bus answers what just happened. The register answers who this is across everything. Tessitura answers what is true. Three jobs, kept apart on purpose, and the event is only ever the messenger between them.
09 · THE PROOFWhat we actually ran, not what we sketched
As with Phase 1, none of this is a whiteboard claim. We stood the pattern up against the Tessitura test environment and a real Event Grid topic in the Australia East region before committing to it. Here is what came back green.
- A completed test purchase in TNEW redirecting to the return URL and reaching the publisher
- The publisher building a valid CloudEvents 1.0 envelope and POSTing it to the
dxp-eventstopic - Two independent subscriptions receiving the same event, filtered by
type - A per-constituent Prospect 2 sync fired from the subscription, keyed only to the
constituentIdon the event - The
phm-identityregister stamped by a second subscriber, in parallel, with no dependency on the first - A deliberately duplicated delivery handled as a no-op via the order-scoped event
id - A subscriber forced to fail, then recovering its event from retry rather than losing it
- A new subscription added and removed with zero change to the publisher or the other consumers
That last line is the one that settles the design. Once you can add a consumer to a live purchase stream without touching the code that produces it, the argument for point-to-point wiring is over.