In the previous lesson we designed the contract: thirteen endpoints, their verbs, their status codes and the URL rules. Now we write the code that fulfils it. A Spring controller is a deceptively simple class —it receives Java objects and returns Java objects— but between the HTTP request and that method there is a machinery of argument resolution and message conversion worth mastering, because it explains 90% of the "my parameter never arrives" and "the JSON isn't coming out as I expected". In this lesson we take @RestController apart, learn to extract every piece of a request (path, query string, headers, body), control how Jackson serialises our objects, decide when to use ResponseEntity and finish with a complete StationController, with filters and pagination, tested from the terminal and from the IDE.
Contents
@RestController: what it is exactly@RequestMappingand the per-verb shortcuts@PathVariable: template variables@RequestParam: query parameters@RequestBody,@RequestHeaderand friends- JSON serialisation with Jackson
- Global Jackson configuration in YAML
ResponseEntityversus returning the object- The complete
StationController - Testing the API: curl and
.httpfiles - CORS and
@CrossOrigin - Common Mistakes and Tips
- Exercises
@RestController: what it is exactly
@RestController: what it is exactlyLet us open the annotation, as we did with @SpringBootApplication in lesson 02-01:
@Controller // <-- it is a stereotype: a bean detected by @ComponentScan
@ResponseBody // <-- the return value goes to the body, not to a view
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}Two annotations combined, nothing more:
@Controlleris one of the five stereotypes from 02-01: a specialised@Componentthat additionally makesRequestMappingHandlerMappinginspect the class looking for methods annotated with@RequestMapping.@ResponseBodychanges how the returned value is interpreted. Without it, a method returning theString"stations"is interpreted as the name of a view and Spring looks for astations.htmltemplate. With it, thatStringis the response body.
| Annotation | Method return value | When to use it |
|---|---|---|
@Controller |
View name (Thymeleaf, JSP) | Web with server-generated HTML |
@Controller + per-method @ResponseBody |
Response body | Mixed classes (rare) |
@RestController |
Response body, always | REST APIs: our case |
An extremely frequent beginner's mistake is annotating an API controller with @Controller and running into a view resolution error or a cryptic 404. If the response must be JSON, it is @RestController.
@RequestMapping and the per-verb shortcuts
@RequestMapping and the per-verb shortcuts@RequestMapping is the base mapping annotation. Its attributes:
| Attribute | What it does | Example |
|---|---|---|
path / value |
Path pattern | "/api/v1/stations" |
method |
Accepted verbs | RequestMethod.GET |
params |
Requires or forbids parameters | "active", "!draft" |
headers |
Requires headers | "X-API-Client=mobile" |
consumes |
Content-Type it accepts |
"application/json" |
produces |
Content-Type it returns |
"application/json" |
Since Spring 4.3 there are shortcuts that fix method: @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping. @GetMapping("/{id}") is identical to @RequestMapping(path = "/{id}", method = RequestMethod.GET), and always preferable for readability.
The usual pattern combines a class-level @RequestMapping —which fixes the common prefix— with the shortcuts at method level:
@RestController
@RequestMapping(path = "/api/v1/stations", // class prefix
produces = MediaType.APPLICATION_JSON_VALUE)
public class StationController {
@GetMapping // GET /api/v1/stations
public List<Station> list() { ... }
@GetMapping("/{id}") // GET /api/v1/stations/1
public Station get(@PathVariable Long id) { ... }
}Concentrating the prefix in a single place avoids repeating it in every method and makes changing the API version trivial. About produces and consumes: they are not mandatory —Spring Boot already negotiates JSON by default— but declaring them makes the contract visible in the code, produces correct responses (415, 406) instead of confusing errors, and springdoc uses them to generate the documentation (03-07).
@PathVariable: template variables
@PathVariable: template variablesA template variable is a path segment that acts as a parameter. It is declared between braces and captured with @PathVariable.
@GetMapping("/{id}")
public Station getById(@PathVariable Long id) {
return stationService.findById(id).orElse(null);
}Spring extracts the segment, converts it to the declared type using its ConversionService and passes it as an argument. Conversion works with Long, int, UUID, LocalDate, enums and any type for which a converter exists.
The name must match. If the Java parameter has a different name from the template, you have to say so: @GetMapping("/{stationId}") with @PathVariable("stationId") Long id. When they match, the name can be omitted only if the code is compiled with parameter information. Projects generated by Spring Initializr do so (the spring-boot-maven-plugin adds -parameters), but if you ever see an IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, that is the cause. Always writing the name is a cheap and robust habit.
A path may have several variables (@GetMapping("/{stationId}/bikes/{bikeId}")), and a variable can be optional by declaring it as @PathVariable Optional<Integer> year and registering two patterns in the same annotation: @GetMapping({"/statistics", "/statistics/{year}"}).
Patterns and regular expressions. The {name:regex} syntax restricts which values the segment captures. It is very useful for disambiguating routes:
@GetMapping("/{id:\\d+}") // digits only: /stations/1
public Station byId(@PathVariable Long id) { ... }
@GetMapping("/{code:[A-Z]{3}-\\d{3}}") // /stations/RIB-001
public Station byCode(@PathVariable String code) { ... }Without the regular expressions, /stations/RIB-001 would try to convert to Long and fail with a type error. With them, each path goes to its own method. Spring's path wildcards:
| Pattern | Matches | Does not match |
|---|---|---|
/stations/{id} |
/stations/1 |
/stations/1/bikes |
/stations/{id:\\d+} |
/stations/1 |
/stations/abc |
/stations/* |
/stations/1 |
/stations/1/bikes |
/stations/** |
/stations/1/bikes/42 |
— |
/st?tion |
/station, /stotion |
/staation |
When several patterns match, Spring chooses the most specific one: a literal pattern beats one with a variable, and that beats one with a wildcard.
@RequestParam: query parameters
@RequestParam: query parametersQuery string parameters —what comes after the ?— are captured with @RequestParam.
// GET /api/v1/stations?minimumCapacity=20
@GetMapping
public List<Station> list(@RequestParam int minimumCapacity) { ... }By default they are mandatory: if one is missing, Spring responds 400 Bad Request with a MissingServletRequestParameterException. The three ways to make one optional:
@RequestParam(defaultValue = "0") int minimumCapacity // the best: never null
@RequestParam(required = false) Integer minimumCapacity // arrives null if absent
@RequestParam Optional<Integer> minimumCapacity // explicit in the signatureA classic mistake: @RequestParam(required = false) int capacity with the primitive type. If the parameter is missing, Spring tries to assign null to an int and throws an exception. With required = false, the type must always be a wrapper.
Lists and multiple values. @RequestParam List<Long> ids accepts both usual conventions: ?ids=1,2,3 and ?ids=1&ids=2&ids=3. You can also receive every parameter at once with @RequestParam Map<String, String> filters, but you lose typing, automatic validation and OpenAPI documentation: use it only if the parameters are genuinely dynamic.
Grouping parameters into an object. When an endpoint has five or six parameters, the signature becomes unreadable. Spring lets you bind them to an object without any annotation:
public record StationFilter(String name, Integer minimumCapacity,
int page, int size) {}
@GetMapping
public List<Station> list(StationFilter filter) { ... }Spring uses standard data binding (parameter name → record component). It is cleaner, it validates with @Valid (03-04) and it documents well. We will use it in exercise 2.
| Annotation | Source of the data | Example request |
|---|---|---|
@PathVariable |
Path segment | /stations/**1** |
@RequestParam |
Query string or form | /stations?**minimumCapacity=20** |
@RequestBody |
Request body | {"name":"Central Market"} |
@RequestHeader |
HTTP header | Accept-Language: ca |
@CookieValue |
Cookie | Cookie: preference=map |
@MatrixVariable |
Pairs inside a segment | /stations/1;zone=centre |
@RequestBody, @RequestHeader and friends
@RequestBody, @RequestHeader and friends@RequestBody takes the request body and deserialises it into the declared type using the appropriate HttpMessageConverter for the Content-Type; for application/json, that converter is Jackson. You write public Station create(@RequestBody Station station). There can be only one @RequestBody per method: there is a single body. If it is missing or the JSON is malformed, an HttpMessageNotReadableException is thrown, which we will turn into a decent response in 03-06. The full implementation of creation is the subject of the next lesson.
@RequestHeader captures headers, with the same defaultValue and required semantics. It also allows receiving them all: @RequestHeader HttpHeaders headers.
@GetMapping
public List<Station> list(
@RequestHeader(value = "Accept-Language", defaultValue = "en") String language) { ... }In CicloUrbana we will use it for the language of the error messages (03-04) and for the If-Match of concurrency control (03-03).
@MatrixVariable captures key-value pairs inside a path segment, separated by semicolons: /api/v1/stations/1;zone=centre. It is part of RFC 3986 and Spring supports it, but it is disabled by default and has to be switched on by configuring UrlPathHelper. It is mentioned for completeness: CicloUrbana does not use it, because that same data belongs in the query string.
Besides the annotations, a method can declare types that Spring injects directly: HttpServletRequest, Locale, UriComponentsBuilder, Principal (module 5). They couple the controller to the servlet API, so it is best to save them for when there is no alternative.
- JSON serialisation with Jackson
When a @RestController method returns an object, MappingJackson2HttpMessageConverter converts it to JSON. With a Java record the process is direct: each record component becomes a JSON property with the same name.
Our record Station(Long id, String name, String address, int capacity, double latitude, double longitude) becomes, with no annotation whatsoever:
{ "id": 1, "name": "Main Square", "address": "Main Square 1",
"capacity": 24, "latitude": 41.3851, "longitude": 2.1734 }Jackson supports record natively since version 2.12: it uses the canonical constructor to deserialise and the accessors to serialise. No getters, no empty constructor, no @JsonCreator needed. This is one of the reasons the course uses record for everything that travels over the API.
The Jackson annotations we will use:
| Annotation | Effect | Example |
|---|---|---|
@JsonProperty("name") |
Renames the property in the JSON | capacity → "total_capacity" |
@JsonIgnore |
Excludes the field from the JSON | Internal maintenance coordinates |
@JsonInclude(NON_NULL) |
Omits the field when it is null |
Do not send "operator": null |
@JsonFormat |
Controls the format of dates and numbers | "2026-08-31T14:05:00" |
@JsonPropertyOrder |
Fixes the order of the properties | {"id", "name", ...} |
@JsonAlias |
Accepts several names when deserialising | Compatibility with old clients |
@JsonIgnoreProperties(ignoreUnknown) |
Tolerates unknown fields when reading | Spring Boot's default value |
A complete example applied to the representation of a bike, which we will introduce formally in the next lesson:
package com.ciclourbana.bikes;
@JsonInclude(JsonInclude.Include.NON_NULL) // omits null properties
public record Bike(
Long id,
String plate,
@JsonProperty("battery") // in the JSON it is called "battery"
int batteryLevel,
BikeStatus status,
Long stationId,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime lastInspection,
@JsonIgnore // internal use: never goes out
String internalDockCode
) {}Point by point:
@JsonInclude(NON_NULL)at type level: ifstationIdisnullbecause the bike is in use, the property does not appear in the JSON. Careful: it forces the client to distinguish absence fromnull, a nuance we will revisit withPATCHin 03-03.@JsonProperty("battery"): the Java field follows the project's naming and the JSON exposes the name agreed with the app team. Decoupling the two names allows renaming in Java without breaking the contract.@JsonFormat:shape = STRINGprevents the date from being emitted as an array of numbers, which is Jackson's behaviour without the JSR-310 module.@JsonIgnore:internalDockCodeis Ribalta operational information that must not go outside. Here a serious problem starts to show: the internal model contains data the API must not expose. Annotating fields with@JsonIgnoreworks at small scale and becomes a source of leaks as soon as the model grows. The structural solution is DTOs (03-05).
- Global Jackson configuration in YAML
Annotating field by field does not scale. Spring Boot exposes Jackson's global configuration under spring.jackson.*, and that is the right way to set project-wide policies.
# src/main/resources/application.yml
spring:
jackson:
date-format: yyyy-MM-dd'T'HH:mm:ss # affects java.util.Date
time-zone: Europe/Madrid
default-property-inclusion: non_null # omit null properties across the API
serialization:
write-dates-as-timestamps: false # ISO-8601 dates, not numbers
fail-on-empty-beans: false
indent-output: false # in production it saves bandwidth
deserialization:
fail-on-unknown-properties: false # tolerate fields we do not know
fail-on-null-for-primitives: true # reject null in an int instead of using 0The important decisions in this configuration:
write-dates-as-timestamps: falseis probably the most relevant Jackson property in a project. Without it, anInstantis serialised as1756645500.000000000: unreadable and fragile. Spring Boot already sets it tofalse, but it is worth declaring. It requiresjackson-datatype-jsr310, whichspring-boot-starter-webincludes and Spring Boot registers on its own.default-property-inclusion: non_nullapplies to the whole application what@JsonIncludedid to a single class, and avoids repeating the annotation in every DTO.fail-on-unknown-properties: false(the default value) is a compatibility decision: if an old client sends a field that has been removed, the request does not fail. It is precisely the behaviour that made adding and removing fields compatible in 03-01.fail-on-null-for-primitives: truedoes change the default value. Without it,{"capacity": null}silently becomescapacity = 0, and a station with zero capacity is a data error that is hard to trace. Better an immediate 400.
If you need something the properties do not cover, customise with a Jackson2ObjectMapperBuilderCustomizer in com.ciclourbana.common:
@Configuration
public class JacksonConfig {
/** It accumulates with spring.jackson.* instead of replacing it. */
@Bean
Jackson2ObjectMapperBuilderCustomizer cicloUrbanaCustomization() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
}
}The detail follows from what we learned about @ConditionalOnMissingBean: if you declare your own ObjectMapper bean, JacksonAutoConfiguration steps aside and you lose in one stroke every spring.jackson.* property, the date module and the registered converters. Always customise with the customizer, never by replacing the ObjectMapper.
ResponseEntity versus returning the object
ResponseEntity versus returning the objectA method can return the object directly or wrap it in a ResponseEntity<T>, which represents the complete HTTP response: status, headers and body.
// Direct form: always 200 OK, no headers of our own
public Station get(@PathVariable Long id) { ... }
// With ResponseEntity: full control of the status and the headers
public ResponseEntity<Station> get(@PathVariable Long id) {
return stationService.findById(id)
.map(ResponseEntity::ok) // 200 + body
.orElseGet(() -> ResponseEntity.notFound().build()); // 404 with no body
}| Criterion | Returning the object | Returning ResponseEntity |
|---|---|---|
| Status code | Always 200 (or the one from @ResponseStatus) |
Any, decided at runtime |
| Own headers | No | Yes (Location, ETag, Cache-Control) |
| Readability | Maximum | Slightly noisier |
| Body typing | Direct | Wrapped in a generic |
| OpenAPI documentation | Inferred on its own | Sometimes needs @ApiResponse |
| Recommended use | GET that always succeeds |
POST (201 + Location), 204, conditional responses |
CicloUrbana's policy for the whole module: return the object directly when the success case is unique and the status is 200 (all the listing GETs); use ResponseEntity when the response needs a header (Location in the POSTs, ETag in conditional ones) or when the status varies; and do not use ResponseEntity to return errors. That last point is key and is not obvious yet: writing ResponseEntity.notFound() in every method scatters error logic across all the controllers. From 03-06 on we will throw ResourceNotFoundException and a global handler will turn it into a well-formed 404. In this lesson we still use ResponseEntity for the 404, with a TODO so as not to forget it.
Useful ways of building a ResponseEntity:
ResponseEntity.ok(station); // 200 with body
ResponseEntity.noContent().build(); // 204 with no body
ResponseEntity.created(uri).body(station); // 201 + Location
ResponseEntity.status(HttpStatus.CONFLICT).build(); // any code
ResponseEntity.ok()
.header("X-Total-Elements", "42")
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(list); // custom headersThere is also @ResponseStatus on the method, which fixes the success code without a ResponseEntity; we will apply it to the POSTs in the next lesson.
- The complete
StationController
StationControllerLet us put it all together. First we extend StationService (package com.ciclourbana.stations) with the methods the controller needs:
@Service
public class StationService {
private final StationRepository stationRepository;
public StationService(StationRepository stationRepository) {
this.stationRepository = stationRepository;
}
/** Filtered and paginated listing. Local variables only: the bean is a
* singleton shared by the Tomcat threads (seen in 02-03). */
public List<Station> search(String name, Integer minimumCapacity,
int page, int size) {
List<Station> filtered = stationRepository.findAll().stream()
.filter(s -> name == null
|| s.name().toLowerCase().contains(name.toLowerCase()))
.filter(s -> minimumCapacity == null || s.capacity() >= minimumCapacity)
.sorted(Comparator.comparing(Station::name))
.toList();
// In-memory pagination. In module 4, Spring Data will do it in the
// database with Pageable, which is the right way in production.
int from = page * size;
if (from >= filtered.size()) {
return List.of();
}
return filtered.subList(from, Math.min(from + size, filtered.size()));
}
public Optional<Station> findById(Long id) {
return stationRepository.findById(id);
}
}And now the controller:
package com.ciclourbana.stations;
/**
* CicloUrbana stations API.
*
* It still returns the Station domain entity; the split into DTOs
* arrives in lesson 03-05, and centralised error handling in 03-06.
*/
@RestController
@RequestMapping(path = "/api/v1/stations",
produces = MediaType.APPLICATION_JSON_VALUE)
public class StationController {
private static final Logger log = LoggerFactory.getLogger(StationController.class);
private static final int MAX_PAGE_SIZE = 100;
private final StationService stationService;
// Constructor injection: no @Autowired, as argued in 02-02
public StationController(StationService stationService) {
this.stationService = stationService;
}
/** GET /api/v1/stations?name=north&minimumCapacity=20&page=0&size=20
* Returns the list directly: a single success case (200), no custom headers. */
@GetMapping
public List<Station> list(
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer minimumCapacity,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
// Provisional defence: without this, ?size=1000000 takes the service down.
// In 03-04 it is replaced by declarative @Min/@Max.
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
int safePage = Math.max(page, 0);
log.debug("Station listing: name={}, minimumCapacity={}, page={}",
name, minimumCapacity, safePage);
return stationService.search(name, minimumCapacity, safePage, safeSize);
}
/** GET /api/v1/stations/1 — ResponseEntity because the status varies.
* TODO (03-06): replace with throwing ResourceNotFoundException. */
@GetMapping("/{id:\\d+}")
public ResponseEntity<Station> getById(@PathVariable("id") Long id) {
return stationService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> {
log.info("Station not found: id={}", id);
return ResponseEntity.notFound().build();
});
}
}Details worth commenting on:
@GetMapping("/{id:\\d+}"): restricting to digits prevents/api/v1/stations/summary, if we ever add it, from trying to convert toLong.- The pagination limits in the controller are provisional and deliberately ugly: 03-04 replaces them with
@Min(0)and@Max(100), and the contrast makes it obvious what declarative validation buys you. - The logging uses SLF4J's substitution pattern (
{}), not concatenation: withconcat, theStringis built even whenDEBUGis off. And the 404 goes tolog.info, notlog.error: it is operational information, not a system failure (detailed in 03-06).
We start up with ./mvnw spring-boot:run and DemoStationLoader leaves Ribalta's four stations in the in-memory repository.
- Testing the API: curl and
.http files
.http filesWith curl, the universal tool:
curl -s http://localhost:8080/api/v1/stations | jq
curl -s "http://localhost:8080/api/v1/stations?name=north" | jq
curl -s "http://localhost:8080/api/v1/stations?minimumCapacity=24&page=0&size=2" | jq
curl -s http://localhost:8080/api/v1/stations/1 | jq
curl -i http://localhost:8080/api/v1/stations/999 # see headers and statusThe last call returns HTTP/1.1 404 with Content-Length: 0. And the filter by minimum capacity 24:
[ { "id": 1, "name": "Main Square", "capacity": 24, "latitude": 41.3851, "longitude": 2.1734 },
{ "id": 2, "name": "North Station", "capacity": 30, "latitude": 41.4012, "longitude": 2.1698 } ]They come out sorted by name, as the service's Comparator dictates; "Main Square" (24), "North Station" (30) and "University" (36) pass the filter, but with size=2 only the first two arrive.
For daily work an .http file in src/test/http/stations.http is more convenient; IntelliJ IDEA and the VS Code REST Client extension run it directly. It is versioned with the code, so the API stays documented and testable from the repository itself:
@base = http://localhost:8080/api/v1
### List every station
GET {{base}}/stations
Accept: application/json
### Filter by name
GET {{base}}/stations?name=north
### Filter by minimum capacity and paginate
GET {{base}}/stations?minimumCapacity=24&page=0&size=2
### Detail of Main Square
GET {{base}}/stations/1
### Non-existent station: must respond 404
GET {{base}}/stations/999
### Parameter with the wrong type: responds 400
GET {{base}}/stations?minimumCapacity=lotsThe last request is interesting. Spring tries to convert "lots" to Integer, fails and throws MethodArgumentTypeMismatchException, which is translated into a 400 Bad Request with an uninformative default response. Keep it in the file: we will improve it in 03-06.
- CORS and
@CrossOrigin
@CrossOriginBrowsers apply the same-origin policy: a page served from https://panel.ribalta.example cannot call https://api.ciclourbana.example from JavaScript unless the server authorises it. That permission is CORS (Cross-Origin Resource Sharing). The mechanism, in short: before a "non-simple" request (one with Content-Type: application/json, or with a PUT/DELETE verb, or with custom headers), the browser sends a preflight and the server must authorise it:
OPTIONS /api/v1/stations HTTP/1.1
Origin: https://panel.ribalta.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://panel.ribalta.example
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600Two clarifications that save hours of debugging:
- CORS is a browser matter. curl, Postman and native mobile apps ignore it completely. If your curl works and the frontend does not, it is CORS.
- CORS is not server-side security. It protects the API from nobody: it merely stops the browser from handing the response to a script from another origin. The real protection is authentication (module 5).
In Spring, the quick way is @CrossOrigin(origins = "https://panel.ribalta.example") on the class or the method. And the correct way for a project, centralised in com.ciclourbana.common:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://panel.ribalta.example", "http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.exposedHeaders("Location", "ETag") // visible to the client's JS
.maxAge(3600);
}
}exposedHeaders deserves attention: by default, the browser's JavaScript can read only a handful of response headers. If the frontend needs the Location of a POST or the ETag for a conditional request, they must be exposed here explicitly.
Never use allowedOrigins("*") together with credentials: the specification forbids it and Spring will throw a configuration error. Hardening CORS together with Spring Security is picked up again in lesson 05-05.
Common Mistakes and Tips
Using @Controller instead of @RestController. The method returns "Main Square" and Spring looks for a template with that name: a view resolution error or a baffling 404.
@RequestParam(required = false) with a primitive type. int does not accept null. Use Integer, Optional<Integer> or, better, defaultValue.
Forgetting the name in @PathVariable. If the project is compiled without -parameters, it fails at runtime with a message about the argument name. Always write it.
Ambiguous paths. @GetMapping("/{id}") with id of type Long and a call to /stations/abc produces a confusing 400. The {id:\\d+} restriction avoids it.
Defining your own ObjectMapper bean. It overrides JacksonAutoConfiguration and with it every spring.jackson.* property and the date module. Use Jackson2ObjectMapperBuilderCustomizer. Related symptom: if you see dates like [2026,8,31,14,5] or 1756645500.000000000, either write-dates-as-timestamps: false or the JSR-310 module is missing.
Putting business logic in the controller. The controller translates HTTP into Java calls and nothing more. If an if about Ribalta's business rules shows up, that code belongs in StationService. The test: when a message consumer arrives, could it reuse the logic without going through HTTP?
Tip: one controller per aggregate. StationController, BikeController, RentalController. Not an ApiController with twenty methods.
Tip: keep the requests in a versioned .http file. Every odd case you discover —a wrong type, an empty filter— add it. That file is living documentation and the draft of module 6's integration tests.
Exercises
Exercise 1: Proximity search endpoint
Add to StationController the endpoint GET /api/v1/stations/nearby?lat=41.38&lon=2.17&radiusMeters=800, which returns the stations within the given radius, sorted by distance. radiusMeters is optional with a default value of 500; lat and lon are mandatory. Implement the calculation in StationService and make sure the path does not clash with /{id}.
Exercise 2: Group the filters into an object and return pagination metadata
The signature of list already has four parameters and will grow. Refactor it to (a) group the filters into a record StationFilter and (b) return, besides the list, the total number of elements and the number of pages, using HTTP headers instead of wrapping the body.
Exercise 3: Control the JSON representation
Ribalta's mobile app team asks for three changes in the station response:
- That
capacitybe calledtotalCapacityin the JSON, without renaming the Java field. - That a
coordinatesfield be added with the format"41.3851,2.1734"and thatlatitudeandlongitudestop appearing separately. - That the properties always come out in the order
id,name,totalCapacity,coordinates,address.
Solve it using Jackson annotations only and explain why this solution does not scale.
Solutions
Solution 1.
In StationService:
private static final double EARTH_RADIUS_METERS = 6_371_000;
public List<Station> findNearby(double latitude, double longitude, int radiusMeters) {
return stationRepository.findAll().stream()
.map(s -> Map.entry(s, distanceInMeters(latitude, longitude, s.latitude(), s.longitude())))
.filter(pair -> pair.getValue() <= radiusMeters)
.sorted(Map.Entry.comparingByValue()) // nearest first
.map(Map.Entry::getKey)
.toList();
}
/** Haversine formula: distance over the earth's surface. */
private double distanceInMeters(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}In the controller:
@GetMapping("/nearby")
public List<Station> nearby(@RequestParam double lat,
@RequestParam double lon,
@RequestParam(defaultValue = "500") int radiusMeters) {
return stationService.findNearby(lat, lon, radiusMeters);
}About the path clash. /nearby is a literal pattern and /{id} one with a variable; Spring gives priority to the literal one, so there is no ambiguity. Even so, the {id:\\d+} restriction makes the intent explicit and protects against future carelessness.
Design note. If lat or lon are missing, Spring responds 400 automatically. But a latitude of 200 degrees would go through without a problem, and radiusMeters=-50 too. That is exactly the gap lesson 03-04 covers with @DecimalMin/@DecimalMax and @Positive.
Solution 2.
The filter record, in com.ciclourbana.stations:
public record StationFilter(String name, Integer minimumCapacity,
Integer page, Integer size) {
// Compact constructor: normalises null values and caps the size
public StationFilter {
page = (page == null || page < 0) ? 0 : page;
size = (size == null || size < 1) ? 20 : Math.min(size, 100);
}
}A record's compact constructor is the ideal place to normalise: it always runs, wherever the object comes from, and the result is immutable.
The controller:
@GetMapping
public ResponseEntity<List<Station>> list(StationFilter filter) {
List<Station> page = stationService.search(filter.name(),
filter.minimumCapacity(), filter.page(), filter.size());
long total = stationService.countWithFilter(filter.name(), filter.minimumCapacity());
int totalPages = (int) Math.ceil((double) total / filter.size());
return ResponseEntity.ok()
.header("X-Total-Elements", String.valueOf(total))
.header("X-Total-Pages", String.valueOf(totalPages))
.header("X-Current-Page", String.valueOf(filter.page()))
.body(page);
}Spring binds the query parameters to the record with no annotation at all, by name matching: the request is still ?name=north&page=0&size=10.
Why headers and not a wrapping body. Both are legitimate. Headers keep the body a pure array of stations, which is what the "collection" resource represents; it is GitHub's choice. The alternative —{"content": [...], "totalElements": 42}— is what Spring Data produces with Page<T> and the one we will adopt in module 4, because it comes for free. A warning: if you choose headers, declare them in exposedHeaders of the CORS configuration, or the browser's JavaScript will not be able to read them.
Solution 3.
@JsonPropertyOrder({"id", "name", "totalCapacity", "coordinates", "address"})
public record Station(
Long id,
String name,
String address,
@JsonProperty("totalCapacity")
int capacity,
@JsonIgnore double latitude,
@JsonIgnore double longitude
) {
/**
* Computed property: Jackson serialises any no-argument method
* annotated with @JsonProperty, even if it is not a record component.
*/
@JsonProperty("coordinates")
public String coordinates() {
return latitude + "," + longitude;
}
}Result:
{ "id": 1, "name": "Main Square", "totalCapacity": 24,
"coordinates": "41.3851,2.1734", "address": "Main Square 1" }Why this solution does not scale, which is the point of the exercise:
- It pollutes the domain with the contract.
Stationis the internal model and now it carries the mobile app's formatting preferences. If tomorrow the council's open data portal asks forlatitudeandlongitudeseparately, there is no way to satisfy both with a single class. @JsonIgnoreis a deny list, and deny lists fail by omission. The day anaccessCodefield is added to therecordand nobody remembers to annotate it, it is published by accident. An allow list —a DTO enumerating what does go out— fails the other way round: at worst you forget to expose something, and that is spotted instantly.- It breaks module 4. When
Stationbecomes a JPA entity with lazy relationships, serialising it directly will causeLazyInitializationExceptionor unexpected cascading queries.
All of this is solved with a StationResponse distinct from Station: the content of lesson 03-05.
Conclusion
You now know how to write real REST controllers. You have seen that @RestController is nothing more than @Controller + @ResponseBody, and why confusing them produces incomprehensible view errors. You master path mapping with a class-level @RequestMapping and the per-verb shortcuts, including the regular-expression restrictions that avoid ambiguous routes. You know how to extract every part of a request —@PathVariable, @RequestParam with its mandatory flag, default values, lists and filter objects, @RequestBody, @RequestHeader— and you know the traps of each one, starting with required = false on a primitive. You control how Jackson turns a record into JSON, field by field with @JsonProperty, @JsonIgnore, @JsonFormat and @JsonInclude, and globally from spring.jackson.*, with the golden rule of always customising with a Jackson2ObjectMapperBuilderCustomizer instead of replacing the ObjectMapper. You have criteria for choosing between returning the object and wrapping it in a ResponseEntity. And you understand what CORS is, why it is not a security mechanism and how to configure it centrally.
Above all, CicloUrbana now has a StationController that looks like a real one: listing with a filter by name and by capacity, pagination, lookup by identifier with its 404, logging at appropriate levels, and a versioned .http file with the test cases, including those that still respond badly. Exercise 3 has pinned down, by name, the technical debt we are dragging along: we are exposing the domain class directly.
Lesson 03-03, Handling HTTP Methods, completes the CRUD. We will implement POST with its 201 Created and the Location header built with ServletUriComponentsBuilder, PUT as a full replacement, PATCH with the problem —subtler than it looks— of distinguishing an absent field from a field set to null, and DELETE with its idempotence. We will add the bike resource and the /stations/{id}/bikes sub-resource, model the actions that do not fit pure CRUD (POST /api/v1/rentals/{id}/finish) without breaking the REST design, and solve the lost-update problem with ETag and If-Match. Ribalta's API stops being read-only.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
