Skip to content

2. Controllers, Actions, Routing

This chapter examines the path a request takes, from the browser to the action that processes it, and the response that action returns. Views will not be covered until Chapter 4: here, actions return text or JSON directly.

2.1. Example 01: The Structure of a Spring Application MVC

The first example is the smallest possible [Spring MVC] application. In addition to the [pom.xml] and [application.properties] files presented in the introduction, it has two files:

01-structure/
├── src/
│   └── main/
│       ├── java/
│       │   └── example01/
│       │       ├── controllers/
│       │       │   └── HomeController.java
│       │       └── Example01Application.java
│       └── resources/
│           └── application.properties
└── pom.xml

2.1.1. The entry point: Example01Application.java

package example01;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// @SpringBootApplication: this class is the starting point of the application.
// Spring Boot looks for the components (controllers, services...) in its
// package [example01] and in all its sub-packages.
@SpringBootApplication
public class Example01Application {

    // the Java main method: the entry point of the program
    public static void main(String[] args) {
        // we start Spring: configuration, creation of the objects, Tomcat web server
        // (http://localhost:5000, see application.properties); Ctrl-C stops it
        SpringApplication.run(Example01Application.class, args);
    }
}

Let’s walk through this code:

  • Line 1: the class package. It must match the file’s directory ([src/main/java/example01]);
  • Lines 3–4: Classes from other packages used by this file. Unlike [C#], [Java] has no implicit imports (except for the [java.lang] package, which contains [String], [Integer]...): each file explicitly imports the classes it uses;
  • Line 9: [@SpringBootApplication] is an annotation. A [Java] annotation is written as [@Name], immediately before what it describes (a class, a method, a parameter...): it adds information that the framework reads at startup. This is equivalent to the [[...]] attributes of [C#]. [@SpringBootApplication] marks the application’s startup class and combines three annotations: [@Configuration] (the class can declare objects managed by [Spring]; see Example 06), [@EnableAutoConfiguration] (autoconfiguration: [Spring Boot] automatically configures everything supported by the available libraries, in this case [Spring MVC], [Tomcat], and [Jackson]) and [@ComponentScan] (component scanning: [Spring] searches for classes annotated with [@Controller], [@Service]... in the package of that class, [example01], and in all its subpackages, such as [example01.controllers]);
  • Line 13: The [main] method, the entry point for any [Java] program. It is [public] (visible from anywhere) and [static] (it belongs to the class, not to an object of the class). The parameter [args] contains the command-line arguments;
  • line 16: [SpringApplication.run] starts [Spring]: reads the configuration (the [application.properties] file, environment variables, command-line arguments...), creates the application objects, then starts the web server [Tomcat], which waits for requests until the application is stopped ([Ctrl-C]). [Example01Application.class] refers to the class itself: [Spring] reads the annotation [@SpringBootApplication] from it and deduces the package to scan.

There’s nothing else to write: no list of controllers, no route declarations. The component scan finds the controllers, and auto-configuration sets up everything else.

2.1.2. The controller: controllers/HomeController.java

package example01.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

// @Controller: this class is a Spring MVC controller
@Controller
public class HomeController {

    // an action: the method called for the GET / request
    @GetMapping("/")
    // @ResponseBody: the value returned by the action IS the response sent to the browser
    @ResponseBody
    public String index() {
        return "Hello from Spring MVC!";
    }
}
  • line 1: the class is in the [example01.controllers] package, a subpackage of [example01]: the component scan finds it;
  • line 8: the annotation [@Controller] designates the class as a controller for [Spring MVC]. The class does not need to inherit from a framework class (this differs from [ASP.NET Core MVC]); by convention, its name ends with [Controller]. [Spring] creates the controller object itself at startup;
  • line 12: the annotation [@GetMapping("/")] associates the method [index] with requests of type [GET /]. A method associated in this way with a route is an action;
  • line 14: the annotation [@ResponseBody] specifies that the value returned by the action is the body of the response sent to the browser. Without it, [Spring MVC] would treat the returned string as the name of a view to be displayed (we’ll see this in Example 04, then in Chapter 4);
  • Lines 15–16: The action returns a string. By convention, [Java] method names begin with a lowercase letter.

2.1.3. Execution

In a terminal window open to the [01-structure] folder, type [../mvnw spring-boot:run] ([..\mvnw spring-boot:run] under [Windows]). After compilation, [Spring Boot] displays its banner, followed by its startup messages (we replace the date, time, process number, and thread name with [...], and abbreviate the paths):

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v4.1.1)

... INFO ... example01.Example01Application : Starting Example01Application using Java 21.0.10 with PID 4545 (.../examples/01-structure/target/classes ...)
... INFO ... example01.Example01Application : No active profile set, falling back to 1 default profile: "default"
... INFO ... o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 5000 (http)
... INFO ... o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
... INFO ... o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 5000 (http) with context path '/'
... INFO ... example01.Example01Application : Started Example01Application in 1.509 seconds (process running for 1.864)

The startup messages indicate the version of [Java], the listening port (5000, set by [application.properties]), and the version of the [Tomcat] server embedded in the application. They also indicate the active profile: [default]. A profile is a set of settings (development, production, etc.); the case study will use one. Let’s request URL and [http://localhost:5000/] using a browser:

Image

then with [curl]. The [-i] option also displays the HTTP headers from the response (we’ll only include a few of them here):

curl -i http://localhost:5000/
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 22

Hello from Spring MVC!

The first line of the response is the status code, 200, without the usual text [OK]: [Tomcat] does not send it, because the HTTP protocol does not require it. The browser and [curl] accept only the code.

[curl] received the string with the header [Content-Type: text/plain]: plain text. However, the browser displayed it as HTML (as can be seen from the font, which is the same as that used on HTML pages). This is because, for a string returned by an action with no other specifications, [Spring MVC] selects the response type based on the [Accept] header in the request, through which the client specifies the types it accepts: this is content negotiation. [curl] sends [Accept: */*] (all types) and receives [text/plain]; a browser sends [Accept: text/html,...] and receives [text/html]. Let’s verify this by sending a browser header (option [-H]) with [curl]:

curl -i -H "Accept: text/html" http://localhost:5000/
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 22

Hello from Spring MVC!

This differs from [ASP.NET Core MVC], which always sent a string as plain text. Example 02 will show how to specify the response type.

Upon the first request, the console displays three more lines:

1
2
3
... INFO ... o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
... INFO ... o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
... INFO ... o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms

[DispatcherServlet] is the entry point for [Spring MVC] (see the diagram in the introduction): [Tomcat] forwards all requests to it, and it is this function that selects the action assigned to each one. [Tomcat] only prepares it when the first request is received.

2.2. Example 02: Routing

Routing associates a URL and a method HTTP (GET, POST...) with an action. Example 02 has two controllers:

1
2
3
4
5
02-routing/src/main/java/example02/
├── controllers/
│   ├── HomeController.java
│   └── FirstController.java
└── Example02Application.java

The class [Example02Application] is identical, except for the name, to the one in Example 01: this is true for all examples. The file [controllers/HomeController.java]:

package example02.controllers;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

// @RestController = @Controller + @ResponseBody on each action
@RestController
public class HomeController {

    // GET /: an HTML page containing links to the other actions.
    // produces: the type of the response (Content-Type header), here HTML
    @GetMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE)
    public String index() {
        return """
            <h2>Example 02 - routing</h2>
            <ul>
              <li><a href="/first">/first</a></li>
              <li><a href="/first/action01">/first/action01</a></li>
              <li><a href="/first/products/17">/first/products/17</a></li>
              <li><a href="/first/products/17/reviews/3">/first/products/17/reviews/3</a></li>
              <li><a href="/first/files/images/logo.png">/first/files/images/logo.png</a></li>
              <li><a href="/first/products/abc">/first/products/abc</a></li>
              <li><a href="/unknown">/unknown</a></li>
            </ul>
            """;
    }
}
  • Line 8: [@RestController] combines [@Controller] and [@ResponseBody]: all actions in the class directly return the body of the response. This is the most common form for a controller that does not generate views; we will use it until Chapter 4;
  • line 13: An annotation can have multiple attributes, written as [name = value]. [value] is the route path (when it stands alone, [value =] can be omitted, as in Example 01) ; [produces] specifies the response type (the header [Content-Type]), here [text/html], regardless of the request header [Accept]. [MediaType.TEXT_HTML_VALUE] is a constant that equals ["text/html"]: a typo in its name would cause a compilation error, which is not the case for a hand-written string;
  • Lines 15–26: a block of text ([text block], [Java] 15), delimited by three quotation marks. It can span multiple lines and contain quotation marks without escaping them. The indentation common to all its lines (including that of the closing ["""]) is removed.

The [index] action thus returns a HTML page that contains links to the actions of the other controller:

Image

The [controllers/FirstController.java] file:

package example02.controllers;

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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// prefix shared by all the routes of the controller
@RestController
@RequestMapping("/first")
public class FirstController {

    // GET /first
    @GetMapping("")
    public String index() {
        return "Action [index] of the [First] controller";
    }

    // GET /first/action01
    @GetMapping("/action01")
    public String action01() {
        return "Action [action01] of the [First] controller, GET request";
    }

    // POST /first/action01: same path, another HTTP method
    @PostMapping("/action01")
    public String action01Post() {
        return "Action [action01Post] of the [First] controller, POST request";
    }

    // GET /first/products/17: {id} is a path variable; \d+: digits only
    @GetMapping("/products/{id:\\d+}")
    public String product(@PathVariable int id) {
        return "Product no. " + id;
    }

    // GET /first/products/17/reviews/3: two path variables
    @GetMapping("/products/{id:\\d+}/reviews/{number:\\d+}")
    public String review(@PathVariable int id, @PathVariable int number) {
        return "Review no. %d of product no. %d".formatted(number, id);
    }

    // GET /first/files/...: {*path} accepts any end of path
    @GetMapping("/files/{*path}")
    public String file(@PathVariable String path) {
        return "Requested file: " + path;
    }
}

Let’s comment on this code:

  • lines 10–11: The annotation ***[@RequestMapping("/first")]***, applied to the class, sets the prefix common to all routes in the controller: its actions handle URL routes that begin with ***[/first]***;
  • Lines 15–16: [@GetMapping("")]: The action [index] processes [GET /first]. The action ID URL is the concatenation of the controller prefix and the action path;
  • lines 21–22: The action [action01] processes [GET /first/action01];
  • lines 27–28: the same path, but for the method POST: [@PostMapping] replaces [@GetMapping]. There are also [@PutMapping], [@DeleteMapping], [@PatchMapping]... (and [@RequestMapping], applied to a method, accepts all HTTP methods). The name of the method [Java] ([action01Post]) is arbitrary: only the annotations matter for routing. Furthermore, within the same class, two methods cannot have the same name with the same parameters;
  • Lines 33–34: [{id}] is a path variable: this segment of URL can take on multiple values. [\d+] is a regular expression: the segment must consist of one or more digits; otherwise, the route does not match. In a string [Java], the character [\] is written as [\\]: hence [{id:\\d+}]. The annotation [@PathVariable] instructs [Spring MVC] to pass the segment’s value to the action’s parameter of the same name, [id], after converting it to [int]: For [/first/products/17], [id] is equal to the number 17. This is the data binding ([data binding]), which the next chapter will examine in detail. In order for [Spring] to know the names of the parameters, the compiler must preserve them in the compiled classes (option [-parameters]): [spring-boot-starter-parent] handles this;
  • line 35: the operator [+] concatenates a string and a number;
  • lines 39–41: two path variables are passed as two parameters to the action. The method [formatted] replaces each instance of [%d] in the text with an integer, in the order of the arguments;
  • lines 45–47: [{*path}] is a “catch-all” variable ([catch-all]): it accepts any ending of URL, including [/]. Its value begins with a [/].

Here are the results for a few queries:

curl -i http://localhost:5000/first
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 40

Action [index] of the [First] controller
curl -i http://localhost:5000/first/action01
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 56

Action [action01] of the [First] controller, GET request
curl -i http://localhost:5000/first/products/17
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 14

Product no. 17
curl -i http://localhost:5000/first/products/17/reviews/3
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 30

Review no. 3 of product no. 17
curl -i http://localhost:5000/first/files/images/logo.png
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 32

Requested file: /images/logo.png

To send a POST request, use the [-X POST] option of [curl]:

curl -i -X POST http://localhost:5000/first/action01
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 61

Action [action01Post] of the [First] controller, POST request

A HTTP method that is not handled by any action for this path results in a 405 error ([Method Not Allowed]). The [Allow] header lists the accepted methods:

curl -i -X PUT http://localhost:5000/first/action01
1
2
3
4
5
HTTP/1.1 405
Allow: POST, GET
Content-Type: application/json

{"timestamp":"2026-09-25T16:45:30.867Z","status":405,"error":"Method Not Allowed","message":"Method 'PUT' is not supported.","path":"/first/action01"}

Finally, a URL that is not handled by any route results in a 404 error. This is also the case for an identifier that does not comply with the regular expression [\d+]: no route matches [/first/products/abc].

curl -i http://localhost:5000/first/products/abc
1
2
3
4
HTTP/1.1 404
Content-Type: application/json

{"timestamp":"2026-09-25T16:45:30.828Z","status":404,"error":"Not Found","message":"No static resource first/products/abc.","path":"/first/products/abc"}
curl -i http://localhost:5000/unknown
1
2
3
4
HTTP/1.1 404
Content-Type: application/json

{"timestamp":"2026-09-25T16:45:30.841Z","status":404,"error":"Not Found","message":"No static resource unknown.","path":"/unknown"}

Unlike those for [ASP.NET Core], the error responses for [Spring Boot] contain the following information: the date of the error, the status code, its description, a message, and the requested path. The message [No static resource] can be explained as follows: when no action matches URL, [Spring MVC] searches for a static file with that name (an image, a stylesheet... stored in the application’s [src/main/resources/static] folder); since it cannot find one, it reports a 404 error. [Spring Boot] then generates the error response, and content negotiation comes into play again: [curl] receives from JSON, a browser, a page named HTML, the “Whitelabel Error Page” (an “unbranded” error page):

Image

Chapter 7 will show how to replace this page with an application-specific error page.

2.3. Example 03: Action Responses

What an action returns determines the response HTTP. Example 03 reviews the main cases. The file [controllers/ResponsesController.java]:

package example03.controllers;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/responses")
public class ResponsesController {

    // a string, announced as plain text: the browser displays it as is
    @GetMapping(value = "/text", produces = MediaType.TEXT_PLAIN_VALUE)
    public String text() {
        return "<h1>Action [text]</h1>";
    }

    // the same string, announced as HTML: the browser interprets it
    @GetMapping(value = "/html", produces = MediaType.TEXT_HTML_VALUE)
    public String html() {
        return "<h1>Action [html]</h1>";
    }

    // a record: a data class (see example 06), declared here inside the controller
    public record Person(String lastName, String firstName, int age) {
    }

    // an object: serialized to JSON, with the [application/json] type
    @GetMapping("/json")
    public Person json() {
        return new Person("Martin", "Jean", 42);
    }

    // a list: serialized to a JSON array
    @GetMapping("/list")
    public List<String> list() {
        return List.of("Mme", "Melle", "Mr");
    }

    // ResponseEntity: the action chooses the complete response (code, headers, content)
    // status(201): the 201 code (Created) instead of 200 (OK)
    @GetMapping("/created")
    public ResponseEntity<Map<String, String>> created() {
        return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "resource created"));
    }

    // no response: the 204 code (No Content) means "nothing to display"
    @GetMapping("/empty")
    public ResponseEntity<Void> empty() {
        return ResponseEntity.noContent().build();
    }

    // an asynchronous action: Spring MVC waits for the end of the processing (CompletableFuture)
    @GetMapping("/task")
    public CompletableFuture<Map<String, String>> task() {
        Instant start = Instant.now();
        // a 500 ms wait, without blocking the thread that received the request
        return CompletableFuture.supplyAsync(
                () -> Map.of("wait", Duration.between(start, Instant.now()).toMillis() + " ms"),
                CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS));
    }

    // an "expected" error: status(404) produces a 404 response
    @GetMapping("/not-found")
    public ResponseEntity<Map<String, String>> notFound() {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "This resource does not exist"));
    }

    // an "unexpected" error: an unhandled exception produces a 500 response
    @GetMapping("/exception")
    public String exception() {
        throw new IllegalStateException("An unexpected error");
    }
}

Let’s break down this code:

  • lines 22–25: a string, marked as plain text by the [produces] attribute (see Example 02): the browser displays it as-is, tags included;
  • lines 28–31: the same string, declared as HTML: the browser interprets it;
  • lines 34–35: a record ([Java] 16): a data class, declared on a single line. The compiler creates a private, read-only field for each component ([lastName], [firstName], [age]), a constructor that initializes them, an access method for each ([lastName()], [firstName()], [age()]), and utility methods ([equals], [hashCode], [toString]). The record is declared here within the controller: it is a nested class, whose full name is [ResponsesController.Person];
  • lines 38–41: an object is serialized into JSON, with the header [Content-Type: application/json]. It is the [Jackson] library, provided by the [spring-boot-starter-webmvc] starter, that performs the conversion: the names of the JSON properties correspond to the components of the record;
  • lines 44–47: a list is serialized into a JSON array. [List.of(...)] creates a read-only list. [List<String>] is a generic type: a list whose elements are strings;
  • lines 51–54: when the action needs to select the status code, it returns a [ResponseEntity<T>]: an object that describes the complete response (code, headers, body of type [T]). It is created using static methods of the [ResponseEntity] class, which are called sequentially: [status(HttpStatus.CREATED)] sets the code to 201 ([Created]), [body(...)] sets the content, which will be serialized in JSON. [Map.of("message", "resource created")] creates a non-modifiable dictionary (a [Map]) with a single key, which [Jackson] serializes into the JSON object;
  • Lines 57–60: [noContent()] returns a 204 response ([No Content]): “no content.” [build()] returns a response with no body, resulting in the type [ResponseEntity<Void>];
  • Lines 63–70: an asynchronous action. It returns a [CompletableFuture<T>], a “pending” result (the equivalent of a [Task<T>] task from [C#] or a [JavaScript] promise); [Spring MVC] immediately releases the thread of [Tomcat] that was processing the request, and sends the response only when the result is available. [supplyAsync(function, executor)] calculates the result using the function, which is executed here after a delay of 500 ms. The function is written as a lambda: [() -> expression] is a function with no parameters that returns the value of the expression. [Duration.between] measures the time elapsed between two points in time;
  • lines 73–76: an error anticipated by the program: a [ResponseEntity] with code 404, containing JSON. The [ResponseEntity] class also has aliases: [ok(...)] (200), [badRequest()] (400), [notFound()] (404)...;
  • Lines 79–82: An unexpected error: the action throws an exception, which it does not handle. [Spring Boot] then responds with a 500 error ([Internal Server Error]).

Responses received:

curl -i http://localhost:5000/responses/text
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 22

<h1>Action [text]</h1>
curl -i http://localhost:5000/responses/html
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
Content-Length: 22

<h1>Action [html]</h1>
curl -i http://localhost:5000/responses/json
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 49

{"lastName":"Martin","firstName":"Jean","age":42}
curl -i http://localhost:5000/responses/list
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 20

["Mme","Melle","Mr"]
curl -i http://localhost:5000/responses/created
1
2
3
4
HTTP/1.1 201
Content-Type: application/json

{"message":"resource created"}
curl -i http://localhost:5000/responses/empty
HTTP/1.1 204
curl -i http://localhost:5000/responses/task
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 17

{"wait":"506 ms"}
curl -i http://localhost:5000/responses/not-found
1
2
3
4
HTTP/1.1 404
Content-Type: application/json

{"message":"This resource does not exist"}
curl -i http://localhost:5000/responses/exception
1
2
3
4
HTTP/1.1 500
Content-Type: application/json

{"timestamp":"2026-09-25T16:45:56.039Z","status":500,"error":"Internal Server Error","message":"An unexpected error","path":"/responses/exception"}

For the exception, the error response from [Spring Boot] (see Example 02) contains the exception message but not the call stack, which is prohibited by the [application.properties] file (see introduction). The message itself appears there only because the development tools ([Spring Boot DevTools]) are active: in production, without them, the response would be:

1
2
3
4
HTTP/1.1 500
Content-Type: application/json

{"timestamp":"2026-09-25T16:46:17.984Z","status":500,"error":"Internal Server Error","path":"/responses/exception"}

An exception message can indeed provide information to an attacker. The error details are written to the server console (we are reproducing only the beginning here):

1
2
3
4
5
... ERROR ... o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: An unexpected error] with root cause

java.lang.IllegalStateException: An unexpected error
    at example03.controllers.ResponsesController.exception(ResponsesController.java:81) ~[classes/:na]
    ...

In a browser, the difference between [text/plain] and [text/html] is visible:

Image

Image

2.4. Example 04: Redirects

An action may respond with a redirect: a 301 or 302 status code and a [Location] header that tells the browser which URL to request next. The browser requests it immediately, without any user intervention. Redirects are ubiquitous in a web application: after a successful form submission, after logging in, after changing the language...

The [controllers/RedirectsController.java] file:

package example04.controllers;

import java.net.URI;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

// @Controller (and not @RestController): a string returned by an action is
// here the NAME OF A VIEW, and "redirect:..." a special view name
@Controller
public class RedirectsController {

    // permanent redirect (301): the page has permanently changed address
    @GetMapping("/old")
    public ResponseEntity<Void> oldPage() {
        return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY).location(URI.create("/new")).build();
    }

    @GetMapping("/new")
    @ResponseBody
    public String newPage() {
        return "You are on the [new] page";
    }

    // temporary redirect (302), to a computed address
    @GetMapping("/go/{destination}")
    public String go(@PathVariable String destination) {
        return "redirect:/page/" + destination;
    }

    // redirect to a TEMPLATE address: Spring replaces {name} with the value
    // of the [name] attribute, encoding it correctly for a URL
    @GetMapping("/to-template/{destination}")
    public String toTemplate(@PathVariable String destination, RedirectAttributes attributes) {
        attributes.addAttribute("name", destination);
        return "redirect:/page/{name}";
    }

    @GetMapping("/page/{name}")
    @ResponseBody
    public String page(@PathVariable String name) {
        return "You are on the [" + name + "] page";
    }

    // redirect to another site
    @GetMapping("/documentation")
    public String documentation() {
        return "redirect:https://docs.spring.io/spring-framework/reference/web/webmvc.html";
    }
}

Let’s comment on this code:

  • line 15: the class is annotated as [@Controller], not [@RestController]. A string returned by an action is therefore—except for [@ResponseBody]—the name of a view; and a view name that begins with [redirect:] is a special name that triggers a redirect;
  • Lines 19–22: A permanent redirect (status code 301, [Moved Permanently]): The page has permanently moved to a new address. Browsers and search engines can save the new address. It is created using a [ResponseEntity] (see Example 03): [location(...)] sets the header [Location], whose value is an object [URI] (an address);
  • lines 24–28: The annotation [@ResponseBody] is required here: the rendered string is the body of the response, not a view name;
  • lines 31–34: ["redirect:" + url] generates a temporary redirect (status code 302, [Found]), in this case to a calculated address;
  • lines 38–42: a redirect to a template address: in [redirect:/page/{name}], [Spring MVC] replaces [{name}] with the value of the [name] attribute, which is stored in the [RedirectAttributes] object. This object is provided by [Spring MVC] to the action that requests it as a parameter. The advantage over the previous form: the value is correctly encoded for a URL (see below). The [RedirectAttributes] object will also be used, in Chapter 4, to send messages to the next page;
  • lines 51–54: you can redirect to another site.

[curl] demonstrates the redirection without actually following it:

curl -i http://localhost:5000/old
1
2
3
HTTP/1.1 301
Location: /new
Content-Length: 0

With the option [-L], [curl] follows the redirect, just as a browser would. We then see the two successive responses:

curl -i -L http://localhost:5000/old
1
2
3
4
5
6
7
8
9
HTTP/1.1 301
Location: /new
Content-Length: 0

HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 25

You are on the [new] page
curl -i http://localhost:5000/go/contact
1
2
3
HTTP/1.1 302
Location: http://localhost:5000/page/contact
Content-Length: 0
curl -i http://localhost:5000/to-template/contact
1
2
3
HTTP/1.1 302
Location: http://localhost:5000/page/contact
Content-Length: 0

Both actions result in the same redirect. Note that the address in the [Location] header is absolute this time ([http://localhost:5000/...]), whereas the action returned a relative address ([/page/contact]): A redirect to [redirect:] goes through the server [Tomcat], which completes the address because [Spring Boot] sets it that way by default (property [server.tomcat.use-relative-redirects], which is set to [false]). The redirection of the action [oldPage], generated by a [ResponseEntity], on the other hand, keeps the address as is. Both forms are correct.

The difference between the two actions becomes apparent when the destination contains a character that must be encoded in a URL, such as a space (encoded as [%20]):

curl -i http://localhost:5000/go/my%20contact
1
2
3
HTTP/1.1 302
Location: http://localhost:5000/page/my contact
Content-Length: 0
curl -i http://localhost:5000/to-template/my%20contact
1
2
3
HTTP/1.1 302
Location: http://localhost:5000/page/my%20contact
Content-Length: 0

The path variable [destination] was assigned the decoded value, [my contact]. The concatenation of the action [go] copies it exactly as is: the header [Location] contains a raw space, which makes it an invalid address (a URL does not contain a space). Some clients correct this on their own ([curl -L], for example, still follows the redirect and lands on the [my contact] page), while others do not: this is a pitfall, as the error goes unnoticed during testing. The template URL for the [toTemplate] action, on the other hand, is correctly encoded: this is the proper way to insert a variable value into a redirect. In the browser, only the final page is visible; the address bar displays the new URL:

Image

curl -i http://localhost:5000/documentation
1
2
3
HTTP/1.1 302
Location: https://docs.spring.io/spring-framework/reference/web/webmvc.html
Content-Length: 0

2.5. Example 05: The Request and Response Objects

[Spring MVC] is based on API, [Servlet], and [Jakarta EE] (formerly [Java EE]), the [Java] web server standard: [Tomcat] is a servlet container, and [DispatcherServlet] is a servlet—that is, an object to which the server delegates requests. In this standard, each request is represented by a [HttpServletRequest] object, and the response being generated by a [HttpServletResponse] object. An action can receive either one: you simply need to request them as parameters. This is sometimes necessary, and we’ll do it in the case study (to read or set a cookie, for example), but you need to know what you’re doing.

The file [controllers/HttpController.java]:

package example05.controllers;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("/http")
public class HttpController {

    // the request: method, path, parameters, headers...
    // Spring provides the HttpServletRequest object to the action that asks for it
    @GetMapping("/request")
    public Map<String, String> request(HttpServletRequest request) {
        // LinkedHashMap: a dictionary that keeps the insertion order of the keys
        Map<String, String> info = new LinkedHashMap<>();
        info.put("method", request.getMethod());
        info.put("path", request.getRequestURI());
        info.put("parameters", request.getQueryString());
        info.put("browser", request.getHeader("User-Agent"));
        info.put("ip", request.getRemoteAddr());
        return info;
    }

    // the response: we add a header to it, then the action returns its value normally
    @GetMapping("/header")
    public String header(HttpServletResponse response) {
        response.setHeader("X-Example", "05-request-response");
        return "Response with an added header";
    }

    // the response written "by hand": code, type, then the body
    @GetMapping("/response")
    public void response(HttpServletResponse response) throws IOException {
        response.setStatus(200);
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write("Response written by [response.getWriter()]");
    }

    // MISTAKE not to make: write the response, THEN return a result
    @GetMapping("/double")
    public ResponseEntity<Void> doubleResponse(HttpServletResponse response) throws IOException {
        response.getWriter().write("Start of the response... ");
        // flushBuffer: the first bytes leave for the browser
        response.flushBuffer();
        // the response has already left: its code and headers can no longer change
        return ResponseEntity.notFound().build();
    }
}

Let’s comment on this code:

  • lines 12–13: the classes API and [Servlet] are in the [jakarta.servlet...] package;
  • line 22: the action declares a parameter of type [HttpServletRequest]: [Spring MVC] passes it the current request. This is how [Spring MVC] provides an action with what it needs: based on the type of its parameters, or based on their annotations ([@PathVariable]...);
  • line 24: a mutable dictionary. [Map] is an interface (a type that describes operations without implementing them; we’ll return to this in Example 06), [LinkedHashMap] is one of its implementations; [<>] uses the generic types from the declaration ([<String, String>]);
  • lines 25–29: the request: the method HTTP ([getMethod]), the path ([getRequestURI]), the parameters of URL ([getQueryString], excluding [?], or [null] if none exists), a header ([getHeader]) and the client’s address IP ([getRemoteAddr]);
  • lines 34–38: the action adds a header to the response, then normally returns its value, which [Spring MVC] sends. This is the most common use of the response object;
  • lines 41–46: the action writes the response itself—status code, type, and then the body—using the [PrintWriter] object returned by [getWriter()]. It returns nothing ([void]): [Spring MVC] then considers that the response has been written by the action. The [throws IOException] clause declares that the method may throw a [IOException] (a write error): in [Java], a method must declare in this way the so-called controlled exceptions that it does not handle;
  • Lines 49–56: The mistake to avoid: writing part of the response and then returning a result. Once the first bytes of the body have been sent to the browser (here, forced by [flushBuffer]), the status code and headers—which are sent before the body—can no longer be changed.

The responses obtained:

curl -i -A "Mozilla/5.0 (example)" "http://localhost:5000/http/request?name=martin"
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 117

{"method":"GET","path":"/http/request","parameters":"name=martin","browser":"Mozilla/5.0 (example)","ip":"127.0.0.1"}

(the [-A] option of [curl] sets the [User-Agent] header, which identifies the browser)

curl -i http://localhost:5000/http/header
1
2
3
4
5
6
HTTP/1.1 200
X-Example: 05-request-response
Content-Type: text/plain;charset=UTF-8
Content-Length: 29

Response with an added header
curl -i http://localhost:5000/http/response
1
2
3
4
5
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 42

Response written by [response.getWriter()]
curl -i http://localhost:5000/http/double
1
2
3
HTTP/1.1 200

Start of the response...

For the last request, the client receives a 200 status code and the beginning of the response, rather than the requested 404. And, unlike [ASP.NET Core], the server does not report anything: the console remains silent, and the error may go unnoticed. The response reveals a second flaw: it has no [Content-Type] header. The action did not set the response type before writing to it: the response object then encodes the text according to the default character set of the [Servlet] standard, [ISO-8859-1], whereas the terminal expects UTF-8, so any accented characters would be replaced by [�] (this English text contains none). The browser, for its part, receives no indication of a character set; it guesses one—in this case, successfully:

Image

Later in the document, actions will almost always return a value (an object, a [ResponseEntity], a view name), and let [Spring MVC] write the response. [HttpServletRequest] and [HttpServletResponse] will only be used to read the request and set cookies.

2.6. Example 06: Services and Dependency Injection

Controllers should not contain application logic; they delegate it to services. A service is an ordinary object that [Spring] creates and provides to the classes that need it: this is dependency injection. The objects that [Spring] creates and manages in this way are called beans (from the English word for “grains”; controllers are also beans). The set of beans in an application forms the [Spring] context, or bean container. Example 06 breaks down a small application into two parts: products and customers:

06-services/src/main/java/example06/
├── clients/
│   └── ClientsController.java
├── products/
│   ├── Product.java
│   ├── ProductsConfig.java
│   ├── ProductsController.java
│   ├── ProductsService.java
│   └── InMemoryProductsService.java
└── Example06Application.java

2.6.1. The Product

1
2
3
4
5
6
7
package example06.products;

import java.math.BigDecimal;

// a "record": a data class whose fields are set by the constructor
public record Product(int id, String name, BigDecimal price) {
}

A record (see Example 03), this time in its own file. The type [BigDecimal] represents amounts without rounding errors, which the type [double] does not guarantee.

2.6.2. The service contract: ProductsService

package example06.products;

import java.util.List;
import java.util.Optional;

// the service CONTRACT: what it can do, without saying how
public interface ProductsService {

    List<Product> findAll();

    Optional<Product> findById(int id);
}
  • Line 7: An interface describes what the service can do, without specifying how. Classes that use the service will only be aware of this interface. Unlike the convention for [C#] ([IProductsService]), the name of an interface [Java] does not begin with [I];
  • line 9: [List<Product>]: a list of products;
  • line 11: [Optional<Product>]: a product, or nothing if no product has this identifier. A [Optional] is a box, either empty or containing a value: it forces the caller to consider the case where there is no product, whereas a [null] might be overlooked (and cause a [NullPointerException]).

2.6.3. The implementation: InMemoryProductsService

package example06.products;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

// an IMPLEMENTATION of the contract: here, in-memory data
public class InMemoryProductsService implements ProductsService {

    private final List<Product> products = List.of(
            new Product(1, "pen", new BigDecimal("1.5")),
            new Product(2, "notebook", new BigDecimal("3.2")),
            new Product(3, "eraser", new BigDecimal("0.8")));

    @Override
    public List<Product> findAll() {
        return products;
    }

    @Override
    public Optional<Product> findById(int id) {
        return products.stream().filter(p -> p.id() == id).findFirst();
    }
}
  • line 8: the class implements the interface ([implements]): it must provide all of its methods;
  • lines 10–13: the data, here in memory, in a read-only list. The amounts are constructed from strings: [new BigDecimal("1.5")] is exactly 1.5;
  • Lines 15 and 20: The annotation [@Override] indicates that the method belongs to an interface (or a parent class): the compiler verifies that it actually exists;
  • line 22: an iterator ([stream]) traverses the list: [filter] retains only the products that satisfy the condition, written as a single-parameter lambda (see Example 03), [p]; [findFirst] returns the first remaining product in a [Optional], which is empty if there are none. [p.id()] is the method for accessing the record’s [id] component.

The class has no annotations: it is an ordinary [Java] class, which knows nothing about [Spring]. We’ll see later how it becomes a bean.

2.6.4. The ProductsController controller

package example06.products;

import java.util.List;
import java.util.Map;

import org.springframework.http.HttpStatus;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/products")
public class ProductsController {

    private final ProductsService productsService;

    // dependency injection: Spring provides the implementation of ProductsService
    public ProductsController(ProductsService productsService) {
        this.productsService = productsService;
    }

    @GetMapping("")
    public List<Product> list() {
        return productsService.findAll();
    }

    @GetMapping("/{id:\\d+}")
    public ResponseEntity<?> oneProduct(@PathVariable int id) {
        return productsService.findById(id)
                .<ResponseEntity<?>>map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
                        .body(Map.of("message", "Product no. " + id + " not found")));
    }
}
  • Lines 17–22: Dependency injection. The controller declares in its constructor that it needs a [ProductsService]; [Spring], which creates the controller, provides it with a bean of that type (see below). The controller never calls [new InMemoryProductsService()]: it doesn’t even know this class, only the interface. We could replace the implementation (with a version that reads a database, or with a test version) without modifying the controller. The field is declared as ***[final]***: it can only be assigned once, in the constructor;
  • lines 24–27: the action delegates to the service. The list is serialized as JSON;
  • lines 29–35: [{id:\\d+}] accepts only numbers; a nonexistent product returns a 404 response. The [Optional] returned by the service is processed in two steps: [map] transforms its content, if any, into a 200 response; [orElseGet] provides the response to use if [Optional] is empty—in this case, a 404 response accompanied by a message. [ResponseEntity::ok] is a method reference: a shorter way to write the lambda [p -> ResponseEntity.ok(p)]. The type [ResponseEntity<?>] ([?]: any body type) allows the action to return a product or a dictionary; the notation [.<ResponseEntity<?>>map(...)] specifies this type to the compiler, which cannot infer it on its own.

2.6.5. The controller ClientsController

package example06.clients;

import java.math.BigDecimal;
import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import example06.products.Product;
import example06.products.ProductsService;

// a single constructor: Spring uses it for the injection, without any other indication.
// The [final] field is therefore necessarily initialized.
@RestController
@RequestMapping("/clients")
public class ClientsController {

    private final ProductsService productsService;

    public ClientsController(ProductsService productsService) {
        this.productsService = productsService;
    }

    // the content of the cart, returned as JSON
    public record Cart(String client, int items, BigDecimal total) {
    }

    @GetMapping("/cart")
    public Cart cart() {
        List<Product> products = productsService.findAll();
        // the sum of the prices: map (transform) then reduce (accumulate)
        BigDecimal total = products.stream().map(Product::price).reduce(BigDecimal.ZERO, BigDecimal::add);
        return new Cart("Jean Martin", products.size(), total);
    }
}
  • lines 19–23: [ClientsController] receives the same service as [ProductsController]. The class has only one constructor: [Spring] uses it for injection without any further specification. This is the recommended form of injection, and the one used throughout the document;
  • lines 26–27: a nested record describing the response JSON;
  • line 33: the calculation of the total. [map] converts the product stream into a price stream ([Product::price] is a reference to the record’s access method [price()]), then [reduce] accumulates them, starting from zero ([BigDecimal.ZERO]) and adding them two by two ([BigDecimal::add]).

2.6.6. Declaring Beans

[Spring] can only provide a service if it is aware of it: each service must be declared as a bean. Controllers are declared via their [@RestController] annotation, which is detected during component scanning (see Example 01). The [InMemoryProductsService] class, on the other hand, has no annotation: it is declared in the [products/ProductsConfig.java] file:

package example06.products;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// a CONFIGURATION class: its @Bean methods build objects that Spring
// manages ("beans") and provides to those who need them. It groups
// the declarations of the "products" part of the application.
@Configuration
public class ProductsConfig {

    // "when a ProductsService is requested, provide THIS object"; by default, a
    // bean is a singleton: a single instance, shared by all the requests
    @Bean
    public ProductsService productsService() {
        return new InMemoryProductsService();
    }
}
  • line 9: the annotation [@Configuration] designates the class as a configuration class; it, too, is found during the component scan, and [Spring] executes its annotated methods [@Bean] at startup. It groups together the declarations for the “products” section of the application;
  • lines 14–17: a method named [@Bean] creates a bean. It means: “when a class requests a [ProductsService], provide it with the object returned by this method.” The method name ([productsService]) becomes the bean’s name.

There is another, shorter way to declare a bean: annotate the class itself with [@Service] (or [@Component], including [@Service], [@Controller] and [@Repository] are variants), which the component scan finds just as it finds controllers. Placing [@Service] before the class [InMemoryProductsService] would render the file [ProductsConfig.java] unnecessary. The [@Bean] method is useful when the class cannot carry an annotation (a class from a library, for example), or when creating the object requires code. Both forms will be used later in this document.

By default, a bean is a singleton: [Spring] creates a single instance at startup and provides it to all classes that request it throughout the application's lifetime. [ProductsController] and [ClientsController] thus receive the same [InMemoryProductsService] object. There are other scopes: [prototype] (a new instance for each request), [request] (one instance per HTTP request), [session]... Chapter 6 will revisit this topic.

Controllers are also singletons: a single [ProductsController] object handles all requests, potentially simultaneously, across multiple threads. This is a key difference from [ASP.NET Core MVC], which creates a new controller object for each request. A [Spring] controller must therefore not store request-specific data in its fields: its fields contain only the injected services, declared as [final].

The [Example06Application] class is the same as in the previous examples: there is nothing to register there; the component scan finds the controllers and the configuration class in the [clients] and [products] subpackages.

2.6.7. Execution

curl -i http://localhost:5000/products
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 111

[{"id":1,"name":"pen","price":1.5},{"id":2,"name":"notebook","price":3.2},{"id":3,"name":"eraser","price":0.8}]
curl -i http://localhost:5000/products/2
1
2
3
4
HTTP/1.1 200
Content-Type: application/json

{"id":2,"name":"notebook","price":3.2}
curl -i http://localhost:5000/products/9
1
2
3
4
HTTP/1.1 404
Content-Type: application/json

{"message":"Product no. 9 not found"}
curl -i http://localhost:5000/clients/cart
1
2
3
4
5
HTTP/1.1 200
Content-Type: application/json
Content-Length: 46

{"client":"Jean Martin","items":3,"total":5.5}

The case study is structured exactly like this: lightweight controllers that delegate to injected services, described by interfaces and declared as beans.