System Architecture Patterns
Architecture patterns operate at a higher level than code patterns—they shape how you structure entire applications, organize codebases, and define boundaries between systems. This article covers three patterns that come up in nearly every significant project: Clean Architecture (and its variants), monorepo vs. polyrepo organization, and Backend for Frontend (BFF).
Clean Architecture (Hexagonal / Ports & Adapters)
Clean Architecture, Hexagonal Architecture, and Ports & Adapters are different names for the same core idea: your business logic should not depend on external details. Databases, web frameworks, and third-party services are implementation details that sit at the edges of your system.
The Dependency Rule
The fundamental principle: dependencies point inward. Inner layers define interfaces; outer layers implement them.
┌─────────────────────────────────────────────────────────┐
│ External Systems │
│ (HTTP, Database, Message Queue, File System, etc.) │
├─────────────────────────────────────────────────────────┤
│ Adapters │
│ (Express routes, Postgres repository, SQS consumer) │
├─────────────────────────────────────────────────────────┤
│ Application │
│ (Use cases, orchestration, business workflows) │
├─────────────────────────────────────────────────────────┤
│ Domain │
│ (Entities, business rules, core types) │
└─────────────────────────────────────────────────────────┘
↑ Dependencies point inward
The Domain layer knows nothing about databases or HTTP. The Application layer knows about Domain but not about Express or Postgres. This isolation means you can swap Postgres for MongoDB, or Express for Fastify, without touching business logic.
Practical TypeScript Implementation
Let's build a user registration system following Clean Architecture:
Domain Layer
The innermost layer contains entities and business rules:
// src/domain/entities/user.ts
export interface User {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
emailVerified: boolean;
}
export interface CreateUserInput {
email: string;
password: string;
}
// Business rules live here
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validatePassword(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain a number');
}
return { valid: errors.length === 0, errors };
}
// src/domain/errors.ts
// Domain-specific errors (no HTTP codes—that's an adapter concern)
export class DomainError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'DomainError';
}
}
export class UserAlreadyExistsError extends DomainError {
constructor(email: string) {
super(`User with email ${email} already exists`, 'USER_EXISTS');
}
}
export class InvalidCredentialsError extends DomainError {
constructor() {
super('Invalid email or password', 'INVALID_CREDENTIALS');
}
}
export class ValidationError extends DomainError {
constructor(public readonly errors: string[]) {
super(errors.join(', '), 'VALIDATION_FAILED');
}
}
Ports (Interfaces)
Ports define what the application needs from the outside world. They're interfaces that adapters implement:
// src/domain/ports/user-repository.ts
import { User, CreateUserInput } from '../entities/user';
// This is a PORT - an interface the domain needs
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
findById(id: string): Promise<User | null>;
create(input: CreateUserInput & { passwordHash: string }): Promise<User>;
updateEmailVerified(userId: string, verified: boolean): Promise<void>;
}
// src/domain/ports/password-hasher.ts
export interface PasswordHasher {
hash(password: string): Promise<string>;
verify(password: string, hash: string): Promise<boolean>;
}
// src/domain/ports/email-service.ts
export interface EmailService {
sendVerificationEmail(to: string, token: string): Promise<void>;
sendPasswordResetEmail(to: string, token: string): Promise<void>;
}
// src/domain/ports/token-generator.ts
export interface TokenGenerator {
generate(): string;
generateJwt(payload: Record<string, unknown>, expiresIn: string): string;
verifyJwt<T>(token: string): T | null;
}
Application Layer (Use Cases)
Use cases orchestrate business logic using domain entities and ports:
// src/application/use-cases/register-user.ts
import { UserRepository } from '../../domain/ports/user-repository';
import { PasswordHasher } from '../../domain/ports/password-hasher';
import { EmailService } from '../../domain/ports/email-service';
import { TokenGenerator } from '../../domain/ports/token-generator';
import {
User,
CreateUserInput,
validateEmail,
validatePassword,
} from '../../domain/entities/user';
import {
UserAlreadyExistsError,
ValidationError,
} from '../../domain/errors';
export interface RegisterUserDeps {
userRepository: UserRepository;
passwordHasher: PasswordHasher;
emailService: EmailService;
tokenGenerator: TokenGenerator;
}
export class RegisterUserUseCase {
constructor(private deps: RegisterUserDeps) {}
async execute(input: CreateUserInput): Promise<User> {
// Validate input (business rules)
if (!validateEmail(input.email)) {
throw new ValidationError(['Invalid email format']);
}
const passwordValidation = validatePassword(input.password);
if (!passwordValidation.valid) {
throw new ValidationError(passwordValidation.errors);
}
// Check for existing user
const existing = await this.deps.userRepository.findByEmail(input.email);
if (existing) {
throw new UserAlreadyExistsError(input.email);
}
// Hash password
const passwordHash = await this.deps.passwordHasher.hash(input.password);
// Create user
const user = await this.deps.userRepository.create({
email: input.email,
password: input.password,
passwordHash,
});
// Send verification email (side effect, but part of business process)
const verificationToken = this.deps.tokenGenerator.generate();
await this.deps.emailService.sendVerificationEmail(
user.email,
verificationToken
);
return user;
}
}
// src/application/use-cases/login-user.ts
import { UserRepository } from '../../domain/ports/user-repository';
import { PasswordHasher } from '../../domain/ports/password-hasher';
import { TokenGenerator } from '../../domain/ports/token-generator';
import { InvalidCredentialsError } from '../../domain/errors';
export interface LoginUserDeps {
userRepository: UserRepository;
passwordHasher: PasswordHasher;
tokenGenerator: TokenGenerator;
}
export interface LoginResult {
accessToken: string;
user: { id: string; email: string };
}
export class LoginUserUseCase {
constructor(private deps: LoginUserDeps) {}
async execute(email: string, password: string): Promise<LoginResult> {
const user = await this.deps.userRepository.findByEmail(email);
if (!user) {
throw new InvalidCredentialsError();
}
const passwordValid = await this.deps.passwordHasher.verify(
password,
user.passwordHash
);
if (!passwordValid) {
throw new InvalidCredentialsError();
}
const accessToken = this.deps.tokenGenerator.generateJwt(
{ userId: user.id, email: user.email },
'24h'
);
return {
accessToken,
user: { id: user.id, email: user.email },
};
}
}
Adapters (Infrastructure)
Adapters implement ports with specific technologies:
// src/adapters/repositories/postgres-user-repository.ts
import { Pool } from 'pg';
import { UserRepository } from '../../domain/ports/user-repository';
import { User } from '../../domain/entities/user';
export class PostgresUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.pool.query(
'SELECT id, email, password_hash, created_at, email_verified FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id,
email: row.email,
passwordHash: row.password_hash,
createdAt: row.created_at,
emailVerified: row.email_verified,
};
}
async findById(id: string): Promise<User | null> {
const result = await this.pool.query(
'SELECT id, email, password_hash, created_at, email_verified FROM users WHERE id = $1',
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id,
email: row.email,
passwordHash: row.password_hash,
createdAt: row.created_at,
emailVerified: row.email_verified,
};
}
async create(input: {
email: string;
passwordHash: string;
}): Promise<User> {
const result = await this.pool.query(
`INSERT INTO users (email, password_hash, email_verified)
VALUES ($1, $2, false)
RETURNING id, email, password_hash, created_at, email_verified`,
[input.email, input.passwordHash]
);
const row = result.rows[0];
return {
id: row.id,
email: row.email,
passwordHash: row.password_hash,
createdAt: row.created_at,
emailVerified: row.email_verified,
};
}
async updateEmailVerified(userId: string, verified: boolean): Promise<void> {
await this.pool.query(
'UPDATE users SET email_verified = $1 WHERE id = $2',
[verified, userId]
);
}
}
// src/adapters/services/bcrypt-password-hasher.ts
import bcrypt from 'bcrypt';
import { PasswordHasher } from '../../domain/ports/password-hasher';
export class BcryptPasswordHasher implements PasswordHasher {
private readonly saltRounds = 12;
async hash(password: string): Promise<string> {
return bcrypt.hash(password, this.saltRounds);
}
async verify(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}
// src/adapters/http/routes/auth-routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { RegisterUserUseCase } from '../../../application/use-cases/register-user';
import { LoginUserUseCase } from '../../../application/use-cases/login-user';
import {
DomainError,
UserAlreadyExistsError,
ValidationError,
InvalidCredentialsError,
} from '../../../domain/errors';
// Map domain errors to HTTP responses (adapter concern)
function handleError(error: unknown, res: Response): void {
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message, code: error.code });
return;
}
if (error instanceof UserAlreadyExistsError) {
res.status(409).json({ error: error.message, code: error.code });
return;
}
if (error instanceof InvalidCredentialsError) {
res.status(401).json({ error: error.message, code: error.code });
return;
}
if (error instanceof DomainError) {
res.status(400).json({ error: error.message, code: error.code });
return;
}
console.error('Unexpected error:', error);
res.status(500).json({ error: 'Internal server error' });
}
export function createAuthRoutes(
registerUser: RegisterUserUseCase,
loginUser: LoginUserUseCase
): Router {
const router = Router();
router.post('/register', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await registerUser.execute({ email, password });
res.status(201).json({
id: user.id,
email: user.email,
createdAt: user.createdAt,
});
} catch (error) {
handleError(error, res);
}
});
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const result = await loginUser.execute(email, password);
res.json(result);
} catch (error) {
handleError(error, res);
}
});
return router;
}
Composition Root
Wire everything together at the application entry point:
// src/main.ts
import express from 'express';
import { Pool } from 'pg';
// Adapters
import { PostgresUserRepository } from './adapters/repositories/postgres-user-repository';
import { BcryptPasswordHasher } from './adapters/services/bcrypt-password-hasher';
import { SendgridEmailService } from './adapters/services/sendgrid-email-service';
import { JwtTokenGenerator } from './adapters/services/jwt-token-generator';
// Use cases
import { RegisterUserUseCase } from './application/use-cases/register-user';
import { LoginUserUseCase } from './application/use-cases/login-user';
// HTTP adapters
import { createAuthRoutes } from './adapters/http/routes/auth-routes';
async function main() {
// Create infrastructure
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Create adapters (implementations of ports)
const userRepository = new PostgresUserRepository(pool);
const passwordHasher = new BcryptPasswordHasher();
const emailService = new SendgridEmailService(process.env.SENDGRID_API_KEY!);
const tokenGenerator = new JwtTokenGenerator(process.env.JWT_SECRET!);
// Create use cases with dependencies
const registerUser = new RegisterUserUseCase({
userRepository,
passwordHasher,
emailService,
tokenGenerator,
});
const loginUser = new LoginUserUseCase({
userRepository,
passwordHasher,
tokenGenerator,
});
// Create Express app with routes
const app = express();
app.use(express.json());
app.use('/auth', createAuthRoutes(registerUser, loginUser));
app.listen(3000, () => {
console.log('Server running on port 3000');
});
}
main().catch(console.error);
Testing Benefits
Clean Architecture makes testing straightforward because you can stub dependencies:
// src/application/use-cases/__tests__/register-user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RegisterUserUseCase } from '../register-user';
import { UserAlreadyExistsError, ValidationError } from '../../../domain/errors';
describe('RegisterUserUseCase', () => {
const mockUserRepository = {
findByEmail: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
updateEmailVerified: vi.fn(),
};
const mockPasswordHasher = {
hash: vi.fn(),
verify: vi.fn(),
};
const mockEmailService = {
sendVerificationEmail: vi.fn(),
sendPasswordResetEmail: vi.fn(),
};
const mockTokenGenerator = {
generate: vi.fn(),
generateJwt: vi.fn(),
verifyJwt: vi.fn(),
};
let useCase: RegisterUserUseCase;
beforeEach(() => {
vi.clearAllMocks();
useCase = new RegisterUserUseCase({
userRepository: mockUserRepository,
passwordHasher: mockPasswordHasher,
emailService: mockEmailService,
tokenGenerator: mockTokenGenerator,
});
});
it('creates a user successfully', async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
mockPasswordHasher.hash.mockResolvedValue('hashed-password');
mockUserRepository.create.mockResolvedValue({
id: '123',
email: '[email protected]',
passwordHash: 'hashed-password',
createdAt: new Date(),
emailVerified: false,
});
mockTokenGenerator.generate.mockReturnValue('verification-token');
const result = await useCase.execute({
email: '[email protected]',
password: 'Password123',
});
expect(result.email).toBe('[email protected]');
expect(mockEmailService.sendVerificationEmail).toHaveBeenCalledWith(
'[email protected]',
'verification-token'
);
});
it('throws ValidationError for invalid email', async () => {
await expect(
useCase.execute({ email: 'invalid', password: 'Password123' })
).rejects.toThrow(ValidationError);
});
it('throws UserAlreadyExistsError for duplicate email', async () => {
mockUserRepository.findByEmail.mockResolvedValue({ id: 'existing' });
await expect(
useCase.execute({ email: '[email protected]', password: 'Password123' })
).rejects.toThrow(UserAlreadyExistsError);
});
});
No database, no HTTP server, no email provider—just pure business logic tests.
When Clean Architecture Is Overkill
This pattern adds structure and indirection. It's valuable for:
- Applications that will evolve over years
- Systems where you might swap technologies
- Teams that need clear boundaries
It's overkill for:
- Small scripts or utilities
- Prototypes you'll throw away
- CRUD apps with minimal business logic
Monorepo vs. Polyrepo
How you organize code across repositories affects team velocity, code sharing, and deployment independence.
Monorepo: Everything in One Repository
A monorepo keeps all related projects in a single repository with shared tooling.
my-company/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── mobile/ # React Native app
│ ├── api/ # Node.js backend
│ └── admin/ # Admin dashboard
├── packages/
│ ├── ui/ # Shared component library
│ ├── utils/ # Shared utilities
│ ├── types/ # Shared TypeScript types
│ └── config/ # Shared configs (ESLint, TypeScript, etc.)
├── turbo.json # Turborepo configuration
├── pnpm-workspace.yaml # pnpm workspace config
└── package.json
Setting Up with Turborepo
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "build/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// packages/ui/package.json
{
"name": "@company/ui",
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
}
}
// apps/web/package.json
{
"name": "web",
"dependencies": {
"@company/ui": "workspace:*",
"@company/utils": "workspace:*"
}
}
Monorepo Advantages
Atomic commits across packages:
# One commit updates the shared type, the API, and the frontend
git add packages/types apps/api apps/web
git commit -m "Add user avatar field across all systems"
Shared configuration:
// packages/config/eslint-preset.js
module.exports = {
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
// Company-wide rules
},
};
// apps/web/.eslintrc.js
module.exports = {
root: true,
extends: ['@company/config/eslint-preset'],
};
Type safety across boundaries:
// packages/types/src/api.ts
export interface User {
id: string;
email: string;
avatar?: string; // Add field once, used everywhere
}
// apps/api/src/routes/users.ts
import { User } from '@company/types';
// Type-checked against the shared definition
// apps/web/src/components/UserCard.tsx
import { User } from '@company/types';
// Same types, guaranteed in sync
Monorepo Challenges
Build times grow:
# Tools like Turborepo help with caching
turbo build --filter=web... # Build web and its dependencies only
turbo build --cache-dir=.turbo # Use remote caching
CI complexity:
# .github/workflows/ci.yml
# Only run relevant jobs when specific paths change
jobs:
api:
if: contains(github.event.head_commit.modified, 'apps/api') ||
contains(github.event.head_commit.modified, 'packages/')
steps:
- run: pnpm turbo build --filter=api...
Code ownership becomes implicit:
# CODEOWNERS
/apps/web/ @frontend-team
/apps/api/ @backend-team
/packages/ui/ @design-system-team
/packages/types/ @platform-team # Changes here need platform review
Polyrepo: Separate Repositories
Each project or service lives in its own repository.
github.com/company/
├── web-frontend/
├── mobile-app/
├── api-gateway/
├── user-service/
├── order-service/
├── shared-ui/ # Published as npm package
└── shared-types/ # Published as npm package
Polyrepo Advantages
Clear boundaries:
// user-service/package.json
{
"name": "user-service",
"dependencies": {
"@company/shared-types": "^2.3.0" // Explicit version
}
}
Independent CI/CD:
# user-service/.github/workflows/deploy.yml
# This pipeline only cares about user-service
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: pnpm test
- run: pnpm build
- run: ./deploy.sh
Simpler git history:
# History is focused on one project
git log --oneline # All commits are relevant
Polyrepo Challenges
Dependency hell:
// web-frontend/package.json
{
"dependencies": {
"@company/shared-types": "^2.3.0"
}
}
// api-gateway/package.json
{
"dependencies": {
"@company/shared-types": "^2.1.0" // Version mismatch!
}
}
Cross-repo changes require coordination:
# Adding a field requires updating multiple repos in order
1. Update shared-types, publish 2.4.0
2. Wait for npm to propagate
3. Update user-service, run tests, deploy
4. Update api-gateway, run tests, deploy
5. Update web-frontend, run tests, deploy
Code duplication:
// Utilities get copied instead of shared
// web-frontend/src/utils/formatDate.ts
// mobile-app/src/utils/formatDate.ts
// admin-dashboard/src/utils/formatDate.ts
// Three copies that drift over time
Choosing Between Them
| Factor | Monorepo | Polyrepo |
|---|---|---|
| Team size | Works well with 1-50 engineers | Scales to hundreds with clear ownership |
| Code sharing | Trivial (workspace imports) | Requires publishing packages |
| Atomic changes | Natural | Requires coordination |
| CI complexity | High (need smart filtering) | Simple (per-repo pipelines) |
| Onboarding | Steeper (larger codebase) | Gentler (focused repos) |
| Tool requirements | Turborepo, Nx, or Bazel | Standard tooling works |
Start with monorepo if:
- You're a small team (<20 engineers)
- You share significant code between projects
- You want tight type safety across boundaries
- You can invest in build tooling
Consider polyrepo if:
- Teams have distinct domains with little overlap
- You need strict deployment independence
- Different teams use different tech stacks
- You're at enterprise scale with hundreds of engineers
Backend for Frontend (BFF)
A BFF is a backend layer tailored specifically for a particular frontend. Instead of one API serving web, mobile, and third parties, each client gets its own optimized API.
The Problem BFF Solves
Generic APIs create problems for frontends:
// Generic API returns everything
// GET /api/products/123
{
"id": "123",
"name": "Widget",
"description": "A widget for widgets",
"price": 29.99,
"inventory": 847,
"warehouseLocations": ["A1-B2", "C3-D4"],
"supplierInfo": { /* large object */ },
"internalSku": "WDG-123-456",
"costPrice": 12.50, // Shouldn't be exposed!
"salesHistory": [ /* months of data */ ],
// ... 20 more fields the frontend doesn't need
}
The frontend only needs:
{
"id": "123",
"name": "Widget",
"price": "$29.99",
"inStock": true
}
This mismatch creates:
- Over-fetching: Transferring unnecessary data
- Under-fetching: Multiple requests to assemble one view
- Coupling: Frontend tied to backend data shapes
- Security risks: Backend leaks internal data
BFF Architecture
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Web App │ │ Mobile App │ │ Admin Panel │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Web BFF │ │ Mobile BFF │ │ Admin BFF │
│ (Next.js API │ │ (Express) │ │ (Express) │
│ Routes) │ │ │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└──────────────────┼───────────────────┘
│
▼
┌───────────────────────┐
│ Backend Services │
│ (Microservices, │
│ Databases, etc.) │
└───────────────────────┘
Implementation Example
Backend Services (Shared)
// services/product-service/src/index.ts
// Internal API - returns full data
app.get('/internal/products/:id', async (req, res) => {
const product = await db.products.findUnique({
where: { id: req.params.id },
include: {
inventory: true,
supplier: true,
pricing: true,
},
});
res.json(product);
});
Web BFF
// bff/web/src/routes/products.ts
import { Router } from 'express';
const router = Router();
// Tailored for web product page
router.get('/products/:id', async (req, res) => {
// Fetch from internal services
const [product, reviews, recommendations] = await Promise.all([
fetch(`${PRODUCT_SERVICE}/internal/products/${req.params.id}`).then(r => r.json()),
fetch(`${REVIEW_SERVICE}/internal/products/${req.params.id}/reviews?limit=5`).then(r => r.json()),
fetch(`${RECOMMENDATION_SERVICE}/internal/products/${req.params.id}/similar?limit=4`).then(r => r.json()),
]);
// Shape response for web UI
res.json({
id: product.id,
name: product.name,
description: product.description,
price: formatCurrency(product.pricing.currentPrice),
originalPrice: product.pricing.originalPrice
? formatCurrency(product.pricing.originalPrice)
: null,
discount: product.pricing.discountPercent
? `${product.pricing.discountPercent}% off`
: null,
inStock: product.inventory.quantity > 0,
images: product.images.map(img => ({
url: img.url,
alt: img.altText,
thumbnail: img.thumbnailUrl,
})),
rating: {
average: reviews.averageRating,
count: reviews.totalCount,
recent: reviews.items.map(r => ({
id: r.id,
author: r.authorName,
rating: r.rating,
text: r.text,
date: formatRelativeDate(r.createdAt),
})),
},
similar: recommendations.map(p => ({
id: p.id,
name: p.name,
price: formatCurrency(p.price),
image: p.primaryImage,
})),
});
});
// Tailored for web product listing
router.get('/products', async (req, res) => {
const { category, sort, page = 1 } = req.query;
const products = await fetch(
`${PRODUCT_SERVICE}/internal/products?category=${category}&sort=${sort}&page=${page}&limit=20`
).then(r => r.json());
// Web listing only needs summary data
res.json({
items: products.items.map(p => ({
id: p.id,
name: p.name,
price: formatCurrency(p.pricing.currentPrice),
image: p.primaryImage,
rating: p.averageRating,
})),
pagination: {
currentPage: products.page,
totalPages: products.totalPages,
hasNext: products.page < products.totalPages,
},
});
});
Mobile BFF
// bff/mobile/src/routes/products.ts
import { Router } from 'express';
const router = Router();
// Mobile-optimized: smaller payloads, different fields
router.get('/products/:id', async (req, res) => {
const [product, reviews] = await Promise.all([
fetch(`${PRODUCT_SERVICE}/internal/products/${req.params.id}`).then(r => r.json()),
fetch(`${REVIEW_SERVICE}/internal/products/${req.params.id}/reviews?limit=3`).then(r => r.json()),
]);
// Mobile gets compressed images and fewer fields
res.json({
id: product.id,
name: product.name,
price: formatCurrency(product.pricing.currentPrice),
inStock: product.inventory.quantity > 0,
// Mobile-optimized image sizes
image: product.images[0]?.mobileUrl,
rating: reviews.averageRating,
reviewCount: reviews.totalCount,
// Deep link for sharing
shareUrl: `https://example.com/p/${product.id}`,
});
});
// Mobile home feed: pre-aggregated for one-shot load
router.get('/home', async (req, res) => {
const userId = req.user?.id;
const [featured, personalized, categories] = await Promise.all([
fetch(`${PRODUCT_SERVICE}/internal/featured?limit=5`).then(r => r.json()),
userId
? fetch(`${RECOMMENDATION_SERVICE}/internal/users/${userId}/feed`).then(r => r.json())
: Promise.resolve([]),
fetch(`${CATEGORY_SERVICE}/internal/categories/popular`).then(r => r.json()),
]);
// Single response for entire home screen
res.json({
hero: featured[0] ? {
id: featured[0].id,
image: featured[0].heroBannerMobile,
title: featured[0].promotionTitle,
} : null,
featured: featured.slice(1).map(compactProduct),
forYou: personalized.slice(0, 10).map(compactProduct),
categories: categories.map(c => ({
id: c.id,
name: c.name,
icon: c.iconUrl,
})),
});
});
BFF with Next.js API Routes
For web apps, Next.js API routes can serve as a built-in BFF:
// app/api/products/[id]/route.ts
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const [product, reviews] = await Promise.all([
fetch(`${process.env.PRODUCT_SERVICE_URL}/products/${params.id}`, {
headers: { Authorization: `Bearer ${process.env.SERVICE_TOKEN}` },
}).then(r => r.json()),
fetch(`${process.env.REVIEW_SERVICE_URL}/products/${params.id}/reviews`).then(r => r.json()),
]);
// Transform for frontend consumption
return NextResponse.json({
id: product.id,
name: product.name,
price: new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(product.price),
description: product.description,
inStock: product.inventory > 0,
rating: {
average: reviews.average,
count: reviews.count,
},
});
}
When to Use BFF
Good fit:
- Multiple clients (web, mobile, TV) with different needs
- Complex data aggregation from multiple services
- Client-specific business logic (formatting, filtering)
- Need to hide internal service structure from clients
Not needed:
- Single client consuming a single API
- GraphQL already provides flexible queries
- Simple CRUD with minimal transformation
BFF vs. GraphQL
GraphQL can reduce the need for BFFs by letting clients request exactly what they need:
# Client specifies fields
query ProductPage($id: ID!) {
product(id: $id) {
name
price
inStock
images(size: MEDIUM) {
url
}
}
}
However, BFFs still add value for:
- Aggregating multiple GraphQL services
- Complex business logic that shouldn't live in resolvers
- Caching strategies specific to a client
- Authentication flows unique to a platform
See Also
- Classical Patterns in TypeScript - Code-level patterns that complement architectural patterns
- React & UI Patterns - Frontend patterns that work within these architectures
- Docker - Containerizing services in different architectures
- Kubernetes - Orchestrating microservices
- Turborepo documentation - Monorepo tooling
- Nx documentation - Alternative monorepo tool