Backend in Depth
API Folder Structure: Routes, Controllers, Services, Repositories
Layers vs grouping by business feature, what goes in each layer, and why the request object should never reach your service layer.
On this page
- The four layers
- Layer folders vs feature (component) folders
- app.ts vs server.ts
- A vertical slice, end to end
- Where do these things live?
- Scenario: you need a CLI and a queue worker that reuse the same logic
- Do you need the repository layer?
- Growing the structure
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: a good folder structure makes the dependency direction (which code is allowed to use which other code) easy to see. HTTP code sits at the edge, business logic in the middle, and data access at the bottom. Code never reaches upward. Layers keep different jobs apart. Feature folders keep related code together. Most mature projects use both.
"How do you structure an Express/Node API?" is really a question about boundaries (clear lines between parts of the code). The folder names matter less. Interviewers want to hear what goes where, and why the request object never leaves the controller.
The four layers
Think of a restaurant. The waiter takes your order (routes and controllers). The chef cooks by the recipe (services). The storeroom keeps the ingredients (repositories). The waiter never walks into the storeroom, and the storeroom never talks to customers.
HTTP request │ ▼Routes ← URL + method → which controller function; attaches middleware │ ▼Controllers ← translate HTTP ⇄ application: parse input, call a service, shape the response │ ▼Services ← business rules and use cases; orchestration; transactions │ ▼Repositories ← data access: the only place that knows about the ORM or SQL │ ▼Database / external APIsSome words in this lesson:
- Express is a popular Node.js library for building web servers and APIs. A handler is the function that answers one kind of request. In Express,
reqis the object that describes the incoming request, andresis the object you use to send the response. - A route is a rule that connects an HTTP method and a URL path (for example
POST /orders) to a handler. - A controller is a handler that translates between HTTP and your application code. A service is a module that holds the business rules. A repository is a module that reads and writes data. The difference in one line: the controller talks HTTP, the service talks business, the repository talks data.
- Middleware is a function that runs before your handler. It can check login, check input, or limit how often someone calls.
- Orchestration means calling several steps in the right order.
- A transaction is a group of database changes that either all happen or none happen. Example: take money from account A and add it to account B. Both steps succeed, or neither does.
- An ORM (object-relational mapper) is a tool that lets you use the database from code instead of writing SQL by hand. Prisma is a popular ORM for TypeScript.
- CRUD means Create, Read, Update, Delete: the four basic things you do with stored data.
- Authentication means proving who you are (logging in). Authorization means checking what you are allowed to do. The difference in one line: authentication asks "who are you?", and authorization asks "may you do this?".
- A domain object is a plain object that represents something in your business, such as an
Order, without database details.
| Layer | Responsible for | Must not |
|---|---|---|
| Routes | Matching METHOD /path to a handler, and adding middleware (auth, validation, rate limits) | Contain logic |
| Controllers | Reading req (params, query, body, user), calling one service function, and choosing the status code and response shape | Know about the database; contain business rules |
| Services | Business rules and use cases, calling several repositories and external services, transaction boundaries, and authorization decisions that need data | Import express; see req/res; know HTTP status codes |
| Repositories | Queries and saving data (Prisma/SQL), and turning database rows into domain objects | Contain business decisions |
Here is the dependency rule: a layer may use the layer below it, and never the one above it. In the diagram, arrows point down only. A service never imports a controller. A repository never imports a service. This makes each layer easy to test and easy to replace.
Why the request object must never reach a service
Coupling means one piece of code depends strongly on another. Here is the problem:
// ❌ The service now depends on Expressasync function createOrder(req: Request) { const userId = req.user.id; // hidden coupling to the auth middleware const items = req.body.items; ...}// ✅ The service takes plain, typed dataasync function createOrder(input: { userId: string; items: OrderItemInput[] }): Promise<Order> { ... }Why this matters:
- Reuse: a queue worker (a program that takes jobs from a queue and runs them), a cron job (a task that runs on a schedule), a CLI script (a command-line program) or a GraphQL resolver (the function that answers one field of a GraphQL query) can all call the same
createOrder. None of them has areq. - Testing: you can call
createOrder({ userId, items })without a fake HTTP request. - Clarity: the function signature (its name, parameters and return type) shows its inputs. With
req, the inputs are a mystery bag.
Layer folders vs feature (component) folders
By layer (technical grouping)
src/ routes/ orders.routes.ts, users.routes.ts, ... controllers/ orders.controller.ts, users.controller.ts, ... services/ orders.service.ts, users.service.ts, ... repositories/ orders.repo.ts, users.repo.ts, ...This is fine at the start. The problem is that one feature (orders) is spread across four folders. Every change touches all of them. The folders also grow to dozens of unrelated files.
By feature / business component (recommended as the app grows)
src/ modules/ orders/ orders.routes.ts orders.controller.ts orders.service.ts orders.repository.ts orders.schema.ts ← validation schemas (Zod) orders.types.ts orders.test.ts users/ ... payments/ ... shared/ errors.ts ← AppError and subclasses http.ts ← helpers used by controllers logger.ts middleware/ auth.ts validate.ts error-handler.ts request-id.ts config/ env.ts ← parsed, validated environment (one place) lib/ prisma.ts ← the single PrismaClient app.ts ← builds the Express app (no listen) server.ts ← starts listening, handles shutdownYou keep the layers inside each feature, but you group the files by business area. The Node community recommends this: group by business components first, then use layers inside each one. The benefits are:
- All code for one feature lives together. It is easy to find and change. Later you can move it into its own service.
- Teams can own whole modules.
- Each module shows a small public surface (for example, only
orders.service.tsis imported by other modules). The inside stays private.
Rule for modules: one module may call another module's service. It must never use that module's repository or tables directly. If it does, you get a tangle again.
app.ts vs server.ts
Build the app in one file and start it in another. ("Listening" means opening a network port so the server can accept requests. A port is a numbered door on the machine, like 3000.)
// app.ts — importable by tests; no networkimport express from "express";import { ordersRouter } from "./modules/orders/orders.routes";import { errorHandler } from "./middleware/error-handler";export function buildApp() { const app = express(); app.use(express.json({ limit: "100kb" })); app.use("/v1/orders", ordersRouter); app.use(errorHandler); // last: Express calls it for errors thrown by earlier handlers return app;}// server.ts — the only file that listensimport { buildApp } from "./app";import { env } from "./config/env";const server = buildApp().listen(env.PORT, () => console.log(`listening on ${env.PORT}`));// SIGTERM is the signal an operating system or platform sends to ask a program to stop.// server.close stops accepting new requests and waits for current ones to finish: a graceful shutdown.process.on("SIGTERM", () => server.close(() => process.exit(0)));Tests import buildApp() and call it with Supertest (a testing tool for HTTP). They do not need to open a port.
A vertical slice, end to end
A "vertical slice" is one feature followed from the top layer to the bottom layer.
// orders.routes.tsimport { Router } from "express";import { requireAuth } from "../../middleware/auth";import { validate } from "../../middleware/validate";import { createOrderSchema } from "./orders.schema";import * as controller from "./orders.controller";export const ordersRouter = Router();ordersRouter.post("/", requireAuth, validate({ body: createOrderSchema }), controller.create);ordersRouter.get("/:id", requireAuth, controller.getOne);// orders.controller.ts — HTTP in, HTTP out; nothing else// (req.user is added by the auth middleware; in TypeScript you declare it once with a type augmentation)import type { Request, Response } from "express";import * as service from "./orders.service";export async function create(req: Request, res: Response) { const order = await service.createOrder({ userId: req.user!.id, items: req.body.items }); res.status(201).location(`/v1/orders/${order.id}`).json(order);}export async function getOne(req: Request<{ id: string }>, res: Response) { const order = await service.getOrderForUser({ orderId: req.params.id, userId: req.user!.id }); res.json(order);}// orders.service.ts — business rules; no Expressimport { NotFoundError, ValidationError } from "../../shared/errors";import * as repo from "./orders.repository";export async function createOrder(input: { userId: string; items: { sku: string; qty: number }[] }) { if (input.items.length === 0) throw new ValidationError("Order needs at least one item"); const priced = await repo.priceItems(input.items); return repo.insertOrder({ userId: input.userId, lines: priced, total: sum(priced) }); // sum: a small helper that adds the line totals}export async function getOrderForUser({ orderId, userId }: { orderId: string; userId: string }) { const order = await repo.findByIdForUser(orderId, userId); // ownership is part of the query if (!order) throw new NotFoundError("Order"); // 404 also for "not yours": no information leak return order;}// orders.repository.ts — the only file that knows Prismaimport { prisma } from "../../lib/prisma";export const findByIdForUser = (id: string, userId: string) => prisma.order.findFirst({ where: { id, userId }, include: { lines: true } });A promise is the object an async function returns. It is "rejected" when the function throws. With Express 5, a rejected promise from create goes to the error middleware (the function that turns errors into responses) automatically (Lesson 04). With Express 4, you need a small asyncHandler wrapper, which is a function that catches the rejection and passes it to next(err).
Where do these things live?
| Concern | Where | Why |
|---|---|---|
| Input validation (shape, types, required fields) | Middleware or the top of the controller, using schemas | Reject bad data at the edge, before any logic (Lesson 03) |
| Business validation ("is the stock available?", "has the coupon expired?") | Service | It needs data, and it is a domain rule |
| Authentication ("who are you?") | Middleware | It is the same for every protected route |
| Authorization ("may you do this?") | Service or a policy function, backed by repository queries that include ownership | It depends on the data. Do not rely only on route-level checks (Lesson 06) |
| Transactions | Service (the unit of work), run through the repository | One use case = one atomic operation (all or nothing) |
| Rate limiting, CORS, request ID, logging | Middleware | These apply to many routes, and the order matters. Rate limiting caps how many calls one client may make. CORS is the browser rule that decides which other websites may call your API. A request ID is a unique label on each request, so you can follow it in the logs |
| Environment config | One config/env.ts, checked at startup | Stop early if a variable is missing |
| Error translation (domain error to HTTP) | One error-handling middleware | Same response shape everywhere (Lesson 04) |
| Third-party calls (Stripe, email) | A small client or adapter module (a thin wrapper that hides the vendor's API), given to services | Easy to fake in tests |
Scenario: you need a CLI and a queue worker that reuse the same logic
A CLI is a program you run in a terminal. A queue worker is a program that takes jobs from a queue and runs them. Say your service functions take plain arguments and return plain results. Then the worker just imports orders.service and calls createOrder(...). But if the logic lives in controllers, or services read req, you must copy the code or fake HTTP objects. This scenario tests whether your structure is real.
Do you need the repository layer?
The honest answer: it depends.
- For: it hides the ORM, gives one place for queries, is easy to fake in unit tests, and keeps database types out of services.
- Against: with Prisma, the client is already a typed data layer. A repository that only passes calls along adds files but no value. This is called "ceremony": extra steps and files that give no benefit.
- Practical rule: add repositories when queries are complex or reused. Add them when you need to fake the database in unit tests. Add them when you want ownership and tenancy rules in one place. (Ownership means a user may only touch their own records. Tenancy, or multi-tenancy, means one system serves many customers and keeps each customer's data apart.) For simple CRUD modules, a service that calls Prisma directly may be fine. In an interview, explain your reasoning. Both answers can be correct, but an answer with no reasoning is not.
Growing the structure
- Start simple: a flat
src/with a few files is fine for a small service. Do not build a huge structure on day one. - Split by module once features appear.
- Move a module into its own package or service only when it has a stable interface and needs its own scaling or owner. Start with a modular monolith: one app, deployed as one unit, with clear modules inside. Microservices are many small apps that are deployed separately and talk over the network. Use microservices later, if ever.
- Keep an
index.tsin each module. This is the module's front door: it re-exports only what other modules may use, and everything else stays private.
Common mistakes
- Passing
req/resinto services. This ties business logic to HTTP. - Fat controllers that query the database and hold business rules.
- Layers that do nothing: each layer only passes the call to the next one with no purpose.
- Cross-module database access (module A querying module B's tables).
utils/andhelpers/junk drawers that every file ends up depending on.- Reading
process.envall over the code instead of using one validated config module. - Mixing
appcreation andlisten, which makes testing awkward. - Circular imports between modules (A imports B, and B imports A). They cause tangled code and sometimes values that are
undefinedat load time.
Interview questions
Why separate controllers from services?
Controllers handle HTTP. They read the input and choose status codes. Services hold the business rules and do not care how the request arrived. If I separate them, I can reuse the same logic from jobs, CLIs or other entry points. I can also test it without HTTP. And if the API format changes, the business rules do not change.
Where does validation live? Where does authorization live?
Input validation (shape and types) happens at the edge. I do it in middleware or in the controller with a schema, so bad data never reaches the service. Business validation lives in the service because it needs data. Authentication is middleware. Authorization lives in the service or in a policy function. I also put it in the data query itself (for example where: { id, userId }), not only in a route guard.
Scenario: you need a CLI and a queue worker that reuse the same logic. Does your structure allow it?
Yes, if the logic lives in services whose functions take plain arguments and never use req/res. The CLI and the worker can import and call the same service. If the logic is in controllers, I would first move it into services. Then I would reuse it instead of copying it.
Feature folders vs layer folders?
Layer folders are fine for small apps. But they spread one feature over many folders. Feature (business-component) folders keep related code together and scale better. I still keep the layers inside each module. Modules call each other only through public service functions.
Why separate app.ts and server.ts?
app.ts builds and exports the Express app without opening a port. So tests can import it and use Supertest. server.ts is the only file that listens and handles process signals. This keeps startup work apart from application logic.
Do you always need a repository layer?
No. I use one when queries are complex or shared, when I want to fake the database in unit tests, or when I want to keep rules like tenancy and ownership in one place. For simple CRUD with Prisma, a service that calls the client directly can be simpler. I decide for each module, and I avoid layers that only pass calls along.
Where should database transactions be started?
In the service. A transaction stands for one business use case that has several steps. The service opens it, often with a helper from the repository or ORM. Then it passes the transaction handle to the repository functions, so they all take part in it.
How do you stop modules from becoming tangled?
I give each module a public surface, so only its service can be imported by others. I forbid cross-module access to repositories and tables. I enforce the boundaries with lint or dependency-cruiser rules (dependency-cruiser is a tool that checks which files import which, and fails the build on forbidden imports), and I avoid circular imports. When modules must talk and coupling is unavoidable, they use events (messages such as "order placed" that other modules can react to) or clear interfaces.
Key takeaways
- Routes → controllers → services → repositories, with dependencies pointing down only.
- Controllers speak HTTP. Services speak business. Repositories speak data.
reqnever enters a service. - Group by feature, keep layers inside each feature, and give each module a small public surface.
- Keep
buildApp()andlisten()apart. Keep config, errors and middleware in one place each. - Add layers only when they help. Structure is a tool for testing and change, not a goal.
What's next
Lesson 02: REST API Design Best Practices. Now that the skeleton is ready, we design what the API looks like from the outside: resources, versioning, errors and idempotency.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Frontend and React Scenario Bank · Scenario Debugging
- Architecture, Data and Team Judgement Scenarios · Scenario Debugging
- State Management Decisions: Local, Context, Server Cache, Global Store · Senior Frontend Depth
Related series