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>
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>>
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;
}
Then another one...
class ServiceResponseCustomerDto {
CustomerDto data;
Meta meta;
}
...and another:
class ServiceResponseOrderDto {
OrderDto data;
Meta meta;
}
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:
- The server owns the contract.
- OpenAPI publishes the contract semantics.
- The generator reconstructs the contract.
- 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
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()
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.
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:
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 Service ➔ Generated Client ➔ Consumer Service ➔ BFF / Aggregator ➔ Public API
Without contract reuse, every service boundary introduces a new set of models. You end up with a mapping nightmare:
Contract ➔ Generated Wrapper ➔ Mapped DTO ➔ Another Wrapper ➔ Another 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 DTO ➔ Generated DTO ➔ Mapping Layer
With BYOE/BYOC:
Contract DTO ➔ Generated 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>>
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;
}
It generates a thin wrapper:
public class ServiceResponsePageCustomerDto
extends ServiceResponse<Page<CustomerDto>> {}
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-starterio.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).
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
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
- Get started
- Real-World Example
- Key features in 1.0.x (GA)
- How it works
- Compatibility
- Relationship to OpenAPI Generator
- Modules
- References
- Contributing
- License
The problem in 30 seconds
You return a generic envelope from a Spring Boot controller:
ResponseEntity<ServiceResponse<Page<CustomerDto>>> getCustomers() { ... }
OpenAPI Generator gives your clients this:
// ❌ Generated by default — one of these per endpoint
class ServiceResponsePageCustomerDto {
PageCustomerDto data;
Meta meta;
}
With openapi-generics, the same client looks like this:
// ✅ Generated with openapi-generics
public class ServiceResponsePageCustomerDto
extends ServiceResponse<…📚 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)