backend-java/micronaut-project-starter/SKILL.md
--- name: micronaut-project-starter description: > Scaffold a lightweight Micronaut 4.x application with Java 21+, compile-time DI/AOP, Micronaut Data, Micronaut Security, and GraalVM native-image support. category: backend-java agent-type: coding compatibility: Java 21+, Maven 3.9+ or Gradle 8.5+, Micronaut CLI (optional): `sdk install micronaut` (via SDKMAN) or `brew install --cask micronaut`, Docker, GraalVM 21+ with `native-image` --- # Micronaut Project Starter > Scaffold a lightweigh
npx skillsauth add achreftlili/deep-dev-skills backend-java/micronaut-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 myapp
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
@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/migration
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
datasources:
default:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
driver-class-name: org.postgresql.Driver
logger:
levels:
com.example.myapp: INFO
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", "[email protected]");
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("[email protected]", "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", "[email protected]");
User saved = new User("[email protected]", "Bob");
saved.setId(2L);
when(userRepository.save(any(User.class))).thenReturn(saved);
UserResponse result = userService.create(request);
assertEquals("[email protected]", 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=docker
pom.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.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.