System Design/
Lesson

Open any well-structured backend codebase and you will find the same shape repeating: a folder for routes or controllers, a folder for services or domain logic, and a folder for database access. This is three-tier architectureWhat is three-tier architecture?A pattern separating an application into presentation, business logic, and data access layers, each with a single responsibility., and it has survived decades of framework churn for a simple reason, it works.

The idea is not complicated. You split your application into three layers. Each layer does one category of work. Each layer talks only to the layer directly below it. That constraint sounds restrictive, but it is the source of every benefit the pattern provides.

The three layers

Think of a restaurant. The waiter takes your order (presentation), the chef prepares the food (business logic), and the pantry stores the ingredients (data access). The waiter never walks into the pantry. The pantry never talks to customers. Each role has boundaries.

Presentation layer

This is where the outside world meets your application. It handles 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. requests, validates input formats, and transforms responses. In Express, this is your route handlers. In React with a 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., this is your API gatewayWhat is api gateway?A single entry point that sits in front of multiple backend services, routing requests to the right one and handling shared concerns like authentication and rate limiting..

// routes/users.ts - Presentation layer
router.post('/users', async (req, res) => {
  const { name, email } = req.body;

  // Input validation (is the data shaped correctly?)
  if (!email || !email.includes('@')) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  // Delegate to business logic layer
  const user = await userService.createUser({ name, email });
  res.status(201).json(user);
});

Notice what this code does NOT do: it does not check if the email already exists in the database. That is a business rule, not an input format check.

Business logic layer

This is the heart of your application. It contains the rules that make your product unique: pricing calculations, permission checks, workflow orchestration. It knows nothing about HTTP, nothing about SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables.. It receives plain objects and returns plain objects.

// services/userService.ts - Business logic layer
class UserService {
  constructor(private userRepo: UserRepository) {}

  async createUser(data: { name: string; email: string }) {
    // Business rule: no duplicate emails
    const existing = await this.userRepo.findByEmail(data.email);
    if (existing) {
      throw new ConflictError('Email already registered');
    }

    // Business rule: normalize the name
    const normalizedName = data.name.trim().replace(/\s+/g, ' ');

    return this.userRepo.create({
      name: normalizedName,
      email: data.email.toLowerCase(),
    });
  }
}

Data access layer

This layer owns the database. It translates between your application objects and whatever storage technology you use. Swap PostgreSQL for MongoDB? Only this layer changes.

// repositories/userRepository.ts - Data access layer
class UserRepository {
  constructor(private db: Database) {}

  async findByEmail(email: string): Promise<User | null> {
    const row = await this.db.query(
      'SELECT * FROM users WHERE email = CODE_BLOCK',
      [email]
    );
    return row ? this.toUser(row) : null;
  }

  async create(data: { name: string; email: string }): Promise<User> {
    const row = await this.db.query(
      'INSERT INTO users (name, email) VALUES (CODE_BLOCK, $2) RETURNING *',
      [data.name, data.email]
    );
    return this.toUser(row);
  }

  private toUser(row: any): User {
    return { id: row.id, name: row.name, email: row.email };
  }
}
02

How a request flows through the layers

When a POST request hits /users, here is the complete journey:

  1. Presentation: Express router receives the request, validates the 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. shape, extracts name and email
  2. Business logic: UserService.createUser() checks for duplicate emails, normalizes data, applies business rules
  3. Data access: UserRepository.create() runs the SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. INSERT and returns the new row
  4. Return trip: The data flows back up, repositoryWhat is repository?A project folder tracked by Git that stores your files along with the complete history of every change, inside a hidden .git directory. returns a User object, service returns it, controller sends it as JSON
Client Request
    ↓
[Presentation]  →  Parse input, validate format
    ↓
[Business Logic] →  Apply rules, orchestrate
    ↓
[Data Access]    →  Read/write database
    ↓
Database

The critical rule: data flows down through the layers and results flow back up. No layer ever skips another.

03

DependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. direction

This is where most developers get confused. The rule is: outer layers depend on inner layers, never the reverse.

Your controller imports the service. Your service imports the repositoryWhat is repository?A project folder tracked by Git that stores your files along with the complete history of every change, inside a hidden .git directory.. But the repository never imports the service. The service never imports the controller.

Presentation  →  depends on  →  Business Logic  →  depends on  →  Data Access

Why does this matter? Because it controls what breaks when something changes.

  • Change 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. framework? Only the presentation layer changes. Business logic and data access are untouched.
  • Switch from PostgreSQL to MySQL? Only the data access layer changes. Business logic does not care how data is stored.
  • Add a new business rule? The service layer changes. The database might need a migrationWhat is migration?A versioned script that changes your database structure (add a column, create a table) so every developer and server stays in sync., but the controller stays the same.

If you violate this rule, say, by importing the Express Request type in your service layer, you have coupled your business logic to your HTTP framework. Now switching frameworks means rewriting your core logic.

AI pitfall
Ask AI to "build an Express API" and it will generate fat route handlers every time, database queries, business rules, and HTTP handling all in one function. This happens because the most common code on the internet mixes concerns, and AI generates the most common patterns. Always tell AI explicitly: "Use separate controller, service, and repository layers."
04

Comparing the layers

AspectPresentationBusiness LogicData Access
ResponsibilityHTTP handling, input validation, response formattingDomain rules, orchestration, workflowsDatabase queries, storage, transactions
Knows aboutHTTP, JSON, request/responseDomain objects, business rulesSQL, ORM, connection pools
Does NOT know aboutDatabase, SQL, tablesHTTP, request objects, status codesBusiness rules, HTTP, user sessions
Common technologiesExpress routes, Fastify handlers, Next.js API routesPlain TypeScript classes, no framework dependencyPrisma, Knex, raw SQL, Drizzle
Common mistakesWriting SQL queries directly in route handlersImporting req and res objectsImplementing business rules in queries
Test approachIntegration tests with supertestUnit tests with mocked repositoryIntegration tests against test database
05

Why this matters for testing

Without layers, testing is painful. Imagine a 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. that validates input, checks business rules, AND queries the database, all in one function. To test the business rule, you need a running database and an 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. server.

With layers, you can test each layer in isolation:

// Testing business logic without HTTP or database
describe('UserService', () => {
  it('rejects duplicate emails', async () => {
    const mockRepo = {
      findByEmail: jest.fn().mockResolvedValue({ id: 1, email: '[email protected]' }),
      create: jest.fn(),
    };
    const service = new UserService(mockRepo);

    await expect(
      service.createUser({ name: 'Test', email: '[email protected]' })
    ).rejects.toThrow('Email already registered');

    expect(mockRepo.create).not.toHaveBeenCalled();
  });
});

No database needed. No HTTP server needed. The test runs in milliseconds and checks exactly one thing: the duplicate email rule.

06

When layers feel like overkill

For a 50-line script or a quick prototype, three layers are unnecessary overhead. The pattern shines when:

  • Multiple developers work on the same codebase
  • Business rules are complex or change frequently
  • You need to test business logic independently
  • You might swap storage technology in the future

For a weekend project with one developer, putting everything in route handlers is fine. Just know that the moment complexity grows, you will wish you had separated concerns from the start.

Edge case
Middleware sits awkwardly in three-tier architecture. Authentication middleware runs before the controller but checks business rules (is this token valid?). The pragmatic answer: treat auth middleware as a cross-cutting concern that lives alongside the presentation layer, not inside the business logic layer.
Good to know
You do not need to name your folders controllers/, services/, and repositories/. The pattern is about separation of responsibility, not folder names. Some teams use api/, domain/, and data/. What matters is that the boundaries exist.