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.

Lesson 0120 min read#architecture#express#structure

On this page

  1. The four layers
  2. Layer folders vs feature (component) folders
  3. app.ts vs server.ts
  4. A vertical slice, end to end
  5. Where do these things live?
  6. Scenario: you need a CLI and a queue worker that reuse the same logic
  7. Do you need the repository layer?
  8. Growing the structure
  9. Common mistakes
  10. Interview questions
  11. Key takeaways
  12. 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.

TEXT
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 APIs

Some 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, req is the object that describes the incoming request, and res is 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.
LayerResponsible forMust not
RoutesMatching METHOD /path to a handler, and adding middleware (auth, validation, rate limits)Contain logic
ControllersReading req (params, query, body, user), calling one service function, and choosing the status code and response shapeKnow about the database; contain business rules
ServicesBusiness rules and use cases, calling several repositories and external services, transaction boundaries, and authorization decisions that need dataImport express; see req/res; know HTTP status codes
RepositoriesQueries and saving data (Prisma/SQL), and turning database rows into domain objectsContain 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:

JavaScript
// ❌ 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 a req.
  • 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)

TEXT
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.

TEXT
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 shutdown

You 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.ts is 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.)

JavaScript
// 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.

JavaScript
// 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?

ConcernWhereWhy
Input validation (shape, types, required fields)Middleware or the top of the controller, using schemasReject bad data at the edge, before any logic (Lesson 03)
Business validation ("is the stock available?", "has the coupon expired?")ServiceIt needs data, and it is a domain rule
Authentication ("who are you?")MiddlewareIt is the same for every protected route
Authorization ("may you do this?")Service or a policy function, backed by repository queries that include ownershipIt depends on the data. Do not rely only on route-level checks (Lesson 06)
TransactionsService (the unit of work), run through the repositoryOne use case = one atomic operation (all or nothing)
Rate limiting, CORS, request ID, loggingMiddlewareThese 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 configOne config/env.ts, checked at startupStop early if a variable is missing
Error translation (domain error to HTTP)One error-handling middlewareSame 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 servicesEasy 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.ts in 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

  1. Passing req/res into services. This ties business logic to HTTP.
  2. Fat controllers that query the database and hold business rules.
  3. Layers that do nothing: each layer only passes the call to the next one with no purpose.
  4. Cross-module database access (module A querying module B's tables).
  5. utils/ and helpers/ junk drawers that every file ends up depending on.
  6. Reading process.env all over the code instead of using one validated config module.
  7. Mixing app creation and listen, which makes testing awkward.
  8. Circular imports between modules (A imports B, and B imports A). They cause tangled code and sometimes values that are undefined at 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. req never enters a service.
  • Group by feature, keep layers inside each feature, and give each module a small public surface.
  • Keep buildApp() and listen() 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

Related series