Hotwire Turbo Chat with Micronaut Views
This guide shows how to build a Micronaut Framework chat application such as the Rails application demonstrated in the Hotwire announcement screencast.
On this guide
In this section
What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 21 or greater installed with
JAVA_HOMEconfigured appropriately
Getting started
This guide shows a chat application such as the Rails application demonstrated in the Hotwire announcement screencast.
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.
Screencast
The following screencast shows this guide in action:
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.
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 Thymeleaf 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.
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>
...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.
Highlight Frames
To see how the Frames work easily, we’ll call them out with a blue border.
turbo-frame {
display: block;
border: 1px solid blue;
}Turbo Frame Show View
Now let’s wrap the Room name and the ability to edit it inside a Frame.
Replace this:
<!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:
<!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.
Turbo Frame Edit View
Replace this:
<!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:
<!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 omitting from the frame.
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:
....
<body>
<main>
...
<turbo-frame id="room">
...
<a href="/rooms" th:text="#{action.back}"></a>
</p>
</turbo-frame>
....with:
....
<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.
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:
...
...
<a href="/messages/create" th:text="#{message.new}"></a>
</main>
</body>
</html>with:
....
<turbo-frame id="new_message"
th:src="@{|/rooms/${room.id}/messages/create|}"
target="_top"></turbo-frame>
</main>
</body>
</html>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:
<!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:
<!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.
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 the RoomsControllerEdit::edit method with @TurboFrameView("/rooms/_edit").
The above controller returns the following HTML for a request without the 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>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.
Now we can add Messages to the page without resetting it completely.
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. _
Add a Stimulus controller:
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
export default class extends Controller {
reset() {
this.element.reset()
}
}and register it:
-->
<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:
<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.
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.
Events
When the message is saved, raise an event:
WebSocket Server
Create a WebSocket Server, which publishes a Turbo Stream when a message event is received.
Establish a WebSocket connection to the WebSocket server identified by the Room we’re in.
<!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.
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.
Appendix A: Initial application
The following sections introduce you to the initial application.
Data Source configuration
Define the datasource in src/main/resources/application.properties.
datasources.default.dialect=MYSQL
datasources.default.driver-class-name=${JDBC_DRIVER:com.mysql.cj.jdbc.Driver}|
Note
|
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. |
Database Schema
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:
<dependency>
<groupId>io.micronaut.flyway</groupId>
<artifactId>micronaut-flyway</artifactId>
<scope>compile</scope>
</dependency>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:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<version>@flyway-mysqlVersion@</version>
<scope>compile</scope>
</dependency>|
Note
|
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:
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
);Entities
The application contains two entities with a one-to-many relationship.
Models
The application includes a POJO to map the form submission when the user submits a message to a room.
The application includes a POJO which represents a room’s message.
Repositories
The application includes a repository per entity.
Services
The application contains a service that publishes an event when a message is saved.
Static Resources
Update application.properties to add static resource configuration:
Views
To use the Thymeleaf Java template engine to render views in a Micronaut application, add the following dependency on your classpath.
<dependency>
<groupId>io.micronaut.views</groupId>
<artifactId>micronaut-views-thymeleaf</artifactId>
<scope>compile</scope>
</dependency>The initial application uses Thymeleaf Fragments to organize the views.
It uses a root layout:
<!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>Properties
Create a default messages.properties file:
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=DestroyCreate a messages_es.properties file for the Spanish locale:
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=EliminarMessage Source
Create a MessageSource that uses the previous properties files:
Controllers
The apex url is redirected to /rooms.
We have an abstract class to simplify redirection:
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.
Rooms Index Controller
Create a controller that displays a list of rooms.
Rooms Index Views
The controller uses Thymeleaf to render server-side 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><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><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>Rooms Show Controller
Create a controller that displays a room.
Rooms Show Views
Add Thymeleaf templates to render server-side 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><p th:fragment="room(room)" th:id="@{|room_${room.id}|}">
<strong th:text="#{room.name}">:</strong>
[[${room.name}]]
</p>Rooms Create Controller
Create a controller that displays a form to create a room.
Rooms Create Views
Add Thymeleaf templates to render a form.
<!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><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>Rooms Save Controller
Create a controller that handles the room creation form submission.
Rooms Edit Controller
Create a controller that shows a form to edit a room.
Rooms Edit Views
Add Thymeleaf templates to render an edit form.
<!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><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>Rooms Update Controller
Create a controller that handles the room update form submission.
Rooms Delete Controller
Create a controller that handles the room deletion form submission.
Message Create Controller
Create a controller to display a form to create a message within a room.
Message Create Views
The controller uses Thymeleaf views.
<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><p th:fragment="message(message)" th:id="@{|message_${message.id}|}">
[[${message.formattedDateCreated()}]]: [[${message.content}]]
</p><!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>Message Save Controller
Create a controller that handles the message creation form submission.
Test Resources
When the application is started locally, either under test or while running locally, 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.
Running the application
To run the application, use the ./mvnw mn:run command, which starts the application on port 8080.
License
|
Note
|
All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images). |