Last Updated: May 30, 2026 at 19:00

Micronaut REST APIs Explained for Spring Boot Developers

Controllers, Validation, Filters, and Testing with Side-by-Side Comparisons

Learn how Spring MVC concepts map to Micronaut's compile-time HTTP framework through practical REST examples. By the end of this guide, you'll understand how to build REST APIs in Micronaut with minimal friction — and why the shift from runtime reflection to compile-time processing changes things in ways that matter.

Image

The mental model shift: compile-time over reflection

The single most important thing to understand about Micronaut before writing any code is this: Micronaut moves the heavy lifting Spring does at runtime to compile time using annotation processing.

Spring Boot relies heavily on reflection and classpath scanning to wire beans, resolve routes, and handle serialisation. Micronaut does all of this at build time instead. Route tables are compiled from your controller annotations before the application ever runs, which means faster startup and no reflection overhead during request dispatch.

This compile-time model also explains why you'll encounter annotations like @Introspected and @Serdeable that have no Spring equivalent — they are instructions to the annotation processor to generate the metadata it needs at build time. Forgetting them is the most common early mistake for Spring developers, and understanding why they exist makes them much easier to remember.

The HTTP layer itself is built on Netty — a non-blocking, event-loop-based server — but for the REST programming model covered here, you won't need to think about that unless you start writing filters (more on that later).

Bean scopes: a quick orientation

Spring developers are used to @Service, @Component, and @Repository. In Micronaut, beans are explicitly scoped with annotations like @Singleton and @Prototype. There is no component scanning that picks up arbitrary classes — you declare the scope you want.

import jakarta.inject.Singleton;

@Singleton
public class OrderService {
// available application-wide as a single shared instance
}

@Controller implies @Singleton automatically, so you don't need to annotate controllers separately.

Your first controller

@Controller + @Get / @Post / @Put / @Delete

import io.micronaut.http.annotation.*;

@Controller("/products")
public class ProductController {

private final ProductService productService;

public ProductController(ProductService productService) {
this.productService = productService;
}

@Get("/{id}")
public Product getProduct(Long id) {
return productService.findById(id);
}
}

A few things to note:

  1. @Controller is both the route prefix and the bean declaration — no separate @Singleton needed.
  2. The return value is serialised to JSON automatically using Micronaut Serialization, which is compile-time generated (not Jackson by default). More on this in the serialisation section.
  3. Path variables are bound by argument name — {id} binds to Long id automatically. You can also use @PathVariable explicitly, which is worth doing early on if you're coming from Spring, as it makes the mapping more visible.

Spring comparison: Spring uses @RestController (which combines @Controller + @ResponseBody) and separate @GetMapping, @PostMapping, etc. Micronaut uses @Controller with @Get, @Post, @Put, @Delete, and @Patch. All controller methods return JSON by default — there is no @ResponseBody equivalent.

Route binding

Path variables, query parameters, request body, and headers

@Controller("/orders")
public class OrderController {

private final OrderService orderService;

public OrderController(OrderService orderService) {
this.orderService = orderService;
}

// Path variable: /orders/42
@Get("/{id}")
public Order getOrder(Long id) {
return orderService.findById(id);
}

// Query parameters: /orders?status=PENDING&page=0&size=20
@Get
public List listOrders(
@QueryValue String status,
@QueryValue(defaultValue = "0") int page,
@QueryValue(defaultValue = "20") int size) {
return orderService.findByStatus(status, page, size);
}

// Request body
@Post
public Order createOrder(@Body CreateOrderRequest request) {
return orderService.create(request);
}

// Request header
@Get("/export")
public List exportOrders(
@Header("X-Tenant-ID") String tenantId) {
return orderService.exportForTenant(tenantId);
}
}

The Spring-to-Micronaut annotation mapping for route binding:

  1. @PathVariable → parameter name matches {variable} automatically, or use @PathVariable explicitly
  2. @RequestParam → @QueryValue
  3. @RequestBody → @Body
  4. @RequestHeader → @Header
  5. @CookieValue → @CookieValue (same)

Response status codes

Explicit status codes — no REST semantic defaults

Micronaut does not apply REST semantic defaults. Every HTTP method defaults to 200 OK, including POST. You must set status codes explicitly — this is a deliberate design decision, not an oversight.

Use @Status to annotate the method:

import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Status;

@Post
@Status(HttpStatus.CREATED) // 201
public Order createOrder(@Body CreateOrderRequest request) {
return orderService.create(request);
}

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

Spring comparison: @Status replaces @ResponseStatus.

HttpResponse<T> for dynamic status codes

When the status code depends on the result of the operation, return HttpResponse<T> — the Micronaut equivalent of Spring's ResponseEntity<T>:

import io.micronaut.http.HttpResponse;

@Put("/{id}")
public HttpResponse updateOrder(Long id, @Body UpdateOrderRequest request) {
return orderService.findById(id)
.map(existing -> {
Order updated = orderService.update(id, request);
return HttpResponse.ok(updated); // 200
})
.orElse(HttpResponse.notFound()); // 404
}

HttpResponse has static factory methods for all common status codes: ok(), created(), noContent(), notFound(), badRequest(), unauthorized(), and serverError().

Input validation

@Valid + Bean Validation — works the same as Spring, with one extra annotation

Add the micronaut-validation dependency, then annotate your request classes with standard JSR-380 constraints. The key difference: you must annotate your DTO with @Introspected (and/or @Serdeable) so Micronaut can generate the compile-time metadata it needs for validation and binding.

import io.micronaut.core.annotation.Introspected;
import jakarta.validation.constraints.*;

@Introspected
public class CreateOrderRequest {

@NotBlank(message = "Customer ID must not be blank")
private String customerId;

@NotNull
@Size(min = 1, message = "Order must contain at least one item")
private List items;

@DecimalMin(value = "0.01", message = "Total must be greater than zero")
private BigDecimal total;

// getters and setters
}

Trigger validation by annotating the controller parameter with @Valid:

import jakarta.validation.Valid;

@Post
@Status(HttpStatus.CREATED)
public Order createOrder(@Valid @Body CreateOrderRequest request) {
return orderService.create(request);
}

When validation fails, Micronaut automatically returns a 400 Bad Request with a structured JSON body. The default response looks like this:

{
"message": "Bad Request",
"errors": [
"createOrder.request.customerId: must not be blank",
"createOrder.request.total: must be greater than 0.01"
]
}

Spring comparison: Spring does not require @Introspected. In Micronaut, @Introspected tells the annotation processor to generate the class metadata needed at compile time. Forgetting it is one of the most common early mistakes — you will get a runtime error telling you the class is not introspected.

Validating path variables and query parameters

Add @Validated at the class level to enable constraint annotations directly on method parameters:

import io.micronaut.validation.Validated;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Controller("/orders")
@Validated
public class OrderController {

@Get("/{id}")
public Order getOrder(@Min(1) Long id) {
return orderService.findById(id);
}

@Get
public List listOrders(
@NotBlank @QueryValue String status) {
return orderService.findByStatus(status);
}
}

Exception handling

@Error — local and global handlers

Micronaut uses @Error to handle exceptions, either locally (within one controller) or globally (across the whole application).

Local error handler — handles exceptions thrown by this controller only:

import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Error;

@Controller("/orders")
public class OrderController {

// ... route methods ...

@Error(exception = OrderNotFoundException.class)
public HttpResponse handleNotFound(
HttpRequest request,
OrderNotFoundException ex) {
return HttpResponse.notFound(
new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()));
}
}

Global error handler — handles exceptions across the entire application:

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Error;

@Controller
public class GlobalExceptionHandler {

@Error(global = true, exception = OrderNotFoundException.class)
public HttpResponse handleNotFound(
HttpRequest request,
OrderNotFoundException ex) {
return HttpResponse.notFound(
new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()));
}

@Error(global = true, exception = Exception.class)
public HttpResponse handleGeneric(
HttpRequest request,
Exception ex) {
return HttpResponse.serverError(
new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}

Spring comparison: Spring uses @ControllerAdvice + @ExceptionHandler to define global handlers in a separate class. Micronaut has no @ControllerAdvice equivalent — any @Controller can declare global handlers by setting global = true on @Error.

A reusable error response class

@Introspected
public class ErrorResponse {
private final String code;
private final String message;

public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
}

public String getCode() { return code; }
public String getMessage() { return message; }
}

Handling validation errors globally

Micronaut fires a ConstraintViolationException when @Valid fails. You can customise the response by handling it explicitly:

import jakarta.validation.ConstraintViolationException;

@Error(global = true, exception = ConstraintViolationException.class)
public HttpResponse handleValidation(
HttpRequest request,
ConstraintViolationException ex) {

String message = ex.getConstraintViolations()
.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));

return HttpResponse.badRequest(new ErrorResponse("VALIDATION_FAILED", message));
}

Filters

@Filter — the reactive execution model

Filters run before and after every matching request and are useful for logging, auth token extraction, adding response headers, or measuring latency.

import io.micronaut.http.HttpRequest;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.HttpServerFilter;
import io.micronaut.http.filter.ServerFilterChain;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

@Filter("/api/**")
public class RequestLoggingFilter implements HttpServerFilter {

@Override
public Publisher> doFilter(
HttpRequest request,
ServerFilterChain chain) {

long start = System.currentTimeMillis();

return Mono.from(chain.proceed(request))
.doOnNext(response -> {
long elapsed = System.currentTimeMillis() - start;
System.out.printf("[%s] %s %s → %d (%dms)%n",
Thread.currentThread().getName(),
request.getMethod(),
request.getPath(),
response.getStatus().getCode(),
elapsed);
});
}
}

@Filter takes an Ant-style path pattern. Multiple filters are ordered using @Order.

Spring comparison: Spring filters implement Filter (servlet) or HandlerInterceptor. Micronaut filters use a reactive execution model internally — they implement HttpServerFilter and return a reactive Publisher. This is true even when your application code is otherwise imperative. Mono.from(chain.proceed(request)) is the standard wrapper you'll use every time.

Content negotiation and serialisation

JSON is the default — @Serdeable makes it compile-time safe

Micronaut serialises return values to JSON automatically using Micronaut Serialization, a compile-time library. Mark your DTOs with @Serdeable so the annotation processor generates serialisers at build time:

import io.micronaut.serde.annotation.Serdeable;

@Serdeable
public class OrderSummary {
private Long id;
private String status;
private BigDecimal total;
// getters and setters
}

Spring comparison: Spring uses Jackson at runtime with no extra annotations needed on DTOs. In Micronaut, @Serdeable tells the compile-time processor to generate serialisation code for this class. Like @Introspected for validation, forgetting @Serdeable on a DTO is a common early mistake.

Jackson is also supported as a drop-in alternative — just swap the serialisation dependency. Existing Jackson annotations (@JsonProperty, @JsonIgnore, etc.) continue to work.

Custom content types

For non-JSON content types, use produces and consumes on the route:

@Get(value = "/export", produces = MediaType.TEXT_CSV)
public String exportCsv() {
return "id,name\n1,Widget\n2,Gadget";
}

@Post(value = "/import", consumes = MediaType.TEXT_CSV)
public HttpResponse importCsv(@Body String csvData) {
// parse and import
return HttpResponse.ok();
}

Unlike Spring Boot, where content negotiation is largely implicit, Micronaut is more explicit — you declare what your endpoint produces and consumes directly on the annotation.

Testing

@MicronautTest — real HTTP against an embedded server

Micronaut's test support starts the full embedded HTTP server. Tests make real HTTP calls against a live instance of the application. The server starts in under a second, making this practical even in CI pipelines.

Add the test dependency (build.gradle):

testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter")

Testing a controller end-to-end:

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.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@MicronautTest
class OrderControllerTest {

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

@Test
void createOrderReturns201() {
CreateOrderRequest request = new CreateOrderRequest("CUST-1", List.of(), new BigDecimal("99.99"));

HttpResponse response = client.toBlocking()
.exchange(HttpRequest.POST("/orders", request), Order.class);

assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED);
assertThat(response.body()).isNotNull();
assertThat(response.body().getCustomerId()).isEqualTo("CUST-1");
}
}

toBlocking() turns the reactive client into a synchronous one for tests — you don't need reactive test code unless you want it.

By default, @MicronautTest starts an embedded HTTP server. You can also configure context-only tests when you don't need HTTP, using @MicronautTest with startEmbeddedServer = false.

Spring comparison: Spring Boot uses @SpringBootTest + MockMvc (mock servlet layer) or TestRestTemplate (real HTTP). Micronaut always uses real HTTP against an embedded server — there is no mock servlet layer. The @Client injection replaces TestRestTemplate.

Using a typed declarative client in tests

Micronaut can generate a fully functional HTTP client from a plain Java interface annotated with the same @Get, @Post, etc. annotations used on controllers. You define the interface, and Micronaut implements it at compile time — no boilerplate, no manual request construction.

Instead of constructing HttpRequest objects manually, declare a typed client interface that mirrors your controller:

import io.micronaut.http.annotation.*;
import io.micronaut.http.client.annotation.Client;

@Client("/orders")
interface OrderClient {

@Post
@Status(HttpStatus.CREATED)
Order createOrder(@Body CreateOrderRequest request);

@Get("/{id}")
Order getOrder(Long id);

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

Inject and use it in tests:

@MicronautTest
class OrderControllerTest {

@Inject
OrderClient orderClient;

@Test
void createdOrderCanBeRetrieved() {
CreateOrderRequest request = new CreateOrderRequest("CUST-1", List.of(), new BigDecimal("99.99"));

Order created = orderClient.createOrder(request);
Order retrieved = orderClient.getOrder(created.getId());

assertThat(retrieved.getCustomerId()).isEqualTo("CUST-1");
}
}

This is much cleaner than raw HttpClient calls and gives you compile-time checking on the contract.

Testing error responses

import io.micronaut.http.client.exceptions.HttpClientResponseException;

@Test
void getOrderReturns404WhenNotFound() {
HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> orderClient.getOrder(999L)
);

assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND);
}

Replacing beans in tests

@MicronautTest
class OrderControllerTest {

@Inject
OrderClient orderClient;

@MockBean(OrderService.class)
OrderService mockOrderService() {
return Mockito.mock(OrderService.class);
}

@Inject
OrderService orderService;

@Test
void returnsOrderFromService() {
Order fakeOrder = new Order(1L, "CUST-1");
when(orderService.findById(1L)).thenReturn(Optional.of(fakeOrder));

Order result = orderClient.getOrder(1L);
assertThat(result.getId()).isEqualTo(1L);
}
}

A complete worked example

Putting everything together — a ProductController with full CRUD, validation, error handling, and tests:

// --- Domain ---

@Serdeable
@Introspected
public class Product {
private Long id;
private String name;
private BigDecimal price;
// constructor, getters, setters
}

@Serdeable
@Introspected
public class CreateProductRequest {

@NotBlank
private String name;

@NotNull
@DecimalMin("0.01")
private BigDecimal price;

// getters, setters
}

// --- Controller ---

@Controller("/products")
public class ProductController {

private final ProductService productService;

public ProductController(ProductService productService) {
this.productService = productService;
}

@Get
public List list() {
return productService.findAll();
}

@Get("/{id}")
public Product get(Long id) {
return productService.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}

@Post
@Status(HttpStatus.CREATED)
public Product create(@Valid @Body CreateProductRequest request) {
return productService.create(request);
}

@Put("/{id}")
public Product update(Long id, @Valid @Body CreateProductRequest request) {
return productService.update(id, request)
.orElseThrow(() -> new ProductNotFoundException(id));
}

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

@Error(exception = ProductNotFoundException.class)
public HttpResponse handleNotFound(ProductNotFoundException ex) {
return HttpResponse.notFound(
new ErrorResponse("PRODUCT_NOT_FOUND", ex.getMessage()));
}
}

// --- Test ---

@MicronautTest
class ProductControllerTest {

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

@MockBean(ProductService.class)
ProductService mockProductService() {
return Mockito.mock(ProductService.class);
}

@Inject
ProductService productService;

@Test
void createProductReturns201() {
Product saved = new Product(1L, "Widget", new BigDecimal("9.99"));
when(productService.create(any())).thenReturn(saved);

HttpResponse response = client.toBlocking()
.exchange(HttpRequest.POST("",
new CreateProductRequest("Widget", new BigDecimal("9.99"))),
Product.class);

assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED);
assertThat(response.body().getName()).isEqualTo("Widget");
}

@Test
void getProductReturns404WhenMissing() {
when(productService.findById(99L)).thenReturn(Optional.empty());

HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> client.toBlocking().exchange(HttpRequest.GET("/99"), Product.class)
);

assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND);
}
}

Common pitfalls for Spring developers

These are the mistakes that catch almost everyone in the first week with Micronaut:

Forgetting @Serdeable on DTOs. If your response objects aren't annotated, serialisation will fail at runtime. Every DTO that goes in or out of a controller needs @Serdeable.

Forgetting @Introspected on validated classes. Validation requires compile-time metadata. If you see an error saying a class is not introspected, this is why.

Expecting 201 on POST. Micronaut returns 200 for everything by default. Always add @Status(HttpStatus.CREATED) to POST endpoints explicitly.

Expecting reflection-based behaviour. If something works in Spring without any special annotation, it's probably relying on runtime reflection. In Micronaut, you need to tell the annotation processor explicitly what to generate metadata for.

Writing non-reactive filters. Filters in Micronaut always use a reactive execution model. Even if the rest of your application is imperative, filter code uses Publisher and Mono.from(chain.proceed(request)).

Spring → Micronaut quick reference

Controller declaration: @RestController → @Controller

Route methods: @GetMapping, @PostMapping, etc. → @Get, @Post, @Put, @Delete, @Patch

Path variable: @PathVariable → implicit from {name} in route (or explicit @PathVariable)

Query parameter: @RequestParam → @QueryValue

Request body: @RequestBody → @Body

Request header: @RequestHeader → @Header

Response status: @ResponseStatus → @Status

Dynamic response: ResponseEntity<T> → HttpResponse<T>

Input validation: @Valid + @Validated → same

DTO annotation: none needed → @Introspected + @Serdeable

Global exception handler: @ControllerAdvice + @ExceptionHandler → @Controller + @Error(global = true)

Request filter: OncePerRequestFilter → @Filter implementing HttpServerFilter

Test annotation: @SpringBootTest → @MicronautTest

Test HTTP client: TestRestTemplate / MockMvc → injected @Client

Mock bean in test: @MockBean (Spring Boot) → @MockBean (Micronaut Test)

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Micronaut REST APIs Explained for Spring Boot Developers