Introduction

When you are building a small prototype, it is tempting to put everything in one file. You might have a route handler that reads request parameters, validates the user input, connects to the database, calculates a total price, and sends an email—all in 50 lines of code. It works, it is fast to write, and it feels productive.

But as the application grows, this "Fat Handler" approach becomes a nightmare. If you want to change the database from SQL to NoSQL, you have to rewrite the entire application. If you want to test the pricing logic, you have to mock the database and the email server.

In my experience, the most effective way to manage complexity in a standard web application is Layering. You don't need complex microservices or Hexagonal Architecture for most projects. A simple, disciplined "Three-Layer Architecture" is often enough to keep your codebase clean for years.

The Three-Layer Standard

The standard pattern divides your application into three distinct horizontal layers. Data flows down, and results flow up.

  1. Presentation Layer (Controller/UI): Handles HTTP requests, parses input, and sends responses. It knows nothing about business rules or databases.
  2. Service Layer (Business Logic): The heart of your application. It performs validations, calculations, and orchestrates data flow. It knows nothing about HTTP or SQL.
  3. Data Access Layer (Repository/DAO): Talks to the database or external APIs. It knows how to fetch and save data, but it doesn't know why.

Layer 1: The Data Access Layer (Repository)

Let's start from the bottom. The goal of this layer is to abstract away the database. If you are writing raw SQL queries inside your controllers, you are coupling your application logic to your database technology.

Instead, create a "Repository."

// userRepository.js
// This layer ONLY cares about database queries.

class UserRepository {
    constructor(db) {
        this.db = db;
    }

    async findByEmail(email) {
        // Raw SQL or ORM calls go here
        return this.db.query('SELECT * FROM users WHERE email = ?', [email]);
    }

    async create(userData) {
        return this.db.query('INSERT INTO users SET ?', userData);
    }
}

Why this helps: If you rename a database column, you only have to update this one file. The rest of your application doesn't care.

Layer 2: The Service Layer (Business Logic)

This is where the magic happens. A "Service" class contains the rules of your business. It accepts pure data structures (not HTTP request objects) and returns pure data structures.

A Service should read like a story of what the application does.

// userService.js
// This layer contains the rules.

class UserService {
    constructor(userRepo, emailService) {
        this.userRepo = userRepo;
        this.emailService = emailService;
    }

    async registerUser(email, password) {
        // Rule 1: Check if user exists
        const existing = await this.userRepo.findByEmail(email);
        if (existing) {
            throw new Error('User already exists');
        }

        // Rule 2: Hash password (implementation detail hidden in util)
        const hashedPassword = hashPassword(password);

        // Rule 3: Save user
        const newUser = await this.userRepo.create({ email, password: hashedPassword });

        // Rule 4: Send welcome email
        await this.emailService.sendWelcome(email);

        return newUser;
    }
}

Notice that UserService has no idea that it is running inside a web server. It doesn't see req or res objects. This makes it incredibly easy to unit test. You can test the "User already exists" logic without spinning up a server.

Layer 3: The Presentation Layer (Controller)

Finally, the Controller handles the "Plumbing." Its job is to translate the outside world (HTTP) into your internal world (Service calls).

// userController.js
// This layer handles HTTP.

class UserController {
    constructor(userService) {
        this.userService = userService;
    }

    async register(req, res) {
        try {
            // 1. Extract data from HTTP request
            const { email, password } = req.body;

            // 2. Call the Service
            const user = await this.userService.registerUser(email, password);

            // 3. Format the HTTP response
            res.status(201).json({ id: user.id, email: user.email });
        } catch (error) {
            // 4. Handle errors (translate exceptions to HTTP codes)
            if (error.message === 'User already exists') {
                res.status(409).json({ error: error.message });
            } else {
                res.status(500).json({ error: 'Internal Server Error' });
            }
        }
    }
}

The controller is "dumb." It doesn't make decisions. It just coordinates input and output.

The Dependency Rule

The most critical rule in layered architecture is the Direction of Dependency.

Never reverse this. The Data Layer should never import the Service Layer. The Service Layer should never import the Controller. If you find yourself needing to call a Controller method from a Service, your logic is in the wrong place.

When to Break the Rules

Architecture is a trade-off. For extremely simple CRUD (Create, Read, Update, Delete) apps, this 3-layer approach might feel like boilerplate.

If you are just reading a list of items and sending them to the client with zero logic, it is acceptable for the Controller to call the Repository directly, skipping the Service layer. However, be careful. Business requirements rarely stay simple forever. As soon as you add "only show items if the user is active," you need a Service layer again.

Summary

Layering is about Separation of Concerns.

By strictly adhering to these boundaries, you create a codebase that is resilient to change. You can swap out your web framework (Controller) or your database (Repository) without rewriting your core business logic (Service).

← Back to Architecture & Workflow

Back to Home