Composable architecture with Graph
Design flexible, scalable composable architecture patterns using Optimizely Graph as the foundation for modern digital experiences.
Composable architecture structures digital experiences as modular, interchangeable components rather than monolithic platforms. Optimizely Graph serves as the content layer in composable systems, providing a unified API for content delivery while integrating with best-of-breed services.
This guide focuses on multi-service architecture: how to combine Graph with commerce platforms, search engines, authentication services, and other APIs using patterns like Backend-for-Frontend (BFF), GraphQL Federation, and event-driven architecture. For single-framework integration, see Dynamic frontend integration.
What is composable architecture?
Composable architecture follows MACH principles:
- Microservices – Small, independent services with single responsibilities
- API-first – All functionality exposed through APIs
- Cloud-native – Built for cloud infrastructure, scalable and resilient
- Headless – Decoupled frontend and backend
Benefits:
- Flexibility – Swap components without rebuilding the entire system
- Best-of-breed – Choose the best tool for each function
- Scalability – Scale individual services independently
- Speed – Teams work on components in parallel
- Future-proof – Evolve architecture incrementally
Graph in composable systems
Optimizely Graph fits into composable architecture as the content layer:
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (Next.js, React, Vue, Mobile Apps, Kiosks, Voice, etc.) │
└─────────────────────────────────────────────────────────────┘
↓ GraphQL API
┌─────────────────────────────────────────────────────────────┐
│ Experience Orchestration │
│ (BFF, API Gateway, GraphQL Mesh) │
└─────────────────────────────────────────────────────────────┘
↓ ↓ ↓ ↓
┌────────────────┬────────────────┬────────────────┬──────────┐
│ Content Layer │ Commerce Layer │ Search Layer │ Auth │
│ Optimizely │ Shopify / │ Algolia / │ Auth0 / │
│ Graph │ Commercetools │ Elastic │ Okta │
└────────────────┴────────────────┴────────────────┴──────────┘
Core patterns
Pattern 1: Backend for Frontend (BFF)
A BFF aggregates data from multiple services (Graph, commerce, search) into a unified API:
// pages/api/product/[id].js - BFF endpoint
export default async function handler(req, res) {
const { id, locale = 'en' } = req.query;
// Parallel requests to multiple services
const [contentData, commerceData, reviewData] = await Promise.all([
// Get content from Graph
fetch(process.env.GRAPH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
},
body: JSON.stringify({
query: `
query GetProductContent($key: String!, $locale: [Locales]) {
Product(
where: { _metadata: { key: { eq: $key } } }
locale: $locale
) {
item {
description
features
images {
urlSmall
urlMedium
urlLarge
}
}
}
}
`,
variables: { key: id, locale: [locale] }
})
}).then(r => r.json()),
// Get pricing/inventory from commerce platform
fetch(`${process.env.COMMERCE_API}/products/${id}`, {
headers: { 'Authorization': `Bearer ${process.env.COMMERCE_TOKEN}` }
}).then(r => r.json()),
// Get reviews from review service
fetch(`${process.env.REVIEWS_API}/products/${id}/reviews`)
.then(r => r.json())
]);
// Combine data from multiple sources
const product = {
id,
...contentData.data.Product.item,
price: commerceData.price,
inventory: commerceData.inventory,
reviews: reviewData
};
res.status(200).json(product);
}Frontend consumption: See Dynamic frontend integration for React/Vue/Angular examples.
Pattern 2: GraphQL Federation
Stitch multiple GraphQL APIs into a unified schema using Apollo Federation:
// services/content-service.js
import { buildSubgraphSchema } from '@apollo/subgraph';
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0")
type Product @key(fields: "id") {
id: ID!
description: String
images: [Image]
}
type Image {
urlSmall: String
urlMedium: String
urlLarge: String
alt: String
}
`;
const resolvers = {
Product: {
__resolveReference: async (reference) => {
// Query Optimizely Graph
const response = await fetch(process.env.GRAPH_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
},
body: JSON.stringify({
query: `query GetProduct($key: String!, $locale: [Locales]) {
Product(
where: { _metadata: { key: { eq: $key } } }
locale: $locale
) {
item {
description
images {
urlSmall
urlMedium
urlLarge
alt
}
}
}
}`,
variables: { key: reference.id, locale: [reference.locale ?? 'en'] }
})
});
const { data } = await response.json();
return data.Product.item;
}
}
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });Commerce service:
// services/commerce-service.js
const typeDefs = gql`
type Product @key(fields: "id") {
id: ID!
price: Float
inventory: Int
}
`;
const resolvers = {
Product: {
__resolveReference: async (reference) => {
// Query commerce platform
const response = await fetch(`${process.env.COMMERCE_API}/products/${reference.id}`);
return response.json();
}
}
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });Gateway:
// gateway.js
import { ApolloGateway } from '@apollo/gateway';
import { ApolloServer } from '@apollo/server';
const gateway = new ApolloGateway({
serviceList: [
{ name: 'content', url: 'http://localhost:4001' },
{ name: 'commerce', url: 'http://localhost:4002' }
]
});
const server = new ApolloServer({ gateway });Unified query:
query GetProduct($id: ID!) {
product(id: $id) {
# From content service (Graph)
description
images {
urlSmall
urlMedium
urlLarge
}
# From commerce service
price
inventory
}
}Pattern 3: Event-driven architecture
Use webhooks to keep services synchronized.
Configure webhooks in Graph, and then handle the webhook events as they are received.
// api/webhooks/graph-updated.js
export default async function handler(req, res) {
const { contentId, contentType, action } = req.body;
// Verify webhook signature
if (!verifyWebhookSignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Trigger dependent updates
await Promise.all([
// Invalidate CDN cache
invalidateCDN(contentId),
// Update search index
updateSearchIndex(contentId, contentType),
// Notify connected clients via WebSocket
notifyClients({ type: 'CONTENT_UPDATED', id: contentId }),
// Trigger personalization engine recalculation
recalculatePersonalization(contentId)
]);
res.status(200).json({ success: true });
}Pattern 4: Content as a Service (CaaS)
Expose Graph as a shared content service across multiple applications.
The content delivery service modifies the content for each platform.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Website │ │ Mobile App │ │ Kiosk │
│ (Next.js) │ │(React Native)│ │ (React) │
└──────────────┘ └──────────────┘ └──────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────────────┐
│ Content Delivery Service (BFF) │
│ - Authentication & Authorization │
│ - Content transformation per platform │
│ - Caching & performance optimization │
└─────────────────────────────────────────────────────┘
↓
┌──────────────────┐
│ Optimizely Graph │
│ (Content Layer) │
└──────────────────┘
Content service implementation:
// services/content-service.js
export class ContentService {
constructor(graphEndpoint, graphSingleKey) {
this.endpoint = graphEndpoint;
this.singleKey = graphSingleKey;
}
async getContent(contentType, filters, platform) {
const query = this.buildQuery(contentType, filters, platform);
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `epi-single ${this.singleKey}`
},
body: JSON.stringify({ query })
});
const { data } = await response.json();
// Transform for platform
return this.transformForPlatform(data, platform);
}
buildQuery(contentType, filters, platform) {
const fields = this.getFieldsForPlatform(platform);
return `
query Get${contentType}($filters: FilterInput!) {
${contentType}(where: $filters) {
items {
${fields.join('\n')}
}
}
}
`;
}
getFieldsForPlatform(platform) {
const baseFields = ['id', 'title'];
const platformFields = {
web: ['description', 'fullContent', 'images { urlSmall urlMedium }'],
mobile: ['summary', 'thumbnailImage { urlSmall }'],
kiosk: ['description', 'images { urlLarge }']
};
return [...baseFields, ...platformFields[platform]];
}
transformForPlatform(data, platform) {
// Platform-specific transformations
if (platform === 'mobile') {
return data.map(item => ({
...item,
// Compress for mobile
content: this.truncate(item.content, 500)
}));
}
return data;
}
}Composable commerce example
Combine Graph for content with a commerce platform for transactions.
Architecture:
- Optimizely Graph – Product descriptions, images, features, marketing content
- Commerce platform (Shopify, Commercetools) – Pricing, inventory, cart, checkout
- Recommendation engine – Personalized product suggestions
- Frontend – Aggregates data from all services
For framework integration examples, see Dynamic frontend integration.
Integration patterns
REST API wrapper
Expose Graph data through REST endpoints for legacy systems:
// pages/api/rest/articles.js
export default async function handler(req, res) {
const { category, limit = 10, page = 1 } = req.query;
const skip = (page - 1) * limit;
const response = await fetch(process.env.GRAPH_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `epi-single ${process.env.GRAPH_SINGLE_KEY}`
},
body: JSON.stringify({
query: `
query GetArticles($category: String, $limit: Int!, $skip: Int!) {
Article(
where: { category: { eq: $category } }
limit: $limit
skip: $skip
) {
items { id, title, excerpt, publishedDate }
total
}
}
`,
variables: { category, limit: parseInt(limit), skip }
})
});
const { data } = await response.json();
// Return REST-style response
res.status(200).json({
data: data.Article.items,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: data.Article.total,
totalPages: Math.ceil(data.Article.total / limit)
}
});
}Content mesh
Create a content mesh connecting multiple content sources:
// lib/content-mesh.js
export class ContentMesh {
constructor(sources) {
this.sources = sources; // { graph, cms, dam, etc. }
}
async getUnifiedContent(contentId, locale = 'en') {
const source = this.determineSource(contentId);
switch (source) {
case 'graph':
return this.fetchFromGraph(contentId, locale);
case 'dam':
return this.fetchFromDAM(contentId);
default:
throw new Error(`Unknown source: ${source}`);
}
}
async fetchFromGraph(contentId, locale) {
const response = await fetch(this.sources.graph.endpoint, {
method: 'POST',
headers: {
'Authorization': `epi-single ${this.sources.graph.singleKey}`
},
body: JSON.stringify({
query: `query GetContent($key: String!, $locale: [Locales]) { Content(where: { _metadata: { key: { eq: $key } } }, locale: $locale) { item { ... } } }`,
variables: { key: contentId, locale: [locale] }
})
});
const { data } = await response.json();
return this.normalizeContent(data.Content.item, 'graph');
}
normalizeContent(content, source) {
// Transform to unified format
return {
id: content.id,
title: content.title || content.name,
body: content.content || content.description,
source
};
}
}Best practices
1. Design for failure
Services will fail. Build resilience:
async function fetchWithFallback(primary, fallback) {
try {
const response = await fetch(primary);
if (!response.ok) throw new Error('Primary failed');
return response.json();
} catch (error) {
console.error('Primary service failed, using fallback:', error);
return fetch(fallback).then(r => r.json());
}
}The fallback URL can point to a Redis cache endpoint, a CDN-served static snapshot, or a secondary service.
2. Implement circuit breakers
Prevent cascading failures:
import CircuitBreaker from 'opossum';
const options = {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000
};
const breaker = new CircuitBreaker(fetchFromGraph, options);
breaker.fallback(() => getCachedData());
breaker.on('open', () => console.log('Circuit opened!'));3. Use caching strategically
Cache at multiple levels:
// CDN caching
res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
// Application caching
const cachedData = await redis.get(cacheKey);
if (cachedData) return JSON.parse(cachedData);
// Query result caching in Apollo
const { data } = useQuery(GET_CONTENT, {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-and-network'
});4. Monitor service dependencies
Track service health:
// Health check endpoint
app.get('/health', async (req, res) => {
const services = await Promise.allSettled([
checkGraph(),
checkCommerce(),
checkSearch()
]);
const health = {
status: services.every(s => s.status === 'fulfilled') ? 'healthy' : 'degraded',
services: {
graph: services[0].status === 'fulfilled',
commerce: services[1].status === 'fulfilled',
search: services[2].status === 'fulfilled'
}
};
res.status(health.status === 'healthy' ? 200 : 503).json(health);
});5. Version your APIs
Enable independent evolution:
// v1/api/content.js
export default function handlerV1(req, res) {
// Legacy format
}
// v2/api/content.js
export default function handlerV2(req, res) {
// New format with breaking changes
}Migration strategy
Strangler fig pattern
Gradually replace the monolith with composable services:
Phase 1: Monolith handles all requests
┌──────────────────────────────────────┐
│ Monolithic CMS │
└──────────────────────────────────────┘
Phase 2: Route some content through Graph
┌──────────────────────────────────────┐
│ Proxy Layer (decides routing) │
└──────────────────────────────────────┘
↓ ↓
┌──────────┐ ┌─────────────────┐
│ Monolith │ │ Optimizely Graph│
└──────────┘ └─────────────────┘
Phase 3: All content through composable stack
┌──────────────────────────────────────┐
│ Modern Frontend │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ Optimizely Graph + Services │
└──────────────────────────────────────┘
Next steps
- Explore Headless architecture patterns for frontend strategies
- Learn Caching best practices for multi-service caching
- Review Dynamic frontend integration for framework implementation
Updated 4 days ago
