Skip to content

Backend Layers and Request Context: Keeping Work in Its Place

How controllers, services, repositories, middleware, and request context divide responsibilities so a backend remains understandable as it grows.

Builds on REST API Design: A Contract People Can Guess

Stage 5core4 min read

Small applications can put a route handler, SQL query, validation, and email call in one function. It works until the same rule is needed from a queue worker, a cron job, and a second endpoint. Then the function becomes a crowded room: everybody has a job, nobody knows who owns what, and a harmless change breaks three flows.

Layers are not about adding ceremony. They give recurring kinds of work a home.

Loading diagram…

The simplest useful split

Controller: translate HTTP into an application call

The controller is the front desk. It reads a path parameter and validated request body, calls an application service, and translates the result into HTTP.

async function createTask(req: Request, res: Response) {
  const command = parseCreateTask(req.body);
  const task = await taskService.create({
    projectId: req.params.projectId,
    actorId: req.context.actorId,
    ...command,
  });
  res.status(201).json(toTaskResponse(task));
}

Good controller code is thin. It knows about status codes and JSON because those are HTTP concerns. It should not decide whether the project is archived or compose SQL.

Service: own the use case

The service coordinates the business operation. “Create a task” might mean checking permission, checking that the project is active, enforcing a plan limit, saving a task, and scheduling a notification. That is a meaningful unit of work, and it should be callable without an HTTP request.

async function create(command: CreateTaskCommand) {
  const project = await projects.get(command.projectId);
  assertCanEdit(command.actorId, project);
  project.assertAcceptsTasks();

  const task = Task.create(command);
  await tasks.save(task);
  await jobs.enqueue("notify-task-created", { taskId: task.id });
  return task;
}

This service can now be reused by an import script or a queue consumer. That is the practical reason to keep it free of req and res.

Repository: talk to storage in the language of the domain

A repository hides persistence details behind operations the application actually needs:

interface TaskRepository {
  findById(id: string): Promise<Task | null>;
  save(task: Task): Promise<void>;
  listOpenForProject(projectId: string): Promise<Task[]>;
}

It is not a rule that every database table needs a repository, nor should repositories become a second service layer that contains business decisions. Their job is loading and saving data correctly and efficiently. “Which tasks are overdue?” may be a well-named query. “A customer on the free plan may have three projects” belongs above it.

Middleware: work that surrounds many requests

Middleware runs before or after a route handler. It is a good home for cross-cutting concerns that are the same across many endpoints:

  • assigning a request id and structured logging;
  • parsing a body with a size limit;
  • authenticating a session or token;
  • rate limiting;
  • setting security headers;
  • converting known errors into a consistent response.

Order is behaviour. For example, a request-id middleware should run early so every subsequent log line carries it. An error handler must surround or follow the code it is meant to catch, depending on the framework. Write the intended pipeline down and test a protected route, a malformed body, and an exception.

Request context: carry facts, not a hidden global

Request context is small per-request information that many layers need:

type RequestContext = {
  requestId: string;
  actorId?: string;
  organisationId?: string;
  traceId?: string;
};

It lets a log line, audit record, and downstream call refer to the same request. It is tempting to store context in a global variable, but concurrent requests make that unsafe. Use the framework’s request object or a context mechanism designed to remain isolated across asynchronous work.

Be conservative about what goes into it. A correlation id and authenticated identity are useful; a giant decoded request body, mutable business state, or database connection hidden in context makes dependencies hard to see.

A full request, with each owner named

Consider POST /projects/42/tasks:

  1. middleware assigns requestId=abc and rejects an oversized body;
  2. authentication middleware verifies a session and adds actorId to context;
  3. controller parses the body and returns 400 if its shape is wrong;
  4. service checks that the actor may edit project 42 and that it is active;
  5. repository writes the task in a transaction;
  6. service records a job for the notification;
  7. controller returns 201 with the public task representation;
  8. logging middleware records the status and duration.

No layer is magical; the value is that a future reader can find a concern without searching every route file.

Avoid the two common extremes

The god controller has hundreds of lines, queries the database, makes payment calls, and formats email. It is difficult to reuse and nearly impossible to test in isolation.

The layer cake invents a controller, facade, manager, repository, data-source, adapter, and mapper for a three-line operation. It turns a simple change into a scavenger hunt.

Start with controller, service, and repository when the application needs them. Extract a layer because a responsibility is repeated, needs a different dependency, or needs independent testing—not because a diagram said every application must have seven boxes.

A question that prevents misplaced code

When you are unsure where a line belongs, ask: would this still make sense if the same use case were started by a queue message instead of HTTP?

  • If yes, it is likely service/domain work.
  • If it mentions headers, cookies, or an HTTP status, it belongs near the controller or middleware.
  • If it builds SQL or maps a row, it belongs in persistence.

That one question keeps the important rules reachable from every way your system can be invoked.

Layers make testing smaller too

This separation has a practical payoff. A service can be tested with a fake repository and fake job publisher: give it a command, arrange a project state, and assert the business outcome. No HTTP server, JSON parser, or real database is needed for that test.

Controllers then need only a few focused tests: does an invalid body return 400, does a successful create return 201, and does the response hide internal fields? Repository tests can run against a real PostgreSQL instance or a narrow integration environment to confirm the SQL and constraints work together.

That is more valuable than chasing a fashionable coverage number. Each test is close to the decision it protects, and a bug report points you toward a small place to investigate.

In this topic

Done with this topic?

How well do you know it?

PreviousREST API Design: A Contract People Can Guess