Update (Apr 2026): Platform shipped v1.0.0 to Maven Central. See the repository for adoption guides.
How a Spring Boot experiment evolved into a contract-driven, generics-aware OpenAPI client setup — and why shared contracts change everything.
Modern APIs aim for simplicity — but when it comes to generated clients, things still get messy fast.
Duplicated response wrappers. Lost generics. Boilerplate that silently multiplies.
This article walks through a real Spring Boot setup that teaches OpenAPI tooling to produce type‑safe clients without duplicating response envelopes — by introducing a single canonical API contract shared by server and client.
The result:
- fewer classes
- stronger typing
- deterministic evolution
- clients that finally look production‑ready
🧩 The Problem
Most backend teams standardize API responses with a generic envelope like:
ServiceResponse<T>
wrapping both payload (data) and context (meta).
It starts elegant — until the API surface grows.
As soon as nested containers appear (pagination, slices, windows):
ServiceResponse<Page<T>>
default OpenAPI generators lose semantic intent around generics.
They don't understand generics — so they duplicate the envelope per endpoint:
ServiceResponseCustomerDto
ServiceResponsePageCustomerDto
ServiceResponseOrderDto
Each redeclares the same { data, meta } fields, differing only by inner type.
You'll recognize this if:
- your generated client contains dozens of near‑identical wrapper models
- adding one field to
metacauses massive regeneration noise - nested generics lose strong typing
💡 What started as a clean API pattern quietly becomes a maintenance liability.
💡 The Core Insight
The response envelope is not a generated artifact.
It is a contract.
Instead of letting generators invent response wrappers:
- define one canonical contract
- let the server publish its semantics
- let clients reuse — never redefine that contract
Everything revolves around a single abstraction:
ServiceResponse<T>
Shared by both server and client.
🔓 BYOE / BYOC — Integrating with Your Existing Contracts
The platform does not require adopting a predefined response model.
Instead of forcing a new contract, it integrates with what you already have.
BYOE — Bring Your Own Envelope
If your system already uses a custom response wrapper:
ApiResponse<T>
you can plug it into the pipeline without migration.
Server-side configuration:
openapi-generics:
envelope:
type: io.example.contract.ApiResponse
Client-side configuration (build-time):
<additionalProperties>
<additionalProperty>
openapi-generics.envelope=io.example.contract.ApiResponse
</additionalProperty>
</additionalProperties>
The generator preserves your envelope instead of introducing a new one.
Important constraints (fail-fast enforced):
- must be a concrete class
- must declare exactly one type parameter
- must contain exactly one direct payload field of type
T - nested generic payloads (e.g.
ApiResponse<Page<T>>) are not supported
Violations are detected at startup/build time — not silently ignored.
BYOC — Bring Your Own Contract
If your DTOs are already defined in a shared module:
io.example.contract.CustomerDto
you can instruct the generator to reuse them:
<additionalProperties>
<additionalProperty>
openapi-generics.response-contract.CustomerDto=io.example.contract.CustomerDto
</additionalProperty>
</additionalProperties>
No duplication. No re-generation.
What this changes
Instead of adapting your system to the generator:
the generator aligns with your existing contracts.
- no model rewrites
- no contract duplication
- no migration pressure
ServiceResponse<T> is the default — not a requirement.
Your contract remains the source of truth.
🧱 The Canonical Contract (openapi-generics-contract)
We extract the response model into a standalone module:
io.github.blueprint-platform:openapi-generics-contract
This module is:
- framework‑agnostic
- language‑agnostic
- generator‑friendly
- the single source of truth
Core Types
public class ServiceResponse<T> {
private T data;
private Meta meta;
}
public record Meta(
Instant serverTime,
List<Sort> sort
) {}
public record Page<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean hasNext,
boolean hasPrev
) {}
Supported Shapes (Contract vs Default Behavior)
| Shape | Behavior | Description |
|---|---|---|
ServiceResponse<T> |
Contract-aware | Default envelope |
ServiceResponse<Page<T>> |
Contract-aware | Nested pagination support |
YourEnvelope<T> |
Contract-aware | BYOE (custom envelope) |
| Other shapes | Default | Handled by OpenAPI Generator |
The platform guarantees deterministic behavior only for the contract-aware shapes.
🟥 Before — Envelope Duplication
With default generation, a controller like:
@GetMapping
ResponseEntity<ServiceResponse<Page<CustomerDto>>> getCustomers()
produces client models such as:
class ServiceResponsePageCustomerDto {
PageCustomerDto data;
Meta meta;
}
Every endpoint yields a new envelope class.
Problems:
- model explosion
- envelope drift
- noisy diffs
🟩 After — Thin Wrappers over a Shared Contract
Instead, generated models become thin shells:
public class ServiceResponsePageCustomerDto
extends ServiceResponse<Page<CustomerDto>> {}
Key difference:
- no duplicated envelope fields
- wrappers only bind generic parameters
- the envelope lives once, in the contract
⚙️ How the Generator Learned Generics
1️⃣ Server-Side Schema Enrichment
A Springdoc OpenApiCustomizer:
- detects contract shapes
- registers wrapper schemas
- emits semantic vendor extensions
x-api-wrapper: true
x-api-wrapper-datatype: CustomerDto
x-data-container: Page
x-data-item: CustomerDto
OpenAPI now describes contract semantics, not implementation details.
2️⃣ Generics-Aware Templates
{{#vendorExtensions.x-api-wrapper}}
{{>api_wrapper}}
{{/vendorExtensions.x-api-wrapper}}
public class {{classname}} extends {{vendorExtensions.x-envelope-type}}<...> {
}
No duplication. Just deterministic binding.
⚠️ Error Handling
Error handling is not enforced by the platform.
Two common patterns:
Pattern A — Separate error protocol (default):
Success → ServiceResponse<T>
Error → ProblemDetail (RFC 9457)
Pattern B — Envelope-based errors (BYOE):
Success → YourEnvelope<T>
Error → YourEnvelope<T>
The generator preserves whatever contract the service defines.
🧠 Adapter Boundary (Consumer Side)
Consumers should not depend on generated APIs directly.
interface CustomerClientAdapter {
ServiceResponse<CustomerDto> getCustomer(int id);
ServiceResponse<Page<CustomerDto>> getCustomers(...);
}
Benefits:
- generated code remains disposable
- domain code depends only on contracts
- client usage stays stable across regeneration
🧠 Design Guarantees
This setup provides:
- one shared response contract
- no duplicated envelopes
- deterministic generation
- contract-aligned clients
- support for custom envelopes and external DTOs
This is not a demo.
It is a reference implementation — now on Maven Central.
🔮 What Changed Everything
The real breakthrough wasn't nested generics.
It was contract ownership.
Once the response envelope became a shared artifact:
- generators stopped inventing types
- clients stopped drifting
- APIs became evolvable
📘 Full Reference
- https://github.com/blueprint-platform/openapi-generics
- https://blueprint-platform.github.io/openapi-generics/
Generics were never the problem.
The tools just needed to learn who owns the contract.
Top comments (0)