Spring Boot API Documentation With OpenAPI — Generating, Hosting, and Keeping It Accurate

by Eric Hanson, Backend Developer at Clean Systems Consulting

springdoc-openapi — the standard library

springdoc-openapi generates an OpenAPI 3 specification by scanning your Spring MVC controllers at runtime. Add the dependency and the spec appears at /v3/api-docs with Swagger UI at /swagger-ui.html:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>
springdoc:
  api-docs:
    path: /v3/api-docs
    enabled: true
  swagger-ui:
    path: /swagger-ui.html
    enabled: true
    operations-sorter: method
    tags-sorter: alpha
    display-request-duration: true
  show-actuator: false   # don't include actuator endpoints in API docs
  packages-to-scan: com.example.api   # limit scanning to API controllers

With no annotations, springdoc infers reasonable defaults from method signatures, parameter types, and return types. @RequestBody @Valid CreateOrderRequest produces a required request body with validation constraints. ResponseEntity<OrderResponse> produces the response schema. @PathVariable Long id produces a required path parameter.

Global API metadata

Configure the OpenAPI metadata — title, version, contact, servers — in a @Bean:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Order Service API")
                .description("Manages order creation, fulfillment, and tracking")
                .version("v1.4.2")
                .contact(new Contact()
                    .name("Platform Team")
                    .email("platform@example.com")
                    .url("https://developers.example.com"))
                .license(new License()
                    .name("Proprietary")
                    .url("https://example.com/terms")))
            .servers(List.of(
                new Server().url("https://api.example.com").description("Production"),
                new Server().url("https://api.staging.example.com").description("Staging"),
                new Server().url("http://localhost:8080").description("Local development")
            ))
            .components(new Components()
                .addSecuritySchemes("bearerAuth", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("JWT Bearer token from the authorization server")));
    }
}

Apply the security scheme globally (all endpoints require authentication) or per-controller:

// Global — all endpoints require bearerAuth
@Bean
public OpenAPI openAPI() {
    return new OpenAPI()
        // ...
        .addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
}

// Per-controller — only this controller requires authentication
@RestController
@SecurityRequirement(name = "bearerAuth")
public class OrderController { ... }

Annotation-driven spec enrichment

springdoc infers a lot from method signatures, but the inferred spec is often incomplete. Annotations fill the gaps.

@Operation — describe what an endpoint does:

@GetMapping("/{id}")
@Operation(
    summary = "Retrieve an order by ID",
    description = "Returns the full order details including line items and shipping information. "
                + "Requires the caller to own the order or have ADMIN role.",
    responses = {
        @ApiResponse(responseCode = "200", description = "Order found",
            content = @Content(schema = @Schema(implementation = OrderResponse.class))),
        @ApiResponse(responseCode = "404", description = "Order not found",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
        @ApiResponse(responseCode = "403", description = "Not authorized to view this order",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
    }
)
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) { ... }

@Parameter — describe individual parameters:

@GetMapping
public Page<OrderResponse> listOrders(
    @Parameter(description = "Filter by order status", example = "PENDING")
    @RequestParam(required = false) OrderStatus status,

    @Parameter(description = "Filter orders created after this date (ISO 8601)",
               example = "2026-01-01T00:00:00Z")
    @RequestParam(required = false) Instant createdAfter,

    @Parameter(hidden = true)  // don't document internal parameters
    @RequestHeader(value = "X-Internal-Caller", required = false) String caller
) { ... }

@Schema — enrich request and response models:

@Schema(description = "Request to create a new order")
public record CreateOrderRequest(

    @Schema(description = "Line items to include in the order",
            minItems = 1, maxItems = 100)
    @NotEmpty @Size(max = 100) List<@Valid LineItemRequest> items,

    @Schema(description = "Payment method ID from the payment service",
            example = "pm_1234567890abcdef")
    @NotBlank String paymentMethodId,

    @Schema(description = "Optional discount coupon code",
            example = "SUMMER20", nullable = true)
    String couponCode
) {}

@Tag — group endpoints by feature area:

@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management operations")
public class OrderController { ... }

@RestController
@RequestMapping("/api/v1/payments")
@Tag(name = "Payments", description = "Payment processing and history")
public class PaymentController { ... }

Keeping documentation accurate — the drift problem

Manually written API documentation drifts from the implementation. springdoc generates from code, which helps, but there are still ways the spec can become inaccurate:

Missing response codes. springdoc infers the success response (200/201/204) but doesn't know about all the error responses your exception handlers produce. Document them explicitly with @ApiResponse or accept that error responses aren't in the spec.

@ControllerAdvice exception mapping. Error response shapes come from @ExceptionHandler methods in @ControllerAdvice. Document them at the global level:

@RestControllerAdvice
@Hidden  // exclude from per-controller docs — documented at OpenAPI level
public class GlobalExceptionHandler {
    // handled error responses documented in OpenApiConfig
}

Or document the error schema in the @Operation annotation on each endpoint.

Validation constraints not reflected. Bean Validation annotations (@NotBlank, @Size, @Min) are partially reflected in the schema — springdoc reads them. But custom validators and cross-field constraints aren't. Document complex validation rules in @Schema(description = "...").

Contract testing — enforcing accuracy

The most reliable way to keep docs accurate is to test against the spec:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OpenApiContractTest {

    @LocalServerPort int port;
    @Autowired TestRestTemplate restTemplate;

    @Test
    void openApiSpec_isValid() throws Exception {
        // Fetch the generated spec
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/v3/api-docs", String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        // Validate the spec is valid OpenAPI 3
        OpenAPIV3Parser parser = new OpenAPIV3Parser();
        ParseResult result = parser.readContents(response.getBody(), null, null);

        assertThat(result.getMessages())
            .as("OpenAPI spec validation errors")
            .isEmpty();
    }

    @Test
    void createOrder_responseMatchesSpec() throws Exception {
        // Use swagger-request-validator to validate responses against the spec
        // This catches when the actual response doesn't match the documented schema
    }
}

swagger-request-validator from Atlassian validates actual HTTP requests and responses against an OpenAPI spec:

<dependency>
    <groupId>com.atlassian.oai</groupId>
    <artifactId>swagger-request-validator-spring-webmvc</artifactId>
    <version>2.40.0</version>
    <scope>test</scope>
</dependency>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ApiSpecComplianceTest {

    private OpenApiValidationFilter validationFilter;

    @BeforeEach
    void setUp() {
        validationFilter = new OpenApiValidationFilter(
            "http://localhost:" + port + "/v3/api-docs");
    }

    @Test
    @WithMockUser(roles = "USER")
    void createOrder_responseCompilesWithSpec() throws Exception {
        MockMvc mockMvc = MockMvcBuilders
            .webAppContextSetup(context)
            .addFilters(validationFilter)  // validates request and response against spec
            .build();

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"items": [{"productId": "p-1", "quantity": 2}],
                              "paymentMethodId": "pm-visa"}"""))
            .andExpect(status().isCreated());
        // If the response doesn't match the spec, the validation filter throws an error
    }
}

This test fails if the actual API response doesn't match the OpenAPI spec — catching documentation drift at test time rather than when an integrator reports incorrect docs.

Generating a static spec at build time

Runtime spec generation is useful for development. For CI/CD pipelines, publishing to developer portals, and versioning the spec alongside code, generate a static JSON file at build time:

<plugin>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-maven-plugin</artifactId>
    <version>1.4</version>
    <executions>
        <execution>
            <id>integration-test</id>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
        <outputFileName>openapi.json</outputFileName>
        <outputDir>${project.build.directory}</outputDir>
    </configuration>
</plugin>

The plugin starts the application, fetches the spec, and writes it to a file. This file can be:

  • Committed to the repository (the spec is versioned alongside the code)
  • Published to a developer portal (Stoplight, Redoc, Confluence)
  • Used as input for client SDK generation (openapi-generator)
  • Checked in CI against the previous version to detect breaking changes

Breaking change detection:

# In CI — compare current spec against the released spec
npx openapi-diff \
    https://api.example.com/v3/api-docs \
    ./target/openapi.json

# Exit code 1 if breaking changes detected

openapi-diff flags: removed endpoints, removed fields from request/response schemas, changed required/optional status, changed types. These are all breaking changes for existing integrators.

Securing the documentation endpoint

In production, the Swagger UI and spec endpoint should not be publicly accessible — they reveal the full API surface:

springdoc:
  swagger-ui:
    enabled: ${SWAGGER_UI_ENABLED:false}  # disable by default in production
  api-docs:
    enabled: ${API_DOCS_ENABLED:false}    # disable by default in production
// In SecurityConfig — protect docs behind authentication in staging
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/v3/api-docs/**", "/swagger-ui/**")
        .hasRole("INTERNAL")
    // ...
)

Or expose the docs at a different path that's behind a VPN or internal network only — not on the public API port.

For public APIs that want to expose documentation, expose a read-only spec without the interactive Swagger UI (which could be used to probe the API):

springdoc:
  swagger-ui:
    enabled: false          # disable interactive UI
  api-docs:
    enabled: true           # expose the spec for tooling
    path: /openapi.json     # well-known path

Clients and tooling consume /openapi.json; the interactive test interface isn't publicly available.

The documentation workflow that works

The discipline that keeps API documentation accurate: code first, spec generated, spec tested. Write the controller with proper annotations. springdoc generates the spec. Contract tests verify the spec matches the implementation. The spec is committed at build time. Breaking changes are detected in CI before merging.

Manually written YAML specs that are maintained alongside code always drift — the code is updated, the spec is forgotten. Generated specs drift only when annotations lag the implementation — which contract tests catch immediately.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Service Communication in Spring Boot: REST vs Messaging

Choosing between synchronous REST and asynchronous messaging is not a matter of preference — it is a decision with direct consequences for availability, consistency, and operational complexity. Most systems need both, and the mistake is applying one where the other belongs.

Read more

Feeling Stuck After 3 Years? How to Know if You’re Improving

You’ve been coding for a few years, but it feels… flat. No big jumps, no clear progress—just work on repeat.

Read more

How to Handle a Client Freaking Out Because of a Bug

Bugs happen. How you react can turn a frustrated client into a loyal one—or the opposite. Handling panic gracefully is as important as fixing the issue itself.

Read more

How Good Engineering Teams Use Code Review

Code reviews aren’t just a formality—they’re the secret sauce that separates good engineering teams from the rest. Done right, they improve code, knowledge, and culture.

Read more