DEV Community

Cover image for 💀 Your Spring Boot Controllers Are 80% Swagger Noise - Here's the Fix
Kyryl
Kyryl

Posted on

💀 Your Spring Boot Controllers Are 80% Swagger Noise - Here's the Fix

I never gave much thought to where Swagger annotations lived. Wire up springdoc, scatter @Operation and @ApiResponse across the controller methods, ship it. Swagger UI opens, endpoints show up - good enough.

Then I joined a project with 40+ controllers and around 15 endpoints each.

Opening any controller felt like reading a legal contract. Every method was buried under five or six annotations before you even reached actual logic. Here's what a single endpoint looked like:

@RestController
@RequestMapping("/api/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {

    @Operation(summary = "Get user by ID")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "User found",
            content = @Content(schema = @Schema(implementation = UserDto.class))),
        @ApiResponse(responseCode = "404", description = "User not found",
            content = @Content(schema = @Schema(implementation = ErrorDto.class)))
    })
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getById(
            @Parameter(description = "User ID") @PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    // ... 14 more endpoints exactly like this
}
Enter fullscreen mode Exit fullscreen mode

80% documentation noise. 20% code. And this was one of the smaller controllers.

The pattern

Move every Swagger annotation onto a Java interface. The controller implements the interface and overrides the methods with actual business logic.

The interface - owns the entire OpenAPI contract:

@Tag(name = "Users", description = "User management endpoints")
public interface UserApi {

    @Operation(summary = "Get user by ID")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "User found",
            content = @Content(schema = @Schema(implementation = UserDto.class))),
        @ApiResponse(responseCode = "404", description = "User not found",
            content = @Content(schema = @Schema(implementation = ErrorDto.class)))
    })
    ResponseEntity<UserDto> getById(
            @Parameter(description = "User ID") Long id);

    @Operation(summary = "Create user")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "User created"),
        @ApiResponse(responseCode = "400", description = "Invalid input")
    })
    ResponseEntity<UserDto> create(CreateUserRequest request);
}
Enter fullscreen mode Exit fullscreen mode

The controller - owns only routing and logic:

@RestController
@RequestMapping("/api/users")
public class UserController implements UserApi {

    private final UserService userService;

    @Override
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @Override
    @PostMapping
    public ResponseEntity<UserDto> create(@RequestBody @Valid CreateUserRequest request) {
        return ResponseEntity.status(201).body(userService.create(request));
    }
}
Enter fullscreen mode Exit fullscreen mode

@GetMapping, @PostMapping, and the rest of the Spring MVC routing annotations stay on the controller — they're not part of the OpenAPI contract, they're HTTP wiring. Only the Swagger layer moves.

Does springdoc pick it up automatically?

Yes. springdoc-openapi reads annotations from implemented interfaces. No extra configuration needed.

Tested on:

  • Spring Boot 3.x
  • springdoc-openapi-starter-webmvc-ui 2.x
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The honest trade-off

When a method signature changes, you update two files instead of one. If you rename a path variable or add a parameter, it hits both the interface and the controller. That's real overhead — don't pretend it isn't.

My rule of thumb: worth it past ~15 endpoints. Below that it's ceremony without much payoff. A five-endpoint CRUD controller doesn't need this. A service with 60 endpoints where three developers are touching controllers daily — it does.

What you actually gain

The interface becomes a first-class artifact. It's a scannable list of every endpoint, its return type, and its documented responses — without a single line of implementation detail in the way. It's what you open when you want to understand the API shape, not when you want to debug a specific handler.

It also makes PRs cleaner. API contract changes show up in the interface diff. Implementation changes show up in the controller diff. They're separate concerns and now they live separately.

When not to use it

  • Small services with few endpoints — the split adds files without adding clarity
  • Teams that don't do API design reviews — the interface artifact is most valuable when someone actually reads it
  • Projects where annotation inheritance isn't stable (older springdoc versions had edge cases with @Valid and request body annotations — worth verifying in your setup)

What do you do to keep large Spring Boot controllers readable? I've seen teams solve this differently — some go full OpenAPI YAML, some use annotation processors, some just live with the noise.

Top comments (0)