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 };
}
}How a request flows through the layers
When a POST request hits /users, here is the complete journey:
- 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
nameandemail - Business logic:
UserService.createUser()checks for duplicate emails, normalizes data, applies business rules - 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 - 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
↓
DatabaseThe critical rule: data flows down through the layers and results flow back up. No layer ever skips another.
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 AccessWhy 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.
Comparing the layers
| Aspect | Presentation | Business Logic | Data Access |
|---|---|---|---|
| Responsibility | HTTP handling, input validation, response formatting | Domain rules, orchestration, workflows | Database queries, storage, transactions |
| Knows about | HTTP, JSON, request/response | Domain objects, business rules | SQL, ORM, connection pools |
| Does NOT know about | Database, SQL, tables | HTTP, request objects, status codes | Business rules, HTTP, user sessions |
| Common technologies | Express routes, Fastify handlers, Next.js API routes | Plain TypeScript classes, no framework dependency | Prisma, Knex, raw SQL, Drizzle |
| Common mistakes | Writing SQL queries directly in route handlers | Importing req and res objects | Implementing business rules in queries |
| Test approach | Integration tests with supertest | Unit tests with mocked repository | Integration tests against test database |
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.
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.
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.