API Layer Architecture: REST, GraphQL, tRPC, and the BFF Pattern
API Layer Architecture: REST, GraphQL, tRPC, and the BFF Pattern
The API Layer Decision Matrix
Choosing the right API paradigm affects everything from developer experience to runtime performance. Each approach makes different tradeoffs between flexibility, type safety, caching, and complexity.
┌─────────────────────────────────────────────────────────────────────────────┐
│ API Paradigm Comparison │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ REST GraphQL tRPC gRPC-Web │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Type Safety │ None* │ Schema │ Full E2E │ Protobuf │ │
│ │ Caching │ Excellent │ Complex │ Via React Q│ Manual │ │
│ │ Overfetching │ Common │ None │ None │ None │ │
│ │ Learning Curve│ Low │ Medium │ Low │ High │ │
│ │ Tooling │ Mature │ Excellent │ Growing │ Specialized │ │
│ │ File Upload │ Native │ Complex │ Native │ Complex │ │
│ │ Browser │ Native │ Native │ Native │ Requires lib │ │
│ │ Real-time │ Polling/WS │ Subscript. │ WS/SSE │ Streaming │ │
│ │ Best For │ Public APIs│ Mobile/Web │ Full-stack │ Microservices │ │
│ │ │ │ diverse │ TypeScript │ internal │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ * REST can have types via OpenAPI codegen │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
REST API Architecture
Resource-Oriented Design
// ============================================================
// REST API Client Architecture
// ============================================================
interface RequestConfig {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
params?: Record<string, string | number>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
headers?: Record<string, string>;
}
interface APIResponse<T> {
data: T;
meta?: {
page?: number;
totalPages?: number;
totalCount?: number;
};
}
interface APIError {
code: string;
message: string;
details?: Record<string, string[]>;
}
class RESTClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private interceptors: {
request: Array<(config: RequestConfig) => RequestConfig>;
response: Array<(response: Response) => Response>;
};
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.defaultHeaders = {
'Content-Type': 'application/json',
};
this.interceptors = { request: [], response: [] };
}
setAuthToken(token: string): void {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
addRequestInterceptor(
interceptor: (config: RequestConfig) => RequestConfig
): void {
this.interceptors.request.push(interceptor);
}
addResponseInterceptor(
interceptor: (response: Response) => Response
): void {
this.interceptors.response.push(interceptor);
}
private buildUrl(path: string, params?: Record<string, string | number>): string {
let url = `${this.baseUrl}${path}`;
if (params) {
Object.entries(params).forEach(([key, value]) => {
url = url.replace(`:${key}`, String(value));
});
}
return url;
}
private buildQueryString(query?: Record<string, string | number | boolean | undefined>): string {
if (!query) return '';
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined) {
params.append(key, String(value));
}
});
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
async request<T>(config: RequestConfig): Promise<APIResponse<T>> {
// Apply request interceptors
let finalConfig = config;
for (const interceptor of this.interceptors.request) {
finalConfig = interceptor(finalConfig);
}
const url =
this.buildUrl(finalConfig.path, finalConfig.params) +
this.buildQueryString(finalConfig.query);
const response = await fetch(url, {
method: finalConfig.method,
headers: {
...this.defaultHeaders,
...finalConfig.headers,
},
body: finalConfig.body ? JSON.stringify(finalConfig.body) : undefined,
});
// Apply response interceptors
let finalResponse = response;
for (const interceptor of this.interceptors.response) {
finalResponse = interceptor(finalResponse);
}
if (!finalResponse.ok) {
const error = await finalResponse.json() as APIError;
throw new APIException(finalResponse.status, error);
}
return finalResponse.json();
}
// Convenience methods
get<T>(path: string, query?: RequestConfig['query']): Promise<APIResponse<T>> {
return this.request({ method: 'GET', path, query });
}
post<T>(path: string, body: unknown): Promise<APIResponse<T>> {
return this.request({ method: 'POST', path, body });
}
put<T>(path: string, body: unknown): Promise<APIResponse<T>> {
return this.request({ method: 'PUT', path, body });
}
patch<T>(path: string, body: unknown): Promise<APIResponse<T>> {
return this.request({ method: 'PATCH', path, body });
}
delete<T>(path: string): Promise<APIResponse<T>> {
return this.request({ method: 'DELETE', path });
}
}
class APIException extends Error {
constructor(
public status: number,
public error: APIError
) {
super(error.message);
this.name = 'APIException';
}
}
// ============================================================
// Type-safe Resource Factory
// ============================================================
interface ResourceConfig<T, CreateDTO, UpdateDTO> {
path: string;
client: RESTClient;
}
function createResource<
T extends { id: string | number },
CreateDTO,
UpdateDTO = Partial<CreateDTO>
>(config: ResourceConfig<T, CreateDTO, UpdateDTO>) {
const { path, client } = config;
return {
list: (query?: Record<string, unknown>) =>
client.get<T[]>(path, query as RequestConfig['query']),
get: (id: string | number) =>
client.get<T>(`${path}/:id`, { id: String(id) }),
create: (data: CreateDTO) =>
client.post<T>(path, data),
update: (id: string | number, data: UpdateDTO) =>
client.patch<T>(`${path}/:id`, data),
delete: (id: string | number) =>
client.delete<void>(`${path}/:id`),
};
}
// Usage
interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
interface CreateUserDTO {
email: string;
name: string;
password: string;
}
const api = new RESTClient('https://api.example.com');
const usersResource = createResource<User, CreateUserDTO>({
path: '/users',
client: api,
});
// Type-safe API calls
const users = await usersResource.list({ page: 1, limit: 20 });
const user = await usersResource.get('123');
const newUser = await usersResource.create({ email: 'a@b.com', name: 'Alice', password: '...' });
OpenAPI Code Generation
// ============================================================
// Generated Types from OpenAPI Schema
// ============================================================
// openapi.yaml
/*
openapi: 3.0.0
paths:
/users:
get:
operationId: listUsers
parameters:
- name: page
in: query
schema:
type: integer
- name: limit
in: query
schema:
type: integer
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
*/
// Generated client (via openapi-typescript-codegen or similar)
import { DefaultApi, Configuration, User } from './generated';
const config = new Configuration({
basePath: 'https://api.example.com',
accessToken: () => getAuthToken(),
});
const api = new DefaultApi(config);
// Fully typed API calls
const response = await api.listUsers({ page: 1, limit: 20 });
const users: User[] = response.data;
GraphQL Architecture
Schema-First Design
# schema.graphql
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: PaginationInput): UserConnection!
viewer: User
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
type Subscription {
userUpdated(id: ID!): User!
notificationReceived: Notification!
}
type User {
id: ID!
email: String!
name: String!
avatar: String
posts(first: Int, after: String): PostConnection!
followers: [User!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments(first: Int, after: String): CommentConnection!
likes: Int!
createdAt: DateTime!
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input UserFilter {
email: String
name: String
createdAfter: DateTime
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
input CreateUserInput {
email: String!
name: String!
password: String!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}
Apollo Client Setup
// ============================================================
// Apollo Client Configuration
// ============================================================
import {
ApolloClient,
InMemoryCache,
createHttpLink,
split,
ApolloLink,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
// HTTP link for queries and mutations
const httpLink = createHttpLink({
uri: 'https://api.example.com/graphql',
});
// Auth link
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://api.example.com/graphql',
connectionParams: () => ({
authToken: localStorage.getItem('token'),
}),
})
);
// Split based on operation type
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
authLink.concat(httpLink)
);
// Cache configuration with type policies
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
users: {
// Relay-style pagination merge
keyArgs: ['filter'],
merge(existing, incoming, { args }) {
if (!existing || !args?.after) {
return incoming;
}
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
User: {
fields: {
posts: {
keyArgs: false,
merge(existing, incoming, { args }) {
if (!existing || !args?.after) {
return incoming;
}
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
},
});
export const apolloClient = new ApolloClient({
link: splitLink,
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
errorPolicy: 'all',
},
query: {
fetchPolicy: 'cache-first',
errorPolicy: 'all',
},
mutate: {
errorPolicy: 'all',
},
},
});
// ============================================================
// Generated Hooks (via GraphQL Codegen)
// ============================================================
// codegen.yml
/*
generates:
./src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withHooks: true
withComponent: false
*/
// queries.graphql
/*
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
avatar
posts(first: 10) {
edges {
node {
id
title
}
}
}
}
}
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user {
id
email
name
}
errors {
field
message
}
}
}
*/
// Usage with generated hooks
import { useGetUserQuery, useCreateUserMutation } from './generated/graphql';
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useGetUserQuery({
variables: { id: userId },
});
const [createUser, { loading: creating }] = useCreateUserMutation({
update(cache, { data }) {
if (data?.createUser.user) {
// Update cache after mutation
cache.modify({
fields: {
users(existingUsers = { edges: [] }) {
const newUserRef = cache.writeFragment({
data: data.createUser.user,
fragment: gql`
fragment NewUser on User {
id
email
name
}
`,
});
return {
...existingUsers,
edges: [{ node: newUserRef }, ...existingUsers.edges],
};
},
},
});
}
},
});
if (loading) return <Skeleton />;
if (error) return <Error error={error} />;
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
</div>
);
}
Fragment Colocation
// ============================================================
// Fragment Colocation Pattern
// ============================================================
// UserAvatar.tsx
import { gql } from '@apollo/client';
export const USER_AVATAR_FRAGMENT = gql`
fragment UserAvatarFragment on User {
id
name
avatar
}
`;
interface UserAvatarProps {
user: {
id: string;
name: string;
avatar: string | null;
};
}
export function UserAvatar({ user }: UserAvatarProps) {
return (
<img
src={user.avatar || '/default-avatar.png'}
alt={user.name}
className="avatar"
/>
);
}
// UserCard.tsx
import { gql } from '@apollo/client';
import { USER_AVATAR_FRAGMENT, UserAvatar } from './UserAvatar';
export const USER_CARD_FRAGMENT = gql`
fragment UserCardFragment on User {
id
name
email
...UserAvatarFragment
}
${USER_AVATAR_FRAGMENT}
`;
interface UserCardProps {
user: {
id: string;
name: string;
email: string;
avatar: string | null;
};
}
export function UserCard({ user }: UserCardProps) {
return (
<div className="user-card">
<UserAvatar user={user} />
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// UserList.tsx - Query composes fragments
import { gql, useQuery } from '@apollo/client';
import { USER_CARD_FRAGMENT, UserCard } from './UserCard';
const GET_USERS = gql`
query GetUsers($first: Int!) {
users(first: $first) {
edges {
node {
...UserCardFragment
}
}
}
}
${USER_CARD_FRAGMENT}
`;
export function UserList() {
const { data, loading } = useQuery(GET_USERS, {
variables: { first: 20 },
});
if (loading) return <Loading />;
return (
<div>
{data.users.edges.map(({ node }) => (
<UserCard key={node.id} user={node} />
))}
</div>
);
}
tRPC: End-to-End Type Safety
Server Setup
// ============================================================
// tRPC Server Configuration
// ============================================================
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
import superjson from 'superjson';
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Middleware for authentication
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
list: publicProcedure
.input(
z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
search: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const { page, limit, search } = input;
const [users, total] = await Promise.all([
ctx.db.user.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
ctx.db.user.count({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
}),
]);
return {
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}),
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
include: {
posts: {
take: 10,
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
create: protectedProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
})
)
.mutation(async ({ input, ctx }) => {
const existing = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already in use',
});
}
return ctx.db.user.create({
data: {
...input,
createdBy: ctx.user.id,
},
});
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(2).max(100).optional(),
avatar: z.string().url().optional(),
})
)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.user.update({
where: { id },
data,
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.user.delete({
where: { id: input.id },
});
return { success: true };
}),
});
// server/routers/index.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
Client Setup
// ============================================================
// tRPC Client Configuration
// ============================================================
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink, wsLink, splitLink } from '@trpc/client';
import type { AppRouter } from '../server/routers';
import superjson from 'superjson';
export const trpc = createTRPCReact<AppRouter>();
export function getTRPCClient() {
return trpc.createClient({
transformer: superjson,
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({
url: `wss://api.example.com/trpc`,
}),
false: httpBatchLink({
url: 'https://api.example.com/trpc',
headers() {
return {
authorization: `Bearer ${getToken()}`,
};
},
}),
}),
],
});
}
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc, getTRPCClient } from '../utils/trpc';
import { useState } from 'react';
export default function App({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() => getTRPCClient());
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</trpc.Provider>
);
}
// ============================================================
// Usage in Components
// ============================================================
function UserList() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
// Fully typed query - input and output
const { data, isLoading, error } = trpc.user.list.useQuery(
{ page, limit: 20, search: search || undefined },
{
keepPreviousData: true,
}
);
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return (
<div>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
{data.users.map((user) => (
// user is fully typed!
<UserCard key={user.id} user={user} />
))}
<Pagination
page={page}
totalPages={data.pagination.totalPages}
onPageChange={setPage}
/>
</div>
);
}
function CreateUserForm() {
const utils = trpc.useUtils();
// Fully typed mutation
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.list.invalidate();
},
onError: (error) => {
// error.data?.zodError for validation errors
if (error.data?.zodError) {
// Handle field-level errors
}
},
});
const handleSubmit = (data: { email: string; name: string }) => {
createUser.mutate(data);
};
return (
<form onSubmit={...}>
{createUser.error && (
<div className="error">{createUser.error.message}</div>
)}
{/* form fields */}
</form>
);
}
// Optimistic updates
function LikeButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const likeMutation = trpc.post.like.useMutation({
onMutate: async ({ postId }) => {
await utils.post.byId.cancel({ id: postId });
const previousPost = utils.post.byId.getData({ id: postId });
utils.post.byId.setData({ id: postId }, (old) =>
old ? { ...old, likes: old.likes + 1, isLiked: true } : old
);
return { previousPost };
},
onError: (err, variables, context) => {
if (context?.previousPost) {
utils.post.byId.setData(
{ id: variables.postId },
context.previousPost
);
}
},
onSettled: (data, error, variables) => {
utils.post.byId.invalidate({ id: variables.postId });
},
});
return (
<button onClick={() => likeMutation.mutate({ postId })}>
Like
</button>
);
}
Backend-for-Frontend (BFF) Pattern
┌─────────────────────────────────────────────────────────────────────────────┐
│ BFF Architecture │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Without BFF: │
│ ┌────────────┐ │
│ │ Web App │─────┬────► User Service │
│ └────────────┘ │ │
│ ├────► Product Service │
│ ┌────────────┐ │ │
│ │ Mobile App │─────┼────► Order Service │
│ └────────────┘ │ │
│ └────► Payment Service │
│ │
│ Problems: │
│ • Clients must orchestrate multiple services │
│ • Over-fetching (services return more than needed) │
│ • Different clients need different data shapes │
│ • Business logic leaks into clients │
│ │
│ With BFF: │
│ ┌────────────┐ ┌────────────┐ │
│ │ Web App │────►│ Web BFF │─────┬────► User Service │
│ └────────────┘ └────────────┘ │ │
│ ├────► Product Service │
│ ┌────────────┐ ┌────────────┐ │ │
│ │ Mobile App │────►│ Mobile BFF │─────┼────► Order Service │
│ └────────────┘ └────────────┘ │ │
│ └────► Payment Service │
│ │
│ Benefits: │
│ • Client-specific API optimization │
│ • Aggregation and transformation at BFF │
│ • Single point of orchestration │
│ • Can use different protocols per BFF (GraphQL web, REST mobile) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
BFF Implementation
// ============================================================
// BFF for Web Application
// ============================================================
// bff/services/product-aggregator.ts
interface BackendProduct {
id: string;
name: string;
description: string;
price: number;
categoryId: string;
inventoryId: string;
}
interface BackendInventory {
id: string;
quantity: number;
warehouse: string;
}
interface BackendCategory {
id: string;
name: string;
parentId: string | null;
}
interface WebProduct {
id: string;
name: string;
description: string;
price: number;
formattedPrice: string;
category: {
id: string;
name: string;
breadcrumb: string[];
};
availability: {
inStock: boolean;
quantity: number;
message: string;
};
}
class ProductAggregator {
constructor(
private productService: ProductServiceClient,
private inventoryService: InventoryServiceClient,
private categoryService: CategoryServiceClient
) {}
async getProductForWeb(productId: string): Promise<WebProduct> {
// Parallel fetches to backend services
const [product, inventory, categories] = await Promise.all([
this.productService.getProduct(productId),
this.inventoryService.getInventory(productId),
this.categoryService.getCategoryPath(productId),
]);
// Transform and aggregate for web client
return {
id: product.id,
name: product.name,
description: product.description,
price: product.price,
formattedPrice: this.formatPrice(product.price),
category: {
id: categories[categories.length - 1].id,
name: categories[categories.length - 1].name,
breadcrumb: categories.map((c) => c.name),
},
availability: {
inStock: inventory.quantity > 0,
quantity: inventory.quantity,
message: this.getAvailabilityMessage(inventory.quantity),
},
};
}
async getProductListForWeb(
categoryId: string,
pagination: { page: number; limit: number }
): Promise<{ products: WebProduct[]; total: number }> {
const { products, total } = await this.productService.listProducts({
categoryId,
...pagination,
});
// Batch fetch inventory for all products
const inventories = await this.inventoryService.batchGetInventory(
products.map((p) => p.inventoryId)
);
const inventoryMap = new Map(
inventories.map((i) => [i.id, i])
);
// Transform
const webProducts = products.map((product) => {
const inventory = inventoryMap.get(product.inventoryId)!;
return {
id: product.id,
name: product.name,
description: product.description.slice(0, 150) + '...', // Truncate for list
price: product.price,
formattedPrice: this.formatPrice(product.price),
category: { id: product.categoryId, name: '', breadcrumb: [] }, // Light version
availability: {
inStock: inventory.quantity > 0,
quantity: Math.min(inventory.quantity, 10), // Cap for display
message: inventory.quantity > 0 ? 'In Stock' : 'Out of Stock',
},
};
});
return { products: webProducts, total };
}
private formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
private getAvailabilityMessage(quantity: number): string {
if (quantity === 0) return 'Out of Stock';
if (quantity < 5) return `Only ${quantity} left!`;
if (quantity < 20) return 'Low Stock';
return 'In Stock';
}
}
// bff/routes/products.ts
const router = express.Router();
const aggregator = new ProductAggregator(
productServiceClient,
inventoryServiceClient,
categoryServiceClient
);
router.get('/products/:id', async (req, res) => {
try {
const product = await aggregator.getProductForWeb(req.params.id);
res.json(product);
} catch (error) {
if (error instanceof NotFoundError) {
res.status(404).json({ error: 'Product not found' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
router.get('/categories/:categoryId/products', async (req, res) => {
const { page = '1', limit = '20' } = req.query;
const result = await aggregator.getProductListForWeb(req.params.categoryId, {
page: parseInt(page as string),
limit: parseInt(limit as string),
});
res.json(result);
});
API Error Handling Architecture
// ============================================================
// Standardized Error Handling
// ============================================================
// Common error types
enum ErrorCode {
VALIDATION_ERROR = 'VALIDATION_ERROR',
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR = 'AUTHORIZATION_ERROR',
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
RATE_LIMITED = 'RATE_LIMITED',
INTERNAL_ERROR = 'INTERNAL_ERROR',
}
interface APIErrorResponse {
code: ErrorCode;
message: string;
details?: Record<string, string[]>;
requestId?: string;
}
// Client-side error handler
class APIErrorHandler {
private handlers = new Map<ErrorCode, (error: APIErrorResponse) => void>();
register(code: ErrorCode, handler: (error: APIErrorResponse) => void): void {
this.handlers.set(code, handler);
}
handle(error: APIErrorResponse): void {
const handler = this.handlers.get(error.code);
if (handler) {
handler(error);
} else {
this.defaultHandler(error);
}
}
private defaultHandler(error: APIErrorResponse): void {
console.error('Unhandled API error:', error);
toast.error(error.message);
}
}
const errorHandler = new APIErrorHandler();
// Register handlers
errorHandler.register(ErrorCode.AUTHENTICATION_ERROR, () => {
// Redirect to login
window.location.href = '/login';
});
errorHandler.register(ErrorCode.VALIDATION_ERROR, (error) => {
// Show field-level errors
if (error.details) {
Object.entries(error.details).forEach(([field, messages]) => {
toast.error(`${field}: ${messages.join(', ')}`);
});
}
});
errorHandler.register(ErrorCode.RATE_LIMITED, (error) => {
toast.error('Too many requests. Please try again later.');
});
// Use in fetch wrapper
async function apiRequest<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const error: APIErrorResponse = await response.json();
errorHandler.handle(error);
throw new Error(error.message);
}
return response.json();
}
Key Takeaways
-
REST is simple and cacheable: Best for public APIs, works with existing HTTP infrastructure
-
GraphQL prevents over-fetching: Clients request exactly what they need, great for mobile
-
tRPC provides end-to-end type safety: Zero code generation, instant feedback, ideal for TypeScript monorepos
-
BFF pattern separates concerns: Each client gets an optimized API tailored to its needs
-
Choose based on your constraints: Team expertise, client diversity, performance requirements
-
Type generation is essential: OpenAPI for REST, GraphQL Codegen, tRPC's inference
-
Caching strategies differ: REST has HTTP caching, GraphQL needs Apollo/urql, tRPC uses React Query
-
Error handling must be consistent: Standardize error formats across all APIs
-
Real-time needs vary: REST uses WebSocket/SSE separately, GraphQL has subscriptions, tRPC supports both
-
Performance monitoring is crucial: Track latency, error rates, and cache hit ratios
What did you think?