Building REST APIs with Spring MVC
@RestController is a class whose methods are endpoints. Spring MVC matches an incoming request to a method by HTTP verb and path, converts the URL parts and JSON body into method arguments, calls the method, and serialises whatever you return back into a JSON HTTP response.What You Will Learn
- The
DispatcherServletrequest flow. @RestControllerand@RequestMapping(class + method level).@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMapping.- Binding input:
@PathVariable,@RequestParam,@RequestBody,@RequestHeader. - Shaping output with
ResponseEntityand HTTP status codes. - Why the API uses request/response DTOs and never returns entities.
Chapter Structure
2.1 The Request Flow
Every HTTP request to the backend passes through the same pipeline:
The DispatcherServlet is the "front controller" that Spring Boot registers automatically. You never see it; you only write the handler methods.
2.2 Mapping URLs to Methods
Here is the whole of PatientController:
@RestController
@RequestMapping("/api/patients")
@RequiredArgsConstructor
public class PatientController {
private final PatientService patientService;
@GetMapping
public ResponseEntity<List<PatientResponse>> getAllPatients(
@RequestParam(required = false) String search) {
return ResponseEntity.ok(patientService.getAllPatients(search));
}
@GetMapping("/{id}")
public ResponseEntity<PatientResponse> getPatientById(@PathVariable Long id) {
return ResponseEntity.ok(patientService.getPatientById(id));
}
@PostMapping
public ResponseEntity<PatientResponse> createPatient(@RequestBody @Valid PatientRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(patientService.createPatient(request));
}
@PutMapping("/{id}")
public ResponseEntity<PatientResponse> updatePatient(
@PathVariable Long id, @RequestBody @Valid PatientRequest request) {
return ResponseEntity.ok(patientService.updatePatient(id, request));
}
}@RestController=@Controller+@ResponseBody. The second part means "return values are the response body", not view names.@RequestMapping("/api/patients")on the class is a prefix for every method in it.@GetMapping("/{id}")is shorthand for@RequestMapping(method = GET, path = "/{id}"). The full path becomes/api/patients/{id}.
| Verb | Annotation | Typical meaning | hospital-erp example |
|---|---|---|---|
| GET | @GetMapping | Read, no side effects | GET /api/patients?search=sharma |
| POST | @PostMapping | Create a new resource | POST /api/patients |
| PUT | @PutMapping | Full replace of a resource | PUT /api/patients/1 |
| PATCH | @PatchMapping | Partial update | PATCH /api/appointments/{id} (advance status) |
| DELETE | @DeleteMapping | Remove a resource | — |
2.3 Reading Input
2.3.1 @PathVariable — a value from the URL path
@GetMapping("/{id}")
public ResponseEntity<PatientResponse> getPatientById(@PathVariable Long id) { ... }
// GET /api/patients/42 → id = 42LThe {id} template segment and the parameter name line up. Spring also converts the text "42" to Long for you; a non-numeric value produces a 400.
2.3.2 @RequestParam — a query-string value
@GetMapping
public ResponseEntity<List<PatientResponse>> getAllPatients(
@RequestParam(required = false) String search) { ... }
// GET /api/patients → search = null
// GET /api/patients?search=rao → search = "rao"required = false makes the parameter optional. ReportsController also uses defaultValue:
@GetMapping("/top-medicines")
public ResponseEntity<List<TopMedicineResponse>> getTopMedicines(
@RequestParam(defaultValue = "5") int limit,
@RequestParam(required = false) Long branchId) { ... }2.3.3 @RequestBody — the JSON payload
@PostMapping
public ResponseEntity<PatientResponse> createPatient(
@RequestBody @Valid PatientRequest request) { ... }The request's JSON body is deserialised by Jackson into a PatientRequest object, field by field. Adding @Valid tells Spring to run Bean Validation on it before the method body executes (Chapter 5).
2.3.4 @RequestHeader — a header value
From AuthController.logout:
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing bearer token");
}
authService.logout(authHeader.substring(7));
return ResponseEntity.noContent().build();
}| Comes from | Annotation | Example source |
|---|---|---|
| Path segment | @PathVariable | /api/patients/1 |
| Query string | @RequestParam | ?search=rao |
| Request body (JSON) | @RequestBody | { "firstName": "Ravi" } |
| Header | @RequestHeader | Authorization: Bearer ... |
2.4 Producing Output
A handler can return a plain object (Spring wraps it in a 200) or a ResponseEntity<T> when it needs to control the status code or headers. This project always uses ResponseEntity for consistency:
return ResponseEntity.ok(body); // 200 OK + body
return ResponseEntity.status(HttpStatus.CREATED).body(x); // 201 Created + body
return ResponseEntity.noContent().build(); // 204 No Content, no body| Status | Name | When this API uses it |
|---|---|---|
| 200 | OK | Successful GET / PUT / PATCH |
| 201 | Created | Successful POST /api/patients |
| 204 | No Content | Successful POST /api/auth/logout |
| 400 | Bad Request | Validation failure, unparseable body |
| 401 | Unauthorized | Missing / invalid credentials or token |
| 403 | Forbidden | Authenticated but role not allowed / inactive account |
| 404 | Not Found | Patient not found |
| 409 | Conflict | "A visit already exists for this appointment" |
MappingJackson2HttpMessageConverter, auto-configured because Jackson is on the classpath via spring-boot-starter-webmvc. Getters become JSON fields; Lombok's @Data / @Getter on the response DTOs is what makes those getters exist.2.5 DTOs — Why the API Never Returns an Entity
Look at the patient package: there is a Patient entity, a PatientRequest (input) and a PatientResponse (output). The controller speaks only in the latter two. This separation is deliberate.
| Problem with exposing the entity | What a DTO gives you |
|---|---|
Leaks columns you did not mean to publish (e.g. a password hash on User). | You choose exactly which fields go out. |
Lazy associations blow up during JSON serialisation (LazyInitializationException). | Mapping happens inside the transaction; the DTO holds plain values. |
Clients can set fields they should not (mass assignment) — e.g. patientCode. | PatientRequest simply has no patientCode field; the server generates it. |
| API shape is welded to the database schema; a column rename breaks clients. | The two evolve independently. |
@Data
public class PatientRequest {
@NotBlank @Size(max = 80) private String firstName;
@NotBlank @Size(max = 80) private String lastName;
private Gender gender;
private LocalDate dob;
// ... note: no id, no patientCode, no uhid, no createdAt
}
@Data
@AllArgsConstructor
public class PatientResponse {
private Long id;
private String patientCode; // server-generated, safe to expose
private String uhid;
private String firstName;
// ...
}2.6 Content Negotiation (briefly)
Spring picks a converter using the request's Accept header and the body's Content-Type. Because only Jackson is configured, everything is application/json. A POST without Content-Type: application/json will fail to bind @RequestBody with a 415 Unsupported Media Type.
Quick Concept Map
@RestController → class of endpoints, return values become the body
@RequestMapping (class) + @GetMapping etc. (method) → verb + path
@PathVariable → from the path · @RequestParam → from the query · @RequestBody → from JSON · @RequestHeader → from a header
ResponseEntity → status + headers + body
DTO in / DTO out → never expose the entity
Common Mistakes
- Two handlers mapped to the same verb + path → ambiguous mapping, startup fails.
- Forgetting
@RequestBody, so Spring tries to bind the JSON fields from query parameters and they come outnull. - Returning the JPA entity directly and hitting
LazyInitializationExceptionor leaking a field. - Using
@PathVariablewhen the name does not match the URI template variable (fix with@PathVariable("id")). - Returning
201 Createdfrom a GET, or200from a resource-creating POST — pick the status that matches the semantics. - Putting business rules in the controller instead of delegating to a service.
- Client omits
Content-Type: application/jsonand is surprised by a 415.
Revision Questions
- What is the
DispatcherServletand who registers it? - What does
@RestControlleradd over@Controller? - How is the full path of
PatientController.getPatientByIdassembled? - Give the source of data for
@PathVariable,@RequestParam,@RequestBody,@RequestHeader. - How do you return
201 Createdwith a body? - Give three concrete reasons not to return the entity.
- Why does
PatientRequesthave nopatientCodefield? - What HTTP status results from a request body that fails
@Valid?
Practice
GET /api/patients/{id}/visits to the controller that delegates to a service method returning List<VisitHistoryResponse>.createPatient to also set a Location header pointing at the new resource, using ResponseEntity.created(uri).User entity has a password field. Confirm which DTO the users API returns and verify the hash never leaves the server.Chapter Wrap-Up
Spring MVC routes a request to a controller method by verb and path, fills the method's parameters from the path, query, body and headers, and serialises the return value to JSON. Controllers in this project stay deliberately thin: bind, delegate, wrap. The API speaks in DTOs so its contract is independent of the database schema and nothing private leaks out.
patientService.getAllPatients(search) — entities, repositories and Spring Data JPA.Teaching note: code is quoted from the hospital-erp backend (Spring Boot 4). Some snippets are lightly trimmed with // ... for focus.