DEV Community

Cover image for We Made OpenAPI Generator Think in Generics
Barış Saylı
Barış Saylı

Posted on • Edited on • Originally published at Medium

We Made OpenAPI Generator Think in Generics

Clean APIs are hard. Clean generated clients are even harder.

Most teams eventually run into the exact same wall: duplicated response wrappers, growing model graphs, and generated clients that slowly drift away from the API contracts they were supposed to represent.

At first glance, this looks like a generics problem. It isn't.

The real problem starts when a shared API contract crosses a service boundary and gets reconstructed into an entirely different set of generated models.

What began as a small experiment to preserve ServiceResponse<T> in generated clients led to a larger realization: response envelopes are contracts, not generated artifacts.

Once you establish that line of ownership, the rest of the system becomes radically simpler. Generated clients stop inventing infrastructure models, contracts remain recognizable across service boundaries, and APIs become infinitely easier to evolve over time.

In this post, I'll walk you through the ideas behind OpenAPI Generics, explain how contract preservation differs from traditional code generation, and show why preserving contract ownership matters far more than preserving generic syntax.

🚨 Update (May 2026): OpenAPI Generics v1.0.2 is now available on Maven Central! Adoption guides and sample projects are available in the repository.


🧩 The Problem: Model Explosion

Most real-world APIs standardize responses using a generic envelope:

ServiceResponse<T>
Enter fullscreen mode Exit fullscreen mode

This envelope is usually part of the service contract. It carries payload data, metadata, paging information, and often becomes a platform-wide convention shared across multiple services.

A typical endpoint might return:

ServiceResponse<Page<CustomerDto>>
Enter fullscreen mode Exit fullscreen mode

At this point, you'd expect the generated client to preserve that contract. Instead, OpenAPI Generator typically produces a brand-new model:

class ServiceResponsePageCustomerDto {
    PageCustomerDto data;
    Meta meta;
}
Enter fullscreen mode Exit fullscreen mode

Then another one...

class ServiceResponseCustomerDto {
    CustomerDto data;
    Meta meta;
}
Enter fullscreen mode Exit fullscreen mode

...and another:

class ServiceResponseOrderDto {
    OrderDto data;
    Meta meta;
}
Enter fullscreen mode Exit fullscreen mode

Every single generated model redeclares the exact same envelope structure and differs only by its payload type.

As I mentioned earlier, this looks like a generics problem, but it's actually a contract ownership problem. The original contract starts as a clean, shared abstraction (ServiceResponse<T>). But after generation, it morphs into a growing collection of artifacts that must be regenerated, maintained, and synchronized across service boundaries.

The immediate symptom is model explosion. The deeper problem is that the contract itself is no longer being reused—its generated copies are.


💡 The Core Insight

The breakthrough for us wasn't nested generics, OpenAPI, or even code generation.

The breakthrough was realizing that the response envelope should never have been treated as generated code in the first place. It is an intrinsic part of the contract.

Once that distinction becomes clear, a different mental model emerges:

  1. The server owns the contract.
  2. OpenAPI publishes the contract semantics.
  3. The generator reconstructs the contract.
  4. The client reuses the contract.

Instead of allowing code generation tools to invent new response models, the goal is simply to preserve the original contract across every stage of the pipeline:

☕ Java Contract
  ↓
📄 OpenAPI Projection
  ↓
⚙️ Generated Client
Enter fullscreen mode Exit fullscreen mode

In this model, OpenAPI is no longer the ultimate authority—it’s just a projection of an already existing contract. The generated client isn't responsible for defining response structures; it's responsible for reconstructing them.

That single shift changes everything. The envelope stops being duplicated, generated models become significantly thinner, and the contract remains recognizable from producer to consumer.

The goal is no longer “generics-aware code generation.” The goal is contract preservation.


🟥 Before: Contract Reconstruction

Consider a standard Spring controller:

@GetMapping
ResponseEntity<ServiceResponse<Page<CustomerDto>>> getCustomers()
Enter fullscreen mode Exit fullscreen mode

From a contract perspective, the response shape is straightforward. But traditional code generation reconstructions that contract as a rigid, new model (ServiceResponsePageCustomerDto).

The envelope is no longer reused; its fields are copied into a generated class. As more endpoints appear, more wrappers accumulate, gradually creating a second model hierarchy totally disconnected from the original contract.

Before Contract Reconstruction

The result? Duplicated envelope definitions, massive model graphs, constant regeneration noise, and inevitable contract drift.


🟩 After: Contract Reconstruction Without Redefinition

Now, let's look at the alternative:

After Contract Reconstruction

The generated class no longer owns the envelope structure. It simply binds generic parameters to an existing contract. The envelope exists once, and the generated wrapper exists purely to preserve type information.

Architecturally, this is huge:

  • The contract remains the source of truth.
  • The envelope is reused, not duplicated.
  • Changes to the envelope happen in exactly one place.

The generated client becomes a mere transport adapter. The contract remains the contract.


🏗 Why This Matters in Microservices

Why does this matter beyond just having cleaner generated code? Because generated clients rarely exist in isolation.

In a typical microservice architecture, a contract often travels through multiple layers:

Producer ServiceGenerated ClientConsumer ServiceBFF / AggregatorPublic API

Without contract reuse, every service boundary introduces a new set of models. You end up with a mapping nightmare:

ContractGenerated WrapperMapped DTOAnother WrapperAnother DTO

The data is identical. Only the types change! As teams add more services, the amount of boilerplate code dedicated solely to translating structurally equivalent models skyrockets.

If the envelope remains a shared contract, the generated client stops introducing a second model universe. It becomes a thin transport layer. A response created by one service can traverse generated clients and aggregation layers while remaining recognizable as the exact same contract.


🔓 BYOE and BYOC: Reusing Contracts You Already Own

What if your organization already has shared response envelopes (ApiResponse<T>) or platform-level DTOs used across multiple services? Requiring those to be regenerated would just recreate the same ownership problem.

OpenAPI Generics takes a different approach: it allows existing contracts to remain the source of truth.

📦 BYOE — Bring Your Own Envelope

If you already use a shared wrapper like ApiResponse<T>, OpenAPI Generics won't replace it. The generator reconstructs wrapper types around your existing envelope instead of generating a competing hierarchy. The envelope continues to live where it belongs.

🧩 BYOC — Bring Your Own Contract

The same principle applies to DTOs (e.g., CustomerDto, OrderDto). There is zero value in generating structurally identical copies of these. BYOC allows generated clients to reuse your existing types directly.

Without BYOE/BYOC:
Contract DTOGenerated DTOMapping Layer

With BYOE/BYOC:
Contract DTOGenerated Client

The generated client becomes infrastructure. This dramatically reduces duplication, mapping code, and long-term maintenance overhead.


⚙️ How It Works Under the Hood

The implementation is surprisingly lightweight.

1. Publishing Contract Semantics

On the server side, a Springdoc integration inspects your controller return types and detects contract shapes:

ServiceResponse<T>
ServiceResponse<Page<T>>
Enter fullscreen mode Exit fullscreen mode

Instead of just publishing structural schemas, it enriches the OpenAPI document with metadata describing the contract semantics. OpenAPI becomes a true projection of the original contract.

2. Reconstructing The Contract

During client generation, a custom Java generator reads those semantics and reconstructs the original shape.

Instead of this:

class ServiceResponsePageCustomerDto {
    PageCustomerDto data;
    Meta meta;
}
Enter fullscreen mode Exit fullscreen mode

It generates a thin wrapper:

public class ServiceResponsePageCustomerDto
    extends ServiceResponse<Page<CustomerDto>> {}
Enter fullscreen mode Exit fullscreen mode

It reconnects type information back to the existing contract without redefining it!


🛠 Available on Maven Central

Ready to try it out? OpenAPI Generics is available on Maven Central and can be adopted incrementally in your projects.

You'll need these two building blocks:

  • io.github.blueprint-platform:openapi-generics-server-starter
  • io.github.blueprint-platform:openapi-generics-java-codegen-parent

(Supports Spring Boot 3.4.x, 3.5.x, and 4.x with OpenAPI Generator 7.x).

GitHub logo blueprint-platform / openapi-generics

Prevent OpenAPI Generator from redefining your Java contract. Contract-preserving client generation for Spring Boot — available on Maven Central.

OpenAPI Generics for Spring Boot — Keep Your API Contract Intact End-to-End

Build CodeQL codecov Release

Java Spring Boot OpenAPI Generator

License: MIT

Generics-Aware OpenAPI Contract Lifecycle

Prevent OpenAPI Generator from redefining your Java contract.
A contract-preserving OpenAPI Generator specialization for Java/Spring that keeps shared envelopes and DTOs reusable across service boundaries — no model explosion, no manual templates, no fork.


Table of Contents


The problem in 30 seconds

You return a generic envelope from a Spring Boot controller:

ResponseEntity<ServiceResponse<Page<CustomerDto>>> getCustomers() { ... }
Enter fullscreen mode Exit fullscreen mode

OpenAPI Generator gives your clients this:

// ❌ Generated by default — one of these per endpoint
class ServiceResponsePageCustomerDto {
  PageCustomerDto data;
  Meta meta;
}
Enter fullscreen mode Exit fullscreen mode

With openapi-generics, the same client looks like this:

// ✅ Generated with openapi-generics
public class ServiceResponsePageCustomerDto
    extends ServiceResponse<
Enter fullscreen mode Exit fullscreen mode

📚 Check out the Adoption Guides and Documentation


💭 Final Thought

Most contract problems don't announce themselves. They accumulate quietly—one generated wrapper at a time, one mapping layer at a time. By the time the duplication becomes visible, it's already a structural mess.

OpenAPI Generics is a small intervention at the right point in the pipeline. It simply asks one question before code generation begins:

Who owns this model?

If the answer is "the contract," the generator reconstructs.

If the answer is "the generator," the generator invents.

The difference between those two answers compounds over time. And that is what this project is really about. Not generics. Not templates. Not tooling.

Ownership.

Top comments (0)