In this blog post, I will continue building on the Spring Boot guestbook example by taking it a step further and integrating Thymeleaf templates. The goal is to have Thymeleaf views coexist alongside the REST endpoints, allowing users to interact with the guestbook either through a traditional Spring MVC interface or via RESTful API calls.
By the end, we will be able to add and view guestbook posts using either method, providing flexibility in how the application is used.
Behind the scenes there’s an interesting twist: I broke my collarbone last Thursday, so I’m relying heavily on ChatGPT and WhisperAI’s voice functionality to help with most of the writing today. It’ll be interesting to see how well this approach works for technical blogging. Let’s see how it goes!
If everything goes to plan, at the start of this phase two of development, I don’t think I’ll need to change the REST functionality in any way.
However, while working on the first phase, I realised that I hadn’t created the message in the most optimal way to display it, so some adjustments will be necessary there. Today, though, I’m going to start by focusing on the MVC controller.
What Will You Learn In This Post?
The main things covered are:
- Adding an MVC Controller: We’ll go through the process of setting up a new controller in Spring Boot to handle web requests and render views using Thymeleaf.
- Understanding MVC vs. REST in Spring Boot: A clear explanation of how these two approaches differ and why both are valuable depending on your project’s needs.
- Practical Implementation: By the end of this post, you’ll see firsthand how to implement and configure an MVC controller alongside a REST controller, including handling different types of requests and responses.
Adding an MVC Controller
In this section, we’re going to explore the addition of an MVC controller to our project, but before diving into the implementation, it’s important to clarify why we differentiate between REST and MVC in Spring Boot, especially since REST itself still follows the MVC (Model-View-Controller) paradigm.
At its core, REST is still an MVC pattern because it maintains the same fundamental structure:
- Model: Represents the data or business logic that the application is working with.
- View: Displays the output to the client.
- Controller: Handles user input, updates the model, and determines what view to render.
The primary difference between REST and traditional Spring MVC comes down to the view layer.
In RESTful applications, the view is typically JSON or much less common in my working experience, XML.
Other systems or frontend frameworks (e.g., React or Angular) consume this data.
This means the controller doesn’t return HTML or any user-facing content but instead this structured data.
In contrast, traditional MVC returns an HTML page rendered by a server-side template engine like Thymeleaf. The view in this case is HTML, CSS, and JavaScript, which is presented directly to the user.
So, while both REST and MVC involve controllers and models, the distinction lies in what’s delivered as the “view”:
- MVC: The view is a rendered HTML page.
- REST: The view is JSON (or XML), and the response is data-driven.
This is why we implement both an MVC controller (which returns a Thymeleaf-rendered HTML view) and a REST controller (which returns data in JSON format), even though both follow the same underlying principles of the MVC pattern.
By having both, we provide flexibility. Users can interact with the guestbook through a REST API or a more traditional web interface, depending on their needs.
All that said, here is the code:
// src/main/java/uk/co/a6software/guest_book/infrastructure/controller/MessageMvcController.java
package uk.co.a6software.guest_book.infrastructure.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import uk.co.a6software.guest_book.core.model.Message;
import uk.co.a6software.guest_book.core.model.MessageImpl;
import uk.co.a6software.guest_book.core.service.MessageService;
import uk.co.a6software.guest_book.infrastructure.dto.MessageRequest;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
@Controller
public class MessageMvcController {
private final MessageService messageService;
@Autowired
public MessageMvcController(@Qualifier("springMessageService") MessageService messageService) {
this.messageService = messageService;
}
@PostMapping("/add")
public String postMessage(@RequestParam("user") String user, @RequestParam("message") String message) {
MessageImpl msg = new MessageImpl(user, message);
messageService.postMessage(msg);
return "redirect:/show";
}
@GetMapping("/show")
public String showAllMessages(Model model) {
model.addAttribute("messages", this.messageService.getMessages());
return "messages";
}
}
Code language: Java (java)
Let’s start by looking at the changes inside the MessageMVCController
code and how they differ from the MessageRESTController
. The first notable thing is the constructor in the MessageMVCController
.
In practice, this constructor is nearly identical to the one in the MessageRESTController
. The only real difference lies in the class name, as everything else remains the same. This includes key elements like the use of the @Qualifier
annotation to specify the correct service to inject, as well as the @Autowired
annotation to handle dependency injection. These concepts, which we discussed in the previous post, apply equally here.
Here is that code again for reference:
package uk.co.a6software.guest_book.infrastructure.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import uk.co.a6software.guest_book.core.model.Message;
import uk.co.a6software.guest_book.core.model.MessageImpl;
import uk.co.a6software.guest_book.core.service.MessageService;
import uk.co.a6software.guest_book.infrastructure.dto.MessageRequest;
import java.util.List;
@Controller
public class MessageController {
private final MessageService messageService;
@Autowired
public MessageController(@Qualifier("springMessageService") MessageService messageService) {
this.messageService = messageService;
}
@PostMapping("/post")
public ResponseEntity<String> postMessage(@RequestBody MessageRequest messageRequest) {
MessageImpl message = new MessageImpl(messageRequest.getMessage());
messageService.postMessage(messageRequest.getUser(), message);
return ResponseEntity.ok(message.getMessage());
}
@GetMapping("/all")
public ResponseEntity<List<Message>> getAllMessages() {
return ResponseEntity.ok(this.messageService.getMessages());
}
}
Code language: Java (java)
In the MessageMvcController
, I’ve used the route /add
for the @PostMapping
, whereas in the MessageRestController
, it’s mapped to /post
. The reason for this is simple: I wanted to avoid conflicts between the two controllers. Since both controllers are handling different types of requests (HTML form submission vs. JSON data), having distinct routes is essential or they would conflict.
Now, there are other ways I could have handled this. For example, I could have added a prefix to each controller at the top level, keeping the route names identical, but with different controller names.
Something like this:
@Controller
@RequestMapping("/web")
public class MessageMvcController {
@PostMapping("/post")
public String postMessage(@RequestParam("user") String user, @RequestParam("message") String message) {
// ...
return "redirect:/web/show";
}
}
@RestController
@RequestMapping("/api")
public class MessageRestController {
@PostMapping("/post")
public ResponseEntity<String> postMessage(@RequestBody MessageRequest messageRequest) {
// ...
return ResponseEntity.ok(message.getMessage());
}
}
Code language: Java (java)
In this example, both the MVC and REST controllers use /post
as the route, but the controller-level prefix (either /web
or /api
) distinguishes them. This approach is particularly useful in larger projects where you might have a lot of endpoints and need to clearly separate API routes from web routes. It ensures there’s no conflict, even if the underlying route names are identical.
However, in this project, since it’s relatively small, I opted to keep things simple. Adding extra prefixes would be unnecessary at this stage. Instead, using distinct route names—like /add
for the MVC controller and /post
for the REST controller—keeps things clean and avoids any potential conflicts. There’s no need to over-complicate things when a simpler solution will suffice.
From Things To Strings
One of the less appealing aspects of transitioning from REST to an MVC approach is dealing with strings when it comes to handling view templates.
In the MVC controller, I need to return a String representing the name of a view – the Thymeleaf template.
This is a notable shift from the REST controller, where the response is transformed from an expressive object into a JSON (or XML) data structure.
@GetMapping("/show")
public String showAllMessages(Model model) {
model.addAttribute("messages", this.messageService.getMessages());
return "messages";
}
Code language: Java (java)
As you can see in the GetMapping
method of the MessageMvcController
, I’m returning a single string, which represents the name of the view (messages.html
).
In an MVC controller, when you return a String, you’re telling Spring Boot and Thymeleaf which template to render. The string represents the name of the template (without the file extension), and Thymeleaf looks for that template in the resources/templates
directory by default.
For example, line 4 above refers to:
src/main/resources/templates/messages.html
To use a subfolder within the resources/templates
directory for Thymeleaf templates, you simply include the subfolder in the string that you return from the controller. Here’s an example:
@GetMapping("/show")
public String showAllMessages(Model model) {
model.addAttribute("messages", this.messageService.getMessages());
return "guestbook/messages/sub/directory/some-file";
}
Code language: Java (java)
In this case, the string "guestbook/messages/sub/directory/some-file"
tells Thymeleaf to look for the some-file.html
template at:
src/main/resources/templates/guestbook/messages/sub/directory/some-file.html
This approach helps organise your templates, especially in larger projects where grouping related views into subfolders improves structure and maintainability.
Kebab case (kebab-case
) is the favoured naming convention for Thymeleaf files and folders.
Post Redirect Get With Thymeleaf
In previous blog posts, I’ve talked about my preference for using the post redirect get pattern, and this is another good example of where I might use it.
Here’s the code snippet:
@PostMapping("/add")
public String postMessage(@RequestParam("user") String user, @RequestParam("message") String message) {
MessageImpl msg = new MessageImpl(user,message);
messageService.postMessage( msg);
return "redirect:/show";
}
Code language: Java (java)
In the postMessage
method, the return value "redirect:/show"
is key to how the user is redirected after submitting a form. Here’s what it does:
- “redirect:”: The prefix
"redirect:"
tells Spring MVC that instead of rendering a view directly, it should send an HTTP redirect response to the client. This means that after the form is submitted, the browser will be instructed to make a new request to the specified URL. - “/show”: This is the URL that the user will be redirected to. In our case, it maps to the
@GetMapping("/show")
method, which renders the"messages.html"
template, displaying all the messages.
By using "redirect:/show"
, we prevent the user from staying on the /add
page after posting a message. This redirect pattern is commonly used to avoid form resubmission issues, where refreshing the page could accidentally cause the form to be submitted again. Instead, the user is sent to the /show
page, where they can see the updated list of messages.
Minor Changes To The REST Controller
I know I originally mentioned that no changes would be needed for the REST controller, but unfortunately, a few small adjustments were necessary.
Specifically, I had to rename the controller. Previously, it was just called MessageController
, which was a bit too generic, especially now that we’re introducing both a REST controller and an MVC controller.
To avoid confusion and make the codebase more descriptive, I renamed the MessageController
to something more specific—in this case, MessageRESTController
.
This renaming makes it clear that this particular controller is responsible for handling REST API requests, while the new MVC controller will handle rendering Thymeleaf templates for web-based requests.
The other change I had to make was to the message implementation, and that’s a bit more involved. I’ll dive into that in the next section.
Modifications To Guest Book Messages
Perhaps one of the largest changes in this phase was the refactoring of the Message
interface. Initially, the message implementation returned only a single property called message
, which was a string. I was handling the username separately, which, in hindsight, wasn’t the best approach.
In a typical guestbook implementation, the message and the username (or the visitor’s name) are inherently tied together. It’s not common to separate the two, and I can’t recall a guestbook system where you would have to log in just to post a message. That kind of functionality, like requiring login, usually came later as part of anti-spam measures, but in the context of a basic guestbook, these elements should be treated as a single unit.
So, I made the decision to refactor the Message
interface to link the username and message more closely. By combining the visitor’s name with the message itself, the system feels more intuitive and follows the expected behaviour of a traditional guestbook. This refactor not only simplifies the code but also makes it easier to display and manage guestbook entries without having to worry about juggling separate properties.
package uk.co.a6software.guest_book.core.model;
import java.time.LocalDateTime;
public interface Message {
String getName();
String getMessage();
LocalDateTime getTime();
}
Code language: Java (java)
And the associated implementation:
package uk.co.a6software.guest_book.core.model;
import java.time.LocalDateTime;
public record MessageImpl(String name, String message, LocalDateTime time) implements Message {
public MessageImpl(String name, String message) {
this(name, message, LocalDateTime.now());
}
@Override
public String getName() {
return name;
}
@Override
public LocalDateTime getTime() {
return time;
}
@Override
public String getMessage() {
return message;
}
}
Code language: Java (java)
An interesting aspect of implementing the Message
interface was realising that I’d need some form of time tracking to record when a message was posted. For this, I opted to use the LocalDateTime
object to automatically track the timestamp of each message.
However, I didn’t want the user to provide this information; it needed to be set automatically by the system.
To handle this, I provided a custom overloaded constructor that accepts both the visitor’s name and the message but then calls the parent constructor, where the LocalDateTime
parameter is set internally.
This ensures that the timestamp is always created when the message is posted, without any input from the user.
This required a small change to the way messages are saved internally now that the visitor’s name is encapsulated in the Message
object:
The Thymeleaf Template
For simplicity, and because I’m still learning how to fully utilise Thymeleaf, I’ve kept both the list rendering and the form for adding a new item in the same Thymeleaf template.
Here is the full template for reference:
<!-- src/main/resources/templates/messages.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Messages</title>
</head>
<body>
<h1>Guest Book</h1>
<ul>
<li th:each="message : ${messages}">
<span th:text="${message.getMessage()}"></span>
- <span th:text="${message.getName()}"></span> at <span th:text="${message.getTime()}"></span>
</li>
</ul>
<div>
<form method="post" th:action="@{/add}">
<label>
Your name:
<input name="user" placeholder="your name" type="text" />
</label>
<label>
Message:
<input name="message" type="text" placeholder="your message">
</label>
<button type="submit">Add message</button>
</form>
</div>
</body>
</html>
Code language: HTML, XML (xml)
However, for the purpose of this blog post, I’m going to split the code review into two sections, starting with the list view.
Guest Book Message List View
In the section where we display the list of messages, the core functionality relies on Thymeleaf’s ability to iterate over a collection and dynamically render HTML elements based on that data.
Here’s the relevant part of the template:
<ul>
<li th:each="message : ${messages}">
<span th:text="${message.getMessage()}"></span>
- <span th:text="${message.getName()}"></span> at <span th:text="${message.getTime()}"></span>
</li>
</ul>
Code language: HTML, XML (xml)
The th:each
attribute is used for iteration and is similar to a for-each
loop in Java.
In this case, th:each="message : ${messages}"
means that Thymeleaf is iterating over a collection of messages
, where each individual message is referred to as message
inside the loop.
The ${messages}
part references the model attribute that was set in the controller
@GetMapping("/show")
public String showAllMessages(Model model) {
model.addAttribute("messages", this.messageService.getMessages());
return "messages";
}
Code language: Java (java)
Here, the getMessages()
method returns a list of message objects, and Thymeleaf iterates over each one in the HTML view.
Inside the loop, each message object has its properties accessed using th:text
attributes.
<span th:text="${message.getMessage()}"></span>
Code language: HTML, XML (xml)
th:text
is a Thymeleaf directive, used to dynamically inject text into an HTML element. Personally I don’t find the syntax super intuitive and would prefer to directly put the method call inside the span
element like regular HTML.
For each message in the list, Thymeleaf creates an <li>
element, displaying the message content, the visitor’s name, and the timestamp, with the final result displayed in the video at the end of this post.
Posting A Guest Book Message
Next, let’s take a look at the form element inside the view and how it works.
<form method="post" th:action="@{/add}">
<label>
Your name:
<input name="user" placeholder="your name" type="text" />
</label>
<label>
Message:
<input name="message" type="text" placeholder="your message">
</label>
<button type="submit">Add message</button>
</form>
Code language: HTML, XML (xml)
This form is responsible for allowing users to submit a new message to the guestbook, and it’s handled by the same Thymeleaf template.
The form is set to use the POST
method (method="post"
) because it is submitting data that will change the state of the application (i.e., adding a new message). The th:action="@{/add}"
is a Thymeleaf-specific tag that dynamically generates the action URL for the form. In this case, it resolves to /add
, which corresponds to the @PostMapping("/add")
method in the MessageMvcController
.
There are two input fields in the form.
The first field (<input name="user" />
) captures the user’s name, which will be sent as the user
parameter to the controller.
The second field (<input name="message" />
) captures the message itself, sent as the message
parameter.
These input fields correspond directly to the parameters in the controller method:
@PostMapping("/add")
public String postMessage(@RequestParam("user") String user, @RequestParam("message") String message) {
MessageImpl msg = new MessageImpl(user, message);
messageService.postMessage(msg);
return "redirect:/show";
}
Code language: Java (java)
When the user submits the form, the user
and message
values are passed to this method, where a new MessageImpl
object is created and saved.
The button (<button type="submit">Add message</button>
) submits the form when clicked, triggering the form submission to the /add
route.
Nothing Earth shattering here, and in the real world you would likely use some kind of CSRF protections at a minimum. But that’s beyond this post’s scope.
Testing
No, sadly not unit or acceptance testing, or anything that advanced.
The way I’ve been testing the system so far was initially with Postman, but now I have moved on to HTTP client scratch files.
These files allow you to make HTTP requests directly from the editor without needing external tools like Postman or cURL. This can be incredibly useful when you’re developing and testing your own APIs locally.
Here’s the file contents:
GET http://localhost:8080/all
###
POST http://localhost:8080/post
Content-Type: application/json
{
"user": "Jim Bob",
"message": "What a superb website. Well done."
}
###
POST http://localhost:8080/post
Content-Type: application/json
{
"user": "Kaitlynn Kadaver",
"message": "I love this website. Woo hoo."
}
Code language: HTTP (http)
The first part of our scratch file demonstrates how to perform a GET
request. The request is directed at http://localhost:8080/all
.
I’m using the JSON endpoint but the service layer is shared, so JSON data should be displayed in the Thymeleaf views, and vice-versa.
Next, we have a couple of examples of a POST
request.
In the first example, the POST
request is sent to http://localhost:8080/post
with a header specifying the content type as application/json
. The body of the request contains a JSON payload matching our endpoint’s expected input.
Following the same structure, we have a second POST
request that sends a similar JSON payload, but this time from a different user and message.
Here’s how it looks in the IDE. All we do is click the green play button to send the requests:
All good.
But does it work?