Controllers
A controller's one job is translating HTTP into a call to your application and the result back into HTTP. Compare a fat controller with a thin one and learn exactly what belongs there and what doesn't.
🎯 What you'll learn
- The controller's single job
- Why the split matters as the app grows
- What a controller should and shouldn't do
- Grouping related routes in one controller
📚 Before you start
1The Controller's One Job
A controller's responsibility is narrow and specific: receive a parsed request, delegate to whatever business logic is needed, and shape the result into a response. It does not contain business logic itself. This is the thin-controller principle first introduced in 01-express_fundamentals/ Common Pitfall 5 and enforced structurally by NestJS's controller/provider split (02-nestjs_fundamentals/ section 2) — this topic makes that principle explicit and shows what "thin" concretely looks like in practice.
// THICK controller -- business logic embedded directly in the handler
async function createOrder(req: Request, res: Response) {
const { userId, items } = req.body;
const user = await db.users.findById(userId);
if (!user) return res.status(404).json({ error: "user not found" });
let total = 0;
for (const item of items) {
const product = await db.products.findById(item.productId);
total += product.price * item.quantity;
}
if (total > user.creditLimit) return res.status(422).json({ error: "over credit limit" });
const order = await db.orders.create({ userId, items, total });
await emailService.sendOrderConfirmation(user.email, order);
res.status(201).json(order);
}
// THIN controller -- delegates every business decision to a service
async function createOrder(req: Request, res: Response) {
try {
const order = await ordersService.create(req.body.userId, req.body.items);
res.status(201).json(order);
} catch (err) {
handleServiceError(err, res); // maps known error types to status codes -- 09-error_response_design/
}
}The thick version mixes at least four distinct concerns in one function: request parsing, data lookup, a business rule (credit limit check), order persistence, and a side effect (sending email) — none of which can be reused, tested in isolation, or reasoned about independently. The thin version's controller does exactly one thing: translate an HTTP request into a service call, and a service result into an HTTP response.
2Why This Split Matters as an App Grows
A business rule embedded directly in a controller (like the credit-limit check above) can only ever run in response to an HTTP request — it can't be reused from a CLI script, a scheduled background job (16-background_jobs/), or a test that wants to check the rule in isolation without spinning up a full HTTP request/response cycle. Extracting it into a service (10-services_and_repositories/ covers this split in depth) makes the logic callable from anywhere, and makes the controller itself trivial to test — a thin controller's test just checks "did it call the service correctly and map the result to the right response," not "did the business rule produce the right number."
3What a Controller SHOULD Do
- Extract and lightly validate request data (delegating deeper validation to
08-validation_and_dtos/'s DTO/schema layer, not hand-rolling it inline) - Call exactly the service method(s) needed for this specific request
- Translate the service's result (or thrown error) into the correct HTTP status code and response shape (
09-error_response_design/) - Handle only HTTP-specific concerns: reading headers, setting cookies, choosing status codes — never business rules
4What a Controller Should NOT Do
- Query a database directly (that's the service/repository layer's job)
- Contain conditional business rules (credit limits, eligibility checks, pricing logic)
- Call external services directly (payment providers, email senders) — those belong behind a service abstraction too, both for testability and so the controller doesn't need to know how the side effect happens
- Format data beyond simple response shaping — any real transformation belongs in the service layer or a dedicated mapper
5One Controller, Multiple Related Routes
@Controller("orders")
class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
findAll() { return this.ordersService.findAll(); }
@Get(":id")
findOne(@Param("id") id: string) { return this.ordersService.findById(id); }
@Post()
create(@Body() dto: CreateOrderDto) { return this.ordersService.create(dto); }
@Delete(":id")
cancel(@Param("id") id: string) { return this.ordersService.cancel(id); }
}Grouping every route for one resource (orders) into one controller class is a direct consequence of the route-grouping/prefixing idea from 04-routing/ section 4 — the controller class itself IS the group, with the shared @Controller("orders") prefix applied once instead of repeated on every method.
⚠️ Common Pitfalls
- A conditional business rule living directly in a controller method — makes the rule untestable in isolation and unreachable from any non-HTTP call site (section 2).
- A controller calling the database or an external API directly, skipping the service layer entirely — even if it "works," it means the same data-access logic gets re-implemented in every controller that needs it, instead of written once in a service.
- A controller doing deep, multi-field validation inline instead of delegating to a schema/DTO layer (
08-validation_and_dtos/) — scatters validation rules across every handler instead of centralizing them. - Treating "thin controller" as "no logic at all," including HTTP-specific logic that genuinely belongs there — choosing a status code, reading a header, setting a cookie IS the controller's job; the principle is about business logic, not all logic whatsoever.
📋 Quick Reference
Controller's job: parse request -> call service -> shape response
Controller's NOT job: database queries, business rules, external API calls
Test signal: if a controller's test needs to mock a database, the controller is too thick
Reuse signal: if a rule can only run via HTTP, it's probably misplaced in the controller
Group related routes for one resource into one controller class (ties to 04-routing/ prefixing)🧠 Key takeaways
- Controllers translate: HTTP request → validated input → service call → HTTP response.
- They pick status codes and response shapes, nothing more.
- No business rules, SQL, or emails in controllers; those go to services and repositories.
- Group a resource's routes in one controller; keep methods short.
🎤 Interview Q&A
Q1What should a controller do and not do?core▾
Do: parse and validate input (often via framework binding), call an application service, map the result to an HTTP response and status code. Don't: contain business logic, run SQL directly, or know about infrastructure details.
- Why thin controllers? Logic becomes reusable (CLI, jobs, events) and testable without HTTP.
Q2How do you map domain results to HTTP responses?core▾
Return result types or throw domain exceptions from services, then map in the controller or a global filter: not found → 404, validation → 400/422, conflict → 409, success → 200/201/204.
- Result pattern? Services return success/failure objects instead of throwing for expected errors.
Q3Should controllers return entities?core▾
No; return response DTOs to control exposed fields, decouple API shape from persistence, avoid lazy-loading surprises and circular serialization.
- Mapping tools? Mapperly/AutoMapper (.NET), class-transformer (NestJS), manual mapping (often clearest).
Q4How do you test controllers?senior▾
Mostly through integration tests that exercise the HTTP pipeline (routing, validation, auth, serialization) with real or faked services; unit-test business logic in services instead.
- Why not unit-test controllers heavily? They're thin; value is in the integration with the framework.
Q5What's the difference between MVC controllers and API controllers?core▾
MVC controllers return views (HTML) and handle form posts; API controllers return data (JSON) with status codes. In ASP.NET Core, [ApiController] adds automatic model validation responses and binding conventions.
- Minimal APIs? Lightweight endpoint definitions without controller classes.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.