Skip to main content

OpenAPI Generator

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag openapi-spec.

The Problem with Manual Primary Adapters

Unless you work in microservices, a project usually has more than a few endpoints. As your domain grows, manually crafting primary adapters becomes a monumental task. It comes with a lot of drawbacks:

  • Time-consuming: Every endpoint, every model, all by hand. It's a grind.
  • Prone to human error: One typo, one missed field, and suddenly your API isn't doing what it's supposed to.
  • Compromises documentation and maintainability: The moment your code deviates from your spec, your documentation is a lie, and your maintainability takes a nosedive.
Real case scenario

The SIGEM project (a Grails monolith) boasts 76 controllers and a staggering 1102 endpoints.

Controllers Count Sigem 73f335f981504a788cd7019c83079745

Files Overview

Files to Create/Modify
File Tree

├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ ├── advice
│ │ │ │ └── ControllerAdvice.java
│ │ │ └── ...
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ └── ...
│ │ └── ...
│ └── ...
└── ...

Generating Code

Let’s save us some problems by using one of the greatest libraries to ever exist: openapi-generator.

build.gradle
plugins {
// ...
id 'org.openapi.generator' version '7.17.0'
}
// ...
dependencies {
// ...
implementation 'io.swagger.core.v3:swagger-annotations:2.2.41'
implementation 'org.openapitools:jackson-databind-nullable:0.2.8'
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
// ...
openApiGenerate {
generatorName = "spring"
inputSpec = layout.projectDirectory.file("src/main/resources/openapi.yaml").asFile.toString()
outputDir = layout.buildDirectory.dir("generated/sources/openapi").get().asFile.toString()

def basePackage = "${project.group}.${project.name}.generated".toString()
apiPackage = "${basePackage}.api"
modelPackage = "${basePackage}.model"

configOptions = [
interfaceOnly : "true",
requestMappingMode : "api_interface",
skipDefaultInterface : "true",
useJakartaEe : "true",
useSpringBoot3 : "true",
useTags : "true",
]
}

sourceSets {
main {
java {
srcDir(layout.buildDirectory.dir("generated/sources/openapi/src/main/java"))
}
}
}

tasks.named('compileJava') {
dependsOn 'openApiGenerate'
}
info

You can find more information about the different configurations in the Documentation for the spring Generator.

warning

inputspec must be pointing to the desired OpenAPI Specification YAML file (src/main/resources/openapi.yaml).

Now that everything is set up, run the Build Task. When the task finishes, check the build\generated\sources\openapi folder. You’ll find the representation of the OpenAPI Specification (our contract) in classes, ready to be used.

Understand the Generated Code

  • What’s Inside the Generated Code?
    • Models: Java (or Kotlin) classes mirroring your OpenAPI schemas (e.g., Film). These include validation annotations, serialization logic, and builder patterns.
    • API Interfaces: Spring @RestController interfaces (e.g., FilmApi) that define your endpoints and their method signatures.
  • Why Does It Look So Complicated? The generated code includes:
    • Boilerplate for OpenAPI/Spring compatibility (e.g., @Validated, @Generated annotations).
    • Validation logic (e.g., @NotNull, @Size) to enforce your contract.
    • Serialization/deserialization support (e.g., JSON ↔ Java (or Kotlin) object mapping).
  • Should I care? No.
    • It’s autogenerated: Treat it like a compiled dependency. You use it, not modify it.
    • Contract-first philosophy: The code exactly matches your OpenAPI spec. If you need changes, update the YAML file and regenerate.
    • Maintenance-free: The generator handles updates, so you avoid manual refactoring.

Use the Generated Code

  1. Make the @RestController class implement the generated Api interface:

    java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
    package dev.pollito.spring_java.sakila.film.adapter.in.rest;

    import static io.opentelemetry.api.trace.Span.current;
    import static java.time.OffsetDateTime.now;
    import static org.springframework.http.HttpStatus.OK;
    import static org.springframework.http.ResponseEntity.ok;

    import dev.pollito.spring_java.generated.api.FilmsApi;
    import dev.pollito.spring_java.generated.model.FilmListResponse;
    import dev.pollito.spring_java.generated.model.FilmResponse;
    import dev.pollito.spring_java.sakila.film.domain.port.in.FindByIdPortIn;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.RestController;

    @RestController
    @RequiredArgsConstructor
    public class FilmRestController implements FilmsApi {
    private final FindByIdPortIn findByIdPortIn;
    private final FilmRestMapper mapper;
    private final HttpServletRequest request;

    @Override
    public ResponseEntity<FilmListResponse> findAll() {
    throw new UnsupportedOperationException();
    }

    @Override
    public ResponseEntity<FilmResponse> findById(Integer id) {
    return ok(
    new FilmResponse()
    .data(mapper.convert(findByIdPortIn.findById(id)))
    .instance(request.getRequestURI())
    .timestamp(now())
    .trace(current().getSpanContext().getTraceId())
    .status(OK.value()));
    }
    }
  2. Update the Mapper interface so it returns the generated Response model instead of the handwritten one:

    java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
    package dev.pollito.spring_java.sakila.film.adapter.in.rest;

    import dev.pollito.spring_java.config.mapper.MapperSpringConfig;
    import dev.pollito.spring_java.sakila.film.domain.model.Film;
    import org.jspecify.annotations.Nullable;
    import org.mapstruct.Mapper;
    import org.springframework.core.convert.converter.Converter;

    @Mapper(config = MapperSpringConfig.class)
    public interface FilmRestMapper extends Converter<Film, dev.pollito.spring_java.generated.model.Film> {
    @Override
    dev.pollito.spring_java.generated.model.Film convert(@Nullable Film source);
    }
  3. Update the @RestControllerAdvice class so it returns the generated Error model (which models ProblemDetail) to keep consistency:

    java/dev/pollito/spring_java/config/advice/ControllerAdvice.java
    package dev.pollito.spring_java.config.advice;

    import static io.opentelemetry.api.trace.Span.current;
    import static java.time.OffsetDateTime.now;
    import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
    import static org.springframework.http.HttpStatus.NOT_FOUND;
    import static org.springframework.http.ResponseEntity.status;

    import dev.pollito.spring_java.generated.model.Error;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.jspecify.annotations.NonNull;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    import org.springframework.web.servlet.resource.NoResourceFoundException;

    @RestControllerAdvice
    @RequiredArgsConstructor
    @Slf4j
    public class ControllerAdvice {
    private final HttpServletRequest request;

    private @NonNull ResponseEntity<Error> buildProblemDetail(
    @NonNull Exception e, @NonNull HttpStatus status) {
    String exceptionSimpleName = e.getClass().getSimpleName();
    String logMessage = "{} being handled";

    switch (status.series()) {
    case SERVER_ERROR -> log.error(logMessage, exceptionSimpleName, e);
    case CLIENT_ERROR -> log.warn(logMessage, exceptionSimpleName, e);
    default -> log.info(logMessage, exceptionSimpleName, e);
    }

    return status(status)
    .body(
    new Error()
    .detail(e.getLocalizedMessage())
    .instance(request.getRequestURI())
    .status(status.value())
    .timestamp(now())
    .title(status.getReasonPhrase())
    .trace(current().getSpanContext().getTraceId()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Error> handle(Exception e) {
    return buildProblemDetail(e, INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<Error> handle(NoResourceFoundException e) {
    return buildProblemDetail(e, NOT_FOUND);
    }
    }
  4. Delete handwritten FilmResponse, it is no longer needed.

Build and run the application. Then go to http://localhost:8080/api/films/42

Terminal
curl -s http://localhost:8080/api/films/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-02-19T16:41:30.343577085Z",
"trace": "0342323f19e37da6b13a009854548007",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 42,
"language": "English",
"lengthMinutes": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}

What's the Point of Generating Code?

Setting up this OpenAPI Generator might feel like performing ancient rituals just to get some code out. You might be thinking, "Wasn't this supposed to save me time?" And to that, I say: Yes, you're investing a little pain upfront to avoid a lot of pain later.

The OpenAPI Generator doesn't just spew out code; it acts as a very strict guardian of your API contract.

  • No more manual DTO creation: Forget the mind-numbing task of writing Data Transfer Objects (DTOs) by hand. Your API specification defines them, and the generator builds them perfectly, every single time. This kills off an entire category of typos and inconsistencies.
  • Trivial controller implementations: Your Spring @RestController interfaces, with all their routing annotations, are also generated. This means your controller classes transition from boilerplate factories to minimalist components that simply implement a predefined interface.
  • Compiler-enforced contract: Because your DTOs and controller interfaces are direct reflections of your OpenAPI spec, the compiler becomes your strictest ally. A missing field, a changed type, an altered endpoint signature, will result in a compile error. It is impossible to break your API contract without immediate feedback.

So, yeah, it's a bit of a setup, but the payoff? Absolutely worth it.