Scaffold a lightweight Micronaut 4.x application with Java 21+, compile-time DI/AOP, Micronaut Data, Micronaut Security, and GraalVM native-image support.
sdk install micronaut (via SDKMAN) or brew install --cask micronautnative-image (only for native compilation)# 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 myappmyapp/
├── 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@Singleton for services, @Controller for HTTP endpoints. These are Micronaut's own annotations, not Jakarta CDI.@JdbcRepository. Query methods are resolved at compile time.@MappedEntity (Micronaut Data annotation), not JPA @Entity.application.yml. Environment-specific overrides go in application-{env}.yml.@Inject).HttpResponse<T> for explicit status codes, or plain types for 200 OK.@MicronautTest which starts an embedded server and injects beans.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; }
}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
) {}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);
}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);
}
}
}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);
}
}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) {}
}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);
}
}-- 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
);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/migrationdatasources:
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: DEBUGdatasources:
default:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
driver-class-name: org.postgresql.Driver
logger:
levels:
com.example.myapp: INFOpackage 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());
}
}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));
}
}createdb myapp_devsrc/main/resources/application-dev.yml with your database credentials./mvnw compile to verify annotation processing and project compilation./mvnw mn:run (Flyway runs migrations automatically on startup)curl http://localhost:8080/health# 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=dockerpom.xml or build.gradle.micronaut-data-hibernate-jpa but adds startup cost. Use JDBC unless you need lazy loading or complex entity graphs.micronaut-serialization (compile-time serialization) instead of Jackson by default. DTOs and entities need @Serdeable. This is faster and GraalVM-friendly.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.src/main/resources/static/. For CORS, configure micronaut.server.cors.enabled: true and micronaut.server.cors.configurations in application.yml.swagger-ui feature generates OpenAPI specs at compile time. Swagger UI is available at /swagger-ui when the micronaut-openapi dependency is present.micronaut-micrometer-registry-prometheus for Prometheus metrics. Health endpoints are available via micronaut-management at /health.@Secured annotation controls access at the controller or method level. Use @Secured(SecurityRule.IS_ANONYMOUS) for public endpoints.181fcbc
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.