Controllers, Services, Repositories, and Middleware
On this page
Your backend starts small. 5 routes, each handler does everything: validates input, queries the database, sends an email, formats the response. It works. Then it grows. 50 routes. 100 routes. Each handler is 200 lines of tangled logic. Changing one thing breaks three others. Testing is impossible.
The fix: split your code into layers with clear responsibilities.
The Three Layers

Controller (or Handler): the front door of your application. It receives the HTTP request, pulls out parameters from the URL/body/headers, calls the appropriate service, and returns an HTTP response. It knows about HTTP. Nothing else.
Service: the brain. Contains your actual business logic. “To create a user, hash the password, check if email is taken, save to DB, send welcome email.” The service doesn’t know about HTTP. It doesn’t touch req or res objects. It just takes input, does work, returns output.
Repository: the data layer. Talks to the database. Knows SQL or ORM syntax. Returns clean domain objects (not raw database rows). It doesn’t know about business rules. It just stores and retrieves data.
Why This Separation Matters
Imagine you have “create user” logic. Without layers, it lives in one giant route handler.
Now you need the same logic in three places:
- The signup API endpoint
- An admin panel “create user” button
- A CLI tool for bulk importing users
- A background job that creates users from CSV uploads
Without layers: copy paste the same 50 lines four times. Change the email template? Fix it in four places. Forget one? Bug.
With layers: all four call userService.create(data). One place. One fix. One test.
Middleware: The Pipeline Before Your Handler

Middleware sits between the raw request and your controller. It’s a chain of functions that each request passes through, one by one.
Common middleware:
- Body parser: converts raw request body into JSON object
- Logger: logs every request (method, path, status, duration)
- Auth: parses the JWT/session, attaches
userto the request - CORS: adds cross-origin headers to responses
- Rate limiter: blocks requests if the client is sending too many
- Error handler: catches any unhandled error and returns a proper 500
The beauty of middleware: write it once, it applies to every request. You don’t sprinkle if (!token) return 401 in every single handler. You write it once in auth middleware, and it protects all routes behind it.
Middleware can also short-circuit the chain. If auth middleware finds no valid token, it returns 401 immediately. Your controller never even runs.
Request Context: Passing Data Along
As a request flows through middleware into your handler, you often need to carry information along. “Who is this user?” “What’s their tenant ID?” “What’s the trace ID for logging?”
That’s request context. A bag of data scoped to this specific request’s lifecycle.
In Express: req.user, req.tenantId
In Go: context.Context passed to every function
In Java/Spring: SecurityContextHolder, @RequestScope
Critical rule: request context is born when the request arrives and dies when the response is sent. Never leak it across requests. Never store it in a global variable.
A Request’s Full Journey
Putting it all together, here’s what a typical request looks like end to end:
Request arrives at port 3001
Body parser middleware: raw bytes become JSON object
Logger middleware: log "POST /users started"
Auth middleware: verify JWT, attach user to context
Rate limit middleware: check if under limit
Controller: extract email/password from body
call userService.create(email, password)
Service: hash password
check if email exists (via repository)
save user (via repository)
queue welcome email (via queue service)
return created user
Controller: format response, return 201 Created
Logger middleware: log "POST /users completed in 45ms"
Response sent
Each piece does one job. Each piece is testable in isolation. The service doesn’t know about HTTP. The repository doesn’t know about business rules. The controller doesn’t know about SQL.
When to Skip Layers
Not every app needs all three layers. For a quick prototype or a tiny microservice with 3 endpoints, putting logic directly in handlers is fine. Don’t over-engineer.
The rule of thumb: add a service layer when you find yourself duplicating logic across handlers. Add a repository layer when you want to swap databases or mock them in tests.
Start simple. Refactor into layers when the complexity demands it.
Wrapping Up
- Controllers handle HTTP (request in, response out)
- Services handle business logic (no HTTP knowledge)
- Repositories handle data access (no business logic knowledge)
- Middleware runs on every request: logging, auth, rate limiting, error handling
- Request context carries per-request data through the pipeline
- This pattern is universal across Express, Spring, Django, Gin, FastAPI
Day 8 of 95 | Backend Engineering Series