Building REST Controllers
Reviewed & published by Brayan K
Expose your beans over HTTP with @RestController, map endpoints with @GetMapping / @PostMapping, return automatic JSON, and control status codes with ResponseEntity.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should understand beans and constructor injection, and have the spring-boot-starter-web dependency from project setup. Records make tidy JSON payloads.
What You'll Learn
- What @RestController combines and does
- Mapping endpoints with @GetMapping / @PostMapping
- Returning objects as automatic JSON (Jackson)
- Controlling status with ResponseEntity
- Using a class-level base path
- Keeping controllers thin by delegating to services
The Big Idea — A Restaurant's Front-of-House
💡 Analogy: A @RestController is the waiter: it takes the customer's order (the HTTP request), passes it to the kitchen (the @Service), and brings back the plated dish (the JSON response). The waiter doesn't cook — it just translates between the dining room (HTTP) and the kitchen (business logic). ResponseEntity is the waiter choosing exactly how to present the plate (status code, headers).
Thin controllers, like good waiters, stay out of the cooking and focus on clean hand-offs.
1️⃣ Your First @RestController
@RestController = @Controller + @ResponseBody, so a method's return value becomes the response body. @GetMapping("/hello") wires a GET endpoint.
// (Conceptual — needs Spring Boot web on the classpath to run.)
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // = @Controller + @ResponseBody
public class HelloController {
// GET /hello -> the returned String IS the response body.
@GetMapping("/hello")
public String hello() {
return "Hello, world!";
}
}2️⃣ Returning JSON Automatically
Return any object (a record is ideal) and Spring serialises it to JSON using Jackson — no manual string-building. A class-level @RequestMapping adds a shared base path.
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// A simple record becomes JSON automatically via Jackson.
record Product(long id, String name, int priceCents) {}
@RestController
@RequestMapping("/api/products") // base path for every handler below
public class ProductController {
// GET /api/products/1 -> {"id":1,"name":"Keyboard","priceCents":4999}
@GetMapping("/{id}")
public Product getOne() {
// (id binding is covered in the next lesson; hard-coded here)
return new Product(1, "Keyboard", 4999);
}
}3️⃣ Controlling the Response with ResponseEntity
When you need more than a 200 OK body — a 201 Created, custom headers, or an empty body — wrap the result in ResponseEntity to set the status explicitly.
package com.example.demo;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
record Product(long id, String name, int priceCents) {}
@RestController
@RequestMapping("/api/products")
public class ProductApi {
// 200 OK with a body — the convenience helper.
@GetMapping("/{id}")
public ResponseEntity<Product> find() {
Product p = new Product(1, "Keyboard", 4999);
return ResponseEntity.ok(p); // HTTP 200
}
// 201 Created with a body — explicit status control.
@PostMapping
public ResponseEntity<Product> create() {
Product saved = new Product(2, "Mouse", 1999);
return ResponseEntity.status(HttpStatus.CREATED).body(saved); // HTTP 201
}
}4️⃣ Keep Controllers Thin — Delegate to a Service
A controller should only handle web concerns and delegate business logic to an injected @Service. This separation keeps logic reusable and testable without the web layer.
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
private final GreetingService service; // a @Service bean (business logic)
// Constructor injection — the controller stays THIN and delegates.
public GreetingController(GreetingService service) {
this.service = service;
}
@GetMapping("/greet")
public String greet() {
return service.greet("world"); // web layer just calls the service
}
}import java.util.*;
public class Main {
// The "controller delegates to a service" shape, in plain Java.
static class GreetingService { // would be a @Service bean
String greet(String name) { return "Hello, " + name + "!"; }
}
static class GreetingController { // would be a @RestController
private final GreetingService service;
GreetingController(GreetingService service) { this.service = service; }
String handleGet() { return service.greet("world"); } // GET /greet
}
public static void main(String[] args) {
GreetingController controller = new GreetingController(new GreetingService());
// Simulate a request -> response (Spring does the HTTP plumbing for you).
String body = controller.handleGet();
System.out.println("HTTP 200 OK");
System.out.println("body: " + body);
}
}// 🎯 YOUR TURN — replace each ___ using the hint beside it.
import java.util.*;
import java.util.function.Function;
public class Main {
record Request(String method, String path) {}
record Response(int status, String body) {}
static class Router {
private final Map<String, Function<Map<String, String>, Response>> routes = new LinkedHashMap<>();
void map(String pattern, Function<Map<String, String>, Response> handler) {
routes.put(pattern, handler);
}
Response handle(Request req) {
for (var entry : routes.entrySet()) {
Map<String, String> vars = match(entry.getKey(), req);
if (vars != null) return entry.getValue().apply(vars);
}
// 4) Nothing matched. That is all a 404 is.
return new Response(___, "no route for " + req.method() + " " + req.path()); // 👉 replace ___ with 404
}
private Map<String, String> match(String pattern, Request req) {
String[] want = pattern.split(" ");
// 1) Wrong verb: this route is not the one, whatever the path says.
if (!want[0].equals(req.___())) return null; // 👉 replace ___ with method
String[] p = want[1].split("/");
String[] a = req.path().split("/");
if (p.length != a.length) return null;
Map<String, String> vars = new LinkedHashMap<>();
for (int i = 0; i < p.length; i++) {
// 2) A {name} segment matches anything and is captured.
if (p[i].___("{") && p[i].endsWith("}")) { // 👉 replace ___ with startsWith
vars.put(p[i].substring(1, p[i].length() - 1), a[i]);
// 3) A literal segment has to match exactly.
} else if (!p[i].___(a[i])) { // 👉 replace ___ with equals
return null;
}
}
return vars;
}
}
public static void main(String[] args) {
Router router = new Router();
router.map("GET /books", vars -> new Response(200, "[Dune, Emma]"));
// 5) The captured variable, by the name inside the braces.
router.map("GET /books/{id}", vars -> new Response(200, "book " + vars.___("id"))); // 👉 replace ___ with get
router.map("POST /books", vars -> new Response(201, "created"));
for (Request r : List.of(
new Request("GET", "/books"),
new Request("GET", "/books/7"),
new Request("POST", "/books"),
new Request("DELETE", "/books/7"))) {
Response res = router.handle(r);
System.out.println(r.method() + " " + r.path() + " -> " + res.status() + " " + res.body());
}
}
}🧠 Quick Recall
Predict before you peek.
Answer: @Controller and @ResponseBody — so return values become the response body.
Answer: an HttpMessageConverter (Jackson by default, auto-configured by Boot) serialises it.
Answer: ResponseEntity.status(HttpStatus.CREATED).body(obj).
Common Errors
- ❌ Using @Controller for an API. Returns a view name, not JSON. Fix: use @RestController.
- ❌ Business logic in the controller. Hard to test/reuse. Fix: delegate to a @Service.
- ❌ Forgetting spring-boot-starter-web. No MVC or Jackson. Fix: add the web starter.
- ❌ Always returning 200. Misleads clients. Fix: use ResponseEntity for 201/404/204.
- ❌ Duplicating the base path. Repetition and bugs. Fix: set a class-level @RequestMapping.
Pro Tips
- 💡 Use records for DTOs — concise, immutable, and serialise cleanly to JSON.
- 💡 Group endpoints under a class-level @RequestMapping("/api/...").
- 💡 Return ResponseEntity when status or headers matter; the bare object when 200 is enough.
- 💡 Keep controllers thin — inject a @Service and delegate.
📋 Quick Reference
| Concept | Syntax / idea | Note |
|---|---|---|
| REST controller | @RestController | Controller + ResponseBody |
| GET | @GetMapping("/x") | Read |
| POST | @PostMapping | Create |
| base path | @RequestMapping("/api") | Class level |
| JSON | return object | Jackson serialises |
| 200 OK | ResponseEntity.ok(body) | Convenience |
| 201 Created | status(CREATED).body(o) | Explicit status |
📚 Resources / Keep Learning
- 📘 Annotated Controllers — Spring MVC docs
- 📗 Building a RESTful Web Service — spring.io guide
- 📗 ResponseEntity — Spring MVC docs
🎉 Lesson Complete!
You can now build REST endpoints with @RestController, map them with @GetMapping/@PostMapping, return objects that Spring serialises to JSON, and shape responses with ResponseEntity — all while keeping controllers thin.
Practice quiz
What does @RestController combine?
- @Controller and @ResponseBody
- @Service and @Bean
- @Component and @Scope
- @Configuration and @Bean
Answer: @Controller and @ResponseBody. @RestController is @Controller + @ResponseBody, so every handler's return value is serialised directly into the response body.
Which annotation maps HTTP GET requests to a method?
- @PostMapping
- @DeleteMapping
- @GetMapping
- @PutMapping
Answer: @GetMapping. @GetMapping is a shortcut for @RequestMapping(method = GET), handling HTTP GET requests.
How does a @RestController turn a returned object into JSON?
- Manually with string concatenation
- Via an HttpMessageConverter (Jackson) configured by Boot
- It cannot return JSON
- Only via a template engine
Answer: Via an HttpMessageConverter (Jackson) configured by Boot. Spring uses HttpMessageConverters — Jackson by default — to serialise returned objects to JSON automatically.
What is ResponseEntity used for?
- Defining a database table
- Returning a body together with an explicit status code and headers
- Injecting a bean
- Reading properties
Answer: Returning a body together with an explicit status code and headers. ResponseEntity lets a handler control the HTTP status, headers and body, not just the body.
Which annotation maps HTTP POST requests?
- @GetMapping
- @PatchMapping
- @PostMapping
- @HeadMapping
Answer: @PostMapping. @PostMapping is the shortcut for handling HTTP POST requests, typically used to create resources.
Where should a controller delegate its business logic?
- Into the controller method itself
- To a @Service bean injected into the controller
- Into the database directly
- To application.properties
Answer: To a @Service bean injected into the controller. Controllers should stay thin and delegate to an injected @Service, keeping web concerns separate from business logic.
By default, a @RestController method returning a String sends...
- a view name to render
- the String itself as the response body
- nothing
- an error
Answer: the String itself as the response body. Because of @ResponseBody, the returned String becomes the response body rather than a view name to resolve.
What does @RequestMapping("/api") at class level do?
- Disables the controller
- Prefixes every handler path in the class with /api
- Sets the port
- Creates a bean
Answer: Prefixes every handler path in the class with /api. A class-level @RequestMapping defines a base path that is prepended to each method's mapping.
What status code does ResponseEntity.ok(body) produce?
- 404 Not Found
- 500 Internal Server Error
- 201 Created
- 200 OK
Answer: 200 OK. ResponseEntity.ok(...) is a convenience for an HTTP 200 OK response carrying the given body.
To return 201 Created with a body, you can use...
- ResponseEntity.status(HttpStatus.CREATED).body(obj)
- return null;
- @GetMapping
- System.exit(1)
Answer: ResponseEntity.status(HttpStatus.CREATED).body(obj). ResponseEntity.status(HttpStatus.CREATED).body(obj) sets a 201 status alongside the response body.
Continue this course
- Previous: Beans, Scopes & Configuration
- Next: Request Mapping & Parameters — @PathVariable, @RequestParam, @RequestBody and HTTP status codes
- Quick reference: Java cheat sheet
Frequently asked questions
What is the difference between @Controller and @RestController?
@Controller is the classic Spring MVC stereotype for web controllers; by default its methods return a view name that a template engine resolves into HTML. @RestController is a convenience that combines @Controller with @ResponseBody, which means every handler method's return value is written directly into the HTTP response body instead of being treated as a view name. For building JSON or XML APIs you almost always want @RestController, because returning a Product object then serialises it to JSON automatically. Use plain @Controller when you are rendering server-side HTML templates and @RestController when you are building a REST API.
How does Spring turn my returned object into JSON?
Through HttpMessageConverters. When a @RestController method returns an object, Spring selects a converter to serialise it for the response, and for JSON the default is Jackson, which Spring Boot auto-configures when spring-boot-starter-web is on the classpath. So returning a record or POJO like new Product(1, "Keyboard", 4999) is automatically converted to {"id":1,"name":"Keyboard","priceCents":4999}. The same converters work in reverse for request bodies. You don't write any serialisation code; you just return your domain object and Spring handles the wire format, content negotiation, and headers.
When should I use ResponseEntity instead of returning the object directly?
Return the object directly when a 200 OK with that body is all you need — it's the cleanest option. Reach for ResponseEntity when you need control over the HTTP response beyond the body: setting a specific status code (such as 201 Created after a successful POST, or 404 Not Found when a resource is missing), adding response headers (like Location), or returning no body at all (204 No Content). ResponseEntity.ok(body) is a shortcut for 200, while ResponseEntity.status(HttpStatus.CREATED).body(saved) gives full control. It makes your handlers explicit about the contract they return to clients.
Why should controllers be 'thin' and delegate to services?
A controller's job is purely web concerns: mapping a URL and HTTP method to a method, binding request data, and shaping the HTTP response. Business logic — validation rules, calculations, orchestration, transactions — belongs in a @Service bean that the controller injects via its constructor. Keeping controllers thin separates concerns, so your business logic is reusable (callable from a scheduled job or another controller), testable without spinning up the web layer, and not tangled with HTTP details. The common shape is: controller receives the request, delegates to the service, and translates the result into a response.
Do the HTTP method annotations like @GetMapping replace @RequestMapping?
They are convenient shortcuts for it. @GetMapping("/x") is equivalent to @RequestMapping(value = "/x", method = RequestMethod.GET), and the same pattern gives @PostMapping, @PutMapping, @DeleteMapping and @PatchMapping. The shortcuts are clearer and the recommended style for method-level mappings. @RequestMapping is still useful, especially at class level to define a shared base path (for example @RequestMapping("/api/products")) that is prepended to every handler in the class. So you typically combine a class-level @RequestMapping for the base path with method-level @GetMapping/@PostMapping for each operation.