Backend for Frontend (BFF) Pattern: Client-Optimized API Architecture
Backend for Frontend (BFF) Pattern: Client-Optimized API Architecture
Different clients have different needs. A mobile app on a 3G connection needs minimal data and battery-friendly polling. A desktop dashboard needs rich real-time updates. A partner API needs stable, versioned contracts. The Backend for Frontend (BFF) pattern creates dedicated backend services for each frontend type, optimizing the API contract, payload shape, and interaction patterns for specific client requirements.
The Problem with One-Size-Fits-All APIs
┌─────────────────────────────────────────────────────────────────┐
│ GENERIC API PROBLEMS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Generic API │
│ ┌─────────────┐ │
│ │ │ │
│ Mobile ──────────▶│ /users │◀────────────── Web │
│ (needs 3 fields) │ /orders │ (needs 50 fields) │
│ │ /products │ │
│ TV App ──────────▶│ │◀────────────── Partner │
│ (needs images) │ │ (needs XML) │
│ └─────────────┘ │
│ │
│ Problems: │
│ │
│ 1. Over-fetching │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Mobile requests /users/123 │ │
│ │ Needs: { name, avatar } │ │
│ │ Gets: { name, avatar, email, phone, address, │ │
│ │ preferences, billing, history, ... } │ │
│ │ Wasted: 95% of payload │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 2. Under-fetching (N+1 problem) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Dashboard needs order with items and user │ │
│ │ Request 1: GET /orders/123 │ │
│ │ Request 2: GET /users/456 │ │
│ │ Request 3-12: GET /products/{id} × 10 │ │
│ │ Total: 12 round trips │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 3. Coupling │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ API change breaks all clients simultaneously │ │
│ │ Can't deprecate fields (mobile still uses v1) │ │
│ │ Can't optimize for one without hurting others │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 4. Mixed concerns │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Mobile needs offline-first with sync │ │
│ │ Web needs real-time WebSocket updates │ │
│ │ Partner needs stable REST with XML support │ │
│ │ All competing requirements in one API │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
BFF Architecture
┌─────────────────────────────────────────────────────────────────┐
│ BFF ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌───────────────┐ │
│ │ Mobile │────▶│ Mobile BFF │──┐ │
│ │ App │ │ │ │ │
│ └──────────┘ │ • Minimal JSON│ │ │
│ │ • Batch APIs │ │ │
│ │ • Offline sync│ │ │
│ └───────────────┘ │ │
│ │ │
│ ┌──────────┐ ┌───────────────┐ │ ┌───────────────────┐ │
│ │ Web │────▶│ Web BFF │──┼─▶│ Domain Services │ │
│ │Dashboard │ │ │ │ │ │ │
│ └──────────┘ │ • Rich payload│ │ │ • User Service │ │
│ │ • WebSocket │ │ │ • Order Service │ │
│ │ • GraphQL │ │ │ • Product Service│ │
│ └───────────────┘ │ │ • Inventory Svc │ │
│ │ │ │ │
│ ┌──────────┐ ┌───────────────┐ │ └───────────────────┘ │
│ │ Partner │────▶│ Partner BFF │──┘ │
│ │ API │ │ │ │
│ └──────────┘ │ • Stable REST │ │
│ │ • XML support │ │
│ │ • Rate limits │ │
│ └───────────────┘ │
│ │
│ Each BFF: │
│ • Owned by frontend team │
│ • Optimized for specific client │
│ • Can evolve independently │
│ • Aggregates/transforms domain data │
│ │
└─────────────────────────────────────────────────────────────────┘
Implementation
// mobile-bff/src/server.ts
import express from 'express';
import compression from 'compression';
// Mobile BFF - Optimized for bandwidth and battery
class MobileBFF {
private readonly app = express();
private readonly userService: UserServiceClient;
private readonly orderService: OrderServiceClient;
private readonly productService: ProductServiceClient;
constructor(
userService: UserServiceClient,
orderService: OrderServiceClient,
productService: ProductServiceClient
) {
this.userService = userService;
this.orderService = orderService;
this.productService = productService;
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
// Aggressive compression for mobile
this.app.use(compression({ level: 9 }));
// Parse minimal JSON
this.app.use(express.json({ limit: '100kb' }));
// Version header for app updates
this.app.use((req, res, next) => {
res.setHeader('X-Min-App-Version', '2.5.0');
next();
});
}
private setupRoutes(): void {
// Batched endpoint - single request for home screen
this.app.get('/api/v1/home', async (req, res) => {
const userId = req.user.id;
// Parallel fetch from domain services
const [user, recentOrders, recommendations] = await Promise.all([
this.userService.getUser(userId, ['name', 'avatar', 'unreadCount']),
this.orderService.getRecentOrders(userId, 3),
this.productService.getRecommendations(userId, 5)
]);
// Mobile-optimized response
res.json({
user: {
name: user.name,
avatarUrl: this.optimizeImageUrl(user.avatar, 'thumbnail'),
badge: user.unreadCount
},
orders: recentOrders.map(o => ({
id: o.id,
status: o.status,
statusLabel: this.localizeStatus(o.status, req.locale),
total: this.formatCurrency(o.total, req.locale),
itemCount: o.items.length
// Exclude: full items, addresses, payment details
})),
recommendations: recommendations.map(p => ({
id: p.id,
name: p.name,
imageUrl: this.optimizeImageUrl(p.images[0], 'card'),
price: this.formatCurrency(p.price, req.locale)
// Exclude: description, specs, reviews
})),
_meta: {
cacheUntil: Date.now() + 300_000, // 5 min cache
syncToken: this.generateSyncToken(userId)
}
});
});
// Delta sync for offline support
this.app.post('/api/v1/sync', async (req, res) => {
const { lastSyncToken, pendingChanges } = req.body;
// Apply offline changes
const conflicts = await this.applyOfflineChanges(
req.user.id,
pendingChanges
);
// Get changes since last sync
const changes = await this.getChangesSince(
req.user.id,
lastSyncToken
);
res.json({
applied: pendingChanges.length - conflicts.length,
conflicts,
changes,
newSyncToken: this.generateSyncToken(req.user.id)
});
});
// Batch operations endpoint
this.app.post('/api/v1/batch', async (req, res) => {
const { operations } = req.body;
const results = await Promise.allSettled(
operations.map((op: BatchOperation) =>
this.executeBatchOperation(op, req.user.id)
)
);
res.json({
results: results.map((r, i) => ({
operationId: operations[i].id,
success: r.status === 'fulfilled',
data: r.status === 'fulfilled' ? r.value : undefined,
error: r.status === 'rejected' ? r.reason.message : undefined
}))
});
});
}
private optimizeImageUrl(url: string, size: 'thumbnail' | 'card' | 'full'): string {
const dimensions = {
thumbnail: '64x64',
card: '200x200',
full: '800x800'
};
// Return CDN URL with resize parameters
return `${url}?size=${dimensions[size]}&format=webp&quality=80`;
}
private formatCurrency(amount: number, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD'
}).format(amount / 100);
}
private localizeStatus(status: string, locale: string): string {
// Return localized status string
return status;
}
private generateSyncToken(userId: string): string {
return `${userId}:${Date.now()}:${crypto.randomUUID()}`;
}
private async applyOfflineChanges(
userId: string,
changes: OfflineChange[]
): Promise<Conflict[]> {
// Apply changes with conflict detection
return [];
}
private async getChangesSince(
userId: string,
token: string
): Promise<Change[]> {
// Get changes since sync token
return [];
}
private async executeBatchOperation(
op: BatchOperation,
userId: string
): Promise<unknown> {
// Execute single batch operation
return {};
}
}
// web-bff/src/server.ts
// Web BFF - Rich features, real-time updates
class WebBFF {
private readonly app = express();
private readonly wss: WebSocket.Server;
constructor(
private readonly services: DomainServices,
httpServer: http.Server
) {
this.wss = new WebSocket.Server({ server: httpServer });
this.setupWebSocket();
this.setupRoutes();
}
private setupWebSocket(): void {
this.wss.on('connection', (ws, req) => {
const userId = this.authenticateWs(req);
// Subscribe to user's events
this.subscribeToUserEvents(userId, (event) => {
ws.send(JSON.stringify(event));
});
ws.on('message', (data) => {
this.handleWsMessage(userId, JSON.parse(data.toString()));
});
});
}
private setupRoutes(): void {
// GraphQL endpoint for flexible queries
this.app.use('/graphql', graphqlHTTP({
schema: this.buildSchema(),
graphiql: process.env.NODE_ENV === 'development'
}));
// Dashboard aggregate endpoint
this.app.get('/api/v1/dashboard', async (req, res) => {
const userId = req.user.id;
// Rich parallel fetch
const [
user,
stats,
recentOrders,
notifications,
analytics
] = await Promise.all([
this.services.user.getFullProfile(userId),
this.services.order.getUserStats(userId),
this.services.order.getRecentOrders(userId, 10),
this.services.notification.getUnread(userId),
this.services.analytics.getUserDashboard(userId)
]);
// Full dashboard response with nested data
res.json({
user: {
...user,
// Include everything for web
preferences: user.preferences,
billingInfo: user.billingInfo,
addresses: user.addresses
},
stats: {
totalOrders: stats.totalOrders,
totalSpent: stats.totalSpent,
avgOrderValue: stats.avgOrderValue,
ordersByMonth: stats.ordersByMonth
},
orders: await this.enrichOrders(recentOrders),
notifications,
analytics: {
charts: analytics.charts,
insights: analytics.insights,
comparison: analytics.comparison
},
realtime: {
wsEndpoint: `/ws?token=${this.generateWsToken(userId)}`,
channels: ['orders', 'notifications', 'inventory']
}
});
});
// Real-time inventory check
this.app.get('/api/v1/products/:id/availability', async (req, res) => {
const product = await this.services.product.getProduct(req.params.id);
const inventory = await this.services.inventory.getStock(req.params.id);
res.json({
productId: req.params.id,
availability: {
inStock: inventory.quantity > 0,
quantity: inventory.quantity,
warehouses: inventory.byWarehouse,
restockDate: inventory.restockDate,
alternatives: inventory.quantity === 0
? await this.services.product.getAlternatives(req.params.id)
: []
},
pricing: {
base: product.price,
discounts: await this.services.pricing.getDiscounts(
req.params.id,
req.user.id
),
final: await this.services.pricing.calculate(
req.params.id,
req.user.id
)
}
});
});
}
private async enrichOrders(orders: Order[]): Promise<EnrichedOrder[]> {
// Batch fetch related data
const userIds = [...new Set(orders.map(o => o.userId))];
const productIds = [...new Set(orders.flatMap(o => o.items.map(i => i.productId)))];
const [users, products] = await Promise.all([
this.services.user.getUsersByIds(userIds),
this.services.product.getProductsByIds(productIds)
]);
const userMap = new Map(users.map(u => [u.id, u]));
const productMap = new Map(products.map(p => [p.id, p]));
return orders.map(order => ({
...order,
customer: userMap.get(order.userId),
items: order.items.map(item => ({
...item,
product: productMap.get(item.productId)
}))
}));
}
private buildSchema(): GraphQLSchema {
// Build GraphQL schema for flexible querying
return buildSchema(`
type Query {
user(id: ID!): User
orders(userId: ID!, limit: Int): [Order]
product(id: ID!): Product
}
type User {
id: ID!
name: String!
email: String!
orders(limit: Int): [Order]
}
type Order {
id: ID!
status: String!
items: [OrderItem]
total: Float!
}
type Product {
id: ID!
name: String!
price: Float!
inventory: Inventory
}
`);
}
private subscribeToUserEvents(
userId: string,
callback: (event: unknown) => void
): void {
// Subscribe to event stream
}
private handleWsMessage(userId: string, message: unknown): void {
// Handle WebSocket messages
}
private authenticateWs(req: http.IncomingMessage): string {
// Authenticate WebSocket connection
return '';
}
private generateWsToken(userId: string): string {
// Generate WebSocket auth token
return '';
}
}
API Aggregation Patterns
┌─────────────────────────────────────────────────────────────────┐
│ AGGREGATION PATTERNS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Pattern 1: PARALLEL AGGREGATION │
│ ────────────────────────────────── │
│ │
│ BFF ─────┬──────▶ User Service ──────┐ │
│ │ │ │
│ ├──────▶ Order Service ─────┼──────▶ Response │
│ │ │ │
│ └──────▶ Product Service ───┘ │
│ │
│ All calls in parallel, merge results │
│ Latency = max(individual latencies) │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Pattern 2: SEQUENTIAL WITH DEPENDENCIES │
│ ──────────────────────────────────────── │
│ │
│ BFF ──▶ User Service ──▶ [userId] ──▶ Order Service ──▶ Resp │
│ │ │
│ └──▶ Preferences Service │
│ │
│ First call provides data for subsequent calls │
│ Latency = sum(sequential calls) │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Pattern 3: HYBRID (Parallel + Sequential) │
│ ────────────────────────────────────────── │
│ │
│ BFF ──▶ User Service ──┬──▶ [userId] ──┬──▶ Orders ──┐ │
│ │ │ │ │
│ └──▶ Profile ───┴──▶ Prefs ───┼──▶ Resp│
│ │ │
│ ├─────── parallel ───────┤├── parallel ───────┤ │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Pattern 4: STREAMING AGGREGATION │
│ ───────────────────────────────── │
│ │
│ BFF ──▶ User Service ──▶ [Stream partial response] │
│ │ │
│ └──▶ Order Service ──▶ [Append to stream] │
│ │ │
│ └──▶ Product Service ──▶ [Complete stream] │
│ │
│ Send data as it arrives, don't wait for all │
│ │
└─────────────────────────────────────────────────────────────────┘
// aggregation-patterns.ts
// Parallel aggregation with timeout and fallbacks
async function aggregateHomeScreen(
userId: string,
services: DomainServices
): Promise<HomeScreenResponse> {
const timeout = 3000; // 3 second timeout
const results = await Promise.allSettled([
withTimeout(services.user.getProfile(userId), timeout),
withTimeout(services.order.getRecent(userId, 5), timeout),
withTimeout(services.product.getRecommended(userId, 10), timeout),
withTimeout(services.notification.getUnread(userId), timeout)
]);
return {
user: results[0].status === 'fulfilled'
? results[0].value
: getFallbackUser(userId),
orders: results[1].status === 'fulfilled'
? results[1].value
: [], // Empty array fallback
recommendations: results[2].status === 'fulfilled'
? results[2].value
: getDefaultRecommendations(),
notifications: results[3].status === 'fulfilled'
? results[3].value
: { count: 0, items: [] },
_degraded: results.some(r => r.status === 'rejected')
};
}
// Dependency-aware aggregation
class DependencyGraph<T> {
private nodes: Map<string, {
fetch: () => Promise<T>;
dependencies: string[];
}> = new Map();
private results: Map<string, T> = new Map();
add(
id: string,
fetch: () => Promise<T>,
dependencies: string[] = []
): this {
this.nodes.set(id, { fetch, dependencies });
return this;
}
async execute(): Promise<Map<string, T>> {
const executed = new Set<string>();
const executing = new Map<string, Promise<T>>();
const executeNode = async (id: string): Promise<T> => {
if (this.results.has(id)) {
return this.results.get(id)!;
}
if (executing.has(id)) {
return executing.get(id)!;
}
const node = this.nodes.get(id);
if (!node) {
throw new Error(`Unknown node: ${id}`);
}
// Execute dependencies first
await Promise.all(
node.dependencies.map(dep => executeNode(dep))
);
// Execute this node
const promise = node.fetch();
executing.set(id, promise);
const result = await promise;
this.results.set(id, result);
executed.add(id);
return result;
};
// Execute all nodes
await Promise.all(
Array.from(this.nodes.keys()).map(id => executeNode(id))
);
return this.results;
}
}
// Usage
const graph = new DependencyGraph()
.add('user', () => userService.getUser(userId))
.add('preferences', () => prefService.get(userId), ['user'])
.add('orders', () => orderService.getByUser(userId), ['user'])
.add('recommendations', () => recService.get(userId), ['user', 'preferences']);
const results = await graph.execute();
// Streaming aggregation for SSE/chunked responses
async function* streamDashboard(
userId: string,
services: DomainServices
): AsyncGenerator<DashboardChunk> {
// Yield user immediately
const user = await services.user.getProfile(userId);
yield { type: 'user', data: user };
// Start parallel fetches
const orderPromise = services.order.getRecent(userId, 20);
const analyticsPromise = services.analytics.getDashboard(userId);
// Yield orders when ready
const orders = await orderPromise;
yield { type: 'orders', data: orders };
// Yield analytics (slower)
const analytics = await analyticsPromise;
yield { type: 'analytics', data: analytics };
// Signal completion
yield { type: 'complete', timestamp: Date.now() };
}
// Express route using streaming
app.get('/api/v1/dashboard/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const stream = streamDashboard(req.user.id, services);
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
res.end();
});
BFF Ownership and Deployment
┌─────────────────────────────────────────────────────────────────┐
│ BFF OWNERSHIP MODELS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Model 1: FRONTEND TEAM OWNS BFF │
│ ───────────────────────────────── │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Mobile Team │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ iOS App │ │ Mobile BFF │ │ │
│ │ │ Android App│ ───▶ │ (Node.js) │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ │ │ │
│ │ Same team, same repo, same deploy cycle │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Pros: Fast iteration, perfect alignment with client needs │
│ Cons: Backend skills required on frontend team │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Model 2: SHARED BFF TEAM │
│ ───────────────────────── │
│ │
│ Frontend Teams ──────▶ BFF Team ──────▶ Domain Services │
│ (Platform) │
│ │
│ Pros: Consistent patterns, efficient resource use │
│ Cons: BFF team becomes bottleneck │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Model 3: DOMAIN TEAMS EXTEND BFF │
│ ───────────────────────────────── │
│ │
│ BFF (GraphQL) ◀──── Domain Teams contribute resolvers │
│ │
│ Order Team: OrderResolver │
│ User Team: UserResolver │
│ Product Team: ProductResolver │
│ │
│ Pros: Domain expertise in BFF, decentralized │
│ Cons: Coordination overhead, consistency challenges │
│ │
└─────────────────────────────────────────────────────────────────┘
Error Handling and Resilience
// bff-resilience.ts
import CircuitBreaker from 'opossum';
class ResilientBFF {
private readonly breakers: Map<string, CircuitBreaker> = new Map();
constructor(
private readonly services: DomainServices,
private readonly fallbacks: FallbackProvider
) {
this.initializeCircuitBreakers();
}
private initializeCircuitBreakers(): void {
const options = {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000
};
// Create circuit breaker for each service
for (const [name, service] of Object.entries(this.services)) {
const breaker = new CircuitBreaker(
(method: string, ...args: unknown[]) =>
(service as any)[method](...args),
options
);
breaker.fallback(async (method: string, ...args: unknown[]) => {
// Return fallback data when circuit is open
return this.fallbacks.getFallback(name, method, ...args);
});
breaker.on('open', () => {
console.log(`Circuit breaker opened for ${name}`);
// Alert operations
});
this.breakers.set(name, breaker);
}
}
async getUser(userId: string): Promise<User> {
return this.breakers.get('user')!.fire('getUser', userId);
}
async getOrders(userId: string): Promise<Order[]> {
return this.breakers.get('order')!.fire('getByUser', userId);
}
}
// Partial failure handling
class PartialResponseHandler {
async aggregate<T extends Record<string, unknown>>(
fetchers: Record<keyof T, () => Promise<T[keyof T]>>
): Promise<{
data: Partial<T>;
errors: Record<string, string>;
complete: boolean;
}> {
const results = await Promise.allSettled(
Object.entries(fetchers).map(async ([key, fetch]) => ({
key,
value: await fetch()
}))
);
const data: Partial<T> = {};
const errors: Record<string, string> = {};
for (const result of results) {
if (result.status === 'fulfilled') {
data[result.value.key as keyof T] = result.value.value;
} else {
errors[result.reason.key] = result.reason.message;
}
}
return {
data,
errors,
complete: Object.keys(errors).length === 0
};
}
}
// Response envelope with degradation info
interface BFFResponse<T> {
data: T;
_meta: {
degraded: boolean;
unavailableServices: string[];
cachedFields: string[];
timestamp: number;
latencyMs: number;
};
}
function wrapResponse<T>(
data: T,
context: { degraded: boolean; unavailable: string[]; cached: string[] },
startTime: number
): BFFResponse<T> {
return {
data,
_meta: {
degraded: context.degraded,
unavailableServices: context.unavailable,
cachedFields: context.cached,
timestamp: Date.now(),
latencyMs: Date.now() - startTime
}
};
}
Caching Strategies
┌─────────────────────────────────────────────────────────────────┐
│ BFF CACHING LAYERS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client ──▶ CDN ──▶ BFF ──▶ Cache ──▶ Domain Services │
│ │ │ │ │
│ │ │ └── Redis/Memcached │
│ │ │ Per-user, per-request │
│ │ │ │
│ │ └── Response cache │
│ │ Aggregate response caching │
│ │ │
│ └── Edge cache │
│ Public data, long TTL │
│ │
│ ═══════════════════════════════════════════════════════════ │
│ │
│ Caching Decision Matrix: │
│ │
│ │ Data Type │ Cache Level │ TTL │ Invalidation │ │
│ │──────────────────│─────────────│───────────│──────────────│ │
│ │ Product catalog │ CDN │ 1 hour │ On publish │ │
│ │ User profile │ BFF Redis │ 5 minutes │ On update │ │
│ │ Order history │ BFF Redis │ 1 minute │ On new order │ │
│ │ Real-time stock │ None │ - │ - │ │
│ │ Recommendations │ BFF local │ 15 min │ Time-based │ │
│ │
└─────────────────────────────────────────────────────────────────┘
// bff-caching.ts
class CachingBFF {
private readonly localCache: LRUCache<string, unknown>;
private readonly redisCache: Redis;
constructor(redis: Redis) {
this.localCache = new LRUCache({
max: 1000,
ttl: 60_000 // 1 minute default
});
this.redisCache = redis;
}
async getWithCache<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const {
localTtl = 60_000,
redisTtl = 300_000,
staleWhileRevalidate = false
} = options;
// Check local cache first (fastest)
const localCached = this.localCache.get(key);
if (localCached !== undefined) {
return localCached as T;
}
// Check Redis cache
const redisCached = await this.redisCache.get(key);
if (redisCached) {
const data = JSON.parse(redisCached) as T;
// Populate local cache
this.localCache.set(key, data, { ttl: localTtl });
return data;
}
// Fetch from source
const data = await fetcher();
// Populate caches
this.localCache.set(key, data, { ttl: localTtl });
await this.redisCache.setex(key, redisTtl / 1000, JSON.stringify(data));
return data;
}
async invalidate(patterns: string[]): Promise<void> {
// Invalidate local cache
for (const key of this.localCache.keys()) {
if (patterns.some(p => key.includes(p))) {
this.localCache.delete(key);
}
}
// Invalidate Redis cache
for (const pattern of patterns) {
const keys = await this.redisCache.keys(`*${pattern}*`);
if (keys.length > 0) {
await this.redisCache.del(...keys);
}
}
}
// Cache aggregated responses
async getCachedAggregate<T>(
cacheKey: string,
aggregator: () => Promise<T>,
dependencies: Array<{
key: string;
fetcher: () => Promise<unknown>;
ttl: number;
}>
): Promise<T> {
// Check if aggregate is cached
const cached = await this.redisCache.get(cacheKey);
if (cached) {
return JSON.parse(cached) as T;
}
// Fetch dependencies with individual caching
const results = await Promise.all(
dependencies.map(dep =>
this.getWithCache(dep.key, dep.fetcher, { redisTtl: dep.ttl })
)
);
// Build aggregate
const aggregate = await aggregator();
// Cache aggregate with shortest dependency TTL
const minTtl = Math.min(...dependencies.map(d => d.ttl));
await this.redisCache.setex(
cacheKey,
minTtl / 1000,
JSON.stringify(aggregate)
);
return aggregate;
}
}
interface CacheOptions {
localTtl?: number;
redisTtl?: number;
staleWhileRevalidate?: boolean;
}
Anti-Patterns and Pitfalls
┌─────────────────────────────────────────────────────────────────┐
│ BFF ANTI-PATTERNS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ANTI-PATTERN 1: BFF as Proxy │
│ ──────────────────────────── │
│ Problem: BFF just forwards requests without transformation │
│ Symptoms: 1:1 mapping between BFF and domain endpoints │
│ Solution: BFF should aggregate, transform, optimize │
│ │
│ ANTI-PATTERN 2: Business Logic in BFF │
│ ───────────────────────────────────── │
│ Problem: Domain logic duplicated across BFFs │
│ Symptoms: Same validation in Mobile BFF and Web BFF │
│ Solution: BFF handles presentation, domains handle logic │
│ │
│ ANTI-PATTERN 3: Too Many BFFs │
│ ─────────────────────────── │
│ Problem: BFF for every minor client variation │
│ Symptoms: iOS BFF, Android BFF, iPad BFF, Watch BFF... │
│ Solution: Group by capability, not device │
│ │
│ ANTI-PATTERN 4: Tight Domain Coupling │
│ ───────────────────────────────────── │
│ Problem: BFF knows internal domain schemas │
│ Symptoms: Domain schema change breaks BFF │
│ Solution: Anti-corruption layer, stable domain contracts │
│ │
│ ANTI-PATTERN 5: No Fallbacks │
│ ──────────────────────────── │
│ Problem: Any service failure crashes entire BFF response │
│ Symptoms: Order service down = home screen blank │
│ Solution: Circuit breakers, fallbacks, partial responses │
│ │
│ ANTI-PATTERN 6: Chatty BFF-to-Service Communication │
│ ───────────────────────────────────────────────── │
│ Problem: BFF makes many sequential calls to services │
│ Symptoms: 50 service calls per BFF request │
│ Solution: Batch endpoints, DataLoader, aggregation services │
│ │
└─────────────────────────────────────────────────────────────────┘
Decision Framework
| Factor | Generic API | BFF | GraphQL Gateway |
|---|---|---|---|
| Client diversity | Low | High | Medium-High |
| Team structure | Unified | Frontend teams | Federated |
| Flexibility | Low | Medium | High |
| Complexity | Low | Medium | High |
| Mobile optimization | Hard | Easy | Possible |
| Real-time needs | Hard | Per-BFF | Complex |
When to Use BFF
Use BFF when:
- Different clients have significantly different needs
- Frontend teams need autonomy to iterate quickly
- Mobile/web have different performance requirements
- You need platform-specific optimizations (offline, push, etc.)
Avoid BFF when:
- Single client type (pure web app)
- Clients have similar needs (minor variations)
- GraphQL can satisfy flexibility requirements
- Team size doesn't support multiple backends
The Backend for Frontend pattern optimizes API interactions for specific client needs while keeping domain services generic and reusable. Success requires clear boundaries—BFFs handle presentation concerns while domains handle business logic. The pattern trades increased backend complexity for improved client experiences and team autonomy, making it essential for organizations with diverse client platforms and independent frontend teams.
What did you think?