Solution Engineering — Data Federation

The Orchestration Tradeoff

Merging many backend calls into one response is easy. Deciding what belongs in the same response — and so the same cache — is the real work.

📎 Companion piece: this builds directly on Data Federation Tradeoffs — the caching model here (TTLs, cache-key cardinality, what-not-to-cache) is the same one used throughout.

"Make one call from the frontend, and let the middleware call everything else." It's one of the most reasonable things a frontend architect can ask for — fewer round trips, smaller payloads, less disambiguation in the browser. And the answer is unambiguous: yes. The Alokai Middleware is a BFF; fanning out to several backends and returning a single, shaped response is a few lines in a custom method.

So why doesn't a careful engineer just say "yes, do that" and move on?

Because "one call" describes the client. It says nothing about what happens to caching on the server — and the moment you merge several sources into one response, you merge their cache policies too. Federation and caching aren't two topics. They're the same decision seen from two sides.

This piece is about that coupling: why combining calls is trivial in theory and nuanced in practice, and how to tell, for any given pair of calls, which one you're looking at.


1 The Easy Part Is Real

Let's get this out of the way, because it's true and it matters: the orchestration capability is there, it's first-class, and it does exactly what a BFF should. A custom middleware method can call any number of backends server-to-server — never leaving the network — chain or parallelize them, shape the result, and hand the browser a single payload.

One client request, fanned out server-side, returned as one response — the BFF doing its job
Browser1 request
Middleware (BFF) — custom method
SAPproduct
CMScontent
Searchresults
Inventorystock
One merged response → browser

And it really is a few lines. A custom middleware method receives a context, calls whatever backends it needs server-to-server, and returns one shaped payload:

// storefront-middleware/api/custom-methods/getProductBundle.ts
export async function getProductBundle(context, { sku }) {
  const commerce = await context.getApiClient('commerce');
  const reviews  = await context.getApiClient('reviews');

  // call both backends server-side, then return a single response
  const [product, productReviews] = await Promise.all([
    commerce.api.getProduct({ sku }),
    reviews.api.list({ sku }),
  ]);

  return { product, reviews: productReviews.items };
}

Illustrative — register it as a custom method and the frontend gets a typed, single-call SDK endpoint for it.

The benefit is genuine. A chatty page that would otherwise fire a handful of requests from the browser — each with its own latency, its own payload, its own waterfall risk — becomes one request to one endpoint that returns exactly the shape the page needs. Nobody disputes that this is useful, or that the platform supports it. So the interesting question was never can you. It's should you, for these particular calls.


2 One Call on the Client Is One Cache Entry on the Server

Here's the whole article in a sentence: a merged response is a single cache object — one key, one policy, one fate. Everything you fold into it now shares one TTL, one cache-key cardinality, one invalidation trigger, and one failure domain. That's not a downside, exactly. It's just the thing you're signing up for, and it's worth signing up for on purpose.

When the calls stay separate, each response gets to be cached on its own terms — the CMS content at the edge for minutes, the product behind its own cache, search not cached at all. Merge them, and all of that collapses into the properties of the most demanding ingredient. The rest of this page is just the four ways that collapse shows up.


3 The Four Couplings

TTL coupling — the merged response inherits the shortest life

A cache entry can only be as fresh as its most volatile field. Fold slow-changing CMS content together with fast-changing stock, and the whole blob is only cacheable for as long as the stock is valid. You've taken content that could have sat warm at the edge for minutes and capped it at seconds.

Each source has its own natural TTL — the merged entry is capped by the most volatile one
CMS content~300s
+
Product~60s
+
Live stock~5s
Merged TTL ≈ 5s
content is now 60× more perishable

Cardinality coupling — one personal ingredient makes the whole thing personal

The companion piece frames cacheability as cache-key cardinality: anonymous content is one key, a segment is N keys, per-user is one key per visitor. Merging respects the worst case. Combine three shared ingredients with one per-user one — a customer-specific contract price, say — and the entire merged response becomes per-user. It can no longer be shared-cached at all, even though three-quarters of it is identical for everyone.

A single per-user ingredient drags the whole response to per-user cardinality
Contentshared
+
Base priceshared
+
Contract priceper-user
Whole response = per-user
can't be shared-cached

Invalidation coupling — one key, one blunt purge

Kept separate, each source is invalidated by its own trigger: the CMS purges on publish, the catalog on a price change, search on re-index. Each is a precise, tagged purge of exactly what changed. Merge them and there's one key holding all of it — so any change to any ingredient means purging the whole entry, and you lose the surgical, tag-based invalidation that made the cache cheap to keep fresh in the first place.

Failure coupling — all-or-nothing, unless you make it not

Independent calls fail independently: the CMS being down doesn't take products with it, and the page can degrade gracefully. A single merged endpoint is one failure domain — if one upstream times out, the whole response is in question — unless you explicitly write partial-failure handling for every ingredient. That's doable, and sometimes worth it, but it's real work that the "just merge them" framing quietly hides.

🧩
None of these is a reason not to orchestrate. They're the four prices on the tag. The mistake isn't paying them — it's not noticing you did.

4 The Same Data, Kept Separate

It's worth seeing the contrast directly, because "fewer client calls" is a real win and "worse cacheability" is a real cost, and the right answer depends on which one dominates for a given page.

🔗 One merged response

  • One client round trip — fast for the browser
  • One cache key, one TTL = the shortest one
  • Per-user if any part is per-user
  • Purge-all on any change
  • One failure domain

🧱 Calls kept separate

  • More round trips (or a client-side fan-out)
  • Each cached at its own layer and TTL
  • Shared parts stay shared-cacheable
  • Tag-precise invalidation per source
  • Independent failure, graceful degrade

The latency article showed the fetch-ordering side of this — why fetching the CMS before commerce is a waterfall. This is the caching side of the same coin: merging trades the browser's round trips for the server's cache granularity. Neither column is free.


5 When Merging Is Exactly Right

This isn't an argument against orchestration — it's an argument for doing it where it pays. There are clear cases where the BFF is precisely the right place to merge:

✅ Merge when
There's a real dependency
Call B genuinely needs call A's output — search returns SKUs the index doesn't fully describe, so you enrich them with a product call. You can't parallelize that; the server is where it belongs.
✅ Merge when
Cache profiles match
The ingredients share volatility and cardinality — all shared, similar TTL (say, product detail plus its related-products list). Nothing is dragged down, so there's no coupling cost to pay.

The cleanest case is the genuine dependency. A search index hands back a list of SKUs but not the full product data your tiles need; the only way to render them is to take that list and fetch the products — call B literally consumes call A's output. You wouldn't want the browser doing search-then-a-dozen-product-calls, so the chain belongs server-side. That's the BFF earning its keep.

🛠️
A dependency that has to resolve server-side is the strongest reason to orchestrate. If call B literally needs call A's output, the BFF isn't a convenience — it's the correct home for that logic.

It's worth retiring one example that often gets used here: the PDP that merges product data with stock from a separate inventory system. It feels like the poster child for orchestration, but it's neither a dependency nor a clean merge. You already have the SKU from the route, so product and stock are independent, parallel calls — not a chain. And their cache profiles clash: product is stable, stock is volatile. So the honest move, straight from the TTL coupling above, is usually to cache the product and fetch stock late or on a short TTL — not fuse them into one cached response. It's a keep-them-separate case dressed up as a merge — a good reminder that "they render together" is not the same as "they belong in the same cache entry."


6 A Worked Example: Search + a CMS Slot + Live Stock

Let's consider another scenario. On a single PLP screen: Coveo returns the search results; the third slot in the grid is a CMS content block instead of a product; and every product tile shows a live in-stock count from the commerce backend. Three sources, one screen. The natural question is "can the middleware just orchestrate all three into one response?" — and the reason that doesn't get a reflexive yes is that those three ingredients have three different cache lives.

One PLP, three sources, three cache profiles — merging takes the worst of each
Coveo resultsper-query / ranked
high cardinality
+
CMS slot 3shared, slow
very cacheable
+
Live stockvolatile
~seconds
Merged PLP
high-cardinality · shortest TTL · purge-all · one failure domain

Fuse all three into one PLP endpoint and the response inherits the worst of each: it's keyed per query and filter set (from search), capped at seconds (from stock), can't be purged precisely when marketing edits the slot (it's buried in a per-query blob), and goes dark entirely if Coveo hiccups — taking the static promo and the product grid down with it. The merge didn't reduce the problem; it pooled four of them.

The composition that respects each ingredient's cache life looks like this:

IngredientIts cache lifeWhat to do with it
Coveo search results Per-query, per-filter, often personalized ranking — high cardinality, barely shareable. Take it on its own terms — direct from the client, or via middleware — cached briefly per query at most. (This is also why "go straight to Coveo" can be the right call for search.)
CMS block in slot 3 Shared, changes a few times a week — long-lived, tag-invalidated. It doesn't depend on the search results — its slot is a layout rule, its content is merchandising. Resolve it from the category/CMS layout, cache it independently, and compose it into position 3. Don't route it through search.
Live stock per tile Changes by the second — the most volatile thing on the page. Fetch it late (client-side, after the grid renders), or merge it server-side but on a very short TTL. Never bake live counts into a long-cached grid — that's how you show "in stock" on a sold-out item.

Notice what this doesn't do: it doesn't refuse to use the BFF. You still cut the round trips worth cutting — product plus stock can be a single short-lived server response, for instance. It just declines to fuse the independently-cacheable CMS slot or to pretend search is shared-cacheable. Each piece keeps the cache life it wants. That's the precise answer the example deserves — and why it takes a paragraph instead of a word.

🔎
The "decrease network calls" instinct is right — that's what a BFF is for. The nuance is which calls: merge the ones that share a cache profile, keep separate the ones that don't. A good example like this one isn't a gotcha; it's three different cache lives that happen to render on one screen.

7 Patterns That Sidestep the Tradeoff

Often the cleanest answer to "how do I avoid N client calls?" isn't a runtime merge at all — it's shaping the data so the merge was never needed.

Co-locate at design time

If a content page needs specific products, put the SKUs in the CMS payload. Now one call to the CMS already tells the page what to fetch — there's no runtime chain of "call SAP, then call the CMS" to orchestrate, and each source still caches on its own terms. The dependency is resolved in the content model instead of in a request.

Cache per layer, compose at render

Keep the calls separate, let each cache at its optimal layer and TTL, and compose them on the page. You spend a little more coordination to keep the cheap, independent caching the merge would have thrown away.

Cache the shell, fetch the volatile bits late

The most cache-friendly storefront is a stable, shared shell with a few volatile or per-user holes punched in it — stock, cart, customer pricing — fetched separately after load. The shell caches hard; the holes never touch the shared cache. (This is the same "don't federate per-user data into the cached page" rule from the caching piece.)


8 The Decision, in Four Questions

Before you fold two calls into one, the useful question is never "can the BFF merge these?" — it always can. It's "do these belong in the same cache entry?" Which comes down to whether they agree on four things:

Do the calls share…✅ Merge freely if⚠️ Keep separate if
Volatility (TTL)They change on similar timescales.One is content-stable, another is stock/price-volatile.
CardinalityAll shared, or all the same segment.Any ingredient is per-user / per-customer.
Invalidation triggerThey go stale on the same event.Each has its own publish/re-index/price event you'd want to purge precisely.
Failure toleranceAll-or-nothing is acceptable for the page.One source should degrade gracefully without the others.

Four "merge freely" and it's an easy yes — combine them and enjoy the round-trip savings. A genuine dependency is also an easy yes regardless, because the chain has to live somewhere. But a single "keep separate" is the moment a precise answer takes longer than "sure, merge them" — not because the platform can't, but because the merge would quietly hand a property of the worst ingredient to all the others.

🎯
Federate by cache profile, not by convenience. "One call" is a frontend goal; "one cache entry" is its backend consequence. When those two line up, merge without a second thought. When they don't, that hesitation isn't an objection to orchestration — it's the system telling you these two things want different cache lives.

Built on the Alokai Middleware. The capability is the easy part — it's there, it's first-class, and the example above is a few lines of code. The judgment is the rest. Pair this with Data Federation Tradeoffs for the caching model it leans on.