Patrón Backends for Frontends
Cómo crear dedicated backends per client type. Cubre BFF para web, mobile, y desktop. Cubre API aggregation, client-specific optimization, y GraphQL BFF.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
El backends for frontends (BFF) pattern crea un separate backend service para cada frontend client type: uno para web, uno para mobile, uno para desktop. Cada BFF aggregateéa data desde múltiples microservices, optimizeéa responses para su client’s needs, y handlea client-specific concerns como authentication, caching, y response formatting. En vez de un single generic API que sirve a todos los clients (y termina bloated con client-specific endpoints), cada BFF está tailored a su client. El web BFF puede returnear rich data para large screens; el mobile BFF returnea minimal payloads para slow networks. Los teams ownean su BFF end-to-end, desde frontend hasta backend.
When to Use
- Múltiples frontend clients (web, mobile, desktop, smartwatch) con different data needs
- Mobile clients que necesitan smaller payloads que web
- Frontend teams que quieren iteratear independentemente de backend teams
- Applications donde diferentes clients necesitan different authentication flows
- Reducir over-fetching: clients reciben solo la data que necesitan
When NOT to Use
- Single client type (solo un web app)
- Simple API con few endpoints que ya sirve a todos los clients bien
- Team chico donde maintainar múltiples BFFs agrega too much overhead
- Internal tools donde payload size y client optimization no importan
Solution
BFF architecture diagram
┌──────────────┐
│ Web Client │
└──────┬───────┘
│
┌──────▼───────┐
│ Web BFF │
│ (Node.js) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼─────┐ ┌───▼──────┐
│ User │ │ Order │ │ Inventory│
│ Service │ │ Service │ │ Service │
└───────────┘ └─────────┘ └──────────┘
│ │ │
│ │ │
┌──────▲───────┐
│ Mobile BFF │
│ (Go) │
└──────▲───────┘
│
┌──────┴───────┐
│ Mobile Client│
└──────────────┘
Web BFF (Node.js)
// web-bff/server.js — Web BFF aggregateéa múltiples services
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const USER_SERVICE = process.env.USER_SERVICE_URL || "http://user-service:3001";
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || "http://order-service:3002";
const INVENTORY_SERVICE = process.env.INVENTORY_SERVICE_URL || "http://inventory-service:3003";
// Web dashboard — aggregateéa user profile, recent orders, y recommendations
app.get("/api/dashboard", async (req, res) => {
const userId = req.headers["x-user-id"];
if (!userId) return res.status(401).json({ error: "Unauthorized" });
try {
// Fetcheá data desde múltiples services en parallel
const [userResp, ordersResp, recommendationsResp] = await Promise.all([
axios.get(`${USER_SERVICE}/users/${userId}`),
axios.get(`${ORDER_SERVICE}/users/${userId}/orders?limit=10`),
axios.get(`${INVENTORY_SERVICE}/recommendations?userId=${userId}&limit=5`)
]);
// Aggregateéa y formateá para web client
const dashboard = {
user: {
id: userResp.data.id,
name: userResp.data.name,
email: userResp.data.email,
avatarUrl: userResp.data.avatarUrl,
memberSince: userResp.data.createdAt
},
recentOrders: ordersResp.data.orders.map(order => ({
id: order.id,
date: order.createdAt,
status: order.status,
total: order.total,
items: order.items.length
})),
recommendations: recommendationsResp.data.products.map(product => ({
id: product.id,
name: product.name,
price: product.price,
imageUrl: product.imageUrl,
rating: product.rating
}))
};
res.json(dashboard);
} catch (error) {
console.error("Dashboard aggregation failed:", error.message);
res.status(502).json({ error: "Failed to load dashboard" });
}
});
// Web product detail — incluye reviews y related products
app.get("/api/products/:productId", async (req, res) => {
const { productId } = req.params;
try {
const [productResp, reviewsResp, relatedResp] = await Promise.all([
axios.get(`${INVENTORY_SERVICE}/products/${productId}`),
axios.get(`${INVENTORY_SERVICE}/products/${productId}/reviews?limit=20`),
axios.get(`${INVENTORY_SERVICE}/products/${productId}/related`)
]);
res.json({
product: productResp.data,
reviews: reviewsResp.data.reviews,
relatedProducts: relatedResp.data.products
});
} catch (error) {
res.status(502).json({ error: "Failed to load product" });
}
});
app.listen(4000, () => console.log("Web BFF running on port 4000"));
Mobile BFF (Go)
// mobile-bff/main.go — Mobile BFF returnea minimal payloads
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
)
type MobileDashboard struct {
UserName string `json:"userName"`
OrderCount int `json:"orderCount"`
LastOrder *MobileOrder `json:"lastOrder,omitempty"`
Suggestions []MobileProduct `json:"suggestions"`
}
type MobileOrder struct {
ID string `json:"id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
type MobileProduct struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var (
userService = "http://user-service:3001"
orderService = "http://order-service:3002"
inventoryService = "http://inventory-service:3003"
)
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
if userID == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var wg sync.WaitGroup
var userName string
var orderCount int
var lastOrder *MobileOrder
var suggestions []MobileProduct
// Fetcéa user name
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/users/%s", userService, userID))
if err == nil && resp.StatusCode == 200 {
var user struct{ Name string `json:"name"` }
json.NewDecoder(resp.Body).Decode(&user)
userName = user.Name
}
}()
// Fetcéa orders summary
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/users/%s/orders?limit=1", orderService, userID))
if err == nil && resp.StatusCode == 200 {
var orders struct {
Total int `json:"total"`
Items []MobileOrder `json:"orders"`
}
json.NewDecoder(resp.Body).Decode(&orders)
orderCount = orders.Total
if len(orders.Items) > 0 {
lastOrder = &orders.Items[0]
}
}
}()
// Fetcéa minimal product suggestions
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(fmt.Sprintf("%s/recommendations?userId=%s&limit=3", inventoryService, userID))
if err == nil && resp.StatusCode == 200 {
var recs struct{ Products []MobileProduct `json:"products"` }
json.NewDecoder(resp.Body).Decode(&recs)
suggestions = recs.Products
}
}()
wg.Wait()
dashboard := MobileDashboard{
UserName: userName,
OrderCount: orderCount,
LastOrder: lastOrder,
Suggestions: suggestions,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(dashboard)
}
func main() {
http.HandleFunc("/api/dashboard", dashboardHandler)
log.Println("Mobile BFF running on port 5000")
log.Fatal(http.ListenAndServe(":5000", nil))
}
GraphQL BFF
// graphql-bff/server.js — GraphQL BFF para flexible client queries
const { ApolloServer } = require("@apollo/server");
const { startStandaloneServer } = require("@apollo/server/standalone");
const axios = require("axios");
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
avatarUrl: String
}
type Order {
id: ID!
status: String!
total: Float!
createdAt: String!
items: [OrderItem!]!
}
type OrderItem {
productId: ID!
productName: String!
quantity: Int!
price: Float!
}
type Dashboard {
user: User!
recentOrders: [Order!]!
totalOrders: Int!
}
type Query {
dashboard(userId: ID!): Dashboard!
user(id: ID!): User
orders(userId: ID!, limit: Int): [Order!]!
}
`;
const resolvers = {
Query: {
async dashboard(_, { userId }) {
const [userResp, ordersResp] = await Promise.all([
axios.get(`http://user-service:3001/users/${userId}`),
axios.get(`http://order-service:3002/users/${userId}/orders?limit=5`)
]);
return {
user: userResp.data,
recentOrders: ordersResp.data.orders,
totalOrders: ordersResp.data.total
};
},
async user(_, { id }) {
const resp = await axios.get(`http://user-service:3001/users/${id}`);
return resp.data;
},
async orders(_, { userId, limit = 10 }) {
const resp = await axios.get(
`http://order-service:3002/users/${userId}/orders?limit=${limit}`
);
return resp.data.orders;
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server, { listen: { port: 4000 } })
.then(({ url }) => console.log(`GraphQL BFF ready at ${url}`));
Python BFF con Flask
# bff/mobile.py — Python mobile BFF
from flask import Flask, request, jsonify
import requests
from concurrent.futures import ThreadPoolExecutor
app = Flask(__name__)
USER_SERVICE = "http://user-service:3001"
ORDER_SERVICE = "http://order-service:3002"
INVENTORY_SERVICE = "http://inventory-service:3003"
executor = ThreadPoolExecutor(max_workers=10)
@app.route("/api/dashboard")
def mobile_dashboard():
user_id = request.headers.get("X-User-Id")
if not user_id:
return jsonify({"error": "Unauthorized"}), 401
# Fetcéa minimal data para mobile
def fetch_user():
resp = requests.get(f"{USER_SERVICE}/users/{user_id}", timeout=5)
return resp.json() if resp.status_code == 200 else None
def fetch_orders():
resp = requests.get(
f"{ORDER_SERVICE}/users/{user_id}/orders",
params={"limit": 3, "fields": "id,status,total"},
timeout=5
)
return resp.json() if resp.status_code == 200 else {"orders": []}
def fetch_suggestions():
resp = requests.get(
f"{INVENTORY_SERVICE}/recommendations",
params={"userId": user_id, "limit": 3, "fields": "id,name,price"},
timeout=5
)
return resp.json() if resp.status_code == 200 else {"products": []}
# Parallel fetch
from concurrent.futures import as_completed
futures = {
executor.submit(fetch_user): "user",
executor.submit(fetch_orders): "orders",
executor.submit(fetch_suggestions): "suggestions"
}
results = {}
for future in as_completed(futures):
key = futures[future]
results[key] = future.result()
# Minimal payload para mobile
return jsonify({
"userName": results["user"]["name"] if results["user"] else "",
"orderCount": len(results["orders"].get("orders", [])),
"lastOrder": results["orders"]["orders"][0] if results["orders"]["orders"] else None,
"suggestions": results["suggestions"].get("products", [])
})
@app.route("/api/product/<product_id>")
def mobile_product(product_id):
# Mobile solo necesita name, price, y availability — no reviews, no related
resp = requests.get(
f"{INVENTORY_SERVICE}/products/{product_id}",
params={"fields": "id,name,price,inStock"},
timeout=5
)
if resp.status_code != 200:
return jsonify({"error": "Not found"}), 404
return jsonify(resp.json())
BFF con caching
// bff/cache.js — BFF con Redis caching para expensive aggregations
const express = require("express");
const redis = require("redis");
const axios = require("axios");
const app = express();
const cache = redis.createClient({ url: process.env.REDIS_URL });
cache.connect();
const CACHE_TTL = 60; // 1 minute
// Cache middleware para BFF endpoints
async function cacheMiddleware(key, ttl, handler) {
return async (req, res) => {
const cacheKey = `${key}:${req.headers["x-user-id"] || "anonymous"}`;
try {
const cached = await cache.get(cacheKey);
if (cached) {
res.setHeader("X-Cache", "HIT");
return res.json(JSON.parse(cached));
}
} catch (err) {
console.error("Cache read failed:", err);
}
const result = await handler(req);
res.setHeader("X-Cache", "MISS");
try {
await cache.setEx(cacheKey, ttl, JSON.stringify(result));
} catch (err) {
console.error("Cache write failed:", err);
}
res.json(result);
};
}
app.get("/api/dashboard", cacheMiddleware("dashboard", CACHE_TTL, async (req) => {
const userId = req.headers["x-user-id"];
const [user, orders] = await Promise.all([
axios.get(`http://user-service:3001/users/${userId}`),
axios.get(`http://order-service:3002/users/${userId}/orders?limit=5`)
]);
return { user: user.data, recentOrders: orders.data.orders };
}));
Variants
Shared BFF con client-specific response shaping
# shared_bff.py — un BFF con response shapers per client type
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
def shape_for_web(data):
"""Full payload para web con large screens."""
return {
"user": {
"id": data["user"]["id"],
"name": data["user"]["name"],
"email": data["user"]["email"],
"avatarUrl": data["user"].get("avatarUrl"),
"memberSince": data["user"]["createdAt"]
},
"orders": data["orders"],
"recommendations": data.get("recommendations", [])
}
def shape_for_mobile(data):
"""Minimal payload para mobile."""
return {
"userName": data["user"]["name"],
"orderCount": len(data["orders"]),
"lastOrder": data["orders"][0] if data["orders"] else None
}
@app.route("/api/dashboard")
def dashboard():
client_type = request.headers.get("X-Client-Type", "web")
user_id = request.headers.get("X-User-Id")
# Fetcéa full data una vez
data = fetch_dashboard_data(user_id)
# Shapeéa basado en client
shaper = shape_for_mobile if client_type == "mobile" else shape_for_web
return jsonify(shaper(data))
BFF con WebSocket para real-time updates
// bff/websocket.js — BFF con WebSocket para live updates
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const axios = require("axios");
const app = express();
const server = http.createServer(app);
const io = new Server(server);
io.on("connection", (socket) => {
const userId = socket.handshake.auth.userId;
if (!userId) {
socket.disconnect();
return;
}
console.log(`Client connected: ${userId}`);
// Pusheá order status updates
socket.join(`orders:${userId}`);
// Pusheá inventory updates
socket.join("inventory:updates");
});
// Webhook desde order service — pusheá a connected clients
app.post("/webhooks/order-updated", (req, res) => {
const { userId, order } = req.body;
io.to(`orders:${userId}`).emit("order:updated", order);
res.json({ ok: true });
});
server.listen(4000, () => console.log("BFF with WebSocket on port 4000"));
Best Practices
-
For a deeper guide, see Gateway Routing Pattern.
-
Un BFF per client type, no per device — web BFF sirve a both desktop y mobile web
-
Mantené el BFF thin — aggregation y formatting only, no business logic
-
Usá parallel calls — fetcéa desde múltiples services concurrently para reducir latency
-
Cacheéa agresivamente — dashboard data cambia slowly; cacheéa por 30-60 segundos
-
Handleá partial failures — si un service está down, returneá partial data con un warning
-
Versioneá el BFF API —
/api/v1/dashboardpara podér evolvear sin breakear clients -
Considerá GraphQL para flexible clients — clients requestéan exactamente lo que necesitan
-
Monitoreá BFF latency — es el user’s critical path; trackeá p50, p95, p99
Common Mistakes
- Business logic en BFF: el BFF debería solo aggregatear y formatear. Business rules pertenecen en domain services.
- Un BFF para todos los clients: defeatéa el purpose. Mobile obtiene web payloads, wasting bandwidth.
- Sequential service calls: llamar services uno por uno. Usá
Promise.allo equivalent para parallel calls. - No caching: cada request hittea todos los downstream services. Cacheéa expensive aggregations.
- No error handling para partial failures: un service failure breakea el entire response. Returneá partial data.
FAQ
¿Qué es el BFF pattern?
Un separate backend service para cada frontend client type (web, mobile, desktop). Cada BFF aggregateéa data desde múltiples microservices y formateéa responses optimized para su client. El web BFF returnea rich data; el mobile BFF returnea minimal payloads.
¿Cuántos BFFs debería tener?
Uno per distinct client type. Típicamente: web BFF, mobile BFF. Si tenés un desktop app con muy different needs, agregá un desktop BFF. No creés un BFF per device model — un mobile BFF sirve a todos los mobile devices.
¿Debería el BFF usar GraphQL o REST?
Ambos funcionan. REST es más simple y well-understood. GraphQL deja a los clients requestéar exactamente los fields que necesitan, lo cual es useful cuando diferentes screens necesitan different subsets de data. Arrancá con REST; adoptá GraphQL si over-fetching se vuelve un problema.
¿Puede el BFF llamar a otros BFFs?
No. Los BFFs deberían llamar a domain microservices, no a otros BFFs. BFF-to-BFF calls crean coupling y circular dependencies. Si dos BFFs necesitan la misma data, ambos llaman al mismo domain service.
¿Quién ownea el BFF?
El frontend team ownea el BFF. Este es un key principle: el team que buildea el frontend también buildea su BFF. Esto le da al frontend team end-to-end ownership y les deja iteratear sin esperar por backend teams.
Recursos Relacionados
Modular Monolith: Single Deployable with Module Boundaries
How to build a modular monolith with strict internal module boundaries. Covers module isolation, shared kernel, inter-module communication, and migration to microservices.
PatternAmbassador: Offload Cross-Cutting Concerns to a Proxy
How to offload cross-cutting concerns to a proxy ambassador. Covers connection pooling, retry logic, circuit breaking, monitoring, and TLS termination for client services.
PatternAnti-Corruption Layer: Isolate Legacy with Adapters
How to isolate legacy systems with translation adapters. Covers ACL facade, domain translation, bidirectional mapping, and gradual legacy replacement.
PatternStrangler Fig: Gradually Replace Legacy by Intercepting
How to gradually replace a legacy system by intercepting routes and routing traffic to new services. Covers strangler fig, incremental migration, and cutover.