Skip to content
Learni
View all tutorials
Backend

How to Create a REST API with Micronaut in 2026

Lire en français

Introduction

Micronaut, a modern JVM framework launched in 2018, excels in microservices thanks to its AOT (Ahead-Of-Time) compilation, reducing memory usage by 50-70% compared to Spring Boot. In 2026, it dominates for serverless APIs and Kubernetes-native apps, with startup times under 50ms.

This intermediate tutorial teaches you how to create a REST API CRUD for managing books (title, author, ISBN), integrating Micronaut Data JPA, in-memory H2, validation, tests, and YAML configuration. Ideal for migrating from Spring or scaling Java apps. At the end, you'll have a deployable project on GraalVM Native or Docker—bookmark it for reference!

Prerequisites

  • Java 17+ (SDKMAN recommended: sdk install java 21-tem)
  • Gradle 8+ or Maven 3.9+
  • IDE: IntelliJ IDEA or VS Code with Java Extension Pack
  • Micronaut CLI: sdk install micronaut 4.3.0 (optional, we use Gradle)
  • Knowledge: Intermediate Java, REST, annotations (@Controller, @Inject)

Initialize the Gradle Project

terminal
mkdir micronaut-api-livres
cd micronaut-api-livres
gradle init --type java-library --dsl groovy --test-framework junit-jupiter --project-name micronaut-api-livres --package com.learni.micronaut

cat > build.gradle << 'EOF'
plugins {
    id 'java'
    id 'io.micronaut.application' version '4.3.4'
    id 'org.graalvm.buildtools.native' version '0.10.2'
}

version '0.1'
group = 'com.learni.micronaut'

repositories {
    mavenCentral()
}

micronaut {
    runtime 'netty'
    testRuntime 'junit5'
    processing {
        incremental true
        annotations 'com.learni.micronaut.*'
    }
}

dependencies {
    micronautInjectRuntime 'io.micronaut:micronaut-inject'
    micronautJacksonDatabindRuntime 'io.micronaut.jackson:micronaut-jackson-databind'
    micronautValidationRuntime 'io.micronaut.validation:micronaut-validation'

    micronautDataRuntime 'io.micronaut.data:micronaut-data-hibernate-jpa'
    micronautDataHibernateJpaRuntime 'io.micronaut.data:micronaut-hibernate-jpa'
    runtimeOnly 'com.h2database:h2'

    testAnnotationProcessor 'io.micronaut:micronaut-inject-java'
    testAnnotationProcessor 'io.micronaut.validation:micronaut-validation-processor'
    testImplementation 'io.micronaut.test:micronaut-test-junit5'
}

test {
    useJUnitPlatform()
}
EOF
gradle wrapper
gradle build

This script initializes a Gradle project with the essential Micronaut plugins: application for the HTTP server, GraalVM for native images. Dependencies include Data JPA with H2 for quick CRUD without external config. Run it to get a functional skeleton; ./gradlew build compiles without errors.

Project Structure

After building, create src/main/java/com/learni/micronaut/ for packages: domain (entities), repository (DAO), service (business logic), controller (REST endpoints). Micronaut automatically scans via annotations.

Define the Book Entity

src/main/java/com/learni/micronaut/domain/Livre.java
package com.learni.micronaut.domain;

import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

@MappedEntity
public class Livre {

    @Id
    @GeneratedValue
    private Long id;

    @NotBlank
    private String titre;

    @NotBlank
    private String auteur;

    @Pattern(regexp = "\\d{13}")
    private String isbn;

    public Livre() {}

    public Livre(String titre, String auteur, String isbn) {
        this.titre = titre;
        this.auteur = auteur;
        this.isbn = isbn;
    }

    // Getters et setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitre() { return titre; }
    public void setTitre(String titre) { this.titre = titre; }
    public String getAuteur() { return auteur; }
    public void setAuteur(String auteur) { this.auteur = auteur; }
    public String getIsbn() { return isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }
}

The @MappedEntity entity automatically maps to JPA/Hibernate. @Id @GeneratedValue handles auto-increment, Jakarta Bean Validation protects inputs (ISBN 13 digits). Constructors and getters/setters required for JSON/ORM.

Create the Repository

src/main/java/com/learni/micronaut/repository/LivreRepository.java
package com.learni.micronaut.repository;

import com.learni.micronaut.domain.Livre;
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.jpa.repository.JpaRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.r2dbc.annotation.R2dbcRepository;

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

@Repository(dialect = Dialect.H2)
public interface LivreRepository extends JpaRepository<Livre, Long> {

    List<Livre> findByAuteur(String auteur);

    Optional<Livre> findByIsbn(String isbn);

    void deleteByIsbn(String isbn);
}

Interface extending JpaRepository: CRUD generated automatically (save, findAll, deleteById). Custom methods via query derivation (findByAuteur). H2 dialect for compatibility. Auto-injection in services.

Database Configuration

Coming up: Service and Controller. First, configure H2 in YAML for quick startup.

YAML Configuration File

src/main/resources/application.yml
micronaut:
  application:
    name: api-livres

datasources:
  default:
    url: jdbc:h2:mem:devDb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driverClassName: org.h2.Driver
    username: sa
    password: ''
    schema-generate: CREATE_DROP

jpa.default.properties.hibernate:
  hbm2ddl:
    auto: update
  dialect: org.hibernate.dialect.H2Dialect

logger:
  levels:
    com.learni: DEBUG

endpoints:
  health:
    enabled: true
    sensitive: false

YAML takes precedence over properties. CREATE_DROP recreates the DB on each restart (dev only). JPA auto-scans entities. Health endpoint for Kubernetes/Docker monitoring. DEBUG logs for debugging.

Implement the Service

src/main/java/com/learni/micronaut/service/LivreService.java
package com.learni.micronaut.service;

import com.learni.micronaut.domain.Livre;
import com.learni.micronaut.repository.LivreRepository;
import io.micronaut.transaction.annotation.ReadOnly;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;

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

@Singleton
public class LivreService {

    private final LivreRepository repository;

    public LivreService(LivreRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Livre save(Livre livre) {
        return repository.save(livre);
    }

    @ReadOnly
    public List<Livre> findAll() {
        return repository.findAll();
    }

    @ReadOnly
    public Optional<Livre> findById(Long id) {
        return repository.findById(id);
    }

    @Transactional
    public void deleteById(Long id) {
        repository.deleteById(id);
    }

    @ReadOnly
    public List<Livre> findByAuteur(String auteur) {
        return repository.findByAuteur(auteur);
    }
}

@Singleton for DI lifecycle. @Transactional handles ACID, @ReadOnly optimizes reads (no dirty checks). Inject repo via constructor (preferred over field injection). Business logic centralized.

Develop the REST Controller

src/main/java/com/learni/micronaut/controller/LivreController.java
package com.learni.micronaut.controller;

import com.learni.micronaut.domain.Livre;
import com.learni.micronaut.service.LivreService;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.*;
import io.micronaut.validation.Validated;
import jakarta.inject.Inject;

import java.util.List;

@Controller("/livres")
@Validated
public class LivreController {

    @Inject
    private LivreService service;

    @Get
    public List<Livre> list() {
        return service.findAll();
    }

    @Get("/{id}")
    public HttpResponse<Livre> get(Long id) {
        return service.findById(id)
                .map(HttpResponse::ok)
                .orElse(HttpResponse.notFound());
    }

    @Post
    public Livre create(@Body Livre livre) {
        return service.save(livre);
    }

    @Put("/{id}")
    public HttpResponse<Livre> update(Long id, @Body Livre livre) {
        return service.findById(id)
                .map(entity -> {
                    livre.setId(id);
                    return HttpResponse.ok(service.save(livre));
                })
                .orElse(HttpResponse.notFound());
    }

    @Delete("/{id}")
    public HttpResponse<?> delete(Long id) {
        service.deleteById(id);
        return HttpResponse.ok(null);
    }

    @Get("/auteur/{auteur}")
    public List<Livre> byAuteur(String auteur) {
        return service.findByAuteur(auteur);
    }
}

@Controller("/livres") maps routes. @Validated enables Bean validation. @Body binds JSON, HttpResponse for 200/404 status. PUT idempotent via findById. Test with curl: curl -X POST http://localhost:8080/livres -H 'Content-Type: application/json' -d '{"titre":"1984","auteur":"Orwell","isbn":"9781234567890"}'.

Run and Test the API

./gradlew run starts on http://localhost:8080. Health: GET /health. CRUD via Postman/cURL. Add the JUnit test below.

Integration Test

src/test/java/com/learni/micronaut/LivreControllerSpec.java
package com.learni.micronaut;

import com.learni.micronaut.domain.Livre;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import org.junit.jupiter.api.Test;

import jakarta.inject.Inject;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest
public class LivreControllerSpec {

    @Inject
    @Client("/livres")
    HttpClient client;

    @Test
    void testListIsEmpty() {
        List<Livre> livres = client.toBlocking().exchange(HttpRequest.GET("/"), List.class);
        assertTrue(livres.isEmpty());
    }

    @Test
    void testCreateAndGet() {
        Livre livre = new Livre("Test", "Auteur", "1234567890123");
        HttpResponse<Livre> response = client.toBlocking().exchange(HttpRequest.POST("/", livre), Livre.class);
        assertEquals(HttpStatus.CREATED, response.getStatus());

        Long id = response.body().getId();
        assertNotNull(id);

        HttpResponse<Livre> getResponse = client.toBlocking().exchange(HttpRequest.GET("/" + id), Livre.class);
        assertEquals(HttpStatus.OK, getResponse.getStatus());
        assertEquals("Test", getResponse.body().getTitre());
    }
}

@MicronautTest boots test context (H2 auto). @Client mocks HTTP client. Tests POST/GET with assertions. ./gradlew test validates everything. Covers 80% happy/error cases.

Best Practices

  • AOT-friendly: Avoid heavy reflection; use Micronaut annotations.
  • Validation everywhere: @Valid on params, @NotNull on entities.
  • Granular transactions: @Transactional only on writes.
  • Native image: ./gradlew nativeCompile for <50MB binary.
  • Metrics/Tracing: Add micronaut-micrometer for Prometheus.

Common Errors to Avoid

  • Forget dialect in @Repository → H2/Postgres SQL errors.
  • No @Transactional on save → Missing rollback on error.
  • Field injection (@Inject field) instead of ctor → Hard tests, DI cycles.
  • Poorly indented YAML → Config ignored, empty properties fallback.

To Go Further

  • Official docs: Micronaut Guide
  • Advanced: Reactive R2DBC, OAuth2 Security, gRPC.
  • Deploy: Docker + Kubernetes with Micronaut Oracle.
  • Learni Dev Trainings for Java/Micronaut masterclass.