We Made OpenAPI Generator Think in Generics
How a Spring Boot experiment evolved into a **contract‑driven, generics‑aware API architecture — 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.
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.
🧱 The Canonical Contract (api-contract)
We extract the response model into a standalone module:
io.github.bsayli:api-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 (Guaranteed vs. Default)
| Shape | Guarantee Level | Notes |
|---|---|---|
ServiceResponse<T> |
✅ Guaranteed | Canonical success envelope |
ServiceResponse<Page<T>> |
✅ Guaranteed | Only explicitly supported nested generic |
ServiceResponse<List<T>> |
⚠️ Default | Uses OpenAPI Generator default behavior (not part of contract) |
ServiceResponse<Map<K,V>> |
⚠️ Default | Uses OpenAPI Generator default behavior |
| Arbitrary nested generics | ❌ None | Outside the canonical contract |
This architecture does not restrict or override OpenAPI Generator’s default handling
of standard Java collection types such as List<T> or Map<K,V>.
It defines explicit guarantees only for:
ServiceResponse<T>ServiceResponse<Page<T>>
All other shapes are intentionally left outside the canonical contract
to preserve deterministic schema naming and generator-safe evolution.
🟥 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 client‑side base class
- no duplicated envelope fields
- wrappers merely bind generic parameters
The envelope lives once, in api-contract.
⚙️ How the Generator Learned Generics
1️⃣ Server‑Side Schema Enrichment
The server uses a Springdoc OpenApiCustomizer that:
- inspects controller return types
- detects
ServiceResponse<T>andServiceResponse<Page<T>> - registers composed schemas
- emits semantic vendor extensions
Example:
x-api-wrapper: true
x-api-wrapper-datatype: CustomerDto
x-data-container: Page
x-data-item: CustomerDto
The OpenAPI spec now describes contract semantics, not Java shapes.
2️⃣ Generics‑Aware Mustache Templates
Client generation uses minimal overlays.
model.mustache:
{{#vendorExtensions.x-api-wrapper}}
{{>api_wrapper}}
{{/vendorExtensions.x-api-wrapper}}
api_wrapper.mustache:
public class {{classname}} extends ServiceResponse<
{{#vendorExtensions.x-data-container}}
{{vendorExtensions.x-data-container}}<{{vendorExtensions.x-data-item}}>
{{/vendorExtensions.x-data-container}}
{{^vendorExtensions.x-data-container}}
{{vendorExtensions.x-api-wrapper-datatype}}
{{/vendorExtensions.x-data-container}}
> {}
No logic. No duplication. Just binding generics.
⚠️ Error Handling (RFC 9457)
Errors are not wrapped in ServiceResponse.
They are represented as RFC 9457 Problem Details and surfaced as:
ApiProblemException
With extension support:
ProblemExtensions
ErrorItem
This keeps success and error paths cleanly separated.
🧠 Adapter Boundary (Consumer Side)
Consumers never depend on generated APIs directly.
They use adapters:
interface CustomerClientAdapter {
ServiceResponse<CustomerDto> getCustomer(int id);
ServiceResponse<Page<CustomerDto>> getCustomers(...);
}
Benefits:
- generated code remains disposable
- domain code depends only on contracts
- hexagonal boundaries stay intact
🧠 Design Guarantees
This architecture guarantees:
- one response contract
- no duplicated envelopes
- Page‑only nested generics
- deterministic schema names
- generator‑safe evolution
- RFC 9457‑first error handling
This is not a demo.
It is a reference architecture.
🔮 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
Generics were never the problem.
The tools just needed to learn who owns the contract.


