Hotwire Turbo Chat with Micronaut Views

This guide shows how to build with the Micronaut Framework a chat application such as the Rails application demonstrated in the Hotwire announcement screencast.

Authors: Sergio del Amo

Micronaut Version: 4.6.3

1. What you will need

To complete this guide, you will need the following:

2. Getting started

This guide shows a chat application such as the Rails application demonstrated in the Hotwire announcement screencast

2.1. What is Hotwire?

Hotwire is an umbrella for trio frameworks that implement the HTML-over-the-wire approach to building modern web applications. At its heart is Turbo, which gives you techniques for bringing the speed of a single-page application without writing a lick of JavaScript.

This guide primarily focuses on how Turbo works within a Micronaut application.

3. Screencast

There is a screencast here which shows this guide in action:

4. Download Solution

Download and unzip the source of the guide. You will find two folders:

  • initial. It contains a Micronaut application without any Turbo Integration.

  • complete. The resulting Micronaut application if you follow the instructions in the next sections and apply these changes to the initial application.

5. Initial application introduction

The initial application uses two models, Room and Message. One Room has many Messages.

The initial application contains a basic editing interface for Chat Rooms. It uses Micronaut Views Thymeleaf to render server-side HTML

Moreover, the application leverages Thymleaf Fragments to encapsulate the rendering of parts of the screen.

For Messages, we will have just two actions. create to render the form to create a message and save to handle the form submission.

It gives us a foundation flow for an admittedly cumbersome chat application which we can then use to level up with Hotwire techniques one at a time.

Appendix A: Initial application describes the initial application if you want to learn more.

6. Install Turbo

Install Turbo in compiled form by referencing the Turbo distributable script directly in the <head> of your application.

Modify the initial application, replace src/main/resources/views/layout.html.

<!DOCTYPE html>
...
    <head>
    ...
    <script type="module">
        import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
    </script>
...
    </head>
...

7. Turbo Frames

So let’s introduce our first Turbo feature, Frames.

Turbo Frames decompose pages into independent contexts, which can be lazy-loaded and scope interaction.

So when you follow a link or submit a form, only the content of the Frame changes rather than the entire page.

This allows you to keep the state of the rest of the page from changing, making the app feel more responsive.

7.1. Highlight Frames

To see how the Frames work easily, we’ll call them out with a blue border.

complete/src/main/resources/assets/stylesheets/application.css
turbo-frame {
    display: block;
    border: 1px solid blue;
}

7.2. Turbo Frame Show View

Now let’s wrap the Room name and the ability to edit it inside a Frame.

Replace this:

initial/src/main/resources/views/rooms/show.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <p th:replace="rooms/_room :: room(${room})"></p>
        <p>
            <a th:href="@{|/rooms/${room.id}/edit|}" th:text="#{action.edit}"></a> |
            <a href="/rooms" th:text="#{action.back}"></a>
        </p>
        <div id="messages">
            <div th:each="message : ${room.messages}">
                <p th:replace="messages/_message :: message(${message})"></p>
            </div>
        </div>
        <a th:href="@{|/rooms/${room.id}/messages/create|}" th:text="#{message.new}"></a>
    </main>
</body>
</html>

with:

src/main/resources/views/rooms/show.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <turbo-frame id="room">
            <p th:replace="rooms/_room :: room(${room})"></p>
            <p>
                <a th:href="@{|/rooms/${room.id}/edit|}" th:text="#{action.edit}"></a> |
                <a href="/rooms" th:text="#{action.back}"></a>
           </p>
        </turbo-frame>
        <div id="messages">
            <div th:each="message : ${room.messages}">
                <p th:replace="messages/_message :: message(${message})"></p>
            </div>
        </div>
        <a th:href="@{|/rooms/${room.id}/messages/create|}" th:text="#{message.new}"></a>
    </main>
</body>
</html>

Please, note the usage of <turbo-frame id=" room"> in the previous code snippet.

The Turbo Frame tag goes around the initial display, including the edit link and the part of the edit page we want to appear within the frame.

7.3. Turbo Frame Edit View

Replace this:

initial/src/main/resources/views/rooms/edit.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <h1 th:text="#{room.edit}"></h1>
<p th:replace="rooms/_edit :: edit(${room})"></p>
        <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.show}"></a> |
        <a href="/rooms" th:text="#{action.back}"></a>
    </main>
</body>
</html>

with this:

src/main/resources/views/rooms/edit.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <h1 th:text="#{room.edit}"></h1>
        <turbo-frame id="room">
            <p th:replace="rooms/_edit :: edit(${room})"></p>
        </turbo-frame>
        <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.show}"></a> |
        <a href="/rooms" th:text="#{action.back}"></a>
    </main>
</body>
</html>

We see our frame wrapped in blue.

And when clicking the Edit link, the form from the Edit screen is presented.

And upon submission, it’s replaced again with just a display.

If we go straight to the full page editing screen, we can see it has both a header and navigation links, parts we were emitting from the frame.

7.4. Underscore Top

Note that if we try to click a link within the frame that goes somewhere without a matching Frame, nothing happens.

We can solve this by adding a Data Turbo Frame attribute that points to _top to break out of the frame, just like traditional HTML frames.

Replace:

src/main/resources/views/rooms/show.html
....
<body>
    <main>
        ...
        <turbo-frame id="room">
            ...
                <a href="/rooms" th:text="#{action.back}"></a>
           </p>
        </turbo-frame>
....

with:

src/main/resources/views/rooms/show.html
....
<body>
    <main>
        ...
        <turbo-frame id="room">
            ...
               <a data-turbo-frame="_top" href="/rooms" th:text="#{action.back}"></a>
           </p>
        </turbo-frame>
....

Now the backlink works, and the frame scopes the edit display loop.

7.5. Lazy Loading Frames

Then, let’s add the New Message link into an inline but lazy-loaded Turbo Frame tag that also, just for starters, acts on the whole page.

This frame will be loaded right after the page displays, hitting the New Message Controller action we made earlier.

Replace:

src/main/resources/views/rooms/show.html
...
...
        <a href="/messages/create" th:text="#{message.new}"></a>
    </main>
</body>
</html>

with:

src/main/resources/views/rooms/show.html
....
        <turbo-frame id="new_message"
                     th:src="@{|/rooms/${room.id}/messages/create|}"
                     target="_top"></turbo-frame>
    </main>
</body>
</html>

7.5.1. Plug out the Frame

Like with edit, we wrap the relevant segment in a Frame tag with a matching ID, which is how Turbo knows how to plug out the right frame.

Replace:

initial/src/main/resources/views/messages/create.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
    <head>
        <script></script>
    </head>
<body>
    <main>
    <h1 th:text="#{message.new}"></h1>
<form th:replace="messages/_create :: create(${room})"></form>
    <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.back}"></a>
    </main>
</body>
</html>

with:

complete/src/main/resources/views/messages/create.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
    <h1 th:text="#{message.new}"></h1>
    <turbo-frame id="new_message" target="_top">
<form th:replace="messages/_create :: create(${room})"></form>
    </turbo-frame>
    <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.back}"></a>
    </main>
</body>
</html>

You can now see two requests when we load the room: one for the page, and one for the lazy-loader frame.

Let’s try to add a message.

It works!

But this only demonstrates that the frame was lazy-loaded.

Right now, we’re resetting the whole page upon submission of the New Message form.

Whereas with the Room Name Frame, you can edit and submit without changing the rest of the page state, a real independent context.

You can see how the Frame replacement happens by inspecting the response to edit.

Turbo will plug out just the matching frame from the server response. As you can see here, the header and links are ignored.

7.6. TurboFrameView Annotation

In a Micronaut application, we can optimize the response by using the @TurboFrameView annotation only to render the layout Turbo uses when parsing the response. A Request coming from a Frame includes the HTTP Header Turbo-Frame. Annotate RoomsControllerEdit::edit method with @TurboFrameView("/rooms/_edit")

complete/src/main/java/example/micronaut/controllers/RoomsControllerEdit.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.View;
import io.micronaut.views.turbo.TurboFrameView;

@Controller("/rooms")
public class RoomsControllerEdit extends RoomsController {

    public RoomsControllerEdit(RoomRepository roomRepository) {
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING)
    @View("/rooms/edit")
    @Get("/{id}/edit")
    @Produces(MediaType.TEXT_HTML)
    @TurboFrameView("/rooms/_edit") (1)
    HttpResponse<?> edit(@PathVariable Long id) {
        return modelResponse(id);
    }
}
1 You can render a Turbo Frame easily by annotating a controller route with @TurboFrameView and returning only the HTML used by Turbo. You can specify the view with the annotation value.

The above controller returns the following HTML for a request without HTTP Header Turbo-Frame.

<!DOCTYPE html>
<html>
    <head>
        <title>Chat</title>
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <link rel="stylesheet" media="all" href="/assets/stylesheets/application.css" />
        <link rel="stylesheet" media="all" href="/assets/stylesheets/scaffolds.css" />
        <script type="module">
            import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
        </script>
    </head>
    <body>
        <main>
            <h1>Editing Room</h1>
            <turbo-frame id="room">
                <form action="/rooms/update"
                      accept-charset="UTF-8"
                      method="post">
                     <input type="hidden" value="1" name="id">
                     <div class="field">
                         <label for="room_name">Name</label>
                         <input type="text" value="Micronaut Questions" name="name" id="room_name" />
                     </div>
                     <div class="actions">
                         <input type="submit" name="commit" value="Update Room"/>
                     </div>
                </form>
            </turbo-frame>
            <a href="/rooms/1">Show</a> |
            <a href="/rooms">Back</a>
        </main>
    </body>
</html>

For a request including an HTTP Header Turbo-Frame with value rooms, the above controller returns the following HTML.

<turbo-frame id="room">
    <form action="/rooms/update"
          accept-charset="UTF-8"
          method="post">
        <input type="hidden" value="1" name="id">
        <div class="field">
            <label for="room_name">Name</label>
            <input type="text" value="Micronaut Questions" name="name" id="room_name" />
        </div>
        <div class="actions">
            <input type="submit" name="commit" value="Update Room"/>
        </div>
    </form>
</turbo-frame>

7.7. Turbo Streams

Turbo Streams deliver page changes over WebSocket or in response to form submissions using just HTML and a set of CRUD-like action tags.

Turbo Streams let you append or prepend to replace and remove any target DOM element from the existing page.

They’re strictly limited to DOM changes, though. No direct JavaScript invocation.

If you need more than a DOM change, connect a Stimulus controller.

We will add a Turbo stream response to the message creation action such that we can add the new message to the Room page without replacing the whole page.

This template invokes the append action with the DOM ID of the target container and either a full set of partial rendering options or just a record we wish to render which conforms to the naming conventions for matching to a partial.

complete/src/main/java/example/micronaut/controllers/MessagesControllerSave.java
package example.micronaut.controllers;

import example.micronaut.models.MessageForm;
import example.micronaut.models.RoomMessage;
import example.micronaut.services.MessageService;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.turbo.TurboStream;
import io.micronaut.views.turbo.http.TurboMediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.Optional;

@ExecuteOn(TaskExecutors.BLOCKING)
@Controller("/rooms")
class MessagesControllerSave extends ApplicationController {
    private static final Logger LOG = LoggerFactory.getLogger(MessagesControllerSave.class);
    private final MessageService messageService;

    public MessagesControllerSave(MessageService messageService) {
        this.messageService = messageService;
    }

    @Produces(MediaType.TEXT_HTML)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Post("/{id}/messages")
    HttpResponse<?> save(@PathVariable Long id,
                         @Body("content") String content,
                         HttpRequest<?> request) {
        Optional<RoomMessage> roomMessageOptional = messageService.save(new MessageForm(id, content));
        return roomMessageOptional.map(roomMessage -> {
            if (TurboMediaType.acceptsTurboStream(request)) { (1)
                return HttpResponse.ok(TurboStream.builder() (2)
                        .template("/messages/_message.html", Collections.singletonMap("message", roomMessage))
                        .targetDomId("messages")
                        .append());
            }
            return redirectTo("/rooms", id);
        }).orElseGet(HttpResponse::notFound);
    }
}
1 TurboMediaType::acceptsTurboStream is a convenient method to verify if the request accepts a turbo stream response.
2 You can build a Turbo Stream with fluid API. Micronaut Views templates are supported.

Now we can add Messages to the page without resetting it completely.

7.8. Stimulus Controller

The Edit Name form can stay open while we’re doing this because new Messages are added directly to the Messages div. The Turbo Stream HTML is rendered directly in response to the form submission, and Turbo knows from the MIME type to process it automatically. But notice the input field isn’t cleared. We can fix that by adding a Stimulus controller.


Stimulus is a modest JavaScript framework for the HTML you already have. _

complete/src/main/resources/assets/javascripts/controllers/reset_form_controller.mjs
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"

export default class extends Controller {
    reset() {
        this.element.reset()
    }
}

and register it:

complete/src/main/resources/views/layout.html
-->
    <script type="module">
        import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
        import {Application} from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
        import ResetFormController from "/assets/javascripts/controllers/reset_form_controller.mjs"
        window.Stimulus = Application.start()
        Stimulus.register("reset-form", ResetFormController)
    </script>
<!--

The Stimulus controller we’re going to add will be a dead-simple way to reset the form after creating a new Message.

It has just one method, Reset, which we will call when Turbo is done submitting the form via Fetch.

Add the data-controller and data-action attributes to the form:

complete/src/main/resources/views/messages/_create.html
<form th:fragment="create(room)"
      th:action="@{|/rooms/${room.id}/messages|}"
      data-controller="reset-form"
      data-action="turbo:submit-end->reset-form#reset"
      accept-charset="UTF-8"
      method="post">
    <div class="field">
        <input type="text" name="content" id="message_content"/>
        <input type="submit" name="commit" th:value="#{message.send}"/>
    </div>
</form>

The form is reset, and the Message is added dynamically.

8. Turbo Streams via Web Sockets

But how interesting is a chat app where you’re just talking to yourself?. Let’s start a conversation with another window. You’ll see that new Messages are only added live to the originator’s window. On the other side, we have to reload to see what’s been said.

Let’s fix that.

8.1. Events

When the message is saved, raise an event:

complete/src/main/java/example/micronaut/services/DefaultMessageService.java
package example.micronaut.services;

import io.micronaut.context.event.ApplicationEventPublisher;
import io.micronaut.core.annotation.NonNull;
import example.micronaut.entities.Message;
import example.micronaut.models.MessageForm;
import example.micronaut.models.RoomMessage;
import example.micronaut.repositories.MessageRepository;
import example.micronaut.repositories.RoomRepository;
import jakarta.inject.Singleton;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;

@Singleton
public class DefaultMessageService implements MessageService {
    private final MessageRepository messageRepository;
    private final RoomRepository roomRepository;
    private final ApplicationEventPublisher<RoomMessage> eventPublisher;

    public DefaultMessageService(MessageRepository messageRepository,
                                 RoomRepository roomRepository,
                                 ApplicationEventPublisher<RoomMessage> eventPublisher) { (1)
        this.messageRepository = messageRepository;
        this.roomRepository = roomRepository;
        this.eventPublisher = eventPublisher;
    }

    @Override
    @NonNull
    public Optional<RoomMessage> save(@NonNull @NotNull @Valid MessageForm form) {
        return roomRepository.findById(form.getRoom())
                .map(room -> {
                    Message message = messageRepository.save(new Message(form.getContent(), room));
                    RoomMessage roomMessage = new RoomMessage(message.getId(),
                            form.getRoom(),
                            form.getContent(),
                            message.getDateCreated());
                    eventPublisher.publishEvent(roomMessage); (1)
                    return roomMessage;
                });
    }
}
1 To publish an event, use dependency injection to obtain an instance of ApplicationEventPublisher where the generic type is the type of event and invoke the publishEvent method with your event object. For performance reasons, it’s advisable to inject an instance of ApplicationEventPublisher with a generic type parameter - ApplicationEventPublisher<RoomMessage>.

8.2. WebSocket Server

Create a WebSocket Server, which publishes a Turbo Stream when a message event is received.

complete/src/main/java/example/micronaut/ChatServerWebSocket.java
package example.micronaut;

import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.io.Writable;
import example.micronaut.models.RoomMessage;
import io.micronaut.views.turbo.TurboStream;
import io.micronaut.views.turbo.TurboStreamAction;
import io.micronaut.views.turbo.TurboStreamRenderer;
import io.micronaut.views.turbo.http.TurboMediaType;
import io.micronaut.websocket.WebSocketBroadcaster;
import io.micronaut.websocket.WebSocketSession;
import io.micronaut.websocket.annotation.OnClose;
import io.micronaut.websocket.annotation.OnMessage;
import io.micronaut.websocket.annotation.OnOpen;
import io.micronaut.websocket.annotation.ServerWebSocket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;

@ServerWebSocket("/chat/{room}") (1)
public class ChatServerWebSocket implements ApplicationEventListener<RoomMessage> {
    private static final Logger LOG = LoggerFactory.getLogger(ChatServerWebSocket.class);
    private final WebSocketBroadcaster broadcaster;
    private final TurboStreamRenderer turboStreamRenderer;

    private Map<String, Set<String>> roomSessions = new ConcurrentHashMap<>();

    ChatServerWebSocket(WebSocketBroadcaster broadcaster, (2)
                        TurboStreamRenderer turboStreamRenderer) {
        this.broadcaster = broadcaster;
        this.turboStreamRenderer = turboStreamRenderer;
    }

    @OnOpen (3)
    public void onOpen(String room, WebSocketSession session) {
        LOG.info("onOpen room {}", room);
        roomSessions.computeIfAbsent(room, k -> {
            Set<String> result = new HashSet<>();
            result.add(session.getId());
            return result;
        });
        roomSessions.computeIfPresent(room, (k, sessions) -> {
            sessions.add(session.getId());
            return sessions;
        });
    }

    @OnMessage (4)
    public void onMessage(String room, String message, WebSocketSession session) {
        LOG.info("onMessage room {}", room);
    }

    @OnClose (5)
    public void onClose(String room, WebSocketSession session) {
        LOG.info("onClose room {}", room);
        roomSessions.computeIfPresent(room, (k, sessions) -> {
            sessions.remove(session.getId());
            return sessions;
        });
    }

    @Override
    public void onApplicationEvent(RoomMessage event) {
        broadcast(event);
    }

    private void broadcast(@NonNull RoomMessage message) {
        turboStreamRenderer.render(turboStream(message), null)
                .ifPresent(writable -> broadcast(writable, String.valueOf(message.getRoom())));
    }

    private void broadcast(@NonNull Writable writable, @NonNull String room) {
        writableToString(writable)
                .ifPresent(message -> broadcaster.broadcastAsync(message, TurboMediaType.TURBO_STREAM_TYPE, inRoom(room))); (2)
    }

    private Predicate<WebSocketSession> inRoom(String room) {
        Set<String> websocketIds = roomSessions.getOrDefault(room, Collections.emptySet());
        return s -> websocketIds.contains(s.getId());
    }

    private Optional<String> writableToString(Writable writable) {
        try {
            StringWriter stringWriter = new StringWriter();
            writable.writeTo(stringWriter);
            return Optional.of(stringWriter.toString());
        } catch (IOException e) {
            return Optional.empty();
        }
    }

    private TurboStream.Builder turboStream(@NonNull RoomMessage message) {
        return TurboStream.builder()
                .action(TurboStreamAction.APPEND)
                .template("/messages/_message.html", Collections.singletonMap("message", message))
                .targetDomId("messages"); (6)
    }
}
1 The @ServerWebSocket annotation defines the path the WebSocket is mapped under. The URI can be a URI template.
2 You can use a WebSocketBroadcaster to broadcast messages to every WebSocket session.
3 The @OnOpen annotation declares the method to invoke when the WebSocket is opened.
4 The @OnMessage annotation declares the method to invoke when a message is received.
5 The @OnClose annotation declares the method to invoke when the WebSocket is closed.
6 You can build a Turbo Stream with fluid API. Micronaut Views templates are supported.

Establish a WebSocket connection to the WebSocket server identified by the Room we’re in.

complete/src/main/resources/views/rooms/show.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script type="module">
        const wsUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/chat/[[${room.id}]]";
        const socket = new WebSocket(wsUrl);
        socket.addEventListener('open', function (event) {
            console.log(event);
        });
        import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
        Turbo.session.connectStreamSource(socket);
    </script>
</head>
<body>
    <main>
        <turbo-frame id="room">
        <p th:replace="rooms/_room :: room(${room})"></p>
        <p>
            <a th:href="@{|/rooms/${room.id}/edit|}" th:text="#{action.edit}"></a> |
            <a data-turbo-frame="_top" href="/rooms" th:text="#{action.back}"></a>
        </p>
        </turbo-frame>
        <div id="messages">
            <div th:each="message : ${room.messages}">
                <p th:replace="messages/_message :: message(${message})"></p>
            </div>
        </div>
        <turbo-frame id="new_message"
                     th:src="@{|/rooms/${room.id}/messages/create|}"
                     target="_top"></turbo-frame>
    </main>
</body>
</html>

Now we can add a new message and see it appear in both windows.

9. Next

Hotwire is an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire.

We get to keep all our template rendering on the server, which means writing more of our applications in our favorite programming languages.

10. Appendix A: Initial application

The following sections introduce you to the initial application.

10.1. Data Source configuration

Define the datasource in src/main/resources/application.properties.

initial/src/main/resources/application.yml
datasources:
  default:
    dialect: MYSQL
    driverClassName: ${JDBC_DRIVER:com.mysql.cj.jdbc.Driver}
This way of defining the datasource properties enables us to externalize the configuration, for example for production environment, and also provide a default value for development. If the environment variables are not defined, the Micronaut framework will use the default values.

10.2. Database Schema

10.3. Database Migration with Flyway

We need a way to create the database schema. For that, we use Micronaut integration with Flyway.

Flyway automates schema changes, significantly simplifying schema management tasks, such as migrating, rolling back, and reproducing in multiple environments.

Add the following snippet to include the necessary dependencies:

build.gradle
implementation("io.micronaut.flyway:micronaut-flyway")

We will enable Flyway in the Micronaut configuration file and configure it to perform migrations on one of the defined data sources.

Since Flyway 8.2.0, the Flyway distribution does not contain the MySQL driver.

Add the following dependency:

build.gradle
implementation("org.flywaydb:flyway-mysql:@flyway-mysqlVersion@")
initial/src/main/resources/application.yml
flyway:
  datasources:
    default:
      enabled: true (1)
1 Enable Flyway for the default datasource.
Configuring multiple data sources is as simple as enabling Flyway for each one. You can also specify directories that will be used for migrating each data source. Review the Micronaut Flyway documentation for additional details.

Flyway migration will be automatically triggered before your Micronaut application starts. Flyway will read migration commands in the resources/db/migration/ directory, execute them if necessary, and verify that the configured data source is consistent with them.

Create the following migration files with the database schema creation:

initial/src/main/resources/db/migration/V1__schema.sql
DROP TABLE IF EXISTS room;
DROP TABLE IF EXISTS message;

CREATE TABLE room (
    id   BIGINT NOT NULL AUTO_INCREMENT UNIQUE PRIMARY KEY,
    name  VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE message (
    id   BIGINT NOT NULL AUTO_INCREMENT UNIQUE PRIMARY KEY,
    content  VARCHAR(255) NOT NULL,
    room_id BIGINT,
    date_created datetime NULL,
    INDEX r_id (room_id),
    FOREIGN KEY (room_id)
        REFERENCES room(id)
        ON DELETE CASCADE
);

10.4. Entities

The application contains two entities with a one-to-many relationship.

initial/src/main/java/example/micronaut/entities/Room.java
package example.micronaut.entities;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.Relation;

import jakarta.validation.constraints.NotNull;
import java.util.List;

@MappedEntity (1)
public class Room {
    @Id (2)
    @GeneratedValue(GeneratedValue.Type.AUTO) (3)
    private Long id;

    @NotNull
    private String name;

    @Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "room") (4)
    @Nullable
    private List<Message> messages;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Nullable
    public List<Message> getMessages() {
        return messages;
    }

    public void setMessages(@Nullable List<Message> messages) {
        this.messages = messages;
    }

    @Override
    public String toString() {
        return "Room{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 Specifies the ID of an entity
3 Specifies that the property value is generated by the database and not included in inserts
4 You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation.
initial/src/main/java/example/micronaut/entities/Message.java
package example.micronaut.entities;

import io.micronaut.core.annotation.Creator;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.DateCreated;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.Relation;

import jakarta.validation.constraints.NotBlank;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

@MappedEntity (1)
public class Message {
    @Id (2)
    @GeneratedValue(GeneratedValue.Type.AUTO) (3)
    private Long id;

    @NonNull
    @NotBlank
    private String content;

    @Nullable
    @Relation(value = Relation.Kind.MANY_TO_ONE) (4)
    private Room room;

    @DateCreated (5)
    @Nullable
    private Instant dateCreated;

    @Creator (6)
    public Message() {
    }

    public Message(@NonNull String content, @Nullable Room room) {
        this.content = content;
        this.room = room;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    @NonNull
    public String getContent() {
        return content;
    }

    public void setContent(@NonNull String content) {
        this.content = content;
    }

    @Nullable
    public Instant getDateCreated() {
        return dateCreated;
    }

    public void setDateCreated(@Nullable Instant dateCreated) {
        this.dateCreated = dateCreated;
    }

    @Nullable
    public Room getRoom() {
        return room;
    }

    public void setRoom(@Nullable Room room) {
        this.room = room;
    }

    @Override
    public String toString() {
        return "Message{" +
                "id=" + id +
                ", content='" + content + '\'' +
                '}';
    }

    private static final String PATTERN = "MMM:dd HH:mm:ss";

    public String formattedDateCreated() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PATTERN)
                .withZone(ZoneId.systemDefault());
        return formatter.format(dateCreated);
    }
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 Specifies the ID of an entity
3 Specifies that the property value is generated by the database and not included in inserts
4 You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation.
5 Micronaut Data assigns a data created value (such as a java.time.Instant) prior to an insert.
6 Annotate with @Creator to provide a hint as to which constructor is the primary constructor.

10.5. Models

The application includes a POJO to map the form submission when the user submits a message to a room.

initial/src/main/java/example/micronaut/models/MessageForm.java
package example.micronaut.models;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

@Serdeable (1)
public class MessageForm {

    @NonNull
    @NotNull
    private final Long room;

    @NonNull
    @NotBlank
    private final String content;

    public MessageForm(@NonNull Long room, @NonNull String content) {
        this.room = room;
        this.content = content;
    }

    @NonNull
    public Long getRoom() {
        return room;
    }

    @NonNull
    public String getContent() {
        return content;
    }
}
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

The application includes a POJO which represents a room’s message.

initial/src/main/java/example/micronaut/models/RoomMessage.java
package example.micronaut.models;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.serde.annotation.Serdeable;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

@Serdeable (1)
public class RoomMessage {

    private static final String PATTERN = "MMM:dd HH:mm:ss";

    @NonNull
    @NotNull
    private final Long id;

    @NonNull
    @NotNull
    private final Long room;

    @NonNull
    @NotBlank
    private final String content;

    @Nullable
    private final Instant dateCreated;

    public RoomMessage(@NonNull Long id,
                       @NonNull Long room,
                       @NonNull String content,
                       @Nullable Instant dateCreated) {
        this.id = id;
        this.room = room;
        this.content = content;
        this.dateCreated = dateCreated;
    }

    @NonNull
    public Long getId() {
        return id;
    }

    @NonNull
    public Long getRoom() {
        return room;
    }

    @NonNull
    public String getContent() {
        return content;
    }

    @Nullable
    public Instant getDateCreated() {
        return dateCreated;
    }

    public String formattedDateCreated() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PATTERN)
                .withZone(ZoneId.systemDefault());
        return formatter.format(dateCreated);
    }


}
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.

10.6. Repositories

The application includes a repository per entity.

initial/src/main/java/example/micronaut/repositories/MessageRepository.java
package example.micronaut.repositories;

import example.micronaut.entities.Message;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;

@JdbcRepository(dialect = Dialect.MYSQL) (1)
public interface MessageRepository extends CrudRepository<Message, Long> { (2)
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.
initial/src/main/java/example/micronaut/repositories/RoomRepository.java
package example.micronaut.repositories;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.Join;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import example.micronaut.entities.Room;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;

@JdbcRepository(dialect = Dialect.MYSQL) (1)
public interface RoomRepository extends CrudRepository<Room, Long> { (2)

    @NonNull
    Room save(@NonNull @NotBlank String name);

    void update(@Id Long id, @NonNull @NotBlank String name);

    @Join(value = "messages", type = Join.Type.LEFT_FETCH) (3)
    Optional<Room> getById(@NonNull @NotNull Long id);
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.
3 You can use the @Join annotation on your repository interface to specify that a JOIN LEFT FETCH should be executed to retrieve the associated messages.

10.7. Services

The application contains a service that publishes an event when a message is saved.

initial/src/main/java/example/micronaut/services/MessageService.java
package example.micronaut.services;

import example.micronaut.models.MessageForm;
import example.micronaut.models.RoomMessage;
import io.micronaut.context.annotation.DefaultImplementation;
import io.micronaut.core.annotation.NonNull;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;
@DefaultImplementation(DefaultMessageService.class) (1)
public interface MessageService {
    @NonNull
    Optional<RoomMessage> save(@NonNull @NotNull @Valid MessageForm form); (2)
}
1 You can annotate an interface with DefaultImplementation to indicate the default implementation of a particular type, and hence ease bean replacement.
2 Add @Valid to any method parameter which requires validation.
initial/src/main/java/example/micronaut/services/DefaultMessageService.java
package example.micronaut.services;

import example.micronaut.entities.Message;
import example.micronaut.models.MessageForm;
import example.micronaut.models.RoomMessage;
import example.micronaut.repositories.MessageRepository;
import example.micronaut.repositories.RoomRepository;
import io.micronaut.context.event.ApplicationEventPublisher;
import io.micronaut.core.annotation.NonNull;
import jakarta.inject.Singleton;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.Optional;

@Singleton (1)
public class DefaultMessageService implements MessageService {
    private final MessageRepository messageRepository;
    private final RoomRepository roomRepository;
    private final ApplicationEventPublisher<RoomMessage> eventPublisher;

    public DefaultMessageService(MessageRepository messageRepository,
                                 RoomRepository roomRepository,
                                 ApplicationEventPublisher<RoomMessage> eventPublisher) {  (2)
        this.messageRepository = messageRepository;
        this.roomRepository = roomRepository;
        this.eventPublisher = eventPublisher;
    }

    @Override
    @NonNull
    public Optional<RoomMessage> save(@NonNull @NotNull @Valid MessageForm form) {
        return roomRepository.findById(form.getRoom())
                .map(room -> {
                    Message message = messageRepository.save(new Message(form.getContent(), room));
                    RoomMessage roomMessage = new RoomMessage(message.getId(),
                            form.getRoom(),
                            form.getContent(),
                            message.getDateCreated());
                    eventPublisher.publishEvent(roomMessage); (2)
                    return roomMessage;
                });
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 To publish an event, use dependency injection to obtain an instance of ApplicationEventPublisher where the generic type is the type of event and invoke the publishEvent method with your event object. For performance reasons, it’s advisable to inject an instance of ApplicationEventPublisher with a generic type parameter - ApplicationEventPublisher<RoomMessage>.

10.7.1. Static Resources

Update application.yml to add static resource configuration:

initial/src/main/resources/application.yml
micronaut:
  router:
    static-resources:
      assets: (1)
        paths: 'classpath:assets'
        mapping: '/assets/**'
1 Configure the Framework to resolve static resources from the request path /assets/** in src/main/resources/assets.

11. Views

To use the Thymeleaf Java template engine to render views in a Micronaut application, add the following dependency on your classpath.

build.gradle
implementation("io.micronaut.views:micronaut-views-thymeleaf")

The initial application uses Thymleaf Fragments to organize the views.

It uses a root layout:

initial/src/main/resources/views/layout.html
<!DOCTYPE html>
<html th:fragment="layout (script, content)" xmlns:th="http://www.thymeleaf.org">
    <head>
        <title>Chat</title>
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <link rel="stylesheet" media="all" href="/assets/stylesheets/application.css" />
        <link rel="stylesheet" media="all" href="/assets/stylesheets/scaffolds.css" />
        <script th:replace="${script}"></script>
    </head>
    <body>
    <div th:replace="${content}"></div>
    </body>
</html>

11.1. Properties

Create a default messages.properties file:

initial/src/main/resources/i18n/messages.properties
chat=Chat
message.send=Send
message.new=New Message
room.name=Name
room.list=Rooms
room.new=New Room
room.edit=Editing Room
room.update=Update Room
room.create=Create Room
action.back=Back
action.show=Show
action.edit=Edit
action.destroy=Destroy

Create a messages_es.properties file for the Spanish locale:

initial/src/main/resources/i18n/messages_es.properties
chat=Chat
message.send=Enviar
message.new=Crear Mensaje
room.name=Nombre
room.list=Salas
room.new=Nueva Sala
room.edit=Editar Sala
room.update=Actualizar Sala
room.create=Crear Sala
action.back=Volver
action.show=Mostrar
action.edit=Editar
action.destroy=Eliminar

11.2. Message Source

Create a MessageSource that uses the previous properties files:

initial/src/main/java/example/micronaut/i18n/MessageSourceFactory.java
package example.micronaut.i18n;

import io.micronaut.context.MessageSource;
import io.micronaut.context.annotation.Factory;
import io.micronaut.context.i18n.ResourceBundleMessageSource;
import jakarta.inject.Singleton;

@Factory (1)
class MessageSourceFactory {

    @Singleton (2)
    MessageSource createMessageSource() {
        return new ResourceBundleMessageSource("i18n.messages");
    }
}
1 A class annotated with the @Factory annotated is a factory. It provides one or more methods annotated with a bean scope annotation (e.g. @Singleton). Read more about Bean factories.
2 Use jakarta.inject.Singleton to designate a class as a singleton.

11.3. Controllers

The apex url is redirected to /rooms.

initial/src/main/java/example/micronaut/controllers/HomeController.java
package example.micronaut.controllers;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;

import java.net.URI;
import java.net.URISyntaxException;

@Controller (1)
public class HomeController {
    @Get (2)
    HttpResponse<?> index() throws URISyntaxException {
        return HttpResponse.seeOther(new URI("/rooms"));
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /.
2 The @Get annotation maps the method to an HTTP GET request.

We have an abstract class to simplify redirection:

initial/src/main/java/example/micronaut/controllers/ApplicationController.java
package example.micronaut.controllers;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.uri.UriBuilder;

public abstract class ApplicationController {
    @NonNull
    protected HttpResponse<?> redirectTo(@NonNull CharSequence uri,
                                         @NonNull Long id) {
        return HttpResponse.seeOther(UriBuilder.of(uri)
                .path("" + id)
                .build());
    }
}

Create CRUD controllers for Room.

11.3.1. Rooms Index Controller

Create a controller which displays a list of rooms.

initial/src/main/java/example/micronaut/controllers/RoomsControllerIndex.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.View;

import java.util.Collections;
import java.util.Map;

@Controller("/rooms") (1)
public class RoomsControllerIndex extends RoomsController {
    public static final String ROOMS = "rooms";

    public RoomsControllerIndex(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @View("/rooms/index") (4)
    @Get (5)
    @Produces(MediaType.TEXT_HTML) (6)
    Map<String, Object> index() {
        return Collections.singletonMap(ROOMS, roomRepository.findAll());
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Use View annotation to specify which template to use to render the response.
5 The @Get annotation maps the method to an HTTP GET request.
6 Set the response content-type to HTML with the @Produces annotation.
Rooms Index Views

The controller uses Thymeleaf to render server-side HTML.

initial/src/main/resources/views/rooms/index.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <h1 th:text="#{room.list}"></h1>
<table th:replace="rooms/_table :: table(${rooms})"></table>
        <br />
        <a href="/rooms/create"
           th:text="#{room.new}"></a>
    </main>
</body>
</html>
initial/src/main/resources/views/rooms/_table.html
<table th:fragment="table(rooms)">
    <thead>
    <tr>
        <th th:text="#{room.name}"></th>
        <th colspan="3"></th>
    </tr>
    </thead>
    <tbody>
<tr th:each="room : ${rooms}" th:include="rooms/_tr :: tr(${room})"></tr>
    </tbody>
</table>
initial/src/main/resources/views/rooms/_tr.html
<tr th:fragment="tr(room)">
    <td th:text="${room.name}"></td>
    <td><a th:href="@{|/rooms/${room.id}|}" th:text="#{action.show}"></a></td>
    <td><a th:href="@{|/rooms/${room.id}/edit|}" th:text="#{action.edit}"></a></td>
    <td>
        <form th:action="@{|/rooms/${room.id}/delete|}" method="post" onsubmit="return confirm('Are you sure?');">
            <input type="hidden" name="id" th:value="${room.id}" />
            <input type="submit" th:value="#{action.destroy}"/>
        </form>
    </td>
</tr>

11.3.2. Rooms Show Controller

Create a controller which displays room.

initial/src/main/java/example/micronaut/controllers/RoomsControllerShow.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.View;

@Controller("/rooms") (1)
public class RoomsControllerShow extends RoomsController {

    public RoomsControllerShow(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @View("/rooms/show") (4)
    @Get("/{id}") (5)
    @Produces(MediaType.TEXT_HTML) (6)
    HttpResponse<?> show(@PathVariable Long id) { (7)
        return modelResponse(roomRepository.getById(id).orElse(null));
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Use View annotation to specify which template to use to render the response.
5 The @Get annotation maps the method to an HTTP GET request.
6 Set the response content-type to HTML with the @Produces annotation.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.
Rooms Show Views

Add Thymeleaf templates to render server-side HTML.

initial/src/main/resources/views/rooms/show.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <p th:replace="rooms/_room :: room(${room})"></p>
        <p>
            <a th:href="@{|/rooms/${room.id}/edit|}" th:text="#{action.edit}"></a> |
            <a href="/rooms" th:text="#{action.back}"></a>
        </p>
        <div id="messages">
            <div th:each="message : ${room.messages}">
                <p th:replace="messages/_message :: message(${message})"></p>
            </div>
        </div>
        <a th:href="@{|/rooms/${room.id}/messages/create|}" th:text="#{message.new}"></a>
    </main>
</body>
</html>
initial/src/main/resources/views/rooms/_room.html
<p th:fragment="room(room)" th:id="@{|room_${room.id}|}">
    <strong th:text="#{room.name}">:</strong>
    [[${room.name}]]
</p>

11.3.3. Rooms Create Controller

Create a controller which displays a form to create a room.

initial/src/main/java/example/micronaut/controllers/RoomsControllerCreate.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.views.View;

import java.util.Collections;
import java.util.Map;

@Controller("/rooms") (1)
public class RoomsControllerCreate extends RoomsController {

    public RoomsControllerCreate(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @View("/rooms/create") (3)
    @Get("/create") (4)
    @Produces(MediaType.TEXT_HTML) (5)
    Map<String, Object> create() {
        return Collections.emptyMap();
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 Use View annotation to specify which template to use to render the response.
4 The @Get annotation maps the method to an HTTP GET request.
5 Set the response content-type to HTML with the @Produces annotation.
Rooms Create Views

Add Thymeleaf templates to render a form.

initial/src/main/resources/views/rooms/create.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <h1 th:text="#{room.new}"></h1>
<form th:replace="rooms/_create :: create()"></form>
        <a href="/rooms" th:text="#{action.back}"></a>
    </main>
</body>
</html>
initial/src/main/resources/views/rooms/_create.html
<form th:fragment="create()"
      action="/rooms"
      accept-charset="UTF-8" method="post">
    <div class="field">
        <label for="room_name" th:text="#{room.name}"></label>
        <input type="text" name="name" id="room_name" />
    </div>
    <div class="actions">
        <input type="submit" name="commit" th:value="#{room.create}" />
    </div>
</form>

11.3.4. Rooms Save Controller

Create a controller which handles the room creation form submission.

initial/src/main/java/example/micronaut/controllers/RoomsControllerSave.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

@Controller("/rooms") (1)
public class RoomsControllerSave extends RoomsController {

    public RoomsControllerSave(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @Produces(MediaType.TEXT_HTML) (4)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED) (5)
    @Post (6)
    HttpResponse<?> save(@Body("name") String name) { (7)
        return redirectTo("/rooms", roomRepository.save(name).getId());
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Set the response content-type to HTML with the @Produces annotation.
5 A Micronaut controller action consumes application/json by default. Consuming other content types is supported with the @Consumes annotation or the consumes member of any HTTP method annotation.
6 The @Post annotation maps the method to an HTTP POST request.
7 You can use a qualifier within the HTTP request body. For example, you can use a reference to a nested JSON attribute.

11.3.5. Rooms Edit Controller

Create a controller which shows a form to edit a room.

initial/src/main/java/example/micronaut/controllers/RoomsControllerEdit.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.View;

@Controller("/rooms") (1)
public class RoomsControllerEdit extends RoomsController {

    public RoomsControllerEdit(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @View("/rooms/edit") (4)
    @Get("/{id}/edit") (5)
    @Produces(MediaType.TEXT_HTML) (6)
    HttpResponse<?> edit(@PathVariable Long id) { (7)
        return modelResponse(id);
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Use View annotation to specify which template to use to render the response.
5 The @Get annotation maps the method to an HTTP GET request.
6 Set the response content-type to HTML with the @Produces annotation.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.
Rooms Edit Views

Add Thymeleaf templates to render an edit form.

initial/src/main/resources/views/rooms/edit.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <script></script>
</head>
<body>
    <main>
        <h1 th:text="#{room.edit}"></h1>
<p th:replace="rooms/_edit :: edit(${room})"></p>
        <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.show}"></a> |
        <a href="/rooms" th:text="#{action.back}"></a>
    </main>
</body>
</html>
initial/src/main/resources/views/rooms/_edit.html
<form th:fragment="edit(room)"
      action="/rooms/update"
      accept-charset="UTF-8"
      method="post">
    <input type="hidden" th:value="${room.id}" name="id" />
    <div class="field">
        <label for="room_name" th:text="#{room.name}"></label>
        <input type="text" th:value="${room.name}" name="name" id="room_name" />
    </div>
    <div class="actions">
        <input type="submit" name="commit" th:value="#{room.update}"/>
    </div>
</form>

11.3.6. Rooms Update Controller

Create a controller which handles the room update form submission.

initial/src/main/java/example/micronaut/controllers/RoomsControllerUpdate.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

@Controller("/rooms") (1)
public class RoomsControllerUpdate extends RoomsController {

    public RoomsControllerUpdate(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @Produces(MediaType.TEXT_HTML) (4)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED) (5)
    @Post("/update") (6)
    HttpResponse<?> update(@Body("id") Long id,  (7)
                           @Body("name") String name) { (7)
        roomRepository.update(id, name);
        return redirectTo("/rooms", id);
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Set the response content-type to HTML with the @Produces annotation.
5 A Micronaut controller action consumes application/json by default. Consuming other content types is supported with the @Consumes annotation or the consumes member of any HTTP method annotation.
6 The @Post annotation maps the method to an HTTP POST request.
7 You can use a qualifier within the HTTP request body. For example, you can use a reference to a nested JSON attribute.

11.3.7. Rooms Delete Controller

Create a controller which handles the room deletion form submission.

initial/src/main/java/example/micronaut/controllers/RoomsControllerDelete.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;

import java.net.URI;
import java.net.URISyntaxException;

@Controller("/rooms") (1)
public class RoomsControllerDelete extends RoomsController {

    public RoomsControllerDelete(RoomRepository roomRepository) { (2)
        super(roomRepository);
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (3)
    @Produces(MediaType.TEXT_HTML) (4)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED) (5)
    @Post("/{id}/delete") (6)
    HttpResponse<?> delete(@PathVariable Long id) throws URISyntaxException { (7)
        roomRepository.deleteById(id);
        return HttpResponse.seeOther(new URI("/rooms"));
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
2 Use constructor injection to inject a bean of type RoomRepository.
3 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
4 Set the response content-type to HTML with the @Produces annotation.
5 A Micronaut controller action consumes application/json by default. Consuming other content types is supported with the @Consumes annotation or the consumes member of any HTTP method annotation.
6 The @Post annotation maps the method to an HTTP POST request.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.

11.3.8. Message Create Controller

Create a controller to display a form to create a message within a room.

initial/src/main/java/example/micronaut/controllers/MessagesControllerCreate.java
package example.micronaut.controllers;

import example.micronaut.repositories.RoomRepository;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.views.View;

import java.util.Collections;
import java.util.Map;
import java.util.Optional;

@ExecuteOn(TaskExecutors.BLOCKING) (1)
@Controller("/rooms") (2)
class MessagesControllerCreate extends ApplicationController {
    private final RoomRepository roomRepository;

    public MessagesControllerCreate(RoomRepository roomRepository) { (3)
        this.roomRepository = roomRepository;
    }

    @View("/messages/create") (4)
    @Produces(MediaType.TEXT_HTML) (5)
    @Get("/{id}/messages/create") (6)
    HttpResponse<?> create(@PathVariable Long id) { (7)
        return modelResponse(id);
    }

    private HttpResponse<?> modelResponse(@NonNull Long id) {
        return model(id).map(HttpResponse::ok).orElseGet(HttpResponse::notFound);
    }

    private Optional<Map<String, Object>> model(@NonNull Long id) {
        return roomRepository.findById(id)
                .map(room -> Collections.singletonMap(RoomsController.ROOM, room));
    }
}
1 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
2 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
3 Use constructor injection to inject a bean of type MessageService.
4 Use View annotation to specify which template to use to render the response.
5 Set the response content-type to HTML with the @Produces annotation.
6 The @Get annotation maps the method to an HTTP GET request.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.
Message Create Views

The controller uses Thymeleaf views.

initial/src/main/resources/views/messages/_create.html
<form th:fragment="create(room)"
      th:action="@{|/rooms/${room.id}/messages|}"
      accept-charset="UTF-8"
      method="post">
    <div class="field">
        <input type="text" name="content" id="message_content"/>
        <input type="submit" name="commit" th:value="#{message.send}"/>
    </div>
</form>
initial/src/main/resources/views/messages/_message.html
<p th:fragment="message(message)" th:id="@{|message_${message.id}|}">
    [[${message.formattedDateCreated()}]]: [[${message.content}]]
</p>
initial/src/main/resources/views/messages/create.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
    <head>
        <script></script>
    </head>
<body>
    <main>
    <h1 th:text="#{message.new}"></h1>
<form th:replace="messages/_create :: create(${room})"></form>
    <a th:href="@{|/rooms/${room.id}|}" th:text="#{action.back}"></a>
    </main>
</body>
</html>

11.3.9. Message Save Controller

Create a controller which handles the message creation form submission.

initial/src/main/java/example/micronaut/controllers/MessagesControllerSave.java
package example.micronaut.controllers;

import example.micronaut.models.MessageForm;
import example.micronaut.models.RoomMessage;
import example.micronaut.services.MessageService;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.annotation.Produces;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Optional;

@ExecuteOn(TaskExecutors.BLOCKING) (1)
@Controller("/rooms") (2)
class MessagesControllerSave extends ApplicationController {
    private static final Logger LOG = LoggerFactory.getLogger(MessagesControllerSave.class);
    private final MessageService messageService;

    public MessagesControllerSave(MessageService messageService) {  (3)
        this.messageService = messageService;
    }

    @Produces(MediaType.TEXT_HTML) (4)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED) (5)
    @Post("/{id}/messages") (6)
    HttpResponse<?> save(@PathVariable Long id, (7)
                         @Body("content") String content) { (8)
        Optional<RoomMessage> roomMessageOptional = messageService.save(new MessageForm(id, content));
        return roomMessageOptional.map(roomMessage -> redirectTo("/rooms", id))
                .orElseGet(HttpResponse::notFound);
    }
}
1 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
2 The class is defined as a controller with the @Controller annotation mapped to the path /rooms.
3 Use constructor injection to inject a bean of type MessageService.
4 Set the response content-type to HTML with the @Produces annotation.
5 A Micronaut controller action consumes application/json by default. Consuming other content types is supported with the @Consumes annotation or the consumes member of any HTTP method annotation.
6 The @Post annotation maps the method to an HTTP POST request.
7 You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable.
8 You can use a qualifier within the HTTP request body. For example, you can use a reference to a nested JSON attribute.

12. Test Resources

When the application is started locally — either under test or by running the application — resolution of the datasource URL is detected and the Test Resources service will start a local MySQL docker container, and inject the properties required to use this as the datasource.

For more information, see the JDBC section or R2DBC section of the Test Resources documentation.

13. Running the application

To run the application, use the ./gradlew run command, which starts the application on port 8080.

14. License

All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…​).