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 UIspringdoc.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=falseDescribing 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.jsonWhat 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.