Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

API Documentation with OpenAPI

Generating documentation people actually use: springdoc-openapi setup, annotations worth adding, grouping large APIs, documenting errors and auth, and the API-first workflow.

Beginner6 min readUpdated
On this page

Documentation that is generated from the code cannot drift from it. That single property is why OpenAPI generation beats a hand-written wiki page, even when the generated output needs some annotation work to be genuinely useful.

Key Takeaways

  • One dependency gives you a spec and a UI with no configuration.
  • Annotate the things a generator cannot infer: examples, error responses, auth requirements.
  • Group a large API so consumers see their subset rather than 300 endpoints.
  • Document errors with the same ProblemDetail schema your handler returns.
  • The spec is a build artefact — generate clients from it and diff it in CI to catch breaking changes.

Setup

pom.xml
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.7.0</version>
</dependency>

That alone serves the spec at /v3/api-docs and the UI at /swagger-ui.html. Everything after this point is about making the output worth reading.

OpenApiConfig.java
@Configuration
public class OpenApiConfig {
 
    @Bean
    public OpenAPI apiDefinition(@Value("${app.version}") String version) {
        return new OpenAPI()
            .info(new Info()
                .title("Acme Orders API")
                .version(version)
                .description("""
                    Order lifecycle for the Acme platform.
 
                    All timestamps are ISO 8601 with a UTC offset. All monetary
                    amounts are minor units (cents) with an explicit currency.
                    Errors follow RFC 7807.
                    """)
                .contact(new Contact().name("Platform team").email("platform@acme.com"))
                .license(new License().name("Proprietary")))
            .servers(List.of(
                new Server().url("https://api.acme.com").description("Production"),
                new Server().url("https://sandbox.acme.com").description("Sandbox")))
            .components(new Components().addSecuritySchemes("bearer-jwt",
                new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")))
            .addSecurityItem(new SecurityRequirement().addList("bearer-jwt"));
    }
}

The description block is worth writing properly. Conventions that apply across every endpoint — date formats, money representation, the error shape — belong once at the top rather than repeated in thirty parameter descriptions.

Annotating endpoints

OrderController.java
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Create, query and cancel customer orders")
public class OrderController {
 
    @Operation(
        summary = "Create an order",
        description = """
            Creates an order in PENDING state and reserves stock for 15 minutes.
            Supply an Idempotency-Key header to make retries safe.
            """)
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Created"),
        @ApiResponse(responseCode = "409", description = "Idempotency key reused with a different body",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "422", description = "Validation failed",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
    })
    @PostMapping
    public ResponseEntity<OrderResponse> create(
            @Parameter(description = "Unique key for safe retries", required = true,
                       example = "9f1c2b7e-3d4a-4f2b-8c1d-0a5e7b9f3c21")
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody(description = "Order to create", required = true)
            @Valid @org.springframework.web.bind.annotation.RequestBody CreateOrderRequest request) {
        return null;
    }
}

On the DTO, describe fields and give real examples. An example that a consumer can copy into a request is worth more than a sentence describing the format:

CreateOrderRequest.java
@Schema(description = "A new customer order")
public record CreateOrderRequest(
 
    @Schema(description = "Customer identifier", example = "cus_8Fj3kQ", requiredMode = REQUIRED)
    @NotBlank String customerId,
 
    @Schema(description = "At least one line, at most 100")
    @NotEmpty @Size(max = 100) List<OrderLineRequest> lines,
 
    @Schema(description = "ISO 4217 currency code", example = "EUR",
            allowableValues = { "EUR", "USD", "GBP" })
    @NotBlank String currency,
 
    @Schema(description = "Never returned; write-only", accessMode = WRITE_ONLY)
    String internalNote
) { }

accessMode is the underused one. WRITE_ONLY marks a field as accepted but never returned; READ_ONLY marks the reverse. Generated clients respect it, which stops a consumer trying to set createdAt.

Grouping a large API

Groups split one application's endpoints into separate specs so each audience sees only what applies to it.
ApiGroups.java
@Bean
public GroupedOpenApi publicApi() {
    return GroupedOpenApi.builder().group("public").pathsToMatch("/api/v1/**").build();
}
 
@Bean
public GroupedOpenApi partnerApi() {
    return GroupedOpenApi.builder().group("partner").pathsToMatch("/api/partner/**").build();
}
 
@Bean
@Profile("!prod")
public GroupedOpenApi internalApi() {
    return GroupedOpenApi.builder().group("internal").pathsToMatch("/internal/**").build();
}

Use @Hidden on individual endpoints that should never appear — health checks, admin operations, anything experimental. An endpoint documented by accident becomes an endpoint someone integrates against, and then you own it.

The spec as a build artefact

This is where OpenAPI stops being documentation and starts being useful engineering.

Generate clients. The OpenAPI Generator produces typed clients for Java, TypeScript, Python and others from the spec. Consumers stop hand-writing HTTP code, and a change to a field name becomes a compile error in their build rather than a runtime surprise.

Diff the spec in CI. Commit the generated spec and fail the build when a change is backwards-incompatible — a removed field, a narrowed enum, a newly required parameter. openapi-diff classifies changes for you. This is the single highest-value thing you can do with the spec, because it turns "we did not realise that was breaking" into a red build.

Contract-test against it. A test that validates real responses against the schema catches the case where the annotations claim one shape and the serialiser produces another.

.github/workflows/api.yml
- name: Export the spec
  run: ./mvnw verify -Dtest=OpenApiExportTest
 
- name: Fail on breaking changes
  run: |
    docker run --rm -v "$PWD:/spec" openapitools/openapi-diff:latest \
      /spec/api-baseline.json /spec/target/openapi.json --fail-on-incompatible

Spec-first, when it fits

The alternative workflow writes the YAML first, generates server interfaces from it, and implements those interfaces. The contract becomes reviewable before any code exists, and front-end and back-end teams can work in parallel against a stub server.

The trade-off is real: the build gets more complex, generated interfaces constrain your signatures, and the round trip for a small change is longer. Spec-first earns its cost when several teams consume the API and the contract needs negotiating. For a single team shipping a service they also consume, code-first with a CI diff gate gives most of the benefit for much less machinery.

Making the docs genuinely useful

A generated spec tells a consumer what the fields are. It does not tell them what to do, and that gap is where support tickets come from. Three additions close most of it.

Write one worked flow per major use case — create an order, poll it, cancel it — as a sequence of concrete requests with real payloads. Consumers copy this and adapt it; it saves them assembling the sequence from twenty endpoint pages.

Document the error catalogue as a page rather than only per-endpoint. A consumer hitting insufficient-stock wants to know what causes it and what to do, and that belongs in one place the type URI resolves to.

State the operational contract: rate limits, pagination defaults and maxima, timeout behaviour, retry guidance, and how you announce deprecations. These are the questions that arrive by email when they are not written down.

What to take away

Add the dependency, then invest annotation effort where a generator cannot guess: examples, error responses, auth and access modes. Group large APIs by audience. Then treat the spec as a build artefact — generate clients from it and diff it in CI, so breaking changes fail your build instead of someone else's production.

Frequently Asked Questions

Should Swagger UI be enabled in production?
For an internal or partner API, yes — it is the fastest way for a consumer to try a call. For a public-facing service, expose the generated spec but consider serving the UI from a documentation site rather than the application, and never expose it for endpoints you have not intentionally published.
Code-first or spec-first?
Code-first is faster to start and the spec cannot drift from the implementation, which suits a single team owning both sides. Spec-first is better when several teams build against the contract in parallel, because the spec becomes a reviewable artefact and clients can be generated before the server exists.
Why is my schema showing as an empty object?
Almost always a serialisation mismatch. springdoc introspects the Java type, so a class with no getters, a generic erased at runtime, or a custom serialiser producing a different shape will all document incorrectly. Annotate the method with @Schema(implementation = ...) or register the type explicitly.

Related tutorials