Micronaut Framework Guide¶
Framework: Micronaut 4.x Language: Java 21+ Type: Cloud-Native Microservices Framework Use Cases: Microservices, Serverless, CLI Applications, IoT
Overview¶
Micronaut is a modern, JVM-based framework designed for building modular, easily testable microservice and serverless applications. Unlike reflection-based frameworks, Micronaut uses compile-time dependency injection and AOP, resulting in minimal memory footprint and fast startup times.
Key Features¶
- Compile-time DI: No reflection at runtime
- GraalVM Native: First-class native image support
- Reactive: Built-in reactive programming support
- Cloud-Native: Service discovery, distributed tracing, config management
- Fast Startup: Ideal for serverless and containers
- Low Memory: Minimal runtime overhead
Project Structure¶
myapp/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/
│ │ │ ├── Application.java
│ │ │ ├── controller/
│ │ │ │ └── UserController.java
│ │ │ ├── service/
│ │ │ │ ├── UserService.java
│ │ │ │ └── impl/
│ │ │ │ └── UserServiceImpl.java
│ │ │ ├── repository/
│ │ │ │ └── UserRepository.java
│ │ │ ├── domain/
│ │ │ │ └── User.java
│ │ │ ├── dto/
│ │ │ │ ├── UserRequest.java
│ │ │ │ └── UserResponse.java
│ │ │ ├── mapper/
│ │ │ │ └── UserMapper.java
│ │ │ └── exception/
│ │ │ ├── ResourceNotFoundException.java
│ │ │ └── GlobalExceptionHandler.java
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ ├── application-prod.yml
│ │ └── logback.xml
│ └── test/
│ └── java/
│ └── com/example/
│ ├── controller/
│ │ └── UserControllerTest.java
│ └── service/
│ └── UserServiceTest.java
├── build.gradle
├── gradle.properties
├── settings.gradle
└── README.md
Dependencies (build.gradle)¶
plugins {
id("io.micronaut.application") version "4.2.1"
id("io.micronaut.aot") version "4.2.1"
id("com.google.devtools.ksp") version "1.9.21-1.0.16"
}
version = "0.1"
group = "com.example"
repositories {
mavenCentral()
}
dependencies {
// Micronaut Core
annotationProcessor("io.micronaut:micronaut-http-validation")
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
implementation("io.micronaut:micronaut-http-client")
implementation("io.micronaut.serde:micronaut-serde-jackson")
implementation("io.micronaut.validation:micronaut-validation")
// Database
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
implementation("io.micronaut.flyway:micronaut-flyway")
runtimeOnly("org.postgresql:postgresql")
// Security
annotationProcessor("io.micronaut.security:micronaut-security-annotations")
implementation("io.micronaut.security:micronaut-security-jwt")
// OpenAPI
annotationProcessor("io.micronaut.openapi:micronaut-openapi")
implementation("io.swagger.core.v3:swagger-annotations")
// MapStruct
annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
implementation("org.mapstruct:mapstruct:1.5.5.Final")
// Lombok
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0")
// Health & Metrics
implementation("io.micronaut:micronaut-management")
implementation("io.micronaut.micrometer:micronaut-micrometer-core")
implementation("io.micronaut.micrometer:micronaut-micrometer-registry-prometheus")
// Testing
testImplementation("io.micronaut:micronaut-http-client")
testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.mockito:mockito-core")
testImplementation("org.assertj:assertj-core")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
}
application {
mainClass.set("com.example.Application")
}
java {
sourceCompatibility = JavaVersion.toVersion("21")
targetCompatibility = JavaVersion.toVersion("21")
}
micronaut {
runtime("netty")
testRuntime("junit5")
processing {
incremental(true)
annotations("com.example.*")
}
aot {
optimizeServiceLoading = false
convertYamlToJava = false
precomputeOperations = true
cacheEnvironment = true
optimizeClassLoading = true
deduceEnvironment = true
optimizeNetty = true
}
}
graalvmNative.toolchainDetection = false
Configuration (application.yml)¶
micronaut:
application:
name: myapp
server:
port: 8080
cors:
enabled: true
configurations:
web:
allowed-origins:
- http://localhost:3000
allowed-methods:
- GET
- POST
- PUT
- DELETE
allowed-headers:
- Content-Type
- Authorization
security:
authentication: bearer
token:
jwt:
signatures:
secret:
generator:
secret: ${JWT_SECRET:changeme-in-production-use-256-bit-key}
jws-algorithm: HS256
generator:
access-token:
expiration: 3600 # 1 hour
refresh-token:
enabled: true
secret: ${JWT_REFRESH_SECRET:changeme-refresh-secret}
intercept-url-map:
- pattern: /health/**
http-method: GET
access:
- isAnonymous()
- pattern: /api/auth/**
http-method: POST
access:
- isAnonymous()
- pattern: /swagger/**
access:
- isAnonymous()
- pattern: /api/**
access:
- isAuthenticated()
router:
static-resources:
swagger:
paths: classpath:META-INF/swagger
mapping: /swagger/**
datasources:
default:
url: jdbc:postgresql://localhost:5432/myapp
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
dialect: POSTGRES
schema-generate: NONE
flyway:
datasources:
default:
enabled: true
locations: classpath:db/migration
jackson:
serialization:
indent-output: true
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
endpoints:
health:
enabled: true
sensitive: false
details-visible: AUTHENTICATED
info:
enabled: true
sensitive: false
prometheus:
enabled: true
sensitive: false
logger:
levels:
com.example: DEBUG
io.micronaut.data: DEBUG
---
# Development profile
micronaut:
environments:
- dev
datasources:
default:
url: jdbc:postgresql://localhost:5432/myapp_dev
---
# Production profile
micronaut:
environments:
- prod
logger:
levels:
com.example: INFO
io.micronaut.data: WARN
Application Entry Point¶
package com.example;
import io.micronaut.runtime.Micronaut;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
@OpenAPIDefinition(
info = @Info(
title = "My API",
version = "1.0",
description = "API documentation for MyApp",
license = @License(name = "MIT"),
contact = @Contact(name = "Support", email = "support@example.com")
)
)
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
Domain Entity¶
package com.example.domain;
import io.micronaut.data.annotation.*;
import io.micronaut.serde.annotation.Serdeable;
import lombok.*;
import java.time.Instant;
@Serdeable
@MappedEntity("users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(GeneratedValue.Type.AUTO)
private Long id;
@Column("email")
private String email;
@Column("password")
private String password;
@Column("first_name")
private String firstName;
@Column("last_name")
private String lastName;
@Column("role")
@Builder.Default
private String role = "USER";
@Column("active")
@Builder.Default
private Boolean active = true;
@DateCreated
@Column("created_at")
private Instant createdAt;
@DateUpdated
@Column("updated_at")
private Instant updatedAt;
}
DTOs¶
package com.example.dto;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.*;
@Serdeable
public record UserRequest(
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
@Size(max = 255, message = "Email must not exceed 255 characters")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
String password,
@NotBlank(message = "First name is required")
@Size(max = 100, message = "First name must not exceed 100 characters")
String firstName,
@NotBlank(message = "Last name is required")
@Size(max = 100, message = "Last name must not exceed 100 characters")
String lastName,
@Pattern(regexp = "^(USER|ADMIN)$", message = "Role must be USER or ADMIN")
String role
) {
public UserRequest {
if (role == null) {
role = "USER";
}
}
}
package com.example.dto;
import io.micronaut.serde.annotation.Serdeable;
import java.time.Instant;
@Serdeable
public record UserResponse(
Long id,
String email,
String firstName,
String lastName,
String role,
Boolean active,
Instant createdAt,
Instant updatedAt
) {}
package com.example.dto;
import io.micronaut.serde.annotation.Serdeable;
import java.util.List;
@Serdeable
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
public static <T> PageResponse<T> of(List<T> content, int page, int size, long totalElements) {
int totalPages = (int) Math.ceil((double) totalElements / size);
return new PageResponse<>(
content,
page,
size,
totalElements,
totalPages,
page == 0,
page >= totalPages - 1
);
}
}
Mapper¶
package com.example.mapper;
import com.example.domain.User;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import org.mapstruct.*;
import java.util.List;
@Mapper(componentModel = "jsr330")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "active", constant = "true")
User toEntity(UserRequest request);
UserResponse toResponse(User user);
List<UserResponse> toResponseList(List<User> users);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "password", ignore = true)
void updateEntity(UserRequest request, @MappingTarget User user);
}
Repository¶
package com.example.repository;
import com.example.domain.User;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.PageableRepository;
import java.util.List;
import java.util.Optional;
@JdbcRepository(dialect = Dialect.POSTGRES)
public interface UserRepository extends PageableRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
Page<User> findByRole(String role, Pageable pageable);
@Query("SELECT u FROM User u WHERE u.active = true ORDER BY u.createdAt DESC")
List<User> findAllActiveOrderByCreatedAtDesc();
@Query("UPDATE User u SET u.active = :active WHERE u.id = :id")
void updateActiveStatus(Long id, boolean active);
}
Service Layer¶
package com.example.service;
import com.example.dto.PageResponse;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import java.util.List;
public interface UserService {
UserResponse createUser(UserRequest request);
UserResponse getUserById(Long id);
UserResponse getUserByEmail(String email);
PageResponse<UserResponse> getAllUsers(int page, int size);
List<UserResponse> getActiveUsers();
UserResponse updateUser(Long id, UserRequest request);
void deleteUser(Long id);
void deactivateUser(Long id);
}
package com.example.service.impl;
import com.example.domain.User;
import com.example.dto.PageResponse;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import com.example.exception.DuplicateResourceException;
import com.example.exception.ResourceNotFoundException;
import com.example.mapper.UserMapper;
import com.example.repository.UserRepository;
import com.example.service.UserService;
import io.micronaut.data.model.Pageable;
import io.micronaut.transaction.annotation.Transactional;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.List;
@Singleton
@RequiredArgsConstructor
@Slf4j
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public UserResponse createUser(UserRequest request) {
log.debug("Creating user with email: {}", request.email());
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("User", "email", request.email());
}
User user = userMapper.toEntity(request);
user.setPassword(passwordEncoder.encode(request.password()));
User savedUser = userRepository.save(user);
log.info("Created user with id: {}", savedUser.getId());
return userMapper.toResponse(savedUser);
}
@Override
@Transactional(readOnly = true)
public UserResponse getUserById(Long id) {
log.debug("Fetching user by id: {}", id);
return userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
}
@Override
@Transactional(readOnly = true)
public UserResponse getUserByEmail(String email) {
log.debug("Fetching user by email: {}", email);
return userRepository.findByEmail(email)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", "email", email));
}
@Override
@Transactional(readOnly = true)
public PageResponse<UserResponse> getAllUsers(int page, int size) {
log.debug("Fetching users page: {}, size: {}", page, size);
var pageable = Pageable.from(page, size);
var userPage = userRepository.findAll(pageable);
List<UserResponse> content = userMapper.toResponseList(userPage.getContent());
return PageResponse.of(content, page, size, userPage.getTotalSize());
}
@Override
@Transactional(readOnly = true)
public List<UserResponse> getActiveUsers() {
log.debug("Fetching all active users");
return userMapper.toResponseList(userRepository.findByActiveTrue());
}
@Override
public UserResponse updateUser(Long id, UserRequest request) {
log.debug("Updating user with id: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
// Check email uniqueness if changed
if (!user.getEmail().equals(request.email()) &&
userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("User", "email", request.email());
}
userMapper.updateEntity(request, user);
User updatedUser = userRepository.update(user);
log.info("Updated user with id: {}", id);
return userMapper.toResponse(updatedUser);
}
@Override
public void deleteUser(Long id) {
log.debug("Deleting user with id: {}", id);
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User", "id", id);
}
userRepository.deleteById(id);
log.info("Deleted user with id: {}", id);
}
@Override
public void deactivateUser(Long id) {
log.debug("Deactivating user with id: {}", id);
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User", "id", id);
}
userRepository.updateActiveStatus(id, false);
log.info("Deactivated user with id: {}", id);
}
}
Password Encoder Bean¶
package com.example.config;
import io.micronaut.context.annotation.Factory;
import jakarta.inject.Singleton;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Factory
public class SecurityBeans {
@Singleton
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Controller¶
package com.example.controller;
import com.example.dto.PageResponse;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import com.example.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.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.validation.Validated;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import java.net.URI;
import java.util.List;
@Controller("/api/users")
@Validated
@RequiredArgsConstructor
@ExecuteOn(TaskExecutors.BLOCKING)
@Secured(SecurityRule.IS_AUTHENTICATED)
@Tag(name = "Users", description = "User management endpoints")
public class UserController {
private final UserService userService;
@Post
@Status(HttpStatus.CREATED)
@Operation(summary = "Create a new user", description = "Creates a new user account")
@ApiResponse(responseCode = "201", description = "User created successfully",
content = @Content(schema = @Schema(implementation = UserResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid request")
@ApiResponse(responseCode = "409", description = "User already exists")
public HttpResponse<UserResponse> createUser(@Body @Valid UserRequest request) {
UserResponse user = userService.createUser(request);
return HttpResponse.created(user)
.headers(headers -> headers.location(URI.create("/api/users/" + user.id())));
}
@Get("/{id}")
@Operation(summary = "Get user by ID", description = "Retrieves a user by their ID")
@ApiResponse(responseCode = "200", description = "User found",
content = @Content(schema = @Schema(implementation = UserResponse.class)))
@ApiResponse(responseCode = "404", description = "User not found")
public UserResponse getUserById(
@Parameter(description = "User ID") @PathVariable Long id) {
return userService.getUserById(id);
}
@Get
@Operation(summary = "Get all users", description = "Retrieves all users with pagination")
@ApiResponse(responseCode = "200", description = "Users retrieved successfully")
public PageResponse<UserResponse> getAllUsers(
@Parameter(description = "Page number") @QueryValue(defaultValue = "0") int page,
@Parameter(description = "Page size") @QueryValue(defaultValue = "20") int size) {
return userService.getAllUsers(page, size);
}
@Get("/active")
@Operation(summary = "Get active users", description = "Retrieves all active users")
public List<UserResponse> getActiveUsers() {
return userService.getActiveUsers();
}
@Put("/{id}")
@Operation(summary = "Update user", description = "Updates an existing user")
@ApiResponse(responseCode = "200", description = "User updated successfully")
@ApiResponse(responseCode = "404", description = "User not found")
@ApiResponse(responseCode = "409", description = "Email already in use")
public UserResponse updateUser(
@PathVariable Long id,
@Body @Valid UserRequest request) {
return userService.updateUser(id, request);
}
@Delete("/{id}")
@Status(HttpStatus.NO_CONTENT)
@Secured({"ROLE_ADMIN"})
@Operation(summary = "Delete user", description = "Deletes a user (Admin only)")
@ApiResponse(responseCode = "204", description = "User deleted successfully")
@ApiResponse(responseCode = "404", description = "User not found")
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
@Put("/{id}/deactivate")
@Status(HttpStatus.NO_CONTENT)
@Secured({"ROLE_ADMIN"})
@Operation(summary = "Deactivate user", description = "Deactivates a user (Admin only)")
public void deactivateUser(@PathVariable Long id) {
userService.deactivateUser(id);
}
}
Exception Handling¶
Custom Exceptions¶
package com.example.exception;
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() {
return resourceName;
}
public String getFieldName() {
return fieldName;
}
public Object getFieldValue() {
return fieldValue;
}
}
package com.example.exception;
public class DuplicateResourceException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public DuplicateResourceException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s already exists with %s: '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() {
return resourceName;
}
public String getFieldName() {
return fieldName;
}
public Object getFieldValue() {
return fieldValue;
}
}
Error Response DTO¶
package com.example.dto;
import io.micronaut.serde.annotation.Serdeable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Serdeable
public record ErrorResponse(
String type,
String title,
int status,
String detail,
String instance,
Instant timestamp,
Map<String, Object> properties,
List<FieldError> errors
) {
@Serdeable
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
public static ErrorResponse of(int status, String title, String detail, String instance) {
return new ErrorResponse(
"about:blank",
title,
status,
detail,
instance,
Instant.now(),
null,
null
);
}
public static ErrorResponse withValidationErrors(
int status, String title, String detail, String instance, List<FieldError> errors) {
return new ErrorResponse(
"about:blank",
title,
status,
detail,
instance,
Instant.now(),
null,
errors
);
}
}
Global Exception Handler¶
package com.example.exception;
import com.example.dto.ErrorResponse;
import io.micronaut.context.annotation.Requires;
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 jakarta.inject.Singleton;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import java.util.stream.Collectors;
@Singleton
@Slf4j
@Produces
@Requires(classes = {ResourceNotFoundException.class, ExceptionHandler.class})
public class ResourceNotFoundExceptionHandler
implements ExceptionHandler<ResourceNotFoundException, HttpResponse<ErrorResponse>> {
@Override
public HttpResponse<ErrorResponse> handle(HttpRequest request, ResourceNotFoundException exception) {
log.warn("Resource not found: {}", exception.getMessage());
ErrorResponse error = ErrorResponse.of(
HttpStatus.NOT_FOUND.getCode(),
"Resource Not Found",
exception.getMessage(),
request.getPath()
);
return HttpResponse.notFound(error);
}
}
@Singleton
@Slf4j
@Produces
@Requires(classes = {DuplicateResourceException.class, ExceptionHandler.class})
class DuplicateResourceExceptionHandler
implements ExceptionHandler<DuplicateResourceException, HttpResponse<ErrorResponse>> {
@Override
public HttpResponse<ErrorResponse> handle(HttpRequest request, DuplicateResourceException exception) {
log.warn("Duplicate resource: {}", exception.getMessage());
ErrorResponse error = ErrorResponse.of(
HttpStatus.CONFLICT.getCode(),
"Resource Already Exists",
exception.getMessage(),
request.getPath()
);
return HttpResponse.status(HttpStatus.CONFLICT).body(error);
}
}
@Singleton
@Slf4j
@Produces
@Requires(classes = {ConstraintViolationException.class, ExceptionHandler.class})
class ValidationExceptionHandler
implements ExceptionHandler<ConstraintViolationException, HttpResponse<ErrorResponse>> {
@Override
public HttpResponse<ErrorResponse> handle(HttpRequest request, ConstraintViolationException exception) {
log.warn("Validation error: {}", exception.getMessage());
var fieldErrors = exception.getConstraintViolations().stream()
.map(violation -> new ErrorResponse.FieldError(
violation.getPropertyPath().toString(),
violation.getMessage(),
violation.getInvalidValue()
))
.collect(Collectors.toList());
ErrorResponse error = ErrorResponse.withValidationErrors(
HttpStatus.BAD_REQUEST.getCode(),
"Validation Failed",
"One or more fields have validation errors",
request.getPath(),
fieldErrors
);
return HttpResponse.badRequest(error);
}
}
Authentication¶
Auth Controller¶
package com.example.controller;
import com.example.dto.AuthRequest;
import com.example.dto.AuthResponse;
import com.example.service.AuthService;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.validation.Validated;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@Controller("/api/auth")
@Validated
@RequiredArgsConstructor
@Secured(SecurityRule.IS_ANONYMOUS)
public class AuthController {
private final AuthService authService;
@Post("/login")
public AuthResponse login(@Body @Valid AuthRequest request) {
return authService.authenticate(request);
}
@Post("/refresh")
public AuthResponse refresh(@Body RefreshRequest request) {
return authService.refreshToken(request.refreshToken());
}
}
package com.example.dto;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
@Serdeable
public record AuthRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}
@Serdeable
public record AuthResponse(
String accessToken,
String refreshToken,
String tokenType,
Long expiresIn
) {}
@Serdeable
public record RefreshRequest(
@NotBlank String refreshToken
) {}
Auth Service¶
package com.example.service;
import com.example.domain.User;
import com.example.dto.AuthRequest;
import com.example.dto.AuthResponse;
import com.example.exception.AuthenticationException;
import com.example.repository.UserRepository;
import io.micronaut.security.token.jwt.generator.JwtTokenGenerator;
import io.micronaut.security.token.jwt.generator.claims.JwtClaims;
import io.micronaut.security.token.jwt.generator.claims.JwtClaimsSetAdapter;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.Map;
import java.util.Optional;
@Singleton
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtTokenGenerator jwtTokenGenerator;
public AuthResponse authenticate(AuthRequest request) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new AuthenticationException("Invalid credentials");
}
if (!user.getActive()) {
throw new AuthenticationException("User account is deactivated");
}
return generateTokens(user);
}
public AuthResponse refreshToken(String refreshToken) {
// Validate refresh token and generate new tokens
// Implementation depends on your refresh token strategy
throw new UnsupportedOperationException("Implement refresh token logic");
}
private AuthResponse generateTokens(User user) {
Map<String, Object> claims = Map.of(
"sub", user.getEmail(),
"roles", user.getRole(),
"userId", user.getId()
);
Optional<String> accessToken = jwtTokenGenerator.generateToken(claims);
return new AuthResponse(
accessToken.orElseThrow(() -> new RuntimeException("Failed to generate token")),
generateRefreshToken(user),
"Bearer",
3600L
);
}
private String generateRefreshToken(User user) {
// Generate refresh token - implement based on your strategy
return "refresh-token-placeholder";
}
}
Database Migration (Flyway)¶
-- src/main/resources/db/migration/V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'USER',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_active ON users(active);
Testing¶
Unit Test¶
package com.example.service;
import com.example.domain.User;
import com.example.dto.PageResponse;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import com.example.exception.DuplicateResourceException;
import com.example.exception.ResourceNotFoundException;
import com.example.mapper.UserMapper;
import com.example.repository.UserRepository;
import com.example.service.impl.UserServiceImpl;
import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService")
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private UserMapper userMapper;
@Mock
private BCryptPasswordEncoder passwordEncoder;
private UserServiceImpl userService;
@BeforeEach
void setUp() {
userService = new UserServiceImpl(userRepository, userMapper, passwordEncoder);
}
@Nested
@DisplayName("createUser")
class CreateUser {
@Test
@DisplayName("should create user when email is unique")
void shouldCreateUserWhenEmailIsUnique() {
// Given
UserRequest request = new UserRequest(
"test@example.com", "password123", "John", "Doe", "USER");
User user = createUser(1L, "test@example.com");
UserResponse response = createUserResponse(1L, "test@example.com");
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(userMapper.toEntity(request)).thenReturn(user);
when(passwordEncoder.encode(request.password())).thenReturn("encodedPassword");
when(userRepository.save(user)).thenReturn(user);
when(userMapper.toResponse(user)).thenReturn(response);
// When
UserResponse result = userService.createUser(request);
// Then
assertThat(result).isNotNull();
assertThat(result.email()).isEqualTo("test@example.com");
verify(userRepository).save(user);
}
@Test
@DisplayName("should throw DuplicateResourceException when email exists")
void shouldThrowWhenEmailExists() {
// Given
UserRequest request = new UserRequest(
"existing@example.com", "password123", "John", "Doe", "USER");
when(userRepository.existsByEmail(request.email())).thenReturn(true);
// When/Then
assertThatThrownBy(() -> userService.createUser(request))
.isInstanceOf(DuplicateResourceException.class)
.hasMessageContaining("email");
}
}
@Nested
@DisplayName("getUserById")
class GetUserById {
@Test
@DisplayName("should return user when found")
void shouldReturnUserWhenFound() {
// Given
Long userId = 1L;
User user = createUser(userId, "test@example.com");
UserResponse response = createUserResponse(userId, "test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userMapper.toResponse(user)).thenReturn(response);
// When
UserResponse result = userService.getUserById(userId);
// Then
assertThat(result).isNotNull();
assertThat(result.id()).isEqualTo(userId);
}
@Test
@DisplayName("should throw ResourceNotFoundException when not found")
void shouldThrowWhenNotFound() {
// Given
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// When/Then
assertThatThrownBy(() -> userService.getUserById(userId))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("User");
}
}
private User createUser(Long id, String email) {
return User.builder()
.id(id)
.email(email)
.password("encodedPassword")
.firstName("John")
.lastName("Doe")
.role("USER")
.active(true)
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build();
}
private UserResponse createUserResponse(Long id, String email) {
return new UserResponse(
id, email, "John", "Doe", "USER", true, Instant.now(), Instant.now());
}
}
Controller Test¶
package com.example.controller;
import com.example.dto.UserRequest;
import com.example.dto.UserResponse;
import com.example.service.UserService;
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.security.authentication.UsernamePasswordCredentials;
import io.micronaut.security.token.jwt.render.BearerAccessRefreshToken;
import io.micronaut.test.annotation.MockBean;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@MicronautTest
@DisplayName("UserController")
class UserControllerTest {
@Inject
@Client("/")
HttpClient client;
@Inject
UserService userService;
private String accessToken;
@MockBean(UserService.class)
UserService mockUserService() {
return mock(UserService.class);
}
@BeforeEach
void setUp() {
// Get authentication token for tests
UsernamePasswordCredentials credentials =
new UsernamePasswordCredentials("test@example.com", "password");
HttpRequest<?> request = HttpRequest.POST("/api/auth/login", credentials);
// In real tests, you would authenticate properly
// For now, we'll skip auth in tests or use @MockBean for security
}
@Test
@DisplayName("GET /api/users/{id} should return user when found")
void getUserById_shouldReturnUser() {
// Given
Long userId = 1L;
UserResponse response = new UserResponse(
userId, "test@example.com", "John", "Doe",
"USER", true, Instant.now(), Instant.now());
when(userService.getUserById(userId)).thenReturn(response);
// When
HttpRequest<?> request = HttpRequest.GET("/api/users/" + userId);
// Add auth header: .bearerAuth(accessToken)
// In a real test with proper auth:
// HttpResponse<UserResponse> httpResponse = client.toBlocking()
// .exchange(request, UserResponse.class);
// Then
// assertThat(httpResponse.status()).isEqualTo(HttpStatus.OK);
// assertThat(httpResponse.body().id()).isEqualTo(userId);
}
@Test
@DisplayName("POST /api/users should create user")
void createUser_shouldCreateUser() {
// Given
UserRequest request = new UserRequest(
"new@example.com", "password123", "Jane", "Doe", "USER");
UserResponse response = new UserResponse(
1L, "new@example.com", "Jane", "Doe",
"USER", true, Instant.now(), Instant.now());
when(userService.createUser(any(UserRequest.class))).thenReturn(response);
// When/Then - similar pattern to above
}
}
Integration Test with Testcontainers¶
package com.example;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.test.support.TestPropertyProvider;
import jakarta.inject.Inject;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Map;
@MicronautTest
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("Integration Tests")
class IntegrationTest implements TestPropertyProvider {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Inject
@Client("/")
HttpClient client;
@Override
public Map<String, String> getProperties() {
postgres.start();
return Map.of(
"datasources.default.url", postgres.getJdbcUrl(),
"datasources.default.username", postgres.getUsername(),
"datasources.default.password", postgres.getPassword()
);
}
@Test
@DisplayName("Health endpoint should return UP")
void healthEndpoint_shouldReturnUp() {
HttpRequest<?> request = HttpRequest.GET("/health");
var response = client.toBlocking().exchange(request, String.class);
assertThat(response.status().getCode()).isEqualTo(200);
}
}
Build & Run Commands¶
# Build
./gradlew build
# Run in development
./gradlew run
# Run with specific profile
MICRONAUT_ENVIRONMENTS=dev ./gradlew run
# Run tests
./gradlew test
# Run with coverage
./gradlew test jacocoTestReport
# Build native image (requires GraalVM)
./gradlew nativeCompile
# Run native image
./build/native/nativeCompile/myapp
# Build Docker image
./gradlew dockerBuild
# Build native Docker image
./gradlew dockerBuildNative
# Generate OpenAPI spec
./gradlew generateOpenApi
# Clean build
./gradlew clean build
Best Practices¶
Do's¶
- ✓ Use constructor injection (compile-time verified)
- ✓ Use
@Serdeablefor all DTOs - ✓ Use
@ExecuteOn(TaskExecutors.BLOCKING)for blocking operations - ✓ Use
@Transactionalfor database operations - ✓ Use validation annotations on DTOs
- ✓ Handle exceptions with proper HTTP status codes
- ✓ Use native compilation for production
- ✓ Use Micronaut Data for repositories
- ✓ Enable health checks and metrics
- ✓ Use configuration properties with sensible defaults
Don'ts¶
- ✗ Don't use reflection-based libraries without GraalVM configuration
- ✗ Don't use
@Autowired(Spring annotation) - ✗ Don't block in reactive streams
- ✗ Don't expose sensitive data in error messages
- ✗ Don't skip validation
- ✗ Don't use mutable DTOs
- ✗ Don't ignore compile-time warnings
Comparison with Other Frameworks¶
| Feature | Micronaut | Spring Boot | Quarkus |
|---|---|---|---|
| DI | Compile-time | Runtime (reflection) | Compile-time |
| Startup Time | ~100ms | ~2-5s | ~100ms |
| Memory | ~50MB | ~200MB+ | ~50MB |
| Native Image | Excellent | Good (Spring Native) | Excellent |
| Reactive | Built-in | WebFlux (optional) | Mutiny |
| Data Access | Micronaut Data | Spring Data | Panache |
| Configuration | Type-safe | @Value, @ConfigurationProperties | Type-safe |
| Testing | Built-in mocking | Spring Test | @QuarkusTest |
| Learning Curve | Moderate | Low (familiar) | Moderate |
| Ecosystem | Growing | Extensive | Growing |