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, commit(s) OpenAPI Generation (spring_java), OpenAPI Generation (spring_kotlin), OpenAPI Generation (spring_groovy)

In the previous document we wrote an OpenAPI Specification that defines our API contract. Now we'll use that spec to generate primary adapters automatically, instead of writing them by hand.

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) has 76 controllers and 1102 endpoints.

Controllers Count Sigem 73f335f981504a788cd7019c83079745

Files overview

Files to Create/Modify
File Tree

├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ ├── common
│ │ │ ├── util
│ │ │ │ └── EnumUtils.java
│ │ │ └── ValuedEnum.java
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ ├── domain
│ │ │ │ ├── model
│ │ │ │ │ ├── Film.java
│ │ │ │ │ ├── FilmLanguage.java
│ │ │ │ │ └── FilmRating.java
│ │ │ │ └── service
│ │ │ │ └── FilmUseCasesImpl.java
│ │ │ └── ...
│ │ └── ...
│ └── ...
└── ...

Generating code

Let’s save us some problems by using openapi-generator, a library that turns your OpenAPI spec into Spring adapters.

build.gradle (plugin block)
id 'org.openapi.generator' version '7.20.0'
build.gradle (dependencies block)
implementation 'io.swagger.core.v3:swagger-annotations:2.2.45'
implementation 'org.openapitools:jackson-databind-nullable:0.2.9'
implementation 'org.springframework.boot:spring-boot-starter-validation'
build.gradle (new openApiGenerate block)
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}.sakila.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'
}
Documentation for the Spring Generator

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.sakila.film.domain.port.in.FilmUseCases;
    import dev.pollito.spring_java.sakila.generated.api.FilmsApi;
    import dev.pollito.spring_java.sakila.generated.model.FilmFields;
    import dev.pollito.spring_java.sakila.generated.model.FilmListResponse;
    import dev.pollito.spring_java.sakila.generated.model.FilmResponse;
    import jakarta.servlet.http.HttpServletRequest;
    import java.util.List;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.RestController;

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

    @Override
    public ResponseEntity<FilmResponse> createFilm(FilmFields filmFields) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<Void> deleteFilm(Integer id) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<FilmResponse> getFilm(Integer id) {
    return ok(
    new FilmResponse()
    .data(mapper.map(useCases.getFilm(id)))
    .instance(request.getRequestURI())
    .timestamp(now())
    .trace(current().getSpanContext().getTraceId())
    .status(OK.value()));
    }

    @Override
    public ResponseEntity<FilmListResponse> getFilms(Integer page, Integer size, List<String> sort) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<FilmResponse> updateFilm(Integer id, FilmFields filmFields) {
    throw new RuntimeException("Not implemented");
    }
    }
  2. Update the Mapper interface so it returns generated.model.Film 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 static org.mapstruct.MappingConstants.ComponentModel.SPRING;

    import dev.pollito.spring_java.sakila.film.domain.model.Film;
    import org.mapstruct.Mapper;

    @Mapper(componentModel = SPRING)
    public interface FilmRestMapper {
    dev.pollito.spring_java.sakila.generated.model.Film map(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/web/ControllerAdvice.java
    package dev.pollito.spring_java.config.web;

    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.sakila.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. Update the Domain models so they better represent the use cases:
    • Enum interface + utility class pattern:
    java/dev/pollito/spring_java/common/ValuedEnum.java
    package dev.pollito.spring_java.common;

    public interface ValuedEnum<T> {
    T getValue();
    }
    java/dev/pollito/spring_java/common/util/EnumUtils.java
    package dev.pollito.spring_java.common.util;

    import dev.pollito.spring_java.common.ValuedEnum;
    import org.jspecify.annotations.NonNull;

    public final class EnumUtils {

    private EnumUtils() {}

    public static <E extends Enum<E> & ValuedEnum<V>, V> @NonNull E fromValue(
    @NonNull Class<E> enumClass, V value) {
    for (E constant : enumClass.getEnumConstants()) {
    if (constant.getValue().equals(value)) {
    return constant;
    }
    }
    throw new IllegalArgumentException("Unknown " + enumClass.getSimpleName() + " value: " + value);
    }
    }
    • Domain model update:
    java/dev/pollito/spring_java/sakila/film/domain/model/Film.java
    package dev.pollito.spring_java.sakila.film.domain.model;

    import static lombok.AccessLevel.*;

    import java.math.BigDecimal;
    import java.time.OffsetDateTime;
    import lombok.*;
    import lombok.experimental.FieldDefaults;
    import org.jspecify.annotations.NonNull;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @FieldDefaults(level = PRIVATE)
    public class Film {
    @NonNull Integer id;
    @NonNull String title;
    String description;
    Integer releaseYear;
    FilmRating rating;
    Integer length;
    @NonNull FilmLanguage language;
    FilmLanguage originalLanguage;
    @NonNull Integer rentalDuration;
    @NonNull BigDecimal rentalRate;
    @NonNull BigDecimal replacementCost;
    String specialFeatures;
    @NonNull OffsetDateTime lastUpdate;
    }
  5. 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 is 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.