Spring Boot – REST API Documentation using Swagger

I’ve watched too many teams ship solid APIs and then lose hours answering the same questions: “What does this endpoint return?”, “Which fields are required?”, “Why does the client fail on 400?” The backend is fine, but the documentation drifts, screenshots go stale, and onboarding slows down. That’s where Swagger-style docs matter. When your docs are generated from the same code that serves requests, you stop guessing and start sharing a living contract. In Spring Boot, that contract is also testable, so you can click through every endpoint and see real responses without setting up a separate client.

You’ll learn how I set up Swagger-driven documentation in a modern Spring Boot project, how I model example payloads that stay in sync with code, and how I protect the docs in production while keeping them helpful in development. I’ll also point out edge cases I see in real services, like versioned APIs, validation errors, and auth flows. If you’ve ever felt your API docs fall behind the code, this will give you a stable workflow you can keep for years.

REST, Swagger, and OpenAPI in plain terms

REST stands for Representational State Transfer. It’s an architectural style that relies on clear resources, standard HTTP verbs, and predictable responses. You already use its ideas every time you design an endpoint like GET /tweets or POST /tweets. The docs problem isn’t REST itself; the problem is that REST is a style, not a documentation format.

Swagger is the name many of us still use for the tooling and UI that grew around the OpenAPI specification. OpenAPI is the formal contract, and Swagger UI is the interactive page that renders that contract. So the practical pattern in Spring Boot today is: generate OpenAPI from annotations and code, then render it with Swagger UI for humans to read and test.

A simple analogy I use with juniors is this: REST is the recipe, OpenAPI is the printed cookbook, and Swagger UI is the kitchen counter with every step laid out and clickable. You still cook the same meal, but now anyone can follow it, verify it, and even taste along the way.

Here’s a quick Traditional vs Modern view of API docs in Spring Boot:

Area

Traditional Docs

Modern Swagger/OpenAPI Docs —

— Update flow

Manual edits in wiki

Generated from code Drift risk

High (weeks to months)

Low (minutes to hours) Testability

External tools required

Built-in try-it-out UI Client generation

Rare, manual

Common via OpenAPI tooling Onboarding speed

2-5 days typical

1-2 days typical

Project setup with Maven or Gradle

I prefer you start with a clean Spring Boot project and add only what you need. You can create it from start.spring.io with Spring Web. If you’re on Maven, your baseline should compile with mvn package and run with mvn spring-boot:run. For Gradle, ./gradlew build and ./gradlew bootRun should succeed. I keep the project small so the docs are clear.

For Maven, I add a property for the OpenAPI UI starter and a dependency like this. Replace the version with a current release you’re comfortable with (I pin versions to avoid surprises):

<properties>

<java.version>17</java.version>

<springdoc.version>2.6.0</springdoc.version>

</properties>

<dependencies>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>

</dependency>

<dependency>

<groupId>org.springdoc</groupId>

<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

<version>${springdoc.version}</version>

</dependency>

</dependencies>

For Gradle (Kotlin DSL), I do the same:

plugins {

id("org.springframework.boot") version "3.3.0"

id("io.spring.dependency-management") version "1.1.6"

kotlin("jvm") version "1.9.24"

kotlin("plugin.spring") version "1.9.24"

}

dependencies {

implementation("org.springframework.boot:spring-boot-starter-web")

implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")

}

In 2026, I also run a quick AI-assisted workflow to confirm the OpenAPI endpoints and validate JSON examples. I’ll use my IDE’s AI pair programmer or a local LLM to spot missing descriptions and example values. It doesn’t replace tests, but it catches doc gaps early.

Build a tiny REST API worth documenting

Swagger docs are only as good as the API you expose. I start with a small example: a simple Tweet resource. That lets me show GET and POST, a list response, and an object schema.

A minimal model class:

package com.example.demo.tweet;

public class Tweet {

private Integer id;

private String title;

private String message;

public Integer getId() {

return id;

}

public void setId(Integer id) {

this.id = id;

}

public String getTitle() {

return title;

}

public void setTitle(String title) {

this.title = title;

}

public String getMessage() {

return message;

}

public void setMessage(String message) {

this.message = message;

}

}

A simple service that holds data in memory (fine for demos, not for production):

package com.example.demo.tweet;

import org.springframework.stereotype.Service;

import java.util.ArrayList;

import java.util.List;

@Service

public class TweetService {

private final List<Tweet> tweetList = new ArrayList<>();

public void addSampleTweets() {

Tweet t1 = new Tweet();

t1.setId(1);

t1.setTitle("My first tweet");

t1.setMessage("This is a demo tweet for documentation.");

tweetList.add(t1);

Tweet t2 = new Tweet();

t2.setId(2);

t2.setTitle("My second tweet");

t2.setMessage("Another demo tweet to show list output.");

tweetList.add(t2);

}

public List<Tweet> getTweets() {

return tweetList;

}

}

And a controller with both POST and GET routes:

package com.example.demo.tweet;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController

@RequestMapping("/tweets")

public class TweetController {

private final TweetService tweetService;

@Autowired

public TweetController(TweetService tweetService) {

this.tweetService = tweetService;

}

@PostMapping("/seed")

public void seedTweets() {

tweetService.addSampleTweets();

}

@GetMapping

public List<Tweet> listTweets() {

return tweetService.getTweets();

}

@GetMapping("/hello")

public String hello() {

return "Hello from the API";

}

}

This gives you three endpoints: GET /tweets, POST /tweets/seed, and GET /tweets/hello. That’s enough to show how Swagger renders different response types and how example schemas look in the UI.

Enable Swagger UI with springdoc-openapi

Once springdoc is on the classpath, it exposes two endpoints for you by default:

  • OpenAPI JSON at /v3/api-docs
  • Swagger UI at /swagger-ui/index.html

That’s it. There’s no extra config required to get a baseline UI. But I nearly always add metadata so the docs feel intentional. I also add tags so the UI groups endpoints in a way that matches how the service is used.

Here’s a simple configuration class:

package com.example.demo.config;

import io.swagger.v3.oas.models.OpenAPI;

import io.swagger.v3.oas.models.info.Info;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

@Configuration

public class OpenApiConfig {

@Bean

public OpenAPI apiInfo() {

return new OpenAPI()

.info(new Info()

.title("Tweet Service API")

.description("API docs for demo tweet endpoints")

.version("v1"));

}

}

Now open the UI in your browser and you’ll see the endpoints and schema. If you call POST /tweets/seed in the UI and then GET /tweets, you’ll see the demo list in the response.

I also suggest adding a tag annotation so the UI groups by domain instead of controller class names:

package com.example.demo.tweet;

import io.swagger.v3.oas.annotations.tags.Tag;

import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Tweets", description = "Operations for tweet resources")

@RestController

@RequestMapping("/tweets")

public class TweetController {

// ... endpoints

}

Document endpoints and schemas that real clients can trust

Swagger UI only shines when you add the details clients need. I focus on four items: descriptions, examples, response codes, and validation constraints. If any of these are missing, clients will still ask you questions.

For example, describe what a response means and add example values. I also annotate required fields and size limits so the schema is honest.

package com.example.demo.tweet;

import io.swagger.v3.oas.annotations.media.Schema;

public class Tweet {

@Schema(example = "42", description = "Unique tweet id")

private Integer id;

@Schema(example = "Spring tips", description = "Short title of the tweet")

private String title;

@Schema(example = "Use POST /tweets to create a new tweet", description = "Tweet content")

private String message;

// getters and setters

}

For endpoints, I annotate the operation and response codes. I also add a 400 response for validation failures and a 500 response for unexpected errors. That gives client teams a clear path for error handling.

package com.example.demo.tweet;

import io.swagger.v3.oas.annotations.Operation;

import io.swagger.v3.oas.annotations.responses.ApiResponse;

import io.swagger.v3.oas.annotations.responses.ApiResponses;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RestController;

@RestController

public class TweetController {

@Operation(summary = "List all tweets", description = "Returns every tweet currently in memory")

@ApiResponses({

@ApiResponse(responseCode = "200", description = "Successful response"),

@ApiResponse(responseCode = "500", description = "Server error")

})

@GetMapping("/tweets")

public List<Tweet> listTweets() {

return tweetService.getTweets();

}

}

If you’re using validation, those annotations should show up in the schema too. Here’s a simple DTO with constraints:

package com.example.demo.tweet;

import jakarta.validation.constraints.NotBlank;

import jakarta.validation.constraints.Size;

public class CreateTweetRequest {

@NotBlank

@Size(max = 80)

private String title;

@NotBlank

@Size(max = 500)

private String message;

// getters and setters

}

When the UI shows “title max length 80,” clients will respect it. That alone reduces a lot of back-and-forth. The same is true for enums: if you restrict values to a fixed set, document it so the UI displays possible values.

Security, versioning, and environments that matter in production

Swagger UI is fantastic in development, but you have to be deliberate in production. I usually do three things:

1) Gate the UI behind authentication or a restricted profile

2) Document auth flows clearly

3) Version the API and show that version in the docs

First, I typically disable the UI in prod unless the service is internal. With Spring profiles, it’s easy:

springdoc:

api-docs:

enabled: true

swagger-ui:

enabled: false

---

spring:

config:

activate:

on-profile: dev

springdoc:

swagger-ui:

enabled: true

Second, document security schemes so clients know how to authenticate. For bearer tokens, the annotations are straightforward:

package com.example.demo.config;

import io.swagger.v3.oas.models.Components;

import io.swagger.v3.oas.models.OpenAPI;

import io.swagger.v3.oas.models.security.SecurityRequirement;

import io.swagger.v3.oas.models.security.SecurityScheme;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

@Configuration

public class OpenApiSecurityConfig {

@Bean

public OpenAPI openApi() {

SecurityScheme bearerAuth = new SecurityScheme()

.type(SecurityScheme.Type.HTTP)

.scheme("bearer")

.bearerFormat("JWT");

return new OpenAPI()

.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))

.components(new Components().addSecuritySchemes("bearerAuth", bearerAuth));

}

}

Third, versioning. I usually version via URL for clarity in docs and clients. Something like /api/v1/tweets makes the UI obvious. You could also version via headers, but for many client teams the visible version in the path lowers confusion.

When I say “production,” I also mean performance. Swagger UI itself isn’t heavy, but generating large OpenAPI specs can add a few milliseconds. In a typical mid-sized service, I see 10–15ms for the /v3/api-docs endpoint and under 50ms for the UI page to render in a warm browser. That’s fine for dev. For prod, you should still protect access.

Common mistakes I see and how I avoid them

I see the same issues every year, and they all create doc drift or broken assumptions:

1) Missing examples. Without examples, the schema is still too abstract for many client teams. I add examples for every field that is user-facing.

2) Only happy-path responses. You should include 400, 401, 403, and 500 codes when relevant. Clients need to know why a request fails.

3) Silent constraints. If a field is required or has a max length, add validation and let the UI show it.

4) Ignoring pagination. If a list can grow, document pagination fields from day one. It’s painful to add later.

5) Multiple controllers with overlapping tags. The UI becomes noisy. I group by domain and keep tags clean.

I also recommend you avoid vague names like “request” and “response.” In 2026, teams often generate clients straight from OpenAPI, and clear schema names help with readable generated code.

When I use Swagger and when I don’t

I use Swagger UI for any API that has more than one consumer or is evolving quickly. That includes public APIs, internal APIs with multiple teams, and even partner integrations. It removes friction immediately.

I skip Swagger UI in two cases: ultra-simple services with a single internal consumer, and edge services where docs are already defined by a strict external schema. Even then, I still generate OpenAPI JSON because it’s useful for linting and client generation.

If you’re choosing between manual docs and generated docs, I recommend generated docs every time. Manual docs drift, and drift is expensive. You can still add narrative docs where needed, but the contract should come from code.

Closing guidance and next steps

You don’t need a huge setup to make Swagger work well in Spring Boot. I start with a small API, add springdoc, and then make the docs intentional: clear descriptions, honest constraints, and examples that match real data. Once that’s in place, Swagger UI becomes a shared language between backend and client teams, which saves real time in onboarding and feature delivery.

If you’re ready to move forward, I’d do it in this order. First, add springdoc and confirm /v3/api-docs works locally. Second, document one or two critical endpoints with examples and response codes. Third, add validation annotations so the schema tells the truth about required fields and limits. Fourth, decide how you’ll handle access to the docs in production and set profile rules. Finally, put a simple checklist in your pull request template: “Does this change require OpenAPI docs?” That one line keeps your docs healthy.

I’ve seen this workflow reduce back-and-forth by weeks over a quarter, and it scales well as your API grows. When you combine it with automated tests and client generation, your API becomes easier to maintain, easier to trust, and easier to grow without surprise failures. That’s the outcome I want for you—and it’s achievable with a few disciplined steps.

Scroll to Top