Composable architecture with Graph
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