A

System Architecture Patterns

architectureclean-architecturemonorepobfftypescriptpatterns

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

Last updated: March 23, 2026