CtrlK
BlogDocsLog inGet started
Tessl Logo

micronaut-project-starter

Invalid
This skill can't be scored yet
Validation errors are blocking scoring. Review and fix them to unlock Quality, Impact and Security scores. See what needs fixing →
SKILL.md
Quality
Evals
Security

Micronaut Project Starter

Scaffold a lightweight Micronaut 4.x application with Java 21+, compile-time DI/AOP, Micronaut Data, Micronaut Security, and GraalVM native-image support.

Prerequisites

  • Java 21+ (Eclipse Temurin or GraalVM)
  • Maven 3.9+ or Gradle 8.5+
  • Micronaut CLI (optional): sdk install micronaut (via SDKMAN) or brew install --cask micronaut
  • Docker (for Testcontainers and native-image container builds)
  • GraalVM 21+ with native-image (only for native compilation)

Scaffold Command

# Via Micronaut CLI
mn create-app com.example.myapp \
  --build=maven \
  --lang=java \
  --jdk=21 \
  --features=data-jdbc,postgres,flyway,security-jwt,validation,http-client,swagger-ui,testcontainers,graalvm

# Via Micronaut Launch (curl)
curl -s 'https://launch.micronaut.io/create/default/com.example.myapp?lang=JAVA&build=MAVEN&javaVersion=JDK_21&features=data-jdbc,postgres,flyway,security-jwt,validation,http-client,swagger-ui,testcontainers,graalvm' \
  -o myapp.zip && unzip myapp.zip -d myapp

Project Structure

myapp/
├── src/
│   ├── main/
│   │   ├── java/com/example/myapp/
│   │   │   ├── Application.java
│   │   │   ├── controller/
│   │   │   │   └── UserController.java
│   │   │   ├── dto/
│   │   │   │   ├── UserRequest.java
│   │   │   │   └── UserResponse.java
│   │   │   ├── entity/
│   │   │   │   └── User.java
│   │   │   ├── repository/
│   │   │   │   └── UserRepository.java
│   │   │   ├── service/
│   │   │   │   └── UserService.java
│   │   │   ├── security/
│   │   │   │   └── AuthenticationProviderUserPassword.java
│   │   │   └── exception/
│   │   │       └── GlobalExceptionHandler.java
│   │   └── resources/
│   │       ├── application.yml
│   │       ├── application-dev.yml
│   │       ├── application-prod.yml
│   │       └── db/migration/
│   │           └── V1__create_users_table.sql
│   └── test/
│       └── java/com/example/myapp/
│           ├── controller/
│           │   └── UserControllerTest.java
│           └── service/
│               └── UserServiceTest.java
├── pom.xml
├── micronaut-cli.yml
├── docker-compose.yml
└── .env.example             # Template for env vars (DATABASE_URL, JWT_SECRET) used via application.yml ${...} placeholders

Key Conventions

  • Micronaut performs dependency injection at compile time via annotation processors. No runtime reflection for DI. This means faster startup and lower memory.
  • Use @Singleton for services, @Controller for HTTP endpoints. These are Micronaut's own annotations, not Jakarta CDI.
  • Use Java records for DTOs. Micronaut Serialization (replacement for Jackson) handles records natively.
  • Micronaut Data repositories are interfaces annotated with @JdbcRepository. Query methods are resolved at compile time.
  • Entities use @MappedEntity (Micronaut Data annotation), not JPA @Entity.
  • Validation uses Jakarta Bean Validation annotations but is processed at compile time.
  • Configuration is in application.yml. Environment-specific overrides go in application-{env}.yml.
  • Constructor injection is the default and only practical approach (fields cannot be injected without @Inject).
  • Controllers return HttpResponse<T> for explicit status codes, or plain types for 200 OK.
  • Test with @MicronautTest which starts an embedded server and injects beans.

Essential Patterns

Micronaut Data Entity

package com.example.myapp.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.serde.annotation.Serdeable;
import java.time.Instant;

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

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

    @MappedProperty("email")
    private String email;

    @MappedProperty("name")
    private String name;

    @MappedProperty("created_at")
    private Instant createdAt;

    @MappedProperty("updated_at")
    private Instant updatedAt;

    public User() {}

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

    // Getters and setters
    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 void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}

DTOs (Java Records)

package com.example.myapp.dto;

import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Serdeable
public record UserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "Email must be valid")
    String email
) {}
package com.example.myapp.dto;

import io.micronaut.serde.annotation.Serdeable;
import java.time.Instant;

@Serdeable
public record UserResponse(
    Long id,
    String name,
    String email,
    Instant createdAt
) {}

Micronaut Data Repository

package com.example.myapp.repository;

import com.example.myapp.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.Optional;

@JdbcRepository(dialect = Dialect.POSTGRES)
public interface UserRepository extends CrudRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

Service Layer

package com.example.myapp.service;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.entity.User;
import com.example.myapp.repository.UserRepository;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.stream.StreamSupport;

@Singleton
public class UserService {

    private final UserRepository userRepository;

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

    public List<UserResponse> findAll() {
        return StreamSupport.stream(userRepository.findAll().spliterator(), false)
            .map(this::toResponse)
            .toList();
    }

    public UserResponse findById(Long id) {
        return userRepository.findById(id)
            .map(this::toResponse)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Transactional
    public UserResponse create(UserRequest request) {
        User user = new User(request.email(), request.name());
        User saved = userRepository.save(user);
        return toResponse(saved);
    }

    @Transactional
    public UserResponse update(Long id, UserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        user.setName(request.name());
        user.setEmail(request.email());
        user.setUpdatedAt(Instant.now());
        return toResponse(userRepository.update(user));
    }

    @Transactional
    public void delete(Long id) {
        if (!userRepository.existsById(id)) {
            throw new UserNotFoundException(id);
        }
        userRepository.deleteById(id);
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt());
    }

    public static class UserNotFoundException extends RuntimeException {
        public UserNotFoundException(Long id) {
            super("User not found: " + id);
        }
    }
}

Controller

package com.example.myapp.controller;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.service.UserService;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.*;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.validation.Validated;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;

@Controller("/api/v1/users")
@Validated
@ExecuteOn(TaskExecutors.BLOCKING)
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @Get
    public List<UserResponse> findAll() {
        return userService.findAll();
    }

    @Get("/{id}")
    public UserResponse findById(Long id) {
        return userService.findById(id);
    }

    @Post
    @Status(HttpStatus.CREATED)
    public HttpResponse<UserResponse> create(@Body @Valid UserRequest request) {
        UserResponse user = userService.create(request);
        return HttpResponse.created(user, URI.create("/api/v1/users/" + user.id()));
    }

    @Put("/{id}")
    public UserResponse update(Long id, @Body @Valid UserRequest request) {
        return userService.update(id, request);
    }

    @Delete("/{id}")
    @Status(HttpStatus.NO_CONTENT)
    public void delete(Long id) {
        userService.delete(id);
    }
}

Global Exception Handler

package com.example.myapp.exception;

import com.example.myapp.service.UserService.UserNotFoundException;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Produces;
import io.micronaut.http.server.exceptions.ExceptionHandler;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.inject.Singleton;

@Singleton
@Produces
public class GlobalExceptionHandler
    implements ExceptionHandler<UserNotFoundException, HttpResponse<GlobalExceptionHandler.ErrorBody>> {

    @Override
    public HttpResponse<ErrorBody> handle(HttpRequest request, UserNotFoundException exception) {
        return HttpResponse.status(HttpStatus.NOT_FOUND)
            .body(new ErrorBody(404, exception.getMessage()));
    }

    @Serdeable
    public record ErrorBody(int status, String message) {}
}

Micronaut Security (JWT Authentication Provider)

package com.example.myapp.security;

import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationFailureReason;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider;
import jakarta.inject.Singleton;

@Singleton
public class AuthenticationProviderUserPassword<B>
    implements HttpRequestAuthenticationProvider<B> {

    @Override
    public AuthenticationResponse authenticate(
            HttpRequest<B> httpRequest,
            AuthenticationRequest<String, String> authenticationRequest) {

        // Replace with real user lookup and password verification
        if ("admin".equals(authenticationRequest.getIdentity())
                && "secret".equals(authenticationRequest.getSecret())) {
            return AuthenticationResponse.success("admin", List.of("ROLE_ADMIN"));
        }
        return AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH);
    }
}

Flyway Migration

-- src/main/resources/db/migration/V1__create_users_table.sql
CREATE TABLE users (
    id         BIGSERIAL    PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ
);

application.yml (default)

micronaut:
  application:
    name: myapp
  server:
    port: 8080

  security:
    authentication: bearer
    token:
      jwt:
        signatures:
          secret:
            generator:
              secret: "${JWT_SECRET:pleaseChangeThisSecretForProduction}"
              jws-algorithm: HS256
    intercept-url-map:
      - pattern: /api/v1/auth/**
        http-method: POST
        access:
          - isAnonymous()
      - pattern: /health
        access:
          - isAnonymous()

datasources:
  default:
    dialect: POSTGRES
    schema-generate: NONE

flyway:
  datasources:
    default:
      enabled: true
      locations: classpath:db/migration

application-dev.yml

datasources:
  default:
    url: jdbc:postgresql://localhost:5432/myapp_dev
    username: postgres
    password: postgres
    driver-class-name: org.postgresql.Driver

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

application-prod.yml

datasources:
  default:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}
    driver-class-name: org.postgresql.Driver

logger:
  levels:
    com.example.myapp: INFO

Test with @MicronautTest

package com.example.myapp.controller;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

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

@MicronautTest
class UserControllerTest {

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

    @Test
    void createUser_returns201() {
        UserRequest request = new UserRequest("Alice", "alice@example.com");
        HttpResponse<UserResponse> response = client.toBlocking()
            .exchange(HttpRequest.POST("/api/v1/users", request), UserResponse.class);

        assertEquals(HttpStatus.CREATED, response.status());
        assertNotNull(response.body());
        assertEquals("Alice", response.body().name());
    }

    @Test
    void findById_unknownId_returns404() {
        HttpClientResponseException thrown = assertThrows(
            HttpClientResponseException.class,
            () -> client.toBlocking().exchange(HttpRequest.GET("/api/v1/users/9999"), UserResponse.class)
        );
        assertEquals(HttpStatus.NOT_FOUND, thrown.getStatus());
    }
}

Unit Test with Mockito

package com.example.myapp.service;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.entity.User;
import com.example.myapp.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;

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

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock UserRepository userRepository;
    @InjectMocks UserService userService;

    @Test
    void findById_existingUser_returnsResponse() {
        User user = new User("alice@example.com", "Alice");
        user.setId(1L);
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        UserResponse result = userService.findById(1L);

        assertEquals("Alice", result.name());
        verify(userRepository).findById(1L);
    }

    @Test
    void findById_missingUser_throwsException() {
        when(userRepository.findById(99L)).thenReturn(Optional.empty());

        assertThrows(UserService.UserNotFoundException.class,
            () -> userService.findById(99L));
    }

    @Test
    void create_validRequest_savesAndReturns() {
        UserRequest request = new UserRequest("Bob", "bob@example.com");
        User saved = new User("bob@example.com", "Bob");
        saved.setId(2L);
        when(userRepository.save(any(User.class))).thenReturn(saved);

        UserResponse result = userService.create(request);

        assertEquals("bob@example.com", result.email());
        verify(userRepository).save(any(User.class));
    }
}

First Steps After Scaffold

  1. Ensure PostgreSQL is running and create the database: createdb myapp_dev
  2. Update src/main/resources/application-dev.yml with your database credentials
  3. Run ./mvnw compile to verify annotation processing and project compilation
  4. Start the application: ./mvnw mn:run (Flyway runs migrations automatically on startup)
  5. Verify the health endpoint: curl http://localhost:8080/health

Common Commands

# Run in dev mode (live reload, auto-restart on changes)
./mvnw mn:run
# or with Gradle: ./gradlew run --continuous

# Run all tests
./mvnw test

# Run a specific test class
./mvnw test -Dtest=UserControllerTest

# Package as JAR
./mvnw clean package

# Run the packaged JAR
java -jar target/myapp-0.1.jar

# Build GraalVM native image (requires GraalVM)
./mvnw clean package -Dpackaging=native-image

# Build native image using Docker (no local GraalVM needed)
./mvnw clean package -Dpackaging=docker-native -Dmicronaut.runtime=netty

# Run the native executable
./target/myapp

# Check available features
mn feature-diff --features=data-jdbc,security-jwt

# Generate a controller
mn create-controller com.example.myapp.controller.OrderController

# Create a Docker image
./mvnw clean package -Dpackaging=docker

Integration Notes

  • Compile-Time DI: Micronaut resolves all dependency injection at compile time via annotation processors. This eliminates runtime classpath scanning, resulting in near-instant startup (~100ms for JVM, ~10ms for native). Ensure the annotation processor is correctly configured in pom.xml or build.gradle.
  • Micronaut Data vs. JPA: Micronaut Data JDBC is the recommended approach (compile-time query generation, no Hibernate overhead). JPA/Hibernate support exists via micronaut-data-hibernate-jpa but adds startup cost. Use JDBC unless you need lazy loading or complex entity graphs.
  • Micronaut Serialization: Micronaut 4.x uses micronaut-serialization (compile-time serialization) instead of Jackson by default. DTOs and entities need @Serdeable. This is faster and GraalVM-friendly.
  • GraalVM Native Image: Micronaut's compile-time approach means most applications work with native-image out of the box. No extra reflection configuration needed for Micronaut-managed beans.
  • Test Resources: The micronaut-test-resources module auto-provisions test databases (similar to Quarkus Dev Services). Add micronaut-test-resources-jdbc-postgresql to use auto-provisioned PostgreSQL in tests.
  • Frontend: Serve static files from src/main/resources/static/. For CORS, configure micronaut.server.cors.enabled: true and micronaut.server.cors.configurations in application.yml.
  • API Documentation: The swagger-ui feature generates OpenAPI specs at compile time. Swagger UI is available at /swagger-ui when the micronaut-openapi dependency is present.
  • Observability: Add micronaut-micrometer-registry-prometheus for Prometheus metrics. Health endpoints are available via micronaut-management at /health.
  • Security: Micronaut Security supports JWT, OAuth2, session-based auth, and LDAP. The @Secured annotation controls access at the controller or method level. Use @Secured(SecurityRule.IS_ANONYMOUS) for public endpoints.
Repository
achreftlili/deep-dev-skills
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.