Career OS

REST Controllers

You type one curl command and JSON comes back — and between those two events, your request passes through a dozen hands inside the JVM. When an endpoint returns 500 at 2am, or 404s for no visible reason, the engineer who can name every hand finds the broken one. This module gives SplitEase its first real endpoints and gives you the map.

The Goal

By the end of this module you can:

  • Trace one HTTP request from the socket through DispatcherServlet to your method and back out as JSON
  • Explain @RestController vs @Controller in one sentence, and what @ResponseBody actually changes
  • Choose correctly between @PathVariable, @RequestParam, and @RequestBody without guessing
  • Return deliberate status codes with ResponseEntity — 201 with a Location header, a clean 404, never a lazy 200
  • Name REST resources the way real APIs do: nouns, plural, versioned, nested where it earns it
  • Ship SplitEase’s first endpoints: a ping, and friends over HTTP backed by a (temporary) in-memory list

The Lesson

The journey of one request

In Week 3 you built the outside half of this story: DNS resolves the name, TCP shakes hands, TLS encrypts, and HTTP bytes arrive at a port. That whole journey ends at Tomcat’s socket on 8080 — which is where this module begins. Inside the JVM:

sequenceDiagram
    participant T as Tomcat thread
    participant F as Filter chain
    participant D as DispatcherServlet
    participant C as Your controller method
    participant J as Jackson

    T->>F: raw HTTP parsed into a request object
    F->>D: filters run - logging, CORS, security later
    D->>D: routing table lookup - URL plus verb to method
    D->>C: call the method, arguments extracted and converted
    C-->>D: returns a Java object
    D->>J: convert return value to JSON
    J-->>T: bytes plus status code written to the response

Six hops, every single request. Burn the order in now, because every web debugging session you’ll ever run is the question “which hop broke?” — a 404 is a routing-table miss at hop 3, a 401 (module 07) is a filter rejection at hop 2, a serialization crash is hop 5.

DispatcherServlet — the front door

In the old servlet world you wrote one servlet class per URL and registered each in XML. Spring MVC replaces that with the front controller pattern: exactly one servlet — DispatcherServlet — receives every request, looks up which of your methods matches the URL + HTTP verb, and dispatches to it.

The lookup isn’t a search. At startup — during the component scan you watched in module 02 — Spring reads every mapping annotation and builds a routing table: GET /api/v1/friends/{id}FriendController.byId. At request time it’s a table lookup, not a scan of your code. Two consequences:

  • It’s fast, and it’s why your controller methods can be found among hundreds.
  • Two methods claiming the same route is detected at startup and kills the boot — you’ll trigger this on purpose in Build This. Failing at startup instead of at 3am is a gift; learn to recognise it.

@RestController vs @Controller

@RestController
public class PingController {

    @GetMapping("/api/v1/ping")
    public Map<String, String> ping() {
        return Map.of("status", "ok");
    }
}

The difference between the two annotations is one behaviour:

AnnotationReturn value meansEra / use
@Controller”the name of an HTML template to render” — return "home" and Spring looks for a home viewServer-rendered HTML (Thymeleaf, JSP)
@RestController”the data itself — serialize it into the response body”JSON APIs. Everything in this track

@RestController is literally @Controller + @ResponseBody stacked — same trenchcoat trick as @SpringBootApplication. @ResponseBody is the switch that says “don’t go looking for a template, the return value is the response.” Use @Controller and return an object without @ResponseBody, and Spring hunts for a template named after your object’s toString — a classic confusing 500. One sentence to keep: building an API? @RestController, always.

Both are also @Component underneath — which is why component scan finds them and why your controller is a bean in the container, singleton like every other.

Mapping annotations — how a URL finds your method

@RestController
@RequestMapping("/api/v1/friends")     // class level: the common prefix
public class FriendController {

    @GetMapping                        // GET  /api/v1/friends
    @PostMapping                       // POST /api/v1/friends
    @GetMapping("/{id}")               // GET  /api/v1/friends/42
    @DeleteMapping("/{id}")            // DELETE /api/v1/friends/42
}

The class-level @RequestMapping sets the prefix; the method-level shortcuts (@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping) each bind one HTTP verb to one sub-path. The full route is prefix + method path + verb — and that triple is the key in the routing table. Same path with different verbs is two different routes; that’s the REST design from Week 3’s method table doing real work.

Getting data in: the three extractors

Three annotations pull data out of the request into your method parameters. Choosing the wrong one is the most common beginner controller bug, so here’s the decision table:

AnnotationReads fromExampleWhen to use
@PathVariableThe URL path itself/friends/{id}42Identifying which resource — ids, mostly
@RequestParamThe query string/expenses?page=2&size=20Options on a query — filters, paging, sorting. Optional things
@RequestBodyThe request body (JSON){"name":"Asha"}The payload of a create or update — the data itself
@GetMapping("/{id}")
public Friend byId(@PathVariable long id) { ... }

@GetMapping
public List<Expense> list(@RequestParam(defaultValue = "0") int page) { ... }

@PostMapping
public Friend create(@RequestBody CreateFriendRequest request) { ... }

Rule of thumb that survives every design review: path identifies, params filter, body carries. And @RequestBody has a trap with teeth: forget the annotation and nothing fails. Spring silently binds the parameter from query params instead, the JSON body is ignored, and your object arrives full of nulls. No exception, no log line — just wrong data. You’ll trigger it on purpose in Build This so you recognise it forever.

Jackson — object in, JSON out

You return a Java object; the client receives JSON. The converter is Jackson (it rode in with spring-boot-starter-web, as you saw in module 02’s dependency table). It reads your object’s record components or getters and writes a JSON field for each — and the same machinery in reverse parses the @RequestBody JSON into your object. Records make perfect API objects: all components serialize, no boilerplate.

One teaser to file away: Jackson serializes whatever the object exposes, recursively. In module 04, your entities will have relationships loaded lazily from the database — and Jackson innocently touching a lazy field outside a session is one of the most famous 500s in all of Spring (LazyInitializationException, plus its cousin, the infinite recursion of two entities that reference each other). That trap is exactly why entities stop leaking out of controllers in module 05. For now: we return records, and records are safe.

ResponseEntity — choosing the status code on purpose

Return a bare object and Spring sends 200 OK no matter what happened. Real APIs say what happened — pull up Week 3’s status-code table (revisit it) and pick deliberately. ResponseEntity is the wrapper that gives you the steering wheel: status, headers, and body.

The three you need today:

// Created something? 201 + a Location header pointing at the new resource
return ResponseEntity
        .created(URI.create("/api/v1/friends/" + friend.id()))
        .body(friend);

// Asked for something that does not exist? 404, empty body
return ResponseEntity.notFound().build();

// Bad input? 400 (module 05 automates this with validation)
return ResponseEntity.badRequest().build();

Why care? Because clients are programs. A frontend retry wrapper, a payment webhook, a monitoring system — they branch on status codes, not on reading your JSON apologetically. 200 with {"error":"not found"} inside breaks every standard client. The status line is the API contract; 201 Created + Location is how a client learns its POST worked and where the new resource lives.

Naming resources like you mean it

REST naming is one rule applied ruthlessly: URLs are nouns; the verb is the HTTP method.

SmellRightWhy
POST /createFriendPOST /api/v1/friendsThe verb is already in the method — saying it twice is noise
GET /friend/42GET /api/v1/friends/42Plural, always — the collection is /friends, one item is /friends/{id}
GET /getExpensesForGroup?g=7GET /api/v1/groups/7/expensesNesting states ownership: these expenses belong to group 7

Nest one level when the child can’t exist without the parent (an expense belongs to a group); deeper nesting turns into URL spaghetti — stop at one.

And the /v1? That’s versioning in the path, and it’s insurance you buy before you need it. The day you must rename a field or restructure a response, every existing client — mobile apps you can’t force-update, partner integrations — breaks instantly unless old and new can coexist. With /api/v1/... you ship /api/v2/... alongside and migrate clients on their schedule. Path versioning isn’t the only scheme (headers work too), but it’s the one you can see in a log line and test with curl, which is why it’s the default choice at most companies. Cost of adding it on day one: four characters. Cost of adding it later: every client you’ve ever shipped.

See It Move

Step through the visualizer and watch one request make every hop you just read about — keep an eye on where the routing-table lookup happens and where the Java object turns into JSON.

Step through it and notice:

  • The filter chain runs before routing — in module 07 this is exactly where an invalid JWT bounces a request that never reaches your code.
  • DispatcherServlet picks the method by URL + verb against a table built at startup — nothing scans your controllers per request.
  • Your controller returns a plain Java object; the JSON only exists after Jackson’s hop, on the way out.
  • The whole chain runs on one Tomcat thread per request — when module 06 talks transactions, that thread is what the transaction clings to.

Check The Concept

How This Shows Up At Work

  • The 2am 500. An endpoint that worked for months starts throwing on one record. The trace says serialization failed deep in Jackson. The engineer who knows the request’s hops goes straight to “what is this endpoint returning that Jackson can’t walk?” — and lands on the lazy-loaded relation (module 04’s full story). Everyone else restarts the pod and hopes.
  • Code review, real comment: “Why does this POST return 200? Return 201 with a Location header — the mobile team’s client branches on it.” Status codes chosen deliberately are one of the fastest ways reviewers sort engineers from endpoint-typists.
  • The deploy that dies at startup with Ambiguous mapping — two branches each added a handler for the same route and the merge kept both. Reads as scary, is actually the framework saving you: this conflict failed in CI instead of corrupting traffic in production.
  • Interview staple: “Walk me through what happens when a request hits your Spring API.” The expected answer is this module’s sequence diagram — filters, DispatcherServlet, routing table, method, Jackson. Candidates who can also say where auth happens and where serialization fails stand out immediately.
  • The nulls-everywhere bug. A teammate’s new endpoint inserts empty rows. Cause: missing @RequestBody, JSON silently ignored. You’ve broken it on purpose today, so you’ll spot it in the diff before it ships.

Build This

Where you are after module 02: splitease-api boots, actuator health says UP, H2 sits in as the placeholder database, and there are zero endpoints — port 8080 answers everything with a Whitelabel 404. Now SplitEase speaks HTTP. The Friend domain comes from your Core Java capstone; today it gets its first endpoints, backed by an in-memory list. Say it plainly up front: that list is temporary scaffolding — it forgets everything on restart and it isn’t thread-safe. Module 04 replaces it with PostgreSQL. The endpoints you design today survive; the list does not.

  1. Prove the route first. Create src/main/java/com/splitease/web/PingController.java (note: com.splitease.web is under the main class’s package — component scan finds it):
package com.splitease.web;

import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PingController {

    @GetMapping("/api/v1/ping")
    public Map<String, String> ping() {
        return Map.of("status", "ok", "service", "splitease-api");
    }
}
  1. Run ./mvnw spring-boot:run, then:
curl http://localhost:8080/api/v1/ping

Expected (key order may vary — JSON objects are unordered):

{"status":"ok","service":"splitease-api"}

A Java Map went in, JSON came out — that’s Jackson’s hop, witnessed.

  1. The Friend resource. Two records in com.splitease.web (records for API data — a stack constant of this track):
package com.splitease.web;

public record Friend(long id, String name) {}
package com.splitease.web;

public record CreateFriendRequest(String name) {}
  1. The controller. Create FriendController.java:
package com.splitease.web;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/friends")
public class FriendController {

    // TEMPORARY scaffolding - vanishes on restart, replaced by PostgreSQL in module 04
    private final List<Friend> friends = new ArrayList<>();
    private final AtomicLong nextId = new AtomicLong(1);

    @PostMapping
    public ResponseEntity<Friend> create(@RequestBody CreateFriendRequest request) {
        Friend friend = new Friend(nextId.getAndIncrement(), request.name());
        friends.add(friend);
        return ResponseEntity
                .created(URI.create("/api/v1/friends/" + friend.id()))
                .body(friend);
    }

    @GetMapping
    public List<Friend> all() {
        return friends;
    }

    @GetMapping("/{id}")
    public ResponseEntity<Friend> byId(@PathVariable long id) {
        return friends.stream()
                .filter(f -> f.id() == id)
                .findFirst()
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

Two things to notice before running. The AtomicLong: this controller is a singleton bean and Tomcat serves requests on many threads at once — module 02’s thread pool — so a plain long nextId++ would be a race condition. (The ArrayList has the same problem; we tolerate it for scaffolding precisely because module 04 deletes it.) And byId returns ResponseEntity so the same method can answer 200-with-body or 404-empty — the status is a decision, not a default.

  1. Restart and exercise it. The escaped quotes below are PowerShell-to-native quoting, not JSON — in Git Bash use plain -d '{"name":"Asha"}':
curl.exe -i -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{\"name\":\"Asha\"}'

Expected — note the status line and the Location header, your 201 done right:

HTTP/1.1 201
Location: /api/v1/friends/1
Content-Type: application/json

{"id":1,"name":"Asha"}

Add Rohit the same way, then list and fetch:

curl http://localhost:8080/api/v1/friends
[{"id":1,"name":"Asha"},{"id":2,"name":"Rohit"}]
curl -i http://localhost:8080/api/v1/friends/99

Expected: HTTP/1.1 404 with an empty body — a miss handled deliberately, not a stack trace.

  1. Break it on purpose #1 — duplicate mapping. Add a second method to FriendController annotated @GetMapping("/{id}") (copy byId, rename it byIdAgain). Restart. The app refuses to boot: IllegalStateException: Ambiguous mapping. Cannot map 'friendController' method ... There is already 'friendController' bean method ... mapped. Read the message — it names both methods. This is the startup-built routing table defending itself: route conflicts fail fast, never at request time. Delete the copy.

  2. Break it on purpose #2 — the silent @RequestBody miss. Delete @RequestBody from create, restart, POST Asha again. You get 201 and no error anywhere — but the body says {"id":3,"name":null}. Spring bound the parameter from query params, ignored your JSON completely, and stored a nameless friend. Sit with how quiet that was; this is the bug you find in data, not in logs. Restore the annotation.

  3. Break it on purpose #3 — wrong content type. POST with the body declared as plain text:

curl.exe -i -X POST http://localhost:8080/api/v1/friends -H "Content-Type: text/plain" -d '{\"name\":\"Asha\"}'

Expected: HTTP/1.1 415 — Unsupported Media Type. Spring picks the body converter by the Content-Type header, and no converter turns text/plain into CreateFriendRequest. Every frontend dev who forgot the header has met this 415; now you know which hop sends it.

  1. Final state: ping answers, friends POST/GET/GET-by-id work, 404 and 415 behave. Commit:
git add .
git commit -m "[project]: splitease-api - ping + friends endpoints over in-memory list (module 04 replaces with DB)"

Where to Practice

ResourceWhat to do thereHow long
spring.io/guidesDo “Building a RESTful Web Service” — a second rep of controller + Jackson on a throwaway project30 min
docs.spring.ioSpring Framework reference → Web on Servlet Stack → skim “Annotated Controllers” — see the full menu of mapping and binding options25 min
BaeldungFree articles “Spring RequestMapping” and “Using Spring ResponseEntity to Manipulate the HTTP Response”30 min
Your own APIDesign (on paper) the URL + verb + status for: list a group’s expenses, record a settlement, delete a friend. Defend each choice out loud20 min

Check Yourself

  1. List the hops of a request from Tomcat’s socket to JSON leaving, in order.
  2. What does @ResponseBody change, and how does that explain @RestController?
  3. When does Spring build the URL-to-method routing table, and what bug does that timing catch for free?
  4. @PathVariable vs @RequestParam vs @RequestBody — give the one-line rule and one example each.
  5. Why return 201 + Location instead of 200 when a POST creates something?
  6. Why is /api/v1/friends better than /getFriends, on two separate grounds?
  7. What happens if you forget @RequestBody on a POST handler’s parameter, and why is it dangerous?
  8. Why is today’s ArrayList doubly unfit for real use, and what replaces it?
Answers
  1. Tomcat thread parses HTTP → filter chain runs → DispatcherServlet looks up the routing table by URL + verb → your controller method runs with extracted arguments → Jackson serializes the return value → status + bytes written back on the same thread.
  2. It means “the return value is the response body — serialize it,” instead of “the return value names an HTML template.” @RestController is just @Controller + @ResponseBody, so every method returns data.
  3. At startup, from the mapping annotations found during component scan. Because the table is built once, two methods claiming the same route collide immediately — Ambiguous mapping, boot fails — instead of misrouting live traffic.
  4. Path identifies, params filter, body carries. @PathVariable: which resource — /friends/{id}. @RequestParam: query options — ?page=2. @RequestBody: the JSON payload of a create/update.
  5. Clients are programs that branch on status codes. 201 says “created” precisely, and Location tells the client where the new resource lives — a 200 forces every client to guess from the body.
  6. Nouns-not-verbs: the HTTP method already says “get,” so the URL names the resource, plural. And the /v1 buys the right to ship breaking changes later as /v2 while old clients keep working.
  7. Nothing fails visibly — Spring binds the parameter from query parameters, ignores the JSON body, and your object arrives full of nulls with a 2xx response. Dangerous because the only symptom is wrong data, found later.
  8. It loses everything on restart, and it’s unsynchronized shared state mutated by many Tomcat threads — a race condition. Module 04 replaces it with PostgreSQL via Spring Data JPA; the endpoints stay, the list goes.

Explain it out loud: Draw the request journey from memory — socket to JSON — for POST /api/v1/friends, narrating each hop and naming which hop produces a 404, a 415, and a 500. Then defend the endpoint design (plural noun, v1, 201 + Location) as if a reviewer challenged each choice. Mumbling at any hop means reread that section.

Still Unclear?

Copy-paste any of these into Claude — concept help, not code generation:

Quiz me on the journey of an HTTP request through a Spring Boot app. Give me
a symptom (404, 415, 500 during serialization, request rejected before the
controller) one at a time, and make me name which hop produced it - filter
chain, routing table, argument binding, my method, or Jackson. Correct my
misses with one-line explanations. No code.
I want to internalise PathVariable vs RequestParam vs RequestBody. Give me
ten realistic endpoint descriptions for an expense-splitting API one by one
(create expense, filter by month, fetch one settlement...) and make me say
where each piece of data travels and which annotation extracts it. Push back
when my choice would work but is poor design.
Challenge my REST resource naming. I will paste my endpoint list for an
expense splitter. For each one, ask me why it is a noun, why plural, why
this nesting depth, what status codes it returns, and what breaks for old
clients if I change a response field without versioning. Be a strict
reviewer. Do not rewrite the API for me.

Why AI Can’t Do This For You

AI writes a flawless FriendController in five seconds — this module’s code is the easy part, and nobody will pay you for typing it. What they pay for: the endpoint that returns nulls because someone’s @RequestBody went missing in a refactor, the deploy dying on Ambiguous mapping after a merge, the 415 the mobile team swears is your bug. Those are your routes, your diff, your logs — and diagnosing them takes the map of hops in your head, not generated code.

The design half is even less automatable. Whether the settle endpoint is POST /groups/7/settlements or POST /settle?group=7, what a retried payment request should return, when to break the API into v2 — these are judgment calls about your clients and your domain. AI can list options; choosing one and defending it in review is the job.

Module done? Add it to today’s tracker

Saves your progress on this device.