Spring Boot – API Docs with springdoc-openapi

July 7, 20264 min readUpdated 8/18/2026

API documentation that lives in a separate document is wrong within a fortnight. Generating it from the code means it is wrong only when the code is.

First, a naming clarification that causes real confusion. Swagger was the original project; it was donated and renamed OpenAPI. Springfox was the old Spring integration and is dead — it never supported Spring Boot 3, let alone 4. springdoc-openapi is what you use now. Tutorials mentioning @EnableSwagger2 or Docket are Springfox and will not work.

Setup

<properties>
    <!-- Boot 4.1 does not manage springdoc, so the version is pinned here.
         2.8.6 is verified working against Spring Framework 7.0.8. -->
    <springdoc.version>2.8.6</springdoc.version>
</properties>

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>${springdoc.version}</version>
</dependency>

⚠️ Note the explicit version. springdoc is not in the Spring Boot BOM, so omitting it fails with 'dependencies.dependency.version' … is missing — the same message a renamed starter produces (lesson 3), and it means something different here. Pin it, and pin a version verified against your Spring Framework version; springdoc reads Spring's internals and is sensitive to them.

That is the whole setup. Start the app and you already have:

/v3/api-docs        the OpenAPI 3 document, as JSON
/v3/api-docs.yaml   the same thing as YAML
/swagger-ui.html    the interactive UI
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method

⚠️ Spring Security will hide it from you

The first thing that happens on a secured app is a 401 from Swagger UI. The doc endpoints have to be permitted explicitly:

// ---- documentation -------------------------------------------------
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll()

All three patterns are needed — the UI is static assets under /swagger-ui/**, and it fetches the document from /v3/api-docs/**. Permitting only the HTML page gives you a UI that loads and then reports that it cannot read the spec.

In production, do the opposite. Publishing your complete API surface, including every admin endpoint, is free reconnaissance:

# application-prod.properties
springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false

Describing the API

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI pizzaOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Pizza API")
                        .version("v1")
                        .description("Ordering API for the pizza demo app. "
                                + "Menu browsing and guest checkout are public; everything under "
                                + "/api/admin requires an ADMIN token. "
                                + "Demo accounts: admin@pizza.test / admin123 and "
                                + "customer@pizza.test / pizza123."))
                .components(new Components()
                        .addSecuritySchemes(
                                "bearerAuth",
                                new SecurityScheme()
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")));
    }
}

The description is doing real work. Someone opening this page needs to know which endpoints are public, which need a token, and how to get one — that is what makes the difference between documentation and a list of URLs.

Making "Try it out" work with JWT

Declaring the security scheme above is half of it. Marking which endpoints use it is the other half:

@Tag(name = "Admin · Products", description = "Menu management (ADMIN only)")
@RequestMapping("/api/admin/products")
@RestController
@SecurityRequirement(name = "bearerAuth")     // <- name must match the scheme above
class AdminProductRestController { }

Now Swagger UI shows an Authorize button. Call POST /api/auth/login, copy the token, paste it in, and every subsequent request carries the header. Without @SecurityRequirement there is no button, and every admin call from the UI returns 403 — which reads like a broken endpoint rather than a missing header.

The name is a string in two places. A typo produces exactly the same symptom as omitting it.

Documenting operations

@Operation(summary = "Deactivate a product — hidden from the menu, still editable here")
@PatchMapping("/{id}/deactivate")
public ResponseEntity<Void> deactivateProduct(@PathVariable UUID id) { }

@Operation(summary = "Soft-delete a product — past orders still reference it")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable UUID id) { }

Those two summaries are the model to copy. Neither restates the method name; each explains the consequence — which is the only thing a caller cannot work out from the signature. "Deactivate a product" would be worthless; "hidden from the menu, still editable here" answers the question somebody was about to ask.

Documenting the models

springdoc reads your DTOs, so records and Bean Validation annotations already produce a schema — @NotBlank becomes required, @Size(max = 120) becomes maxLength. Add @Schema where the type does not tell the whole story:

@Schema(description = "A placed order")
public record OrderDTO(
        @Schema(description = "Public UUID — unguessable, unlike a sequential id") UUID id,
        OrderStatus status,
        OrderType orderType,
        BigDecimal total,
        @Schema(description = "Card brand that paid, display only", example = "visa")
        String cardBrand,
        @Schema(description = "Last four digits. NOT the card number.", example = "4242")
        String cardLast4,
        List<OrderItemDTO> items) {}

Note what those descriptions are for. cardLast4 is documented as "Last four digits. NOT the card number" — that is a note to the next developer as much as to an API consumer, and it belongs where they will actually read it.

Hide what is not part of the API:

@Hidden  // A browser page, not part of the JSON API — keep it out of the OpenAPI document.
public class OrderReceiptController { }

What the generated document is good for

The UI is the visible benefit; the document is the useful one. /v3/api-docs is a machine-readable contract you can:

  • Generate clients from — TypeScript, Python, Go — so the frontend's types come from the backend rather than from someone reading and retyping.
  • Diff in CI to catch breaking changes before they ship.
  • Import into Postman or Insomnia as a complete collection.
curl http://localhost:8085/v3/api-docs > openapi.json

What to take from this

  • springdoc, not Springfox, and pin the version — Boot does not manage it.
  • Permit all three doc paths in Spring Security, and disable the whole thing in production.
  • Declare the scheme and mark the endpoints — both, or the Authorize button never appears.
  • Write summaries that state the consequence, not the method name.

Next: server-rendered pages with Thymeleaf — when a document beats a JSON endpoint.