Micronaut #

Micronaut adalah framework JVM modern yang dirancang sejak awal untuk cold-start time secepat native image dan memory footprint sekecil mungkin — tanpa mengorbankan produktivitas developer yang sudah terbiasa dengan ekosistem Java. Berbeda dengan Spring Boot yang menggunakan reflection dan classpath scanning saat runtime, Micronaut melakukan dependency injection, AOP, dan konfigurasi bean di compile-time lewat annotation processor. Hasilnya: startup dalam hitungan milidetik, tidak ada proxy runtime, dan tidak ada lazy loading yang tidak perlu.

Docker Compose melengkapi kekuatan ini dengan cara yang elegan — kamu bisa menjalankan seluruh stack (Micronaut app, Postgres, Redis) dengan satu perintah, tanpa harus install Java, Maven, atau service backend secara manual di host. Artikel ini membahas setup lengkap Micronaut untuk local development: dari Dockerfile multi-stage, konfigurasi docker-compose.yml dengan dependency service, hot reload pakai mn:run, sampai integrasi Micronaut Data, Flyway, dan @MicronautTest untuk testing.

Prasyarat #

Pastikan environment lokal kamu sudah punya:

  • Docker dan Docker Compose versi terbaru (Compose V2 sudah built-in di Docker Desktop)
  • Java 21+ (LTS) — Micronaut 4.x butuh minimal Java 17, tapi Java 21 sudah mainstream dan lebih future-proof
  • Maven 3.8+ atau Gradle 8+ (Gradle lebih cepat untuk build incremental, tapi Maven lebih familiar di banyak tim enterprise)
  • Micronaut CLI (mn) — untuk scaffolding project dan task management

Project Micronaut standar yang dibuat lewat mn create-app punya struktur seperti ini:

my-micronaut-app/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/example/
│   │   │       ├── Application.java
│   │   │       ├── controller/
│   │   │       ├── service/
│   │   │       └── repository/
│   │   └── resources/
│   │       ├── application.yml
│   │       ├── logback.xml
│   │       └── db/migration/
│   └── test/
│       └── java/
├── pom.xml
├── mvnw
├── mvnw.cmd
├── .mvn/
├── Dockerfile
├── docker-compose.yml
├── .env
└── .dockerignore

Filosofi Docker untuk Micronaut #

Sebelum masuk ke konfigurasi, penting untuk dipahami: local development mengutamakan feedback loop, bukan optimasi runtime. Artinya:

  • Boleh pakai image besar — JDK 300MB tidak masalah untuk laptop developer. Image sekecil-kecilnya adalah target production.
  • Boleh pakai build lebih lambat — yang penting iterasi cepat (edit → reload → cek).
  • Boleh mount source code langsung — supaya perubahan di IDE langsung terlihat di container.
  • Boleh skip multi-stage — Dockerfile dev cukup single stage, JRE base, command langsung mvn mn:run.

Tujuan utama Docker di local development Micronaut:

  • Konsistensi environment — semua developer pakai versi Java dan library yang sama
  • Onboarding cepat — developer baru tinggal docker compose up lalu jalan
  • Iterasi cepat — hot reload dengan mn:run restart dalam hitungan detik
  • Setup dependency otomatis — Postgres, Redis, dan service lain start bersamaan dengan app

Arsitektur Stack #

Setup Micronaut untuk local development biasanya melibatkan:

  • Micronaut app — service utama yang kamu develop
  • PostgreSQL — database paling umum untuk Micronaut Data
  • Redis — untuk cache, session, atau rate limiting
  • (Opsional) Kafka — untuk event-driven architecture
  • (Opsional) pgAdmin atau Adminer — UI untuk inspect database
flowchart TB
    subgraph Dev["Local Machine"]
        IDE[IDE - IntelliJ / VS Code]
    end

    subgraph Compose["Docker Compose Stack"]
        APP[Micronaut App<br/>mn:run :8080]
        DB[(PostgreSQL<br/>:5432)]
        CACHE[(Redis<br/>:6379)]
    end

    IDE -- edit & save --> APP
    APP -- JDBC --> DB
    APP -- Lettuce / Redis client --> CACHE

Semua service berjalan di network internal Compose. Micronaut app bisa resolve hostname db dan cache langsung tanpa tahu IP address.

Dockerfile Multi-Stage #

Micronaut tidak punya CLI Docker bawaan sekaya Quarkus, jadi kamu perlu tulis Dockerfile sendiri. Untuk local development, single stage dengan JDK base sudah cukup — kamu butuh JDK karena Micronaut pakai annotation processor dan mn:run menjalankan Maven goal. Tapi karena artikel ini juga membahas production, kita buat multi-stage dengan dev override.

# syntax=docker/dockerfile:1.6

# ============ Stage 1: Builder ============
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /build

# Tool tambahan untuk debugging dan healthcheck
RUN apk add --no-cache bash curl

# Copy descriptor dulu untuk cache layer dependency
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw -B dependency:go-offline

# Copy source code dan build JAR
COPY src ./src
RUN ./mvnw -B package -DskipTests

# Extract layered JAR (Micronaut support sejak 3.x)
RUN mkdir -p target/extracted && \
    cd target/extracted && \
    unzip -q ../*.jar

# ============ Stage 2: Runtime Production ============
FROM eclipse-temurin:21-jre-alpine AS runtime

WORKDIR /app

# Copy layered JAR
COPY --from=builder /build/target/extracted/BOOT-INF/lib /app/lib
COPY --from=builder /build/target/extracted/BOOT-INF/classes /app/classes
COPY --from=builder /build/target/extracted/META-INF /app/META-INF

# Non-root user
RUN addgroup -S micronaut && adduser -S micronaut -G micronaut
USER micronaut:micronaut

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -cp 'classes:lib/*' com.example.Application"]

# ============ Stage 3: Development ============
FROM eclipse-temurin:21-jdk-alpine AS dev

WORKDIR /app

RUN apk add --no-cache bash curl

# Copy semua yang dibutuhkan untuk run via Maven
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw

# Source code di-mount lewat volume di Compose, jadi tidak perlu COPY src
# TAPI: pre-warm dependency cache agar first build cepat
RUN ./mvnw -B dependency:go-offline || true

EXPOSE 8080

# Default command: jalankan mn:run (override via compose command)
CMD ["./mvnw", "mn:run"]

Penjelasan Tiap Stage #

Builder stage — berisi JDK dan Maven untuk compile. Layer dependency:go-offline meng-cache semua dependency, sehingga perubahan source code tidak invalidate layer ini. Build berikutnya hanya butuh compile dan package, biasanya 10–30 detik.

Runtime stage — image final untuk production. Cuma JRE, tanpa Maven. Extract JAR ke layer terpisah (lib, classes, META-INF) supaya Docker cache bisa reuse layer dependency saat source code berubah. Ini yang disebut layered JAR optimization — Spring Boot mengenalkan pola ini di 2.3, dan Micronaut support sejak 3.x.

Dev stage — JDK lengkap dengan Maven wrapper. Image ini yang dipakai untuk docker compose up di local. Berbeda dengan Spring Boot DevTools yang punya auto-restart di JVM, Micronaut andalkan mn:run yang melakukan restart pada proses Maven (bukan JVM — lebih lambat dari Spring DevTools, tapi cukup cepat untuk iterasi).

Tiga stage, bukan satu. Pemisahan dev dan runtime memastikan image development tetap berisi JDK dan Maven (untuk mn:run), sementara image production sekecil mungkin. Build dengan target stage spesifik: docker build --target dev -t myapp:dev . atau docker build --target runtime -t myapp:prod ..

docker-compose.yml untuk Development #

Berikut setup lengkap dengan Postgres, Redis, healthcheck, dan bind mount untuk hot reload:

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: dev  # pakai stage dev (bukan runtime)
    image: my-micronaut-app:dev
    container_name: micronaut-app
    command: ./mvnw mn:run
    volumes:
      - ./src:/app/src
      - ./pom.xml:/app/pom.xml
      - maven_cache:/root/.m2
      - ./target:/app/target
    ports:
      - "8080:8080"
      - "5005:5005"  # JDWP debug port
    environment:
      MICRONAUT_ENVIRONMENTS: dev
      MICRONAUT_DATASOURCES_DEFAULT_URL: jdbc:postgresql://db:5432/micronaut
      MICRONAUT_DATASOURCES_DEFAULT_USERNAME: app
      MICRONAUT_DATASOURCES_DEFAULT_PASSWORD: dev
      MICRONAUT_DATASOURCES_DEFAULT_DRIVER_CLASS_NAME: org.postgresql.Driver
      MICRONAUT_REDIS_URI: redis://cache:6379
      MICRONAUT_SERVER_PORT: 8080
      JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: micronaut-db
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: micronaut
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d micronaut"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: micronaut-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

  adminer:
    image: adminer:4.8.1
    container_name: micronaut-adminer
    ports:
      - "8081:8080"
    environment:
      ADMINER_DEFAULT_SERVER: db
    depends_on:
      - db

volumes:
  db-data:
  maven_cache:

Penjelasan Service #

app — service Micronaut utama. target: dev memilih stage dev dari Dockerfile (JDK + Maven). Command mn:run menjalankan aplikasi via Maven plugin dengan auto-restart saat source code berubah. Volume bind mount ./src ke /app/src membuat perubahan di IDE langsung sinkron ke container. Volume maven_cache menyimpan .m2 repository di named volume — kalau tidak, dependency akan di-download ulang setiap container restart.

db — PostgreSQL 16. Healthcheck pg_isready memastikan database benar-benar siap menerima koneksi sebelum app mencoba connect. Init script di ./db/init (jika ada) akan dijalankan saat container pertama kali start (bukan saat restart dengan data existing).

cache — Redis 7. Healthcheck pakai redis-cli ping yang return PONG saat Redis ready. Micronaut punya module micronaut-redis-lettuce untuk koneksi Redis reactive.

adminer — UI berbasis web untuk inspect database. Akses di http://localhost:8081, login pakai credential Postgres. Bisa diganti dengan pgAdmin atau DBeaver (berbasis desktop).

Healthcheck dan depends_on #

depends_on tanpa condition: service_healthy hanya menunggu container start, bukan service siap. Postgres butuh 2–5 detik untuk initialize cluster, jadi app bisa saja crash dengan “Connection refused” kalau start sebelum Postgres siap. Selalu pakai healthcheck untuk service yang punya initialization time.

Hot Reload dengan mn:run #

Berbeda dengan Spring Boot DevTools atau Quarkus Dev Mode, Micronaut tidak punya hot reload seketika. Restart dilakukan oleh mn:run dengan cara:

  1. Deteksi perubahan di source code (polling setiap beberapa detik)
  2. Kill proses lama (SIGTERM)
  3. Compile ulang dengan annotation processor
  4. Start proses baru dengan classpath yang sama

Total waktu restart: 3–8 detik untuk project kecil-menengah. Lebih lambat dari Quarkus (~1 detik) atau JRebel, tapi cukup cepat untuk kebanyakan workflow development.

pom.xml — dependency untuk development #

<dependencies>
  <!-- Core Micronaut -->
  <dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-http-server-netty</artifactId>
  </dependency>

  <!-- Validation -->
  <dependency>
    <groupId>io.micronaut.validation</groupId>
    <artifactId>micronaut-validation</artifactId>
  </dependency>

  <!-- Data access -->
  <dependency>
    <groupId>io.micronaut.data</groupId>
    <artifactId>micronaut-data-jdbc</artifactId>
  </dependency>
  <dependency>
    <groupId>io.micronaut.sql</groupId>
    <artifactId>micronaut-jdbc-hikari</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Flyway untuk schema migration -->
  <dependency>
    <groupId>io.micronaut.flyway</groupId>
    <artifactId>micronaut-flyway</artifactId>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Redis -->
  <dependency>
    <groupId>io.micronaut.redis</groupId>
    <artifactId>micronaut-redis-lettuce</artifactId>
  </dependency>

  <!-- Management & health -->
  <dependency>
    <groupId>io.micronaut</groupId>
    <artifactId>micronaut-management</artifactId>
  </dependency>

  <!-- Logging -->
  <dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <scope>runtime</scope>
  </dependency>

  <!-- Test -->
  <dependency>
    <groupId>io.micronaut.test</groupId>
    <artifactId>micronaut-test-junit5</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

application.yml — konfigurasi #

micronaut:
  application:
    name: my-micronaut-app
  server:
    port: 8080
  router:
    static-resources:
      default:
        enabled: true
        paths: classpath:public
  http:
    services:
      # default http client config

datasources:
  default:
    url: jdbc:postgresql://localhost:5432/micronaut
    username: app
    password: dev
    driver-class-name: org.postgresql.Driver
    schema-generate: NONE  # pakai Flyway
    dialect: POSTGRES
    maximum-pool-size: 10

flyway:
  datasources:
    default:
      enabled: true
      locations: classpath:db/migration
      baseline-on-migrate: true

redis:
  uri: redis://localhost:6379

endpoints:
  health:
    enabled: true
    sensitive: false
    details-visible: ANONYMOUS
  all:
    sensitive: false

jackson:
  serialization:
    indent-output: true
Override konfigurasi via environment variable. Micronaut baca environment variable dengan pattern MICRONAUT_DATASOURCES_DEFAULT_URL, REDIS_URI, dll. Format Compose di atas pakai pendekatan ini supaya tidak perlu rebuild image saat konfigurasi berubah. Untuk production, simpan di secrets manager atau config file yang di-mount, bukan environment variable di Dockerfile.

Micronaut Data untuk Database Access #

Micronaut Data adalah ORM ringan yang terinspirasi dari GORM (Groovy) dan Spring Data. Berbeda dengan Hibernate yang pakai reflection, Micronaut Data compile repository implementation di build-time lewat annotation processor. Hasilnya: tidak ada startup overhead, tidak ada proxy runtime, dan query dieksekusi langsung ke JDBC.

Entity #

package com.example.entity;

import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.MappedProperty;
import io.micronaut.data.annotation.DateCreated;
import io.micronaut.data.annotation.DateUpdated;
import io.micronaut.serde.annotation.Serdeable;

import java.time.Instant;

@Serdeable
@MappedEntity("users")
public class User {

    @Id
    @GeneratedValue(GeneratedValue.Type.IDENTITY)
    private Long id;

    @MappedProperty("email")
    private String email;

    @MappedProperty("name")
    private String name;

    @DateCreated
    @MappedProperty("created_at")
    private Instant createdAt;

    @DateUpdated
    @MappedProperty("updated_at")
    private Instant updatedAt;

    // constructors, getters, setters
    public User() {}

    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

Repository #

package com.example.repository;

import com.example.entity.User;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;

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

@JdbcRepository(dialect = Dialect.POSTGRES)
public interface UserRepository extends CrudRepository<User, Long> {

    // Method-name query (compile-time)
    Optional<User> findByEmail(String email);

    List<User> findByNameContaining(String nameFragment);

    long countByEmail(String email);

    // Custom query dengan @Query
    @io.micronaut.data.annotation.Query("SELECT * FROM users WHERE created_at > :since")
    List<User> findRecentUsers(java.time.Instant since);
}

Controller #

package com.example.controller;

import com.example.entity.User;
import com.example.repository.UserRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;
import io.micronaut.validation.Validated;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

import java.util.List;

@Controller("/users")
@Validated
@Produces(MediaType.APPLICATION_JSON)
public class UserController {

    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Get
    public List<User> list() {
        return (List<User>) userRepository.findAll();
    }

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

    @Post
    @Consumes(MediaType.APPLICATION_JSON)
    public HttpResponse<User> create(@Body @Valid UserRequest request) {
        User user = new User(request.email(), request.name());
        User saved = userRepository.save(user);
        return HttpResponse.created(saved);
    }

    public record UserRequest(
        @NotBlank @Email String email,
        @NotBlank String name
    ) {}
}

Pattern ini sangat berbeda dari Spring Data JPA: tidak ada JpaRepository, tidak ada @Entity dari JPA, dan tidak ada EntityManager injection. Micronaut Data generate implementasi JDBC langsung di compile-time.

Schema Migration dengan Flyway #

Flyway adalah tool schema migration yang paling mature di ekosistem JVM. Micronaut punya module micronaut-flyway yang otomatis jalankan migration saat startup.

Struktur migration files #

src/main/resources/db/migration/
├── V1__create_users.sql
├── V2__create_orders.sql
└── V3__add_user_index.sql

V1__create_users.sql #

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

V2__create_orders.sql #

CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total_amount DECIMAL(10, 2) NOT NULL,
  status VARCHAR(50) NOT NULL DEFAULT 'pending',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

Flyway otomatis baca file SQL di db/migration sesuai prefix versi (V1__, V2__, dst.) dan run saat aplikasi start. Tracking table flyway_schema_history menyimpan migration yang sudah dijalankan. Untuk reset, hapus volume database:

docker compose down -v  # hapus db-data
docker compose up -d
Jangan edit migration yang sudah di-apply di production. Migration harus immutable setelah di-deploy. Untuk fix bug, buat migration baru (V4__fix_users_table.sql) yang melakukan ALTER atau data fix. Ini menjamin konsistensi antara environment dan mencegah “works on my machine”.

Health Endpoints Built-in #

Micronaut punya built-in health checks via module micronaut-management. Tidak perlu dependency tambahan — endpoint langsung tersedia di /health (atau path lain sesuai konfigurasi).

endpoints:
  health:
    enabled: true
    sensitive: false
    details-visible: ANONYMOUS

Endpoint yang tersedia #

Endpoint Fungsi
GET /health Status keseluruhan (UP/DOWN)
GET /health/liveness Liveness probe — apakah proses hidup
GET /health/readiness Readiness probe — apakah app siap terima traffic
GET /health/diskSpace Detail penggunaan disk
GET /health/db Status koneksi database
GET /health/redis Status koneksi Redis

Untuk Kubernetes, biasanya mapping liveness dan readiness ke path terpisah:

endpoints:
  health:
    probes:
      enabled: true
    liveness:
      enabled: true
    readiness:
      enabled: true

Custom health check #

Tambah HealthIndicator untuk check dependency custom:

package com.example.health;

import io.micronaut.health.HealthStatus;
import io.micronaut.management.endpoint.health.HealthEndpoint;
import io.micronaut.management.health.indicator.HealthIndicator;
import io.micronaut.management.health.indicator.HealthResult;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

import java.util.Map;

@Singleton
public class ExternalApiHealthIndicator implements HealthIndicator {

    @Override
    public Publisher<HealthResult> getResult() {
        // call external API, return result
        boolean isUp = checkExternalApi();
        HealthResult result = isUp
            ? HealthResult.builder("external-api", HealthStatus.UP).build()
            : HealthResult.builder("external-api", HealthStatus.DOWN)
                .details(Map.of("error", "timeout")).build();
        return Mono.just(result);
    }

    private boolean checkExternalApi() {
        // implementation
        return true;
    }
}

Testing dengan @MicronautTest #

Micronaut Test adalah ekstensi JUnit 5 yang menyederhanakan integration test. Annotation @MicronautTest boot application context, dan kamu bisa inject bean apa pun yang kamu butuhkan.

Unit test service #

package com.example.service;

import com.example.entity.User;
import com.example.repository.UserRepository;
import io.micronaut.test.annotation.MockBean;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import java.util.Optional;

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

@MicronautTest
class UserServiceTest {

    @Inject
    UserService userService;

    @Inject
    UserRepository userRepository;

    @MockBean(UserRepository.class)
    UserRepository mockRepository() {
        return mock(UserRepository.class);
    }

    @Test
    void shouldFindUserByEmail() {
        User user = new User("[email protected]", "Test");
        when(userRepository.findByEmail("[email protected]"))
            .thenReturn(Optional.of(user));

        Optional<User> found = userService.findByEmail("[email protected]");

        assertTrue(found.isPresent());
        assertEquals("[email protected]", found.get().getEmail());
    }
}

Integration test dengan Testcontainers #

package com.example;

import com.example.repository.UserRepository;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

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

@MicronautTest
@Testcontainers
class UserRepositoryIntegrationTest implements TestPropertyProvider {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");

    @Inject
    UserRepository userRepository;

    @Override
    public Map<String, String> getProperties() {
        return Map.of(
            "datasources.default.url", postgres.getJdbcUrl(),
            "datasources.default.username", postgres.getUsername(),
            "datasources.default.password", postgres.getPassword()
        );
    }

    @Test
    void shouldSaveAndRetrieveUser() {
        var user = userRepository.save(new User("[email protected]", "Test"));

        var found = userRepository.findById(user.getId());

        assertTrue(found.isPresent());
        assertEquals("[email protected]", found.get().getEmail());
    }
}

Testcontainers otomatis pull image Postgres, jalankan container, dan inject konfigurasi lewat TestPropertyProvider. Container di-shutdown setelah test class selesai.

Debugging dari IDE #

Micronaut app di container bisa di-debug dari IntelliJ atau VS Code. Set JAVA_TOOL_OPTIONS di Compose untuk enable JDWP:

environment:
  JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
ports:
  - "5005:5005"

Di IntelliJ:

  1. Run → Edit Configurations → Add New → Remote JVM Debug
  2. Host: localhost, Port: 5005
  3. Command line arguments: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
  4. Set breakpoint di kode
  5. Run → Debug

Suspend=n artinya JVM start langsung, tidak menunggu debugger attach. Cocok untuk development yang tidak selalu butuh debugger. Kalau mau menunggu attach (untuk debug saat startup), set suspend=y.

Build dan Run #

# Build image
docker compose build

# Jalankan semua service di background
docker compose up -d

# Lihat log aplikasi (real-time)
docker compose logs -f app

# Restart hanya service app (tanpa rebuild)
docker compose restart app

# Stop semua service (data Postgres tetap)
docker compose down

# Stop dan hapus volume (reset database)
docker compose down -v

# Masuk ke container untuk debugging
docker compose exec app sh

Setelah docker compose up -d, akses:

  • App: http://localhost:8080
  • Health check: http://localhost:8080/health
  • Adminer (DB UI): http://localhost:8081
  • Postgres: localhost:5432 (user app, password dev)
  • Redis: localhost:6379

Profile Environment #

Micronaut pakai konsep environment untuk konfigurasi per-stage. Defaultnya ada dev, test, prod. Set via environment variable MICRONAUT_ENVIRONMENTS=dev,custom.

application.yml (default, production-like):

micronaut:
  application:
    name: my-micronaut-app

datasources:
  default:
    url: ${DATABASE_URL:`jdbc:postgresql://localhost:5432/micronaut`}
    username: ${DATABASE_USER:app}
    password: ${DATABASE_PASSWORD:dev}

application-dev.yml (development overrides):

datasources:
  default:
    url: jdbc:postgresql://db:5432/micronaut
    username: app
    password: dev

flyway:
  datasources:
    default:
      clean-disabled: false  # boleh drop & re-create saat dev

logger:
  levels:
    com.example: DEBUG
    io.micronaut.data.query: DEBUG

application-prod.yml (production):

datasources:
  default:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
    maximum-pool-size: 20

logger:
  levels:
    root: WARN
    com.example: INFO

Aktifkan profile dengan MICRONAUT_ENVIRONMENTS=prod atau --micronaut.environments=prod.

Best Practice #

Pisahkan Dockerfile Dev dan Prod #

Pakai multi-stage dengan dev dan runtime stage. Image dev berisi JDK dan Maven, image prod cuma JRE + JAR. Hindari satu image yang dipakai untuk keduanya — JDK di production adalah attack surface tambahan.

Cache Maven Repository Selalu #

volumes:
  - maven_cache:/root/.m2

Named volume untuk .m2 membuat dependency cache persist antar container restart. Tanpa ini, Maven download ratusan MB setiap kali container start.

Pakai Healthcheck untuk Service Dependency #

Database, Redis, dan message broker butuh waktu untuk siap. depends_on: condition: service_healthy membuat app menunggu healthcheck pass, bukan hanya container start.

Resource Limits untuk JVM #

services:
  app:
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: "1.0"

JVM tidak auto-detect Docker memory limit kecuali pakai MaxRAMPercentage. Set di Dockerfile:

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport"

Jangan Mount target/ secara Utuh #

volumes:
  - ./target:/app/target

Mount target memungkinkan reuse compiled classes antar container restart, tapi bisa konflik dengan annotation processor yang menulis ke target/classes. Jika muncul error aneh saat mn:run, hapus target volume dan biarkan container compile ulang.

Logging Configuration #

Default log Micronaut ke stdout/stderr. Untuk development, set level DEBUG atau TRACE untuk package tertentu:

logger:
  levels:
    com.example: DEBUG
    io.micronaut.http.server: DEBUG

Di production, pakai structured JSON log:

<dependency>
  <groupId>net.logstash.logback</groupId>
  <artifactId>logstash-logback-encoder</artifactId>
</dependency>

Pakai Layered JAR untuk Production #

Extract target/*.jar ke layer lib, classes, META-INF di Dockerfile. Layer dependency (lib) jarang berubah, layer classes sering berubah. Docker cache membuat rebuild sangat cepat.

Schema Migration di Production #

Jangan pakai schema-generate: CREATE_DROP atau UPDATE di production. Selalu pakai Flyway atau Liquibase dengan versioned migration files.

Troubleshooting #

Connection Refused ke Database #

app start sebelum Postgres siap. Pastikan:

  • Healthcheck db benar
  • depends_on: db: condition: service_healthy di app
  • Log Postgres menunjukkan “ready to accept connections”

Hot Reload Tidak Jalan #

Periksa:

  • Volume ./src:/app/src terpasang
  • mn:run benar-benar running (bukan mvn package)
  • File berubah di host (cek docker compose exec app ls -la /app/src)
  • Annotation processor tidak error (cek log untuk “annotation processing”)

Maven Build Lambat #

Biasanya karena dependency cache hilang. Pastikan volume maven_cache terpasang dan tidak ke-flush:

docker compose down --remove-orphans  # JANGAN pakai -v
docker compose up -d

OutOfMemoryError #

Tiga penyebab umum:

  • Heap terlalu kecil — naikkan JAVA_OPTS="-Xmx..." atau pakai MaxRAMPercentage
  • Connection pool terlalu besar — turunkan maximum-pool-size di datasource config
  • Memory limit container terlalu kecil — naikkan di deploy.resources.limits.memory

Cek memory usage container:

docker stats micronaut-app

Health Check DOWN #

Lihat detail di /health/details-visible: ANONYMOUS (dev only). Biasanya:

  • Database connection gagal — cek network antar container
  • Redis tidak ready — cek redis-cli ping di container
  • Disk space habis — cek df -h di host

Micronaut dengan GraalVM Native Image #

Micronaut dirancang untuk native image compilation. Berbeda dengan Spring Boot yang baru support native di versi 3, Micronaut sudah native-first sejak awal.

pom.xml:

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
</plugin>

Dockerfile untuk native build:

FROM ghcr.io/graalvm/graalvm-ce:java21-22.3.0 AS builder

WORKDIR /build
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN chmod +x mvnw && ./mvnw dependency:go-offline

COPY src ./src
RUN ./mvnw package -DskipTests -Pnative

FROM alpine:3.19
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=builder /build/target/my-micronaut-app /app/app

EXPOSE 8080
ENTRYPOINT ["/app/app"]

Native image Micronaut startup dalam ~30ms (vs ~1–2 detik untuk JVM) dan memory ~50MB (vs ~200MB untuk JVM). Build lebih lama (menit), tapi runtime sangat efisien — cocok untuk serverless dan Kubernetes.

Micronaut Launch dan CRI #

Untuk multi-profile build, pakai Micronaut Launch API atau CLI mn:

# Create app dengan dependency spesifik
mn create-app my-app \
  --features=data-jdbc,flyway,redis-lettuce,management

# Tambahkan dependency
mn add-dependency micronaut-security-jwt

CLI ini generate project dari template resmi, lalu tambahkan module yang dipilih. Lebih cepat dari manual edit pom.xml.

Distributed Configuration dengan Consul #

Untuk konfigurasi terpusat, Micronaut support Consul, Eureka, atau Kubernetes ConfigMap:

application.yml:

micronaut:
  config-client:
    enabled: true

consul:
  client:
    url: http://consul:8500
  config:
    enabled: true
    format: yaml
    path: config/my-micronaut-app
# docker-compose.yml
services:
  consul:
    image: hashicorp/consul:1.16
    ports:
      - "8500:8500"
  app:
    depends_on:
      - consul

Config di Consul lebih di-prioritaskan dari file lokal — cocok untuk konfigurasi yang sama di banyak service.

Service Discovery dengan Consul #

micronaut:
  discovery-client:
    enabled: true

Setiap Micronaut app otomatis register ke Consul saat start, dan lookup service lain via Consul. Cocok untuk arsitektur microservice tanpa perlu hardcode URL.

Recap Cheatsheet #

Pattern Micronaut Module
HTTP server micronaut-http-server-netty
Validation micronaut-validation
Database micronaut-data-jdbc, micronaut-jdbc-hikari
Schema migration micronaut-flyway
Cache micronaut-redis-lettuce
Health & metrics micronaut-management
Security micronaut-security-jwt
Reactive micronaut-rxjava2/3
Native GraalVM native image
Service discovery micronaut-discovery-client
Distributed config micronaut-config-client
Tracing micronaut-tracing-opentelemetry
Testing micronaut-test-junit5
CLI mn (Micronaut CLI)

Ringkasan #

  • Micronaut ideal untuk local development JVM yang ringan dan cepat, dengan startup time milidetik dan memory footprint kecil.
  • Dockerfile multi-stage dengan dev stage (JDK + Maven) untuk development dan runtime stage (JRE + layered JAR) untuk production. Build pakai target: dev di Compose, target: runtime untuk production image.
  • Hot reload dengan mn:run melakukan restart proses Maven saat source code berubah, total 3–8 detik. Lebih lambat dari Quarkus tapi cukup untuk iterasi.
  • docker-compose.yml dengan Postgres, Redis, dan Adminer. Pakai healthcheck + depends_on: condition: service_healthy untuk startup yang benar.
  • Maven cache pakai named volume maven_cache:/root/.m2 agar dependency tidak di-download ulang setiap container restart.
  • Micronaut Data adalah ORM ringan dengan compile-time repository generation, tanpa reflection dan proxy runtime seperti Hibernate.
  • Flyway untuk schema migration dengan versioned SQL files (V1__, V2__, dst.) di src/main/resources/db/migration/. Auto-run saat aplikasi start.
  • Health endpoints built-in via micronaut-management: /health, /health/liveness, /health/readiness, /health/db, /health/redis. Cocok untuk Kubernetes probes.
  • Testing dengan @MicronautTest + Testcontainers untuk integration test. TestPropertyProvider inject konfigurasi container ke test context.
  • Debug dari IDE via JDWP port 5005 dengan JAVA_TOOL_OPTIONS=-agentlib:jdwp=.... IntelliJ/VS Code attach sebagai Remote JVM Debug.
  • Environment profiles dengan application-{env}.yml dan MICRONAUT_ENVIRONMENTS=dev,prod untuk konfigurasi per-stage.
  • Resource limits penting untuk JVM: set JAVA_OPTS=-XX:MaxRAMPercentage=75.0 dan deploy.resources.limits.memory di Compose.
  • Best practice: pisahkan Dockerfile dev/prod, cache .m2, healthcheck untuk dependency, layered JAR untuk production, Flyway untuk migration, resource limits.
  • Troubleshooting: connection refused (healthcheck), hot reload (cek volume mount), slow build (cache Maven), OOM (resource limits).
  • Layered JAR optimization — extract target/*.jar ke lib, classes, META-INF di Dockerfile untuk Docker cache optimal.
  • Custom health check implement HealthIndicator untuk check dependency eksternal. Built-in untuk database, Redis, disk space.
  • Production readiness dengan profile prod, image multi-stage, health probes, resource limits, dan structured JSON logging.

← Sebelumnya: Quarkus   Berikutnya: Gin →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact