Here is code that works perfectly and will ruin your project.
app.post('/orders', async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = ?', [req.body.userId]);
if (user.credits < req.body.total) return res.status(400).send('Not enough credits');
await db.query('UPDATE users SET credits = credits - ? WHERE id = ?', [req.body.total, user.id]);
const order = await db.query('INSERT INTO orders (user_id, total) VALUES (?, ?)', [user.id, req.body.total]);
await sendEmail(user.email, 'Order confirmed');
res.json(order);
});It responds correctly. Ship it and it will serve customers happily for months.
Then your product manager asks for three things: place orders from a mobile API too, let admins order on a customer's behalf, and write a test for the credit rule.
You cannot do any of them without copying this block. The business rule — a customer must have enough credits — lives inside an HTTP handler. To reuse it you need a fake HTTP request. To test it you need a running database. The rule is trapped.
The problem, stated properly
That function mixes three unrelated responsibilities:
- HTTP concerns — reading
req.body, choosing a status code, sending JSON. - Business rules — customers need sufficient credits; ordering deducts them and sends a confirmation.
- Data access — the exact SQL, table names and column names.
These change for completely different reasons. HTTP changes when you add a mobile client. Business rules change when finance introduces overdrafts. Data access changes when you rename a column.
When they live together, any one of those changes forces you to touch code responsible for the other two — and risk breaking them.
The three layers
Give each responsibility its own home, with dependencies pointing one way only.
Diagram, as text
- Controller speaks HTTP leads to Service knows the rules
- Service knows the rules leads to Repository knows storage
- Repository knows storage leads to Database
Controller translates the outside world into plain values, calls one service method, turns the result into a response. No business rules.
Service holds the business logic. "A customer must have enough credits" lives here. Critically, it knows nothing about HTTP and nothing about SQL.
Repository hides storage behind intention-revealing methods like findById and save. It is the only place that knows you use Postgres.
The same feature, separated:
// repositories/userRepository.js — the only file that knows SQL
class UserRepository {
async findById(id) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
async deductCredits(id, amount) {
return db.query('UPDATE users SET credits = credits - ? WHERE id = ?', [amount, id]);
}
}
// services/orderService.js — the only file that knows the rules
class OrderService {
constructor(userRepo, orderRepo, mailer) {
this.userRepo = userRepo;
this.orderRepo = orderRepo;
this.mailer = mailer;
}
async placeOrder(userId, total) {
const user = await this.userRepo.findById(userId);
if (!user) throw new NotFoundError('User not found');
if (user.credits < total) throw new InsufficientCreditsError();
await this.userRepo.deductCredits(userId, total);
const order = await this.orderRepo.create(userId, total);
await this.mailer.send(user.email, 'Order confirmed');
return order;
}
}
// routes/orders.js — thin, and it shows
app.post('/orders', async (req, res, next) => {
try {
const order = await orderService.placeOrder(req.body.userId, req.body.total);
res.status(201).json(order);
} catch (err) {
next(err);
}
});Now revisit those three requests. The mobile API calls orderService.placeOrder. The admin tool calls orderService.placeOrder. The test calls orderService.placeOrder with fake repositories and no database at all.
The rule was written once and became reusable the moment it stopped being tangled with HTTP.
Why testability improves so dramatically
Testing the original code requires a running web server, a real database seeded with a user, and an email service that will not actually send mail. That test is slow, flaky and painful to set up — so in practice nobody writes it.
Testing the service requires none of that:
test('rejects an order when credits are insufficient', async () => {
const fakeUsers = { findById: async () => ({ id: 1, credits: 50 }) };
const service = new OrderService(fakeUsers, {}, {});
await expect(service.placeOrder(1, 100)).rejects.toThrow(InsufficientCreditsError);
});Three lines, runs in a millisecond, no infrastructure. The layering is what made the test cheap — and cheap tests are the ones that actually get written.
What a repository is really for
A repository is an interface that hides how data is stored behind what you want.
The service says "give me user 42". It does not know whether that comes from Postgres, MongoDB, a cache, or another team's HTTP service. That ignorance is the point.
Two concrete payoffs:
Swapping storage stays contained. Moving orders from Postgres to DynamoDB means rewriting one repository. Every service keeps working, because none of them ever knew.
Queries stop being scattered. Without a repository, SELECT * FROM orders WHERE status = 'pending' appears in nine files. When you add a deleted_at column and need AND deleted_at IS NULL everywhere, you will find eight of the nine.
Where beginners get this wrong
Anaemic services that only forward calls. If every service method is one line calling one repository method, you added indirection without value — the rules are probably still in the controller. Ask where the if statements live; that is where your logic actually is.
Repositories calling other repositories. Coordinating between entities is the service's job. A repository should know about one kind of thing.
Business rules in database triggers. They split your logic across two languages and two deployment processes, and new developers will never find them.
Services returning HTTP status codes. The moment a service returns 404 it has learned about HTTP and can no longer be reused by a background job. Throw a domain error such as NotFoundError and let the controller map it.
When this is overkill
Three layers for a two-endpoint project is bureaucracy. For a small internal tool that will never grow, one file is fine and honest.
The signal to introduce layers is when the same rule needs a second caller, or when you find you cannot write a test without starting a database.
Check your understanding
Common mistakes
Business rules in the controller. The next caller — a mobile endpoint, a cron job, an admin tool — has to reimplement them.
Services that take HTTP objects. Passing the request or response into a service re-couples it to the transport it was meant to escape.
Anaemic services that only forward. A service whose every method calls one repository method is a layer with no content; delete it or give it the rules.
Repositories that return query builders. Leaking the ORM upwards means the database schema reaches the service layer after all.
Dependencies pointing the wrong way. A repository that calls a service inverts the whole arrangement and creates a cycle.
One giant service per entity. OrderService accumulating every order-related operation is the monolith you just moved.
Adding all three layers to a CRUD screen. For a form that writes one table, the ceremony costs more than it returns.
Practice
Place each of these and justify: validating an email format, checking a user has not exceeded their plan's project limit, converting a database row to JSON, and starting a transaction that spans two writes.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask for each: is this about the transport, the domain, or the storage?
Show a strong answer
- Validating an email format — the boundary, on the inbound DTO. It is a shape question, answerable without any domain knowledge or database access, and rejecting it early means the service layer never receives nonsense. It is not a business rule; it is a precondition.
- Checking the plan's project limit — the service layer, without question. This requires domain knowledge (what the plan permits), it requires data (how many projects exist), and it is the rule the product actually sells. It must be enforced wherever a project is created — the API, an import job, an admin tool — which is precisely the argument for it not living in a controller.
- Converting a row to JSON — the boundary, outbound. The shape of the response is a transport concern. Doing it in the service means the service knows about the wire format, and a second consumer wanting a different shape forces a change to business code.
- Starting a transaction spanning two writes — the service layer. The transaction boundary is a business statement: these two things must both happen or neither. A repository cannot decide it, because a repository sees only one of the two writes and has no idea another is coming. Putting it in the controller ties atomicity to the transport.
The pattern: transport concerns at the edges, storage concerns in repositories, and anything that answers "what does the business require here?" in the middle — including transaction boundaries, which look technical and are not.
A small team's CRUD service has controllers that call a service that calls a repository, where every service method is a one-line pass-through. Is the layering wrong? Argue both sides.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what the layer would cost to add later versus what it costs to carry now.
Show a strong answer
The case that it is wrong: a method that only forwards adds a file to open, a name to invent and a hop to follow, and it buys nothing. Readers must check whether each pass-through is truly trivial. This is ceremony, and the module's own advice — do not abstract before the pressure exists — applies to layers as much as to patterns. For genuine CRUD with no rules, controller-to-repository is honest and readable.
The case that it is right: the pass-throughs cost little and the layer is where every rule will eventually go — and rules do arrive: an audit entry, a permission check, an event to publish, a limit to enforce. Adding the layer later means editing every controller at once, under time pressure, usually while adding the rule. And the pass-throughs are not entirely empty even now: they mark where transactions belong and give the operation a domain name.
My position: keep the layer only where a rule exists or is imminent, and let genuinely trivial CRUD go straight through. A mixed codebase where some endpoints have a service and some do not is often criticised as inconsistent; I would rather have that than uniform emptiness, because the presence of a service then means something — it tells a reader there is a rule here.
What would change my mind: if the team is large enough that consistency is worth more than expressiveness — where a rule "every endpoint goes through a service" is cheaper to enforce than a judgement call made forty times a week.
Key takeaways
- Mixing HTTP handling, business rules and data access in one function traps the logic: it cannot be reused by another caller or tested without infrastructure.
- Controllers translate, services decide, repositories store. Dependencies point one way only.
- A service must know nothing about HTTP and nothing about SQL. That ignorance is what makes it reusable.
- The repository hides storage behind intent, so swapping databases touches one file and queries stop being scattered.
- The biggest practical win is cheap tests — a service with injected fakes runs in milliseconds with no database.
- Add the layers when a rule gains a second caller, not on day one.
Further reading
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — service layer, repository and transaction script.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — application services versus domain services.
- Alistair Cockburn, "Hexagonal Architecture" (2005) — ports and adapters, the same separation stated differently.
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013), chapter 14 — where transaction boundaries belong.
- Mark Seemann, "Repositories are DAOs" (2011) — a critical view of the database-swap justification.