Last Updated: June 2, 2026 at 13:00

Micronaut OpenAPI & Swagger UI Explained for Spring Boot Developers

Learn how Micronaut generates OpenAPI documentation at compile time, how it differs from Springdoc in Spring Boot, and how to build production-grade API specifications with Swagger UI, schema modelling, and contract testing

Micronaut takes a fundamentally different approach to OpenAPI generation compared to Spring Boot. Instead of runtime scanning, it generates the entire API specification at compile time using annotation processing. This results in zero runtime overhead, a faster startup, and a first-class API contract that can be versioned and tested in CI. In this guide you will learn how to build, document, and secure OpenAPI definitions in Micronaut, with practical Spring Boot comparisons throughout.

Image

The mental model: compile-time vs runtime

In Spring Boot, Springdoc (springdoc-openapi) scans the classpath and builds the OpenAPI spec by introspecting beans and annotations when the application starts. This works well, but it means the spec exists only while the app is running.

Micronaut works differently:

  1. The annotation processor reads your controller annotations during the build
  2. A static OpenAPI file is generated under META-INF/swagger/ in the build output directory and later packaged into the JAR
  3. The running application serves this pre-built file — there is no runtime classpath scanning or reflection-based API model generation

There are meaningful practical consequences to this approach:

Many OpenAPI generation problems are caught during the build rather than at startup. Some documentation mistakes result in incomplete schemas or warnings rather than a failed build, but catching issues early in CI is still a significant improvement over discovering them at runtime.

The spec is a first-class build artefact. It can be committed to source control, diffed in pull requests, and used as a contract without running the application.

It aligns with GraalVM native image. Because the specification is generated at build time rather than through runtime reflection, OpenAPI generation fits naturally with Micronaut's GraalVM-native architecture.

You can fail CI on a broken or incomplete contract. A consumer team can pin to a specific version of your spec, and your pipeline can reject builds that introduce breaking changes.

For day-to-day development the difference is mostly invisible. You still get Swagger UI in your browser. But for large microservices systems and native builds, the architectural difference matters.

Setup

Dependencies

dependencies {
// Generates the OpenAPI spec at compile time — must be annotationProcessor
annotationProcessor("io.micronaut.openapi:micronaut-openapi")

// Serves the generated spec with an interactive Swagger UI
// Micronaut 4+; older versions used micronaut-swagger-ui
implementation("io.micronaut.openapi:micronaut-openapi-ui")

// Core Swagger annotations for enriching the spec
implementation("io.swagger.core.v3:swagger-annotations")
}

The most common setup mistake is putting micronaut-openapi under implementation only. It must be an annotationProcessor dependency. Without the annotation processor dependency, Micronaut will not generate the OpenAPI specification.

Minimal configuration

micronaut:
application:
name: product-service

swagger-ui:
enabled: true
path: /swagger-ui

The generated OpenAPI document is typically exposed under a /swagger/*.yml endpoint. The exact URL and configuration options vary across Micronaut versions — consult the version-specific documentation if you need a custom path. Start the application and open /swagger-ui — you should see the full interactive documentation.

API info and global metadata

Document the API itself — title, version, description, contact, and licence — using @OpenAPIDefinition on your Application class:

@OpenAPIDefinition(
info = @Info(
title = "Product Service API",
version = "1.0.0",
description = "Manages the product catalogue for the e-commerce platform",
contact = @Contact(
name = "Platform Team",
),
license = @License(
name = "Apache 2.0",
url = "https://www.apache.org/licenses/LICENSE-2.0"
)
),
servers = {
@Server(url = "https://api.example.com", description = "Production"),
@Server(url = "http://localhost:8080", description = "Local")
}
)
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}

This is identical to the Spring Boot equivalent in terms of annotation syntax.

What Micronaut infers automatically

Even with zero Swagger annotations, Micronaut infers a usable spec from your controllers:

  1. HTTP method and path from @Get, @Post, @Put, @Delete
  2. Path parameters from {id} in the route
  3. Query parameters from @QueryValue (Micronaut's equivalent of Spring's @RequestParam)
  4. Request body schema from the @Body parameter type
  5. Response type from the method return type

Swagger annotations add descriptions, examples, and explicit response codes on top of this inferred baseline. You do not have to annotate everything to get a working spec — but for production APIs, you will want to be explicit about at least @Operation, @Schema, and response codes.

What Micronaut does NOT reliably infer

It is worth knowing the limits before you rely on inference:

Micronaut does not reliably infer complex polymorphic types (class hierarchies where the actual subtype is determined at runtime, like a Payment that could be a CardPayment or PayPalPayment), deep generic structures (nested types like List<PageResponse<Product>> where type information is erased at compile time), custom Serde edge cases (unusual serialisation behaviour you have configured manually via @SerdeImport or custom codecs), or validation constraints in nested objects (for example, @Valid on a field inside a request body class may not propagate constraint metadata into the spec). When in doubt, annotate explicitly.

Documenting controllers

@Controller("/products")
@Tag(name = "Products", description = "Product catalogue management")
public class ProductController {

private final ProductService productService;

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

@Get
@Operation(
summary = "List all products",
description = "Returns a paginated list of all products in the catalogue"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Products retrieved successfully",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = Product.class)))),
@ApiResponse(responseCode = "401", description = "Unauthorised",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public List list(
@Parameter(description = "Page number (0-based)", example = "0")
@QueryValue(defaultValue = "0") int page,

@Parameter(description = "Page size (max 100)", example = "20")
@QueryValue(defaultValue = "20") int size) {
return productService.findAll(page, size);
}

@Get("/{id}")
@Operation(summary = "Get a product by ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Product found",
content = @Content(schema = @Schema(implementation = Product.class))),
@ApiResponse(responseCode = "404", description = "Product not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public HttpResponse get(
@Parameter(description = "Product ID", example = "42") Long id) {
return productService.findById(id)
.map(HttpResponse::ok)
.orElse(HttpResponse.notFound());
}

@Post
@Status(HttpStatus.CREATED) // Micronaut equivalent of Spring's @ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create a new product")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Product created",
content = @Content(schema = @Schema(implementation = Product.class))),
@ApiResponse(responseCode = "400", description = "Validation failed",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "Product name already exists",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public Product create(@Valid @Body CreateProductRequest request) {
return productService.create(request);
}

@Delete("/{id}")
@Status(HttpStatus.NO_CONTENT) // Micronaut equivalent of Spring's @ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Delete a product")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Product deleted"),
@ApiResponse(responseCode = "404", description = "Product not found")
})
public void delete(@Parameter(description = "Product ID") Long id) {
productService.delete(id);
}
}

The annotation names are identical to Springdoc. @Operation describes an endpoint — its summary, description, and deprecation status. @ApiResponse documents what the endpoint returns for a given HTTP status code. @Parameter describes a single input — a path variable, query string value, or header. @Tag groups related endpoints together in the Swagger UI sidebar. If you have used these annotations in Spring Boot before, the same knowledge applies here.

Documenting schemas

@Serdeable // Micronaut: enables reflection-free JSON serialisation/deserialisation
@Introspected // Micronaut: allows the framework to inspect fields and constructors at compile time without reflection
@Schema(description = "Request body for creating or updating a product") // OpenAPI: describes this class in the spec
public class CreateProductRequest {

@NotBlank
@Size(min = 2, max = 255)
@Schema(description = "Product display name", example = "Wireless Keyboard",
minLength = 2, maxLength = 255)
private String name;

@NotNull
@DecimalMin("0.01")
@Schema(description = "Product price in GBP", example = "49.99", minimum = "0.01")
private BigDecimal price;

@NotBlank
@Schema(description = "Product category slug", example = "peripherals",
allowableValues = {"peripherals", "displays", "audio", "accessories"})
private String category;

// getters and setters
}

@Serdeable and @Introspected are Micronaut annotations, not OpenAPI ones. @Serdeable tells Micronaut's serialisation layer how to convert the class to and from JSON without using reflection — the equivalent of Jackson's default behaviour in Spring, but done at compile time. In Micronaut 4 with Micronaut Serialization, @Serdeable is typically sufficient for DTOs that cross the HTTP boundary. @Introspected is still useful when a class must also participate in Micronaut's bean introspection facilities — for example, when used with @Validated or accessed via BeanIntrospection. Neither annotation affects the OpenAPI spec directly, but you will frequently see them alongside @Schema.

@Serdeable
@Introspected
@Schema(description = "A product in the catalogue")
public class Product {

@Schema(description = "Unique product identifier", example = "42",
accessMode = Schema.AccessMode.READ_ONLY)
private Long id;

@Schema(description = "Product display name", example = "Wireless Keyboard")
private String name;

@Schema(description = "Product price in GBP", example = "49.99")
private BigDecimal price;

@Schema(description = "Timestamp when the product was created",
accessMode = Schema.AccessMode.READ_ONLY)
private LocalDateTime createdAt;

// getters and setters
}

Documenting enums

@Serdeable
@Schema(description = "Current status of an order", enumAsRef = true)
public enum OrderStatus {

@Schema(description = "Order received but not yet processed")
PENDING,

@Schema(description = "Order confirmed and being fulfilled")
CONFIRMED,

@Schema(description = "Order shipped to the customer")
SHIPPED,

@Schema(description = "Order delivered successfully")
DELIVERED,

@Schema(description = "Order cancelled before fulfilment")
CANCELLED
}

The enumAsRef = true attribute generates a $ref in the spec rather than inlining the enum values everywhere it is used — cleaner for large specs.

Documenting polymorphism

@Serdeable
@Schema(
description = "A payment method",
discriminatorProperty = "type",
discriminatorMapping = {
@DiscriminatorMapping(value = "card", schema = CardPayment.class),
@DiscriminatorMapping(value = "paypal", schema = PayPalPayment.class)
},
oneOf = { CardPayment.class, PayPalPayment.class }
)
public abstract class Payment {
private String type;
}

@Serdeable
@Schema(description = "Card payment details")
public class CardPayment extends Payment {

@Schema(description = "Last four digits of the card", example = "4242")
private String lastFour;

@Schema(description = "Card expiry in MM/YY format", example = "12/27")
private String expiry;
}

Security scheme documentation

@OpenAPIDefinition(
info = @Info(title = "Product Service API", version = "1.0.0"),
security = @SecurityRequirement(name = "bearerAuth")
)
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "JWT token obtained from POST /login"
)
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}

This adds an "Authorise" button to Swagger UI so you can test secured endpoints interactively. For endpoints that should be public, override the global security with an empty @SecurityRequirements:

@Get
@Secured(SecurityRule.IS_ANONYMOUS) // Micronaut security — controls who can call this endpoint at runtime
@SecurityRequirements() // OpenAPI — tells the spec this endpoint requires no auth (overrides the global default)
@Operation(summary = "List all products (public)")
public List list() {
return productService.findAll();
}

@Secured and @SecurityRequirements do different things and must be set independently. @Secured is a Micronaut runtime annotation — it controls whether an incoming request is actually allowed through. @SecurityRequirements is an OpenAPI annotation — it only affects what the generated spec says about authentication. Changing one does not change the other. If you mark an endpoint @Secured(IS_ANONYMOUS) but forget @SecurityRequirements(), the endpoint will be publicly accessible at runtime but Swagger UI will still show it as requiring a JWT.

Pagination and complex responses

Because complex generic schemas sometimes need help to produce the desired OpenAPI output, it is often safer to expose a concrete response type rather than a raw generic for paginated models:

@Serdeable // reflection-free serialisation
@Introspected // compile-time field inspection
@Schema(description = "A paginated result set")
public class PageResponse {

@Schema(description = "Items on this page")
private List content;

@Schema(description = "Current page number (0-based)", example = "0")
private int page;

@Schema(description = "Total number of items across all pages", example = "142")
private long totalElements;

@Schema(description = "Total number of pages", example = "8")
private int totalPages;

@Schema(description = "Whether this is the last page")
private boolean last;
}


// Complex generic response models sometimes require a concrete subclass
// to produce the correct OpenAPI output
@Schema(description = "Paginated list of products")
class ProductPageResponse extends PageResponse {}

@Get
@Operation(summary = "List products with pagination")
@ApiResponse(
responseCode = "200",
content = @Content(schema = @Schema(implementation = ProductPageResponse.class))
)
public PageResponse list(
@QueryValue(defaultValue = "0") int page,
@QueryValue(defaultValue = "20") int size) {
return productService.findAllPaged(page, size);
}

Consistent error responses

Define a single error response schema and reference it everywhere:

@Serdeable // reflection-free serialisation
@Introspected // compile-time field inspection
@Schema(description = "Standard error response")
public class ErrorResponse {

@Schema(description = "Machine-readable error code", example = "PRODUCT_NOT_FOUND")
private String code;

@Schema(description = "Human-readable error message",
example = "No product found with ID 42")
private String message;

@Schema(description = "Request path that caused the error", example = "/products/42")
private String path;

@Schema(description = "Timestamp of the error")
private Instant timestamp;
}

Request body examples

For rich multi-field examples, use @ExampleObject in the requestBody:

@Post
@Status(HttpStatus.CREATED)
@Operation(
summary = "Create a new product",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = @Content(
schema = @Schema(implementation = CreateProductRequest.class),
examples = {
@ExampleObject(
name = "Keyboard",
summary = "A peripheral product",
value = """
{
"name": "Wireless Keyboard",
"price": 49.99,
"category": "peripherals"
}
"""
),
@ExampleObject(
name = "Monitor",
summary = "A display product",
value = """
{
"name": "4K Monitor",
"price": 349.99,
"category": "displays"
}
"""
)
}
)
)
)
public Product create(@Valid @Body CreateProductRequest request) {
return productService.create(request);
}

Grouping with tags

// Define tag descriptions globally on the Application class
@OpenAPIDefinition(
info = @Info(title = "Product Service API", version = "1.0.0"),
tags = {
@Tag(name = "Products", description = "Manage the product catalogue"),
@Tag(name = "Categories", description = "Product category management"),
@Tag(name = "Search", description = "Full-text and faceted search")
}
)
public class Application { ... }

// Apply at the controller level
@Controller("/products")
@Tag(name = "Products")
public class ProductController { ... }

// A controller can carry multiple tags
@Controller("/products/search")
@Tag(name = "Products")
@Tag(name = "Search")
public class ProductSearchController { ... }

Hiding and deprecating endpoints

// Hide an entire controller
@Controller("/internal")
@Hidden
public class InternalController { ... }

// Hide a single endpoint
@Get("/debug")
@Hidden
public DebugInfo debug() { ... }

// Mark as deprecated
@Delete("/{id}")
@Operation(summary = "Delete product (deprecated — use /v2/products/{id})",
deprecated = true)
@Deprecated
public void delete(Long id) { ... }

Bean Validation and the OpenAPI spec

Spring developers expect validation annotations like @NotBlank and @Size to appear in Swagger automatically. Micronaut OpenAPI does the same — it reads Bean Validation constraints from your request classes and reflects them in the generated spec without any extra annotations.

public class CreateProductRequest {

@NotBlank // → marked as required in the spec
@Size(min = 2, max = 255) // → minLength: 2, maxLength: 255 in the spec
private String name;

@NotNull // → marked as required in the spec
@DecimalMin("0.01") // → minimum: 0.01 in the spec
private BigDecimal price;

@Pattern(regexp = "^[a-z-]+$") // → pattern constraint in the spec
private String categorySlug;
}

The constraints derived automatically include required fields from @NotNull and @NotBlank, length bounds from @Size, numeric bounds from @Min, @Max, @DecimalMin, and @DecimalMax, and regex patterns from @Pattern. This means your validation rules and your API documentation stay in sync by default — if you tighten a constraint in code, the spec updates on the next build without any manual documentation change.

These are the mistakes that come up most often when teams migrate from Spring Boot:

Forgetting annotationProcessor. Putting micronaut-openapi under implementation only produces no spec and no error. Always double-check your dependency configuration first.

Expecting runtime regeneration. The specification is regenerated during compilation rather than dynamically rebuilt at request time. If you change a controller and the Swagger UI does not update, rebuild the project.

Missing @Serdeable on response classes. Micronaut's serialisation layer needs @Serdeable to work without reflection. If a class is missing it, you may get a runtime error or an empty schema in the spec.

Generic response confusion. If your spec shows an empty or wrong schema for a paginated response, you likely need a concrete subclass as described in the pagination section above.

Stale Swagger UI. Browsers aggressively cache the spec file. If your changes are not showing up, hard-refresh or clear the cache.

Testing the OpenAPI spec

Verifying the spec endpoint

@MicronautTest
class OpenApiEndpointTest {

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

@Test
void swaggerSpecEndpointIsAccessible() {
HttpResponse response = client.toBlocking()
.exchange(HttpRequest.GET("/swagger/product-service-0.0.yml"),
String.class);

assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
assertThat(response.body()).contains("openapi: 3.");
assertThat(response.body()).contains("Product Service API");
}
}

Contract testing with Swagger Parser

Because the spec is a static file, you can load and assert it programmatically as part of your test suite. This acts as a contract test — it catches breaking changes before they reach consumers.

testImplementation("io.swagger.parser.v3:swagger-parser:2.1.16")


class OpenApiContractTest {

private static OpenAPI openAPI;

@BeforeAll
static void loadSpec() {
// The generated file location depends on your build tool and OpenAPI configuration.
// A more portable alternative is to load from the classpath:
// new OpenAPIV3Parser().read(
// OpenApiContractTest.class.getResource("/META-INF/swagger/product-service-0.0.yml").toString())
openAPI = new OpenAPIV3Parser().read(
"build/classes/java/main/META-INF/swagger/product-service-0.0.yml");
assertThat(openAPI).as("OpenAPI spec must be generated").isNotNull();
}

@Test
void specHasCorrectTitle() {
assertThat(openAPI.getInfo().getTitle()).isEqualTo("Product Service API");
}

@Test
void productsEndpointIsDefined() {
assertThat(openAPI.getPaths()).containsKey("/products");
}

@Test
void createProductEndpointDocumentsAllResponses() {
Operation post = openAPI.getPaths().get("/products").getPost();
assertThat(post.getResponses()).containsKeys("201", "400", "409");
}

@Test
void allEndpointsHaveSummaries() {
openAPI.getPaths().forEach((path, pathItem) ->
pathItem.readOperations().forEach(op ->
assertThat(op.getSummary())
.as("Endpoint %s is missing a summary", path)
.isNotBlank()));
}

@Test
void securitySchemeIsDefined() {
assertThat(openAPI.getComponents().getSecuritySchemes())
.containsKey("bearerAuth");
}
}

This kind of test is particularly useful for enforcing that every new endpoint has a summary — you can make it a required part of your CI pipeline.

Spring Boot → Micronaut quick reference

Generation approach: Springdoc uses runtime introspection at startup; Micronaut uses compile-time annotation processing.

Spec file location: Springdoc generates the spec in memory and serves it dynamically; Micronaut writes it to META-INF/swagger/ in the build output and serves the static file.

Setup dependency: Spring Boot uses springdoc-openapi-starter-webmvc-ui; Micronaut uses micronaut-openapi as an annotationProcessor plus micronaut-openapi-ui as an implementation dependency.

Swagger UI path config: Spring Boot uses springdoc.swagger-ui.path; Micronaut uses swagger-ui.path.

Global API info, controller grouping, endpoint descriptions, response codes, schema annotations, hiding endpoints, security schemes — all use identical annotation names (@OpenAPIDefinition, @Tag, @Operation, @ApiResponse, @Schema, @Hidden, @SecurityScheme, @SecurityRequirement). Prior Springdoc knowledge transfers directly.

Testing the spec: In Spring Boot you typically test against a running @SpringBootTest context; in Micronaut you can assert the generated file at the build path or hit the /swagger/*.yml HTTP endpoint.

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 OpenAPI Explained for Spring Boot Developers