All articles
Backend from First Principles

Controllers, Services, Repositories & Middleware: A Mental Model for Backend Engineers

Separation of concerns isn't about elegance — it's about survival at scale. Here's how to organize your backend into layers that actually work.

2026-02-2014 min read

You can build an entire backend in one massive file. It will work. And it will be the worst code you've ever maintained. Separation of concerns isn't about elegance — it's about survival at scale.

This is the sixth fundamental I'm revisiting in my backend-from-first-principles series. If the previous topics (HTTP, routing, serialization, auth, validation) were individual ingredients, this topic is the recipe — how you organize them into a kitchen that doesn't collapse when orders start flooding in.


The Mental Model: A Factory Assembly Line

Imagine your backend server as a factory. Raw materials (HTTP requests) arrive at the loading dock. Finished products (HTTP responses) leave from the shipping bay. In between, the factory has specialized stations, each doing one job exceptionally well.

You wouldn't have the same worker unloading trucks, assembling products, AND managing the warehouse. That's how things break. Instead:

HTTP Request arrives
      ↓
[ MIDDLEWARE ]     — Security checkpoint. Checks credentials, logs entry, blocks threats.
      ↓
[ CONTROLLER ]     — The floor manager. Reads the order, prepares materials, delegates work.
      ↓
[ SERVICE ]        — The assembly line. Does the actual building — business logic, orchestration.
      ↓
[ REPOSITORY ]     — The warehouse. Stores and retrieves parts — database operations.
      ↓
[ DATABASE ]       — The physical storage.
      ↓
Response flows back up through the same chain

Each station has a single responsibility. The security guard doesn't build products. The assembly worker doesn't manage inventory. The warehouse clerk doesn't talk to customers. This separation is what makes the factory scale from 10 orders to 10 million.


The Controller: The Floor Manager

The mental model: A restaurant host. They greet you, take your name, check the reservation, and seat you at the right table. They don't cook the food. They don't wash the dishes. They manage the flow.

The controller is the entry point after routing matches a URL. It handles everything HTTP and nothing else. Here's exactly what it does, step by step:

  1. Extract — Pull data out of the request (body, query params, path params, headers)
  2. Bind/Deserialize — Convert the JSON string into a native data structure. If this fails → 400 Bad Request
  3. Validate — Check the data against your schema (type, syntax, semantics). If this fails → 400 Bad Request
  4. Transform — Normalize the data (cast types, inject defaults, trim whitespace)
  5. Delegate — Pass the clean data to the Service layer. The controller doesn't know what happens next.
  6. Respond — Take whatever the Service returns, wrap it in the right HTTP status code (200, 201, 404, 500), and send the response.

The litmus test for a good controller: could you swap the entire service layer behind it and the controller wouldn't change? If yes, it's properly separated. The controller only knows about HTTP — it shouldn't contain a single line of business logic, database queries, or email-sending code.

Production best practice: keep controllers as thin as possible. Ideally under 10-15 lines. If your controller is doing heavy lifting, business logic has leaked into the wrong layer.


The Service Layer: The Brain

The mental model: The head chef in the kitchen. They receive an order ("make a pasta"), decide which ingredients to use, coordinate between stations (prep, grill, sauce), combine everything, and deliver the finished dish. They never walk to the dining room. They never take reservations. They just cook.

The service layer is where your application's actual intelligence lives — business rules, decisions, orchestration.

Key rules:

  • Zero HTTP knowledge. The service layer should never import Express, never touch req/res objects, never set status codes. It's a pure function: data in, result out. This is what makes it testable — you can call a service method from a unit test, a CLI script, a WebSocket handler, or a background job, not just from an HTTP controller.
  • Orchestration. A single service method can call multiple repositories, merge data from different databases, call external APIs, send emails, trigger webhooks — whatever the business requirement demands.
  • Rules and decisions. "Is this user allowed to delete this post?" "Should we apply a discount?" "Has this account exceeded its daily transfer limit?" — these decisions live here.

Example of what a service does vs. doesn't do:

// SERVICE DOES:
- userRepo.findById(id)
- Check if user exists → throw "not found" if missing
- Merge user data with their recent orders
- Apply business rules (loyalty discount, account status)
- Return the processed result

// SERVICE DOES NOT:
- Read from req.body (that's the controller's job)
- Set res.status(200) (that's the controller's job)
- Write SQL queries (that's the repository's job)

The Repository Layer: The Warehouse Clerk

The mental model: A librarian. You walk up and say "I need the book with ID 456." They know exactly where it is, retrieve it, and hand it back. They don't care why you need it. They don't read it for you. They just know the shelving system.

The repository is the only layer that touches the database. Every SQL query, every MongoDB operation, every Redis lookup — it all lives here.

Key rules:

  • Single Responsibility per method. findUserById(id) does one thing. findAllUsers() does one thing. Don't build a single method that conditionally returns one user OR all users based on a flag — that's two responsibilities hiding in one function.
  • No business logic. The repository doesn't decide whether a user should be deleted. It just executes the delete query when told to.
  • Swappable. If you switch from PostgreSQL to MongoDB tomorrow, you only rewrite the repository layer. The service layer doesn't change. The controller doesn't change. The database is an implementation detail hidden behind a clean interface.

The Three-Layer Boundary Rules

This is the part that makes the architecture actually work. Each layer has strict knowledge boundaries:

  • Controller knows about: HTTP, request/response, status codes, the Service layer
  • Controller does NOT know about: databases, business rules, external APIs
  • Service knows about: business logic, the Repository layer, external services
  • Service does NOT know about: HTTP, request/response, status codes
  • Repository knows about: the database, query construction
  • Repository does NOT know about: business logic, HTTP, other repositories

Think of it as a chain of command. Each layer only talks to the layer directly below it. The controller never reaches past the service to talk to the repository directly. The service never reaches past the repository to write raw SQL.

The test: if you change your database, only the repository layer should change. If you change your HTTP framework, only the controller layer should change. If you change a business rule, only the service layer should change. If any change ripples across multiple layers, your boundaries have leaked.


Middleware: The Security Checkpoints Before the Factory Floor

The mental model: Airport security checkpoints — but you pass through multiple ones in sequence before reaching your gate.

Middleware functions execute between the server receiving the request and the controller handling it. They process every request (or specific routes) and solve a critical problem: code duplication.

Without middleware, every controller would need to independently check authentication, parse JSON, validate CORS, log the request, handle errors. That's the same 50 lines of boilerplate copy-pasted across 200 endpoints. Middleware centralizes it.

How Middleware Works: The Chain

Every middleware receives three things: the request, the response, and a next() function.

function authMiddleware(req, res, next) {
    const token = req.headers.authorization;

    if (!token || !isValid(token)) {
        return res.status(401).json({ error: "Unauthorized" });
        // ↑ Early termination. next() is never called.
        //   The request STOPS here. Controller never sees it.
    }

    req.context.userId = extractUserId(token);
    next();  // ← Pass control to the next middleware or controller
}

The next() function is the key. It's a gate. Call it, and the request moves forward. Don't call it, and the request stops dead — the server responds immediately. This is how middleware can short-circuit malicious or invalid requests before they ever reach your business logic.

Order Matters — The Middleware Pipeline

Middleware executes sequentially, in the order you register it. The order isn't arbitrary — it follows a logical escalation of trust:

Request arrives
      ↓
[1] CORS Middleware         — Is this origin allowed? If not → block instantly.
      ↓
[2] Rate Limiting           — Is this IP spamming? If yes → 429 Too Many Requests.
      ↓
[3] Body Parsing            — Parse the raw JSON body into a usable object.
      ↓
[4] Authentication          — Is the token valid? If not → 401. If yes → attach user to context.
      ↓
[5] Logging                 — Record request details (method, path, timestamp) for debugging.
      ↓
[6] Controller / Handler    — Finally, the actual endpoint logic.
      ↓
[7] Global Error Handler    — Catches any unhandled errors from above, formats clean responses.

Notice: CORS and rate limiting come first because there's no point authenticating a request from a blocked origin, and there's no point processing a request from an IP that's already hit its limit. Each layer only runs if all previous checks passed. This is progressive trust escalation.

The error handler sits at the very end. If any controller or service throws an unexpected error, it trickles down to this catch-all, which formats it into a standardized error response instead of letting a raw stack trace leak to the client.


Request Context: The Shared Clipboard

The mental model: A patient's chart in a hospital. When you check in, the receptionist starts a chart. The nurse adds your vitals. The doctor reads it all, adds notes, and orders tests. The lab technician reads the doctor's orders. Every person along the way reads from and writes to the same chart — scoped to your visit only.

Request Context is a key-value store that is scoped to a single HTTP request. It's born when the request arrives and dies when the response is sent. No other request can see it.

Why It Exists

The request passes through multiple isolated functions — middleware A, middleware B, the controller, the service. These functions don't directly call each other in a way that makes passing data natural. Context gives them a shared communication channel without tightly coupling their code.

The Three Most Important Uses

1. Passing authenticated user data.

The auth middleware validates the token and extracts the user ID and role. It writes { userId: 123, role: "admin" } to the context. Later, the controller (or service) reads userId from context to know who is making the request.

This is a security pattern: never trust a user ID sent in the JSON body. A malicious user could send { "userId": 999 } to impersonate someone else. Instead, always extract identity from the verified token via context — the client can't forge it.

2. Request tracing.

An early middleware generates a unique ID (UUID) and writes it to context. Every log message, every downstream microservice call, every error report includes this ID. When a bug report comes in, you search for that one ID and see the entire journey of that specific request across your distributed system.

3. Cancellations and timeouts.

Context can carry abort signals and deadline timestamps. If a request has a 5-second timeout and the database query takes 10 seconds, the context signal tells the system to cancel the operation rather than hanging indefinitely. This is especially critical when calling external services that might be unresponsive.


The Complete Lifecycle: One Request, End to End

Let's trace a real request through every component. A client sends a POST request to create a new blog post:

POST /api/posts
Authorization: Bearer eyJhbG...
Content-Type: application/json
{ "title": "My Post", "content": "Hello world" }

[1] CORS Middleware — Origin is allowed. Calls next().

[2] Rate Limit Middleware — IP hasn't exceeded limits. Calls next().

[3] Body Parser Middleware — Parses JSON string into a native object. Calls next().

[4] Auth Middleware — Extracts and verifies the JWT. Writes { userId: 42, role: "editor" } to request context. Calls next().

[5] Logging Middleware — Logs: POST /api/posts by user 42. Calls next().

[6] Controller

  • Extracts title and content from the parsed body
  • Validates: both fields present, title is 1-200 chars, content is a string
  • Reads userId from context (not from the request body — security)
  • Calls postService.create({ title, content, authorId: 42 })

[7] Service

  • Checks business rules: does this user have permission to create posts?
  • Calls postRepo.insert({ title, content, authorId: 42 })
  • Returns the created post object

[8] Repository

  • Constructs and executes: INSERT INTO posts (title, content, author_id) VALUES (...)
  • Returns the new row with its generated ID

Response flows back up:

  • Repository → returns new post to Service
  • Service → returns processed result to Controller
  • Controller → wraps in 201 Created with the post data as JSON
  • Logging middleware records the response status
  • Client receives the response

Every component did exactly one job. None of them reached into another's responsibility. That's the pattern working as intended.


Why Not Just Put Everything in One Place?

Because it works until it doesn't. Here's what happens without separation:

  • Debugging: A bug in business logic is tangled with HTTP parsing code and database queries. Finding it means reading through everything.
  • Testing: You can't test business rules without spinning up a database AND an HTTP server. Tests become slow and brittle.
  • Changing the database: Switching from PostgreSQL to MongoDB means rewriting your controllers, your business logic, and your data access — because they're all mixed together.
  • Team scaling: Two engineers can't work on the same endpoint without stepping on each other's code.

With separation:

  • Debugging: Bug in business logic? Look at the service. Database issue? Look at the repository. HTTP problem? Look at the controller.
  • Testing: Test services with mock repositories. Test controllers with mock services. Test repositories against a test database. Each layer in isolation.
  • Changing the database: Rewrite the repository. Service and controller don't change.
  • Team scaling: One engineer works on the controller, another on the service, another on the repository. Clean boundaries.

The Mental Model Summary

Carry this forward:

  • Controller = The host. Handles HTTP. Extracts, validates, transforms, delegates, responds. Thin as possible.
  • Service = The chef. Business logic, orchestration, decisions. Knows nothing about HTTP.
  • Repository = The librarian. Database operations only. Knows nothing about business rules or HTTP.
  • Middleware = Security checkpoints. Run before the controller, in sequence. Can short-circuit with early termination.
  • Request Context = The shared clipboard. Scoped to one request. Carries auth data, trace IDs, and cancellation signals between isolated functions.
  • next() = The gate. Call it to pass the request forward. Don't call it to stop the request dead.
  • Boundary rule = Each layer only talks to the layer directly below it. Changes in one layer don't ripple to others.

This pattern isn't mandatory — you can build a working server without it. But the moment your codebase grows beyond a handful of endpoints, the absence of these boundaries turns every change into a risk and every bug into a treasure hunt. The assembly line exists so the factory doesn't collapse under its own weight.


This is part 6 of my "Backend from First Principles" series. Everything in this article comes from what I learned watching the Backend from First Principles playlist by Sriniously — specifically the video "What are controllers, services, repositories, middlewares and request context?" I'm not claiming original research. This is me taking what I studied, internalizing it, and presenting it as a mental model that made the concepts stick for me. If it helps you too, even better.

Follow along for the next one.

#BackendEngineering #Architecture #Middleware #CleanCode #SoftwareEngineering #SystemDesign #LearningInPublic