Engineering Notes
Home/Backend/Frameworks/Controllers
Backend · Frameworks

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.

⚙️ Backend● Beginner⏱ 4 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

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

ts
// 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."

A thin controller extracts input, calls one service method and maps the result to HTTP, while business rules live in the service and data access in the repository
A thin controller extracts input, calls one service method and maps the result to HTTP, while business rules live in the service and data access in the repository

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
ts
@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

  1. 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).
  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.
  3. 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.
  4. 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

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Where should 'gold members get 10% off' live?
Business rule.
2. What is a controller responsible for?
Translation layer.
3. Why does a thin controller help a CLI importer?
Reuse.
4. Returning raw ORM entities from controllers risks…
Use response DTOs.