Most Spring Boot APIs don’t fail because the code is wrong—they fail because nobody knows how to call them correctly.\n\nI see the same pattern on teams of every size: the backend ships endpoints quickly, the frontend guesses payload shapes, QA builds ad-hoc Postman collections, and new engineers spend their first week asking what does this endpoint return on a 400? When the API surface area grows, the hidden cost shows up as integration bugs, slow onboarding, and broken clients after small changes.\n\nWhen I document a Spring Boot REST API, I want three things: (1) an always-available, interactive reference that shows paths, schemas, and error cases, (2) a machine-readable contract that tools can validate and clients can generate from, and (3) a workflow that makes documentation drift hard.\n\nThat’s where Swagger-style documentation fits. In modern Spring Boot (Spring Boot 3.x / 2026-era projects), the common approach is OpenAPI 3 + Swagger UI, typically via springdoc-openapi. You’ll end up with a /v3/api-docs JSON contract and a /swagger-ui browser UI that lets you try GET/POST/PUT/DELETE calls safely.\n\n## REST Documentation: What Swagger Really Gives You\n\nREST (Representational State Transfer) is an architectural style with constraints (statelessness, uniform interface, resource-oriented design). In real systems, though, your consumers don’t experience REST constraints—they experience:\n\n- What URL to call\n- Which HTTP method (GET, POST, PUT, DELETE)\n- Required headers (auth, content type, correlation IDs)\n- Request and response bodies (schemas, nullability, formats)\n- Error responses (400 vs 404 vs 409 vs 422)\n- Paging, sorting, filtering conventions\n\nSwagger is often used as a shorthand term for the interactive documentation UI. The contract behind it today is usually OpenAPI (OAS). The UI is great for humans, while the OpenAPI JSON/YAML is great for tooling: client generation, server stubs, linting, contract tests, and change detection.\n\nIn practice, I treat the OpenAPI spec as the single source of truth, and Swagger UI as the friendly front-end for it.\n\n### Traditional vs Modern (2026) Swagger-Style Docs in Spring Boot\n\n
Older approach (common in legacy projects)
\n
—
\n
Swagger 2.0
\n
Springfox (often)
\n
/v2/api-docs
\n
Swagger UI
\n
Manual docs, wiki pages
\n\nIf you’re on Spring Boot 3+, avoid older libraries that lag behind Jakarta namespace changes. You want something that is actively maintained and aligned with OpenAPI 3.\n\n## Project Setup: Spring Boot + OpenAPI + Swagger UI\n\nYou can start from Spring Initializr with Maven or Gradle. The key is: add spring-boot-starter-web and an OpenAPI/Swagger UI starter.\n\nI’ll show Maven and Gradle examples using springdoc-openapi-starter-webmvc-ui.\n\n### Maven (pom.xml)\n\n \n 4.0.0\n \n org.springframework.boot\n spring-boot-starter-parent\n 3.3.0\n \n \n\n com.example\n tweet-api\n 0.0.1-SNAPSHOT\n\n \n 21\n 2.6.0\n \n\n \n \n org.springframework.boot\n spring-boot-starter-web\n \n\n \n org.springframework.boot\n spring-boot-starter-validation\n \n\n \n org.springdoc\n springdoc-openapi-starter-webmvc-ui\n ${springdoc.version}\n \n\n \n org.springframework.boot\n spring-boot-starter-test\n test\n \n \n \n\nRun it:\n\n mvn -q -DskipTests=false test\n mvn spring-boot:run\n\n### Gradle Kotlin DSL (build.gradle.kts)\n\n plugins {\n id("org.springframework.boot") version "3.3.0"\n id("io.spring.dependency-management") version "1.1.6"\n java\n }\n\n group = "com.example"\n version = "0.0.1-SNAPSHOT"\n\n java {\n toolchain {\n languageVersion.set(JavaLanguageVersion.of(21))\n }\n }\n\n repositories {\n mavenCentral()\n }\n\n dependencies {\n implementation("org.springframework.boot:spring-boot-starter-web")\n implementation("org.springframework.boot:spring-boot-starter-validation")\n implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")\n\n testImplementation("org.springframework.boot:spring-boot-starter-test")\n }\n\n tasks.test {\n useJUnitPlatform()\n }\n\nRun it:\n\n ./gradlew test\n ./gradlew bootRun\n\n### Confirm the Docs Endpoints\n\nOnce the app is running locally (default port 8080):\n\n- OpenAPI JSON: http://localhost:8080/v3/api-docs\n- Swagger UI: http://localhost:8080/swagger-ui/index.html\n\nAt this point, the UI might look empty because you haven’t added controllers yet. Let’s fix that.\n\n## Build a Small REST API You Can Actually Document\n\nI prefer documenting something that has:\n\n- A simple domain model\n- At least one POST with validation\n- A GET that returns a list\n- A clear error model\n\nHere’s a minimal Tweet API with an in-memory store. It’s not production storage, but it’s perfect for showing documentation patterns.\n\n### Application Entry Point\n\n package com.example.tweetapi;\n\n import org.springframework.boot.SpringApplication;\n import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n @SpringBootApplication\n public class TweetApiApplication {\n public static void main(String[] args) {\n SpringApplication.run(TweetApiApplication.class, args);\n }\n }\n\n### DTOs: Requests, Responses, and Errors\n\nIn 2026, I try hard to keep controller contracts explicit. That means request/response DTOs, not your persistence entities.\n\n package com.example.tweetapi.api;\n\n import io.swagger.v3.oas.annotations.media.Schema;\n import jakarta.validation.constraints.NotBlank;\n import jakarta.validation.constraints.Size;\n\n public class CreateTweetRequest {\n\n @Schema(description = "Short title shown in lists", example = "Shipping status")\n @NotBlank\n @Size(max = 80)\n private String title;\n\n @Schema(description = "Tweet body text", example = "We shipped the new release today.")\n @NotBlank\n @Size(max = 280)\n private String message;\n\n public String getTitle() { return title; }\n public void setTitle(String title) { this.title = title; }\n\n public String getMessage() { return message; }\n public void setMessage(String message) { this.message = message; }\n }\n\n package com.example.tweetapi.api;\n\n import io.swagger.v3.oas.annotations.media.Schema;\n\n import java.time.OffsetDateTime;\n\n public class TweetResponse {\n\n @Schema(example = "101")\n private long id;\n\n @Schema(example = "Shipping status")\n private String title;\n\n @Schema(example = "We shipped the new release today.")\n private String message;\n\n @Schema(description = "ISO-8601 timestamp", example = "2026-01-30T17:22:11Z")\n private OffsetDateTime createdAt;\n\n public long getId() { return id; }\n public void setId(long id) { this.id = id; }\n\n public String getTitle() { return title; }\n public void setTitle(String title) { this.title = title; }\n\n public String getMessage() { return message; }\n public void setMessage(String message) { this.message = message; }\n\n public OffsetDateTime getCreatedAt() { return createdAt; }\n public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }\n }\n\n package com.example.tweetapi.api;\n\n import io.swagger.v3.oas.annotations.media.Schema;\n\n import java.util.Map;\n\n public class ApiError {\n\n @Schema(example = "VALIDATIONERROR")\n private String code;\n\n @Schema(example = "Request validation failed")\n private String message;\n\n @Schema(description = "Field-level errors when available")\n private Map fieldErrors;\n\n public ApiError() {}\n\n public ApiError(String code, String message, Map fieldErrors) {\n this.code = code;\n this.message = message;\n this.fieldErrors = fieldErrors;\n }\n\n public String getCode() { return code; }\n public void setCode(String code) { this.code = code; }\n\n public String getMessage() { return message; }\n public void setMessage(String message) { this.message = message; }\n\n public Map getFieldErrors() { return fieldErrors; }\n public void setFieldErrors(Map fieldErrors) { this.fieldErrors = fieldErrors; }\n }\n\n### Service Layer (In-Memory)\n\n package com.example.tweetapi.core;\n\n import com.example.tweetapi.api.CreateTweetRequest;\n import com.example.tweetapi.api.TweetResponse;\n import org.springframework.stereotype.Service;\n\n import java.time.OffsetDateTime;\n import java.util.ArrayList;\n import java.util.Comparator;\n import java.util.List;\n import java.util.concurrent.ConcurrentHashMap;\n import java.util.concurrent.atomic.AtomicLong;\n\n @Service\n public class TweetService {\n\n private final ConcurrentHashMap store = new ConcurrentHashMap();\n private final AtomicLong idSeq = new AtomicLong(100);\n\n public TweetResponse create(CreateTweetRequest req) {\n long id = idSeq.incrementAndGet();\n\n TweetResponse resp = new TweetResponse();\n resp.setId(id);\n resp.setTitle(req.getTitle());\n resp.setMessage(req.getMessage());\n resp.setCreatedAt(OffsetDateTime.now());\n\n store.put(id, resp);\n return resp;\n }\n\n public List list() {\n List out = new ArrayList(store.values());\n out.sort(Comparator.comparing(TweetResponse::getCreatedAt).reversed());\n return out;\n }\n\n public TweetResponse get(long id) {\n return store.get(id);\n }\n }\n\n### Controller: HTTP Mappings + Documentation Hooks\n\n package com.example.tweetapi.api;\n\n import com.example.tweetapi.core.TweetService;\n import io.swagger.v3.oas.annotations.Operation;\n import io.swagger.v3.oas.annotations.media.Content;\n import io.swagger.v3.oas.annotations.media.Schema;\n import io.swagger.v3.oas.annotations.responses.ApiResponse;\n import io.swagger.v3.oas.annotations.responses.ApiResponses;\n import io.swagger.v3.oas.annotations.tags.Tag;\n import jakarta.validation.Valid;\n import org.springframework.http.HttpStatus;\n import org.springframework.http.MediaType;\n import org.springframework.http.ResponseEntity;\n import org.springframework.web.bind.annotation.*;\n\n import java.util.List;\n\n @RestController\n @RequestMapping(value = "/api/tweets", produces = MediaType.APPLICATIONJSONVALUE)\n @Tag(name = "Tweets", description = "Create and read short messages")\n public class TweetController {\n\n private final TweetService tweetService;\n\n public TweetController(TweetService tweetService) {\n this.tweetService = tweetService;\n }\n\n @Operation(\n summary = "Create a tweet",\n description = "Creates a new tweet and returns the created resource."\n )\n @ApiResponses({\n @ApiResponse(responseCode = "201", description = "Created"),\n @ApiResponse(\n responseCode = "400",\n description = "Validation error",\n content = @Content(schema = @Schema(implementation = ApiError.class))\n )\n })\n @PostMapping(consumes = MediaType.APPLICATIONJSONVALUE)\n public ResponseEntity create(@Valid @RequestBody CreateTweetRequest req) {\n TweetResponse created = tweetService.create(req);\n return ResponseEntity.status(HttpStatus.CREATED).body(created);\n }\n\n @Operation(summary = "List tweets")\n @ApiResponses({\n @ApiResponse(responseCode = "200", description = "OK")\n })\n @GetMapping\n public List list() {\n return tweetService.list();\n }\n\n @Operation(summary = "Get a tweet by id")\n @ApiResponses({\n @ApiResponse(responseCode = "200", description = "OK"),\n @ApiResponse(\n responseCode = "404",\n description = "Not found",\n content = @Content(schema = @Schema(implementation = ApiError.class))\n )\n })\n @GetMapping("/{id}")\n public ResponseEntity get(@PathVariable long id) {\n TweetResponse found = tweetService.get(id);\n if (found == null) {\n return ResponseEntity.status(HttpStatus.NOTFOUND).build();\n }\n return ResponseEntity.ok(found);\n }\n }\n\nIf you start the app now, Swagger UI will display:\n\n- POST /api/tweets\n- GET /api/tweets\n- GET /api/tweets/{id}\n\n…and the schemas for your DTOs.\n\n### Try It With curl\n\n curl -s -X POST "http://localhost:8080/api/tweets" \n -H "Content-Type: application/json" \n -d ‘{"title":"Shipping status","message":"We shipped the new release today."}‘\n\n curl -s "http://localhost:8080/api/tweets"\n\nSwagger UI gives you an equivalent Try it out flow, but curl is still the fastest sanity check when debugging headers and proxies.\n\n## Making the Documentation Accurate (Not Just Pretty)\n\nSwagger UI that kind of matches reality is worse than no docs at all, because it teaches your consumers the wrong contract.\n\nHere’s how I keep the docs honest.\n\n### Document Your Error Responses Explicitly\n\nIn the controller above, I referenced ApiError for 400 and 404. That’s a start, but the 404 handler currently returns an empty body. If your OpenAPI spec says the body is ApiError, your implementation should match.\n\nI usually centralize error handling with @RestControllerAdvice and return a consistent error shape.\n\nFirst, I introduce a not-found exception so controllers can stay clean:\n\n package com.example.tweetapi.api;\n\n public class ResourceNotFoundException extends RuntimeException {\n public ResourceNotFoundException(String message) {\n super(message);\n }\n }\n\nThen I throw it from the controller when needed (this is a small change, but it makes error behavior consistent and documentable):\n\n @GetMapping("/{id}")\n public TweetResponse get(@PathVariable long id) {\n TweetResponse found = tweetService.get(id);\n if (found == null) {\n throw new ResourceNotFoundException("Tweet " + id + " not found");\n }\n return found;\n }\n\nNow the exception handler:\n\n package com.example.tweetapi.api;\n\n import org.springframework.http.HttpStatus;\n import org.springframework.http.ResponseEntity;\n import org.springframework.validation.FieldError;\n import org.springframework.web.bind.MethodArgumentNotValidException;\n import org.springframework.web.bind.annotation.ExceptionHandler;\n import org.springframework.web.bind.annotation.RestControllerAdvice;\n\n import java.util.LinkedHashMap;\n import java.util.Map;\n\n @RestControllerAdvice\n public class ApiExceptionHandler {\n\n @ExceptionHandler(MethodArgumentNotValidException.class)\n public ResponseEntity handleValidation(MethodArgumentNotValidException ex) {\n Map fields = new LinkedHashMap();\n for (FieldError fe : ex.getBindingResult().getFieldErrors()) {\n fields.put(fe.getField(), fe.getDefaultMessage());\n }\n ApiError err = new ApiError("VALIDATIONERROR", "Request validation failed", fields);\n return ResponseEntity.status(HttpStatus.BADREQUEST).body(err);\n }\n\n @ExceptionHandler(ResourceNotFoundException.class)\n public ResponseEntity handleNotFound(ResourceNotFoundException ex) {\n ApiError err = new ApiError("NOTFOUND", ex.getMessage(), null);\n return ResponseEntity.status(HttpStatus.NOTFOUND).body(err);\n }\n\n @ExceptionHandler(Exception.class)\n public ResponseEntity handleGeneric(Exception ex) {\n ApiError err = new ApiError("INTERNALERROR", "Unexpected error", null);\n return ResponseEntity.status(HttpStatus.INTERNALSERVER_ERROR).body(err);\n }\n }\n\nThis solves two real problems at once:\n\n- Consumers can reliably parse errors (same JSON shape across endpoints).\n- Swagger docs can accurately promise that shape on every endpoint that might throw those errors.\n\n### When I Prefer 422 Over 400 (And How to Document It)\n\nA lot of teams mix up two distinct failures:\n\n- Syntactically invalid request: malformed JSON, wrong content type, missing required fields (often 400).\n- Semantically invalid request: JSON is valid, but the values violate business rules (commonly 422 Unprocessable Entity).\n\nSpring validation (@Valid) typically maps to 400 by default, which is fine. But business rule validations—like uniqueness, state transitions, or cross-field constraints—often deserve 409 or 422.\n\nExample: imagine tweets must have unique titles. That’s not a JSON format problem; it’s a domain constraint. I’d use 409 Conflict (because the resource you want to create conflicts with existing state). In docs, I want those distinctions visible so consumers can handle them properly.\n\n## Configuring springdoc-openapi Like I Actually Do in Real Projects\n\nThe default setup (dependency + run) is great for a demo. In a real codebase, I almost always add three layers of configuration:\n\n1) Basic metadata: title, version, contact\n2) URL layout: stable paths for docs endpoints\n3) Scoping: include only public controllers, not internal endpoints\n\n### Add OpenAPI Metadata (Title, Version, Servers)\n\nAt minimum, I want the spec to say what it is, and which environment it describes. I typically do this via an OpenAPI bean.\n\n package com.example.tweetapi.config;\n\n import io.swagger.v3.oas.models.OpenAPI;\n import io.swagger.v3.oas.models.info.Info;\n import io.swagger.v3.oas.models.info.Contact;\n import io.swagger.v3.oas.models.servers.Server;\n import org.springframework.context.annotation.Bean;\n import org.springframework.context.annotation.Configuration;\n\n import java.util.List;\n\n @Configuration\n public class OpenApiConfig {\n\n @Bean\n public OpenAPI tweetApiOpenAPI() {\n return new OpenAPI()\n .info(new Info()\n .title("Tweet API")\n .version("v1")\n .description("Example API demonstrating OpenAPI 3 documentation in Spring Boot")\n .contact(new Contact().name("API Team").email("[email protected]")))\n .servers(List.of(\n new Server().url("http://localhost:8080").description("Local"),\n new Server().url("https://api.example.com").description("Production")\n ));\n }\n }\n\nA few practical notes I’ve learned the hard way:\n\n- I keep version in the OpenAPI info aligned with my external API versioning (v1, v2), not the build number.\n- I don’t try to make server URLs perfectly dynamic; it’s fine to list the canonical environments.\n- If you deploy behind a reverse proxy, the server URL shown in Swagger UI can confuse people (they copy the wrong base URL). Set expectations in the description, or set servers carefully for your gateway pattern.\n\n### Pin the Docs Paths (So Links Don’t Rot)\n\nWhen docs URLs change, integrations don’t just break—habits break. People stop trusting the docs. I like to choose a stable layout early.\n\nIn application.yml:\n\n springdoc:\n api-docs:\n path: /openapi\n swagger-ui:\n path: /docs\n operationsSorter: method\n tagsSorter: alpha\n\nWith that, your endpoints become:\n\n- OpenAPI JSON: /openapi\n- Swagger UI: /docs\n\nThis is optional, but I like it because it separates internal implementation (v3) from your public link structure.\n\n### Scope What Gets Documented (Public vs Internal)\n\nSwagger should not be an accidental inventory of everything you’ve ever deployed. I routinely see internal admin endpoints, debug controllers, or old test controllers show up in Swagger UI because nobody filtered them.\n\nTwo patterns I use:\n\n- Package scoping: put public controllers under a package like com.example.api.public and only scan that.\n- Path scoping: only include paths matching /api/ and exclude /internal/.\n\napplication.yml example (path-based):\n\n springdoc:\n paths-to-match: /api/\n paths-to-exclude: /internal/\n\nThis is especially important if you expose actuator endpoints; those should not appear in your public OpenAPI spec.\n\n## Documenting Authentication: JWT, API Keys, and the Try-It-Out Experience\n\nSwagger UI becomes dramatically more useful when it can authenticate like a real client. But auth is also where teams accidentally leak capabilities if they misconfigure security rules. I treat Swagger UI as a client, not a special backdoor.\n\n### Add a Bearer JWT Security Scheme\n\nIf you’re using Authorization: Bearer , document it so Swagger UI shows an Authorize button.\n\nOption A: annotation-based scheme (simple and readable).\n\n package com.example.tweetapi.config;\n\n import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;\n import io.swagger.v3.oas.annotations.security.SecurityScheme;\n import io.swagger.v3.oas.annotations.security.SecuritySchemeIn;\n import org.springframework.context.annotation.Configuration;\n\n @Configuration\n @SecurityScheme(\n name = "bearerAuth",\n type = SecuritySchemeType.HTTP,\n scheme = "bearer",\n bearerFormat = "JWT",\n in = SecuritySchemeIn.HEADER\n )\n public class OpenApiSecurityConfig {}\n\nThen, on endpoints that require auth, I annotate operations:\n\n import io.swagger.v3.oas.annotations.security.SecurityRequirement;\n\n @Operation(summary = "List tweets")\n @SecurityRequirement(name = "bearerAuth")\n @GetMapping\n public List list() {\n return tweetService.list();\n }\n\nThis keeps the spec honest: public endpoints stay public; protected endpoints show the requirement explicitly.\n\n### Spring Security: Permit the Docs Endpoints Without Permitting the API\n\nOne of the most common pitfalls is either:\n\n- Blocking Swagger UI entirely (making it useless), or\n- Accidentally permitting the entire /api/ because you tried to get the docs working quickly\n\nThe docs need access to a few endpoints: the UI itself and the OpenAPI JSON. The API endpoints still follow normal auth rules.\n\nA typical allowlist looks like:\n\n- /v3/api-docs/ (or /openapi if you changed it)\n- /swagger-ui/ (or /docs/)\n\nI also call out an edge case: if you run behind a custom servlet context path (like /service), the permit rules need to match that. It’s not a Swagger problem; it’s URL matching.\n\n### When I Disable Swagger UI\n\nThere are two very different concerns:\n\n- Security: Swagger UI is not inherently insecure; it’s just another endpoint. If it’s behind auth, it can be fine in production.\n- Product posture: Some companies prefer to expose API docs only to internal networks or dev portals.\n\nIn those cases, I keep /v3/api-docs available (for tooling), but disable Swagger UI in production profiles.\n\nExample:\n\n springdoc:\n swagger-ui:\n enabled: false\n\nI usually put that in application-prod.yml, not in application.yml.\n\n## Going Beyond Happy Paths: Examples, Edge Cases, and Better Schemas\n\nA Swagger UI that only documents happy-path JSON shapes still leaves consumers guessing. The difference between usable docs and frustrating docs is usually in the details: examples, enums, nullability, and constraints.\n\n### Use Stronger Schemas (Enums, Formats, and Constraints)\n\nIf you have an enum, document it as an enum. If you have a timestamp, document it as date-time. If you have constraints, make sure they show up in the spec.\n\nA practical DTO improvement I often make is to switch from Stringly-typed fields to constrained types. For instance, if tweets have a visibility field, it should be an enum.\n\n public enum TweetVisibility {\n PUBLIC,\n UNLISTED,\n PRIVATE\n }\n\n public class CreateTweetRequest {\n\n @NotBlank\n @Size(max = 80)\n private String title;\n\n @NotBlank\n @Size(max = 280)\n private String message;\n\n @Schema(description = "Who can see this tweet", example = "PUBLIC")\n private TweetVisibility visibility = TweetVisibility.PUBLIC;\n\n // getters/setters\n }\n\nNow the consumer sees the allowed values directly in Swagger UI. That prevents entire categories of integration bugs.\n\n### Add Request/Response Examples That Match Real Payloads\n\nI’m a big believer that examples should look like the payloads people actually send, including headers and realistic IDs. When examples are too toy-like, consumers copy them and then immediately hit edge cases.\n\nFor example, for a create endpoint, I want an example with:\n\n- A title at the max boundary (to show truncation rules)\n- A message with punctuation\n- Visibility explicitly set\n\nIf the docs show the same example everywhere, it’s usually a sign nobody curated them.\n\n### Document Headers That Matter (Correlation IDs, Idempotency Keys)\n\nReal APIs often require or strongly encourage headers like:\n\n- X-Correlation-Id: for traceability\n- Idempotency-Key: to avoid duplicate creates\n\nSwagger docs are a perfect place to make those conventions discoverable. If consumers don’t know about them, they won’t use them.\n\nIn Spring, I typically model these as @RequestHeader parameters and document them with @Parameter annotations when needed.\n\n## Practical Scenario: Pagination, Filtering, and Sorting Without Confusing People\n\nThe first time your list endpoint grows up, documentation gets harder. A simple GET /api/tweets becomes:\n\n- GET /api/tweets?page=0&size=50\n- GET /api/tweets?sort=createdAt,desc\n- GET /api/tweets?visibility=PUBLIC\n\nIf the docs don’t explain the conventions, every consumer reinvents their own assumptions.\n\n### A Simple, Explicit Pagination Contract\n\nEven if you use Spring Data Pageable internally, I like to make the public contract explicit in the docs:\n\n- page: integer, 0-based\n- size: integer, max allowed\n- sort: field and direction\n\nA good pagination response includes:\n\n- items: array\n- page: number\n- size: number\n- totalItems (or totalElements)\n- totalPages\n\nThis is not required, but it massively improves usability over returning a raw List and forcing consumers to infer metadata from headers or hidden conventions.\n\n### Edge Case: Stable Sorting\n\nIf I return pages, I want stable ordering. If two tweets have identical createdAt values, paging can duplicate or skip items across requests. In docs, I mention that sort defaults to createdAt desc, then id desc as a tiebreaker (or whatever your system uses).\n\nThis sounds small, but it prevents subtle pagination bugs.\n\n## Production Considerations: Performance, Caching, and Deployment\n\nSwagger docs are usually not your performance bottleneck, but they can become one in a few scenarios. I like to call these out because teams tend to discover them at the worst time (during an incident or a load test).\n\n### Runtime Generation Costs\n\nspringdoc generates the spec at runtime by scanning mappings and model schemas. For most APIs, the overhead is modest and only paid when someone requests /v3/api-docs. In typical services, docs endpoints represent a tiny fraction of traffic.\n\nWhere it can matter:\n\n- Very large APIs with hundreds of endpoints and deeply nested schemas\n- Aggressive security filters or gateways that process every request heavily\n- High-frequency polling of /v3/api-docs by external tools\n\nIn those cases, two mitigations help:\n\n- Cache the docs response at the edge (CDN) or gateway with a reasonable TTL (minutes to hours).\n- Generate and publish a static OpenAPI artifact in CI for clients, and avoid runtime access in production entirely.\n\nI avoid quoting exact performance numbers because it varies wildly by service size and environment, but in practice I’ve seen docs generation range from effectively instant to noticeably slow when schema graphs get large.\n\n### Don’t Let Swagger UI Become an Unmonitored Dependency\n\nIf your frontend, partner integrations, or client generators rely on /v3/api-docs, treat it like a contract endpoint and monitor it. At minimum, I want:\n\n- An uptime check that /v3/api-docs returns 200\n- Alerts if it suddenly returns 500 (often caused by a schema generation issue)\n- A record of spec changes per release\n\n### Reverse Proxies and Base Paths\n\nIn production, your service might be behind:\n\n- An API gateway\n- A load balancer\n- A reverse proxy that rewrites paths\n\nSwagger UI can show incorrect server URLs if the proxy strips/changes prefixes. If users click Try it out and calls go to localhost:8080 or the wrong base path, they stop trusting the UI.\n\nWhen that happens, I adjust:\n\n- server URLs in the OpenAPI bean\n- forwarded headers support in Spring (so scheme/host are correct)\n- gateway configuration so the docs endpoints are reachable on the same hostname consumers use\n\n## Common Pitfalls (And How I Avoid Them)\n\nThese are the mistakes I see over and over. Fixing them turns Swagger from a demo into real documentation.\n\n### Pitfall 1: Documenting Entity Models Instead of API Models\n\nIf you expose your JPA entities directly, Swagger will happily document them—but your API becomes coupled to persistence details (lazy-loaded relationships, internal columns, audit fields).\n\nMy rule: controller DTOs are the contract; entities are internal. Swagger should document DTOs, not entities.\n\n### Pitfall 2: Missing Nullability and Optional Semantics\n\nConsumers need to know what can be null, omitted, or empty. In Java, that’s often ambiguous. I reduce ambiguity by:\n\n- Using validation annotations to express required fields\n- Using wrapper types (Long vs long) to indicate optional values\n- Using clear descriptions in @Schema for fields with tricky semantics\n\n### Pitfall 3: Returning 200 for Everything\n\nA Swagger page full of 200 OK responses is a red flag. Real APIs have meaningful status codes:\n\n- 201 Created for POST that creates\n- 204 No Content for delete success\n- 400/422 for client errors\n- 404 for missing resources\n- 409 for conflicts\n\nIf you don’t document these, clients won’t handle them, and your API will feel flaky even when it’s behaving correctly.\n\n### Pitfall 4: Swagger UI Works Locally but Not in QA/Prod\n\nThis is usually caused by security config or missing forwarded headers behind a proxy. The fix is rarely in springdoc; it’s almost always environment wiring.\n\n### Pitfall 5: Drift Between Implementation and Spec\n\nSwagger generated from annotations reduces drift, but it doesn’t eliminate it. Drift still happens when:\n\n- Exception handlers change but controller annotations don’t\n- New validation rules are added but schemas aren’t updated\n- Response bodies evolve without versioning\n\nThat’s why I treat /v3/api-docs as testable output.\n\n## CI Workflow: Making Documentation Drift Hard\n\nIf I had to pick one thing that separates high-trust APIs from low-trust APIs, it’s this: spec changes are reviewed like code changes.\n\n### Snapshot the OpenAPI Spec in Tests\n\nOne straightforward approach is:\n\n- Start the Spring context in a test\n- Fetch /v3/api-docs\n- Compare it to a committed snapshot JSON\n\nWhen the spec changes, the diff is visible in the PR. Reviewers can ask:\n\n- Did you mean to make this field required?\n- Did you mean to remove this endpoint?\n- Did you change the error shape?\n\nThis is the same mindset as database migrations: changes are allowed, but they’re explicit.\n\n### Lint the Spec\n\nEven if Swagger UI renders, the spec can still be messy. Linting catches issues like:\n\n- Missing operationIds\n- Inconsistent naming\n- Missing error responses\n- Undocumented auth requirements\n\nThe exact linter tooling varies by team, but the concept is stable: treat the spec like a first-class artifact.\n\n### Generate a Client in CI (As a Contract Check)\n\nIf you have a downstream consumer (frontend, mobile, partner SDK), I like to generate a client from OpenAPI as a validation step. If generation fails, your contract has a problem (or you rely on undocumented behavior).\n\nI don’t always commit generated clients, but I do like generating them in CI as a signal.\n\n## Alternative Approaches (And When I Use Them Instead)\n\nSwagger/OpenAPI via springdoc is my default for interactive REST docs, but it’s not the only viable approach. Here are the alternatives I reach for, and why.\n\n### Alternative 1: Spring REST Docs (Tests First, Documentation as Output)\n\nIf my highest priority is absolute accuracy, I consider Spring REST Docs. It generates documentation from tests, so the docs reflect real request/response payloads.\n\nTrade-off:\n\n- Pro: extremely accurate, especially for edge cases\n- Con: more work to maintain, and less immediately interactive than Swagger UI\n\nIn practice, I like REST Docs when:\n\n- I’m building a public API where correctness matters more than convenience\n- I want docs to be enforced by integration tests\n- I have complex payloads where annotation-driven docs get confusing\n\n### Alternative 2: Spec-First (Design OpenAPI, Generate Server Stubs)\n\nSpec-first flips the workflow: you write OpenAPI YAML/JSON first, then generate server stubs and implement them.\n\nWhen it works well:\n\n- Large organizations with strict API governance\n- Multi-team systems where contract negotiations happen before code\n- APIs that must be consistent across languages\n\nWhen it’s painful:\n\n- Early-stage products where endpoints change daily\n- Teams without a strong spec review culture\n\nI’ll be honest: many teams say they want spec-first, but they actually want annotation-first with better review discipline.\n\n### Alternative 3: No Swagger UI, Only a Published Spec\n\nSometimes the right decision is to not host Swagger UI in the service at all. Instead, you publish the spec artifact to:\n\n- A developer portal\n- A docs site\n- A shared artifacts repository\n\nThis can be great if you need centralized documentation across many services. The trade-off is losing that convenient local Try it out experience unless you run a UI separately.\n\n## Modern Tooling and AI-Assisted Workflows (How I Actually Use Them)\n\nThis is one of those areas where AI is genuinely helpful, as long as you treat the OpenAPI spec as the truth. The workflow I like looks like this:\n\n- I make an API change (new field, new endpoint, new error code).\n- I regenerate or fetch /v3/api-docs.\n- I ask an assistant to summarize the diff in human terms (what changed, which clients might break).\n- I validate that summary against the spec diff, not against memory.\n\nWhere AI helps in practice:\n\n- Generating example payloads that match schemas (then I verify them)\n- Writing client snippets for curl/JS/Python based on the spec\n- Producing migration notes when an endpoint changes\n\nWhere I do not rely on AI:\n\n- Deciding whether a change is breaking\n- Inferring undocumented behavior\n- Guessing how auth and gateways are wired in production\n\nIf you keep the spec as the authority, AI becomes a productivity multiplier instead of a source of hallucinated contracts.\n\n## Final Checklist: What I Want Before I Call an API Documented\n\nIf I’m shipping a Spring Boot REST API with Swagger/OpenAPI, I consider it done when:\n\n- Swagger UI lists every public endpoint, grouped and named clearly\n- Each endpoint has: summary, description (when needed), and tags\n- Request/response schemas include constraints and realistic examples\n- Auth requirements are visible and usable via Swagger UI\n- Error responses are consistent and documented (400/404/409/422/500 as appropriate)\n- /v3/api-docs is stable, monitored, and validated in CI\n\nThe biggest win isn’t the UI itself—it’s the culture shift: the contract is visible, reviewable, and hard to accidentally break. When that’s true, integrations get faster, onboarding gets easier, and your API stops being tribal knowledge.


