Data Federation
Data federation is a technique that allows you to combine multiple API requests into a single endpoint. This guide shows you how to use the getApiClient method to access and combine data from different integrations within your custom methods.
Why Use Data Federation?
- Minimized Network Traffic: Combining multiple server calls into one reduces latency and improves responsiveness for your users.
- Simplified Frontend Logic: Your frontend code becomes cleaner and less complex when you group related requests into a single endpoint.
- Data Unification: Retrieve data from multiple sources (commerce, CMS, legacy systems) and return a unified response that combines information from all of them.
Basic Usage
To access an integration, you need to pass its key to the context.getApiClient() method. The integration key must match the key you defined in your middleware.config.ts:
export const config = {
integrations: {
contentful: contentfulConfig, // key: "contentful"
commerce: commerceConfig, // key: "commerce"
},
}With these keys defined, you can now access the integrations using context.getApiClient("contentful") or context.getApiClient("commerce").
Here's how to combine data from multiple sources in a single method:
import type { Endpoints as ContentfulEndpoints } from '@vsf-enterprise/contentful-api';
export async function getPdp(
context: SapccIntegrationContext,
params: { id: string },
) {
const contentful = await context.getApiClient<ContentfulEndpoints>("contentful");
// Tip: starting from `@alokai/connect@1.2.0`
// calling getApiClient without arguments returns the current integration's API client
// also a `SapccEndpoints` generic type can be omitted
const sapcc = await context.getApiClient();
const [product, content] = await Promise.all([
sapcc.api.getProduct({ id: params.id }),
contentful.api.getEntries({
content_type: "product",
"fields.sku": params.id,
})
]);
return { product, content };
}When Should You Federate?
Federation is easy to do and easy to overuse. The capability is first-class - a custom method can call any number of backends server-side and return one shaped payload - so the interesting question is never can you merge two calls, but should you.
Here's the tradeoff in one sentence: one call on the client becomes one cache entry on the server. The moment you merge several sources into one response, you merge their cache policies too. Everything you fold in now shares one TTL, one cache key, one invalidation trigger, and one failure domain - and the merged response inherits the properties of its most demanding ingredient.
That collapse shows up in four ways:
- TTL - a cached response is only as fresh as its most volatile field. Merge slow-changing content with fast-changing stock and the whole entry is capped at the stock's lifetime.
- Cardinality - one per-user ingredient, such as a customer-specific contract price, makes the entire response per-user, so it can no longer be shared-cached even when the rest is identical for everyone.
- Invalidation - kept separate, each source purges on its own trigger (publish, re-index, price change). Merged into one key, any change to any ingredient forces purging the whole entry.
- Failure - independent calls fail independently and a page can degrade gracefully. A merged endpoint is a single failure domain unless you write partial-failure handling for every ingredient.
None of these is a reason not to federate - they're the prices on the tag, and the mistake is paying them without noticing. Before you fold two calls into one, check 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- or price-volatile |
| Cardinality | All shared, or all the same segment | Any ingredient is per-user / per-customer |
| Invalidation trigger | They go stale on the same event | Each has its own publish / re-index / price event to purge precisely |
| Failure tolerance | All-or-nothing is acceptable for the page | One source should degrade gracefully without the others |
A genuine server-side dependency is always a clean yes, regardless of cache profiles: when call B needs call A's output, the chain has to live somewhere, and the middleware is the right home. The example below is exactly that case.
Federation and caching are two sides of the same decision. See the Caching guide for cache-key cardinality and what should never be shared-cached.
Real-World Example: A Genuine Dependency
The cleanest reason to federate is a genuine dependency, where one call literally needs another's output. A search index is the classic case: it returns a list of SKUs, but not the full product data your tiles need to render. The only way to display them is to take that list and fetch the products - call B consumes call A's output, so it can't be parallelized.
You wouldn't want the browser running a search and then firing a dozen product calls, each with its own round trip. The chain belongs server-side, in a custom method:
import { type SapccIntegrationContext } from '@vsf-enterprise/sapcc-api';
import { type Endpoints as SearchEndpoints } from '../../types';
export async function getSearchResultsWithProducts(
context: SapccIntegrationContext,
params: { query: string },
) {
const sapcc = await context.getApiClient();
// Access the search integration using getApiClient
const search = await context.getApiClient<SearchEndpoints>("search");
// 1. The search index returns matching SKUs, but not full product data
const { hits } = await search.api.search({ query: params.query });
const skus = hits.map((hit) => hit.sku);
// 2. Enrich those SKUs with product details the index doesn't hold.
// This call depends on the search result - it cannot run in parallel.
const products = await Promise.all(
skus.map((sku) => sapcc.api.getProduct({ id: sku })),
);
// Return a single, ready-to-render response
return { query: params.query, products };
}This is the middleware earning its keep: a dependency that has to resolve server-side, handed to the browser as one typed call.
Don't merge volatile and stable data A tempting but mistaken example is a PDP that merges product details with live stock from a separate inventory system. It feels like federation, 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 - and their cache profiles clash. Product is stable; stock changes by the second. Fuse them and the cached response inherits stock's few-second TTL, throwing away the cheap, long-lived product cache. Cache the product instead, and fetch stock late - client-side, or on a very short TTL. "They render together" is not the same as "they belong in the same cache entry."
Using federation methods in the frontend To call federation methods from your storefront, follow the extension methods guide.
TypeScript Support
Proper typing is essential when working with getApiClient to maintain type safety and get full autocomplete support in your IDE.
Quick Start: Direct Typing
Each integration exports an Endpoints type that you can pass to getApiClient:
import { type Endpoints as ContentfulEndpoints } from '@vsf-enterprise/contentful-api';
const contentful = await context.getApiClient<ContentfulEndpoints>("contentful");For integrations with extensions, combine types based on the isNamespaced configuration:
import type { Endpoints, Endpoints as UnifiedEndpoints } from '@vsf-enterprise/sapcc-api';
// Namespaced: methods accessed via commerce.api.unified.method()
const commerce = await context.getApiClient<Endpoints & { unified: UnifiedEndpoints }>("commerce");
// Non-namespaced: methods accessed via commerce.api.method()
const commerce = await context.getApiClient<Endpoints & CustomEndpoints>("commerce");Recommended: Centralized Type Helper
Already have typedApiClient.ts?
If your project includes apps/storefront-middleware/integrations/typedApiClient.ts (shipped by default in new projects), you can skip to Adding New Integrations to learn how to extend it.
For better maintainability and to avoid repeating types across your codebase, create a centralized type-safe helper:
import type { ApiClient, IntegrationContext } from '@alokai/connect/middleware';
import type { SapccIntegrationContext } from '@vsf-enterprise/sapcc-api';
import type { ContentfulIntegrationContext } from '@vsf-enterprise/contentful-api';
import type { Endpoints as UnifiedEndpoints } from '@vsf-enterprise/unified-api-sapcc';
interface IntegrationContextMap {
commerce: {
context: SapccIntegrationContext;
extensions: { unified: UnifiedEndpoints };
};
contentful: {
context: ContentfulIntegrationContext;
extensions: {};
};
}
export async function getTypedApiClient<TKey extends keyof IntegrationContextMap>(
context: IntegrationContext,
key: TKey,
): Promise<
ApiClient<
IntegrationContextMap[TKey]['context']['api'] & IntegrationContextMap[TKey]['extensions'],
IntegrationContextMap[TKey]['context']['config'],
IntegrationContextMap[TKey]['context']['client']
>
> {
return context.getApiClient(key);
}Benefits:
- Define types once, use everywhere
- Full autocomplete and type checking
- Easy to maintain when adding new integrations or extensions
- Included by default in all new store templates
Usage:
import { getTypedApiClient } from '@/integrations/typedApiClient';
const contentful = await getTypedApiClient(context, "contentful");
const commerce = await getTypedApiClient(context, "commerce");
await commerce.api.unified.getProductDetails({ id: "123" });Adding New Integrations
When you add a new integration to your project, update the IntegrationContextMap to include it.
Example: Adding a Legacy System Integration
Let's say you need to connect to a legacy inventory system:
Configure the integration in your middleware.config.ts:
export const config = {
integrations: {
commerce: commerceConfig,
contentful: contentfulConfig,
legacyInventory: legacyInventoryConfig,
},
};Add the new integration to your IntegrationContextMap:
import type { LegacyInventoryContext } from './integrations/legacy-inventory/types';
interface IntegrationContextMap {
commerce: {
context: SapccIntegrationContext;
extensions: { unified: UnifiedEndpoints };
};
contentful: {
context: ContentfulIntegrationContext;
extensions: {};
};
legacyInventory: {
context: LegacyInventoryContext;
extensions: {}; // No extensions
};
}Use it in your custom methods:
const inventory = await getTypedApiClient(context, "legacyInventory");
const stock = await inventory.api.getStock({ sku: "12345" });Integration without Extensions
Most integrations start without extensions. Use extensions: {} for the type definition, and you can add extensions later as needed.
Adding Extensions to the Helper
When you add a new extension to an integration, you need to update the IntegrationContextMap types. The key difference is how you type the extensions property based on the isNamespaced configuration.
Example: Adding a Loyalty Extension
Let's say you're adding a loyalty program extension to your commerce integration:
// Extension configuration
const loyaltyExtension = {
name: 'loyalty',
extendApiMethods: {
getPoints: () => { /* ... */ }
},
isNamespaced: true,
}How to Type in IntegrationContextMap:
Methods accessed via commerce.api.loyalty.method()
import { Endpoints as LoyaltyEndpoints } from './extensions/loyalty';
interface IntegrationContextMap {
commerce: {
context: SapccIntegrationContext;
extensions: {
unified: UnifiedEndpoints;
loyalty: LoyaltyEndpoints; // Nested property
};
};
}Usage:
const commerce = await getTypedApiClient(context, "commerce");
await commerce.api.loyalty.getPoints({ customerId: "123" });Methods accessed via commerce.api.method()
import { Endpoints as LoyaltyEndpoints } from './extensions/loyalty';
interface IntegrationContextMap {
commerce: {
context: SapccIntegrationContext;
extensions: { unified: UnifiedEndpoints } & LoyaltyEndpoints; // Merged with &
};
}Usage:
const commerce = await getTypedApiClient(context, "commerce");
await commerce.api.getPoints({ customerId: "123" });Key Takeaways:
- Namespaced (
isNamespaced: true): Add extension as a nested property{ extensionName: Endpoints } - Non-namespaced (
isNamespaced: false): Merge with existing extensions using& - Multiple namespaced: Add all as nested properties
{ ext1: Type1; ext2: Type2 } - Mixed: Combine both patterns
{ namespaced: Type } & NonNamespacedType
Learn More See the Extensions Guide for details on creating and configuring extensions.