Available Hire Me
← All Writing Spring Boot

Production-Ready API Documentation with OpenAPI 3 and Spring Boot

How to generate clean, accurate OpenAPI 3 documentation from Spring Boot with springdoc-openapi — schema customisation, security schemes, versioning, and separating the UI from the spec.

API documentation is infrastructure. Not an afterthought, not a nicety for external partners — if another team or service consumes your API, documentation is as important as the code itself. The problem most teams have is not lack of tooling; it’s that documentation drifts from the implementation. Manually maintained docs go stale. Auto-generated docs from annotations get cluttered with technical noise and lack the context a consumer actually needs.

The right approach is to generate the spec from code, then annotate selectively where the generated output needs improving. springdoc-openapi does the former well; this post covers how to do both.

Setup

Add to pom.xml:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>

This registers:

  • /v3/api-docs — the raw OpenAPI JSON spec
  • /swagger-ui.html — the Swagger UI

That’s it for basic setup. Springdoc inspects your controllers, request/response types, and validation annotations, and generates a spec automatically. For many internal APIs, this alone is sufficient.

API metadata

Configure metadata via a @Bean:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI apiInfo() {
        return new OpenAPI()
                .info(new Info()
                        .title("Trading API")
                        .description("Betfair market data and order management")
                        .version("v2.1")
                        .contact(new Contact()
                                .name("Sam Jackson")
                                .email("api-support@trinitylogic.co.uk"))
                        .license(new License()
                                .name("Internal")
                                .url("https://trinitylogic.co.uk")))
                .externalDocs(new ExternalDocumentation()
                        .description("Full API guide")
                        .url("https://docs.trinitylogic.co.uk/api"));
    }
}

Documenting controllers

Springdoc reads Spring MVC annotations natively — @RequestMapping, @PathVariable, @RequestParam, @RequestBody, @ResponseStatus. Most of the spec is generated without any extra annotations. Where you do want to add context:

@RestController
@RequestMapping("/markets")
@Tag(name = "Markets", description = "Market data and lifecycle operations")
public class MarketController {

    @Operation(
            summary = "Get market book",
            description = "Returns the current best available prices and volumes for a market. " +
                          "Prices are updated in real time from the Betfair streaming feed."
    )
    @ApiResponse(responseCode = "200", description = "Market book returned")
    @ApiResponse(responseCode = "404", description = "Market not found or no longer active")
    @GetMapping("/{marketId}/book")
    public MarketBookResponse getMarketBook(
            @Parameter(description = "Betfair market ID in format 1.xxxxxxxx")
            @PathVariable String marketId) {
        return marketService.getBook(marketId);
    }
}

Use annotations sparingly. The summary and description add value; restating what Spring already infers from the method signature does not. A @GetMapping on a method returning a 200 needs no @ApiResponse(responseCode = "200") annotation — that’s the default. Annotate the non-obvious cases: error responses, partial content, conditional behaviour.

Schema customisation

Springdoc generates schemas from your record and class types. @Schema adds documentation at the field level:

public record PlaceOrderRequest(
        @Schema(description = "Selection ID from the runner catalogue", example = "1234567")
        @NotNull long selectionId,

        @Schema(description = "Order side", allowableValues = {"BACK", "LAY"})
        @NotNull OrderSide side,

        @Schema(description = "Requested odds in decimal format", example = "3.5", minimum = "1.01", maximum = "1000")
        @DecimalMin("1.01") @DecimalMax("1000")
        BigDecimal price,

        @Schema(description = "Stake in GBP", example = "10.00", minimum = "2.00")
        @DecimalMin("2.00")
        BigDecimal size
) {}

The @NotNull and @DecimalMin validation annotations are automatically picked up by springdoc and reflected in the schema — required, minimum, maximum fields appear in the generated spec without duplication. Don’t re-declare constraints in @Schema if they’re already expressed in validation annotations.

Security schemes

For APIs protected by JWT bearer tokens:

@Bean
public OpenAPI apiInfoWithSecurity() {
    var securityScheme = new SecurityScheme()
            .name("bearerAuth")
            .type(SecurityScheme.Type.HTTP)
            .scheme("bearer")
            .bearerFormat("JWT");

    return new OpenAPI()
            .info(...)
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components().addSecuritySchemes("bearerAuth", securityScheme));
}

addSecurityItem applies the scheme globally — every endpoint requires authentication. To mark individual endpoints as public:

@Operation(security = @SecurityRequirement(name = ""))
@GetMapping("/health")
public HealthResponse health() { ... }

For APIs with mixed authentication requirements (some endpoints public, some protected), apply the security requirement at the operation level rather than globally.

API versioning and multiple specs

For a versioned API with separate /v1 and /v2 prefixes:

@Bean
public GroupedOpenApi v1Api() {
    return GroupedOpenApi.builder()
            .group("v1")
            .pathsToMatch("/v1/**")
            .build();
}

@Bean
public GroupedOpenApi v2Api() {
    return GroupedOpenApi.builder()
            .group("v2")
            .pathsToMatch("/v2/**")
            .build();
}

Springdoc generates separate specs: /v3/api-docs/v1 and /v3/api-docs/v2. The Swagger UI shows both as selectable groups.

Separating UI from spec in production

In production, you likely want the JSON spec available (for tooling, client generation) but not the Swagger UI (which exposes your API surface to anyone who can reach the host). Separate them in application-production.yml:

springdoc:
  swagger-ui:
    enabled: false
  api-docs:
    enabled: true
    path: /internal/api-docs

In application-dev.yml:

springdoc:
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    try-it-out-enabled: true
    filter: true
  api-docs:
    enabled: true

This pattern keeps the developer experience intact locally while not exposing the Swagger UI in production environments.

Generating a static spec for CI

If you need a static OpenAPI spec for client code generation or contract testing, generate it as part of your build:

<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 your app, hits the spec endpoint, and writes the JSON. Commit the generated spec to source control and add a CI check that fails if it drifts from the current implementation — it’s a lightweight form of API contract testing.

ProTips

Use @Hidden to exclude internal endpoints: Some controllers are for infrastructure (actuator paths, internal health checks) and should not appear in the API spec. @Hidden on the class suppresses them entirely.

Default values in examples, not in code: Use @Schema(defaultValue = "...") to document defaults rather than setting them in the Java constructor. This keeps your domain objects free of documentation coupling.

Don’t annotate everything: The value of OpenAPI annotations is context that the code doesn’t already express. A field called price of type BigDecimal doesn’t need @Schema(description = "The price"). A field called f that is actually a Betfair fractional odds code does.

If you’re building an API that external teams or partners will consume and want to discuss the documentation strategy, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.