System Design/
Lesson

Every application has a front door. For a RESTWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses., it is the route handlerWhat is route handler?A Next.js file named route.js inside the app/ directory that handles HTTP requests directly - the App Router equivalent of API routes.. For a web app, it could be a server-rendered page or a single-page applicationWhat is spa?A Single Page Application that loads once and then dynamically updates content without full page reloads as the user navigates. fetching data from an endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users.. The presentation layer is that front door, it decides what comes in, what goes out, and how it is shaped.

Getting this layer right means clean input, consistent responses, and a clear boundary that protects your business logic from the messy realities of HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted..

What the presentation layer does

The presentation layer has exactly four jobs:

  1. Receive the incoming request (HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. method, URL, headers, body)
  2. Validate the input shape (not business rules, just "is this valid JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it. with the right fields?")
  3. Delegate to the business logic layer
  4. Format the response (status codeWhat is status code?A three-digit number in an HTTP response that tells the client what happened: 200 means success, 404 means not found, 500 means the server broke., headers, JSON body)

That is it. If your controller is doing anything else, querying the database, checking permissions, calculating prices, it has overstepped its boundaries.

02

APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. Controllers in Express

A well-structured controller is thin. It does not think. It translates between HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. and your domain.

// controllers/orderController.ts
import { Request, Response } from 'express';
import { OrderService } from '../services/orderService';
import { CreateOrderSchema } from '../schemas/order';

export class OrderController {
  constructor(private orderService: OrderService) {}

  async create(req: Request, res: Response) {
    // Step 1: Validate input shape
    const result = CreateOrderSchema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.flatten(),
      });
    }

    // Step 2: Delegate to business logic
    try {
      const order = await this.orderService.placeOrder(
        req.user.id,
        result.data
      );
      // Step 3: Format response
      return res.status(201).json({ data: order });
    } catch (err) {
      if (err instanceof ConflictError) {
        return res.status(409).json({ error: err.message });
      }
      throw err; // Let error middleware handle unexpected errors
    }
  }
}

Count the lines that touch HTTP (req, res, status codes). Then count the lines that contain business logic. If the ratio is wrong, the controller is too fat.

03

Input validation with ZodWhat is zod?A TypeScript-first schema validation library that validates data at runtime while automatically inferring static TypeScript types from the schema.

Zod has become the standard validation library in the TypeScript ecosystem. It defines a schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required., parses input, and gives you a fully typed result, all in one step.

import { z } from 'zod';

// Define the expected shape
export const CreateOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive().max(100),
  })).min(1).max(50),
  shippingAddress: z.object({
    street: z.string().min(1).max(200),
    city: z.string().min(1).max(100),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/),
    country: z.string().length(2), // ISO country code
  }),
  couponCode: z.string().optional(),
});

// TypeScript type is inferred automatically
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;

Compare this with Joi:

import Joi from 'joi';

const CreateOrderSchema = Joi.object({
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().uuid().required(),
      quantity: Joi.number().integer().positive().max(100).required(),
    })
  ).min(1).max(50).required(),
  shippingAddress: Joi.object({
    street: Joi.string().min(1).max(200).required(),
    city: Joi.string().min(1).max(100).required(),
    zip: Joi.string().pattern(/^\d{5}(-\d{4})?$/).required(),
    country: Joi.string().length(2).required(),
  }).required(),
  couponCode: Joi.string().optional(),
});

Both work. Zod wins on TypeScript integration (automatic type inferenceWhat is type inference?When TypeScript automatically figures out a variable's type from its assigned value, so you don't have to annotate it yourself.). Joi wins on ecosystem maturity and runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary.-only validation. Pick one and use it everywhere, consistency matters more than the choice itself.

AI pitfall
When you ask AI to generate validation schemas, it tends to be either too loose (accepts everything) or too strict (rejects valid input). Check the edge cases: does the email regex accept [email protected]? Does the zip code pattern handle international formats? AI rarely gets these right on the first try.
04

Two kinds of validation

This is a crucial distinction that most tutorials blur:

Validation typeWhere it happensExamplesWho owns it
Input validationPresentation layerIs email a valid format? Is quantity a positive integer? Is the JSON well-formed?Controller / schema
Business validationBusiness logic layerIs this email already registered? Does the user have enough balance? Is the product in stock?Service

Input validation asks: "Is this data shaped correctly?" Business validation asks: "Is this operation allowed by our rules?"

If you put business validation in the controller, you are coupling your HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. layer to your domain. If someone calls userService.createUser() from a CLIWhat is cli?Short for Command Line Interface. A tool you use by typing commands in the terminal instead of clicking buttons. tool or a background job, the business rules would be skipped because they live in the HTTP handler.

// WRONG: Business rule in the controller
router.post('/orders', async (req, res) => {
  const product = await db.query('SELECT stock FROM products WHERE id = CODE_BLOCK',
    [req.body.productId]);
  if (product.stock < req.body.quantity) {
    return res.status(400).json({ error: 'Out of stock' });
  }
  // ...
});

// RIGHT: Controller validates shape, service validates rules
router.post('/orders', async (req, res) => {
  const input = CreateOrderSchema.parse(req.body); // Shape only
  const order = await orderService.placeOrder(input); // Rules here
  res.status(201).json({ data: order });
});
05

Request/Response transformation

The presentation layer also transforms data between the HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. format and the domain format. Your database might store a created_at timestamp, but your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. should return createdAt. Your domain might use a User object with 20 fields, but the API response should only include 5.

// Transform domain object to API response
function toUserResponse(user: User) {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    memberSince: user.createdAt.toISOString(),
    // Intentionally omitting: passwordHash, internalNotes, etc.
  };
}

router.get('/users/:id', async (req, res) => {
  const user = await userService.getById(req.params.id);
  res.json({ data: toUserResponse(user) });
});

This is sometimes called a "DTOWhat is dto?A plain object that carries data between layers or over the network, containing only the fields the recipient needs." (Data Transfer Object) or a "serializer." The name does not matter. What matters is that your internal domain model never leaks directly into your API responses.

06

SSRWhat is ssr?Server-Side Rendering - generating HTML on the server for every request so users and search engines see fully formed pages immediately. vs CSRWhat is csr?Client-Side Rendering - shipping a mostly empty HTML file with JavaScript that builds the page content in the user's browser. vs APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses.-only vs BFFWhat is bff?Backend For Frontend - a dedicated API layer built specifically for one client type (mobile, web) that shapes data exactly as that client needs it.

The presentation layer takes different shapes depending on how your application serves content. Here is the comparison:

ApproachHow it worksStrengthsWeaknessesBest for
SSR (Server-Side Rendering)Server generates full HTML on each requestSEO-friendly, fast first paint, works without JSHigher server load, slower navigation between pagesContent sites, e-commerce, blogs
CSR (Client-Side Rendering)Server sends empty HTML + JS bundle, browser rendersRich interactivity, fast after initial loadPoor SEO, slow first paint, requires JSDashboards, admin panels, internal tools
API-onlyServer returns JSON, no HTMLClean separation, multiple clientsRequires separate frontend, more round tripsMobile apps, third-party integrations, microservices
BFF (Backend For Frontend)A dedicated API layer per client typeOptimized payloads per client, simpler frontendsMore services to maintain, potential duplicationCompanies with web + mobile + third-party clients

The BFF pattern

Imagine you have a web app that needs a detailed user profile and a mobile app that needs a compact version. Without BFF, either the API returns too much data for mobile or too little for web.

With BFF, each client gets its own API layer:

Web App  →  Web BFF  →  User Service, Order Service, ...
Mobile   →  Mobile BFF  →  User Service, Order Service, ...
// web-bff/routes/profile.ts - Rich response for web
router.get('/profile', async (req, res) => {
  const user = await userService.getById(req.user.id);
  const orders = await orderService.getRecent(req.user.id, 10);
  const recommendations = await recService.getForUser(req.user.id);
  res.json({ user, orders, recommendations });
});

// mobile-bff/routes/profile.ts - Compact response for mobile
router.get('/profile', async (req, res) => {
  const user = await userService.getById(req.user.id);
  res.json({
    name: user.name,
    avatar: user.avatarUrl,
    orderCount: await orderService.countForUser(req.user.id),
  });
});

The BFF pattern is not something you need on day one. It is useful when you have multiple clients with genuinely different data needs and the "one API fits all" approach creates waste or complexity.

Edge case
File uploads break the typical request/response pattern. A 50MB image upload should not go through the same JSON validation pipeline as a form submission. Most production systems use pre-signed URLs (upload directly to S3) for large files, bypassing the presentation layer entirely.
Good to know
Frameworks like Next.js and Remix blur the SSR/CSR line. They server-render the first page load, then switch to client-side navigation. This hybrid approach gives you the SEO benefits of SSR with the interactivity of CSR.