Building REST APIs with Spring MVC

From an HTTP request line to a typed Java method call — and back to JSON
Big idea: A @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 DispatcherServlet request flow.
  • @RestController and @RequestMapping (class + method level).
  • @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping.
  • Binding input: @PathVariable, @RequestParam, @RequestBody, @RequestHeader.
  • Shaping output with ResponseEntity and HTTP status codes.
  • Why the API uses request/response DTOs and never returns entities.

Chapter Structure

2.1 The request flow2.2 Mapping URLs to methods2.3 Reading input2.4 Producing output2.5 DTOs2.6 Content negotiation2.7 Wrap-up

2.1 The Request Flow

Every HTTP request to the backend passes through the same pipeline:

ClientServlet filters (incl. JwtAuthFilter)DispatcherServletHandlerMapping picks a controller methodargument resolvers build the parametersyour method runsHttpMessageConverter writes JSONClient

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}.
VerbAnnotationTypical meaninghospital-erp example
GET@GetMappingRead, no side effectsGET /api/patients?search=sharma
POST@PostMappingCreate a new resourcePOST /api/patients
PUT@PutMappingFull replace of a resourcePUT /api/patients/1
PATCH@PatchMappingPartial updatePATCH /api/appointments/{id} (advance status)
DELETE@DeleteMappingRemove a resource
Notice the controller is thin. Every method does three things: bind input, call one service method, wrap the result. No business logic lives here — that is Chapter 4's subject.

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 = 42L

The {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 fromAnnotationExample source
Path segment@PathVariable/api/patients/1
Query string@RequestParam?search=rao
Request body (JSON)@RequestBody{ "firstName": "Ravi" }
Header@RequestHeaderAuthorization: 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
StatusNameWhen this API uses it
200OKSuccessful GET / PUT / PATCH
201CreatedSuccessful POST /api/patients
204No ContentSuccessful POST /api/auth/logout
400Bad RequestValidation failure, unparseable body
401UnauthorizedMissing / invalid credentials or token
403ForbiddenAuthenticated but role not allowed / inactive account
404Not FoundPatient not found
409Conflict"A visit already exists for this appointment"
Serialisation: the object you return is turned into JSON by a 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 entityWhat 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;
    // ...
}
Rule of thumb: the entity is for the database layer, the request DTO is the contract for what clients may send, the response DTO is the contract for what they receive. The service maps between them (Chapter 4).

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

  1. Two handlers mapped to the same verb + path → ambiguous mapping, startup fails.
  2. Forgetting @RequestBody, so Spring tries to bind the JSON fields from query parameters and they come out null.
  3. Returning the JPA entity directly and hitting LazyInitializationException or leaking a field.
  4. Using @PathVariable when the name does not match the URI template variable (fix with @PathVariable("id")).
  5. Returning 201 Created from a GET, or 200 from a resource-creating POST — pick the status that matches the semantics.
  6. Putting business rules in the controller instead of delegating to a service.
  7. Client omits Content-Type: application/json and is surprised by a 415.

Revision Questions

  1. What is the DispatcherServlet and who registers it?
  2. What does @RestController add over @Controller?
  3. How is the full path of PatientController.getPatientById assembled?
  4. Give the source of data for @PathVariable, @RequestParam, @RequestBody, @RequestHeader.
  5. How do you return 201 Created with a body?
  6. Give three concrete reasons not to return the entity.
  7. Why does PatientRequest have no patientCode field?
  8. What HTTP status results from a request body that fails @Valid?

Practice

1. Add an endpoint. Add GET /api/patients/{id}/visits to the controller that delegates to a service method returning List<VisitHistoryResponse>.
2. Status codes. Change createPatient to also set a Location header pointing at the new resource, using ResponseEntity.created(uri).
3. DTO discipline. The 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.

Next chapter: what happens behind 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.