How a small Spring Boot experiment evolved into a generics-aware API client framework — and what it means for clean OpenAPI clients.
Modern APIs aim for simplicity — but when it comes to generated clients, things get messy fast.
Every microservice team knows the pain: duplicated response wrappers, inconsistent mappers, and boilerplate that quietly multiplies.
In this guide, I’ll walk through a real Spring Boot 3.4 setup that teaches OpenAPI Generator to produce dynamic, type-safe models like ServiceClientResponse<Page<CustomerDto>>
— with nested generics, unified { data, meta }
envelopes, and no manual mapping.
The result: fewer classes, stronger typing, and clients that finally look production-ready.
🧩 Problem Statement
Most backend teams standardize their API responses with a generic envelope like ServiceResponse<T>
— wrapping both the payload (data
) and its context (meta
) in a uniform structure.
It starts simple and elegant — until your API surface expands.
Once you introduce nested containers such as pagination (ServiceResponse<Page<T>>
) or sliced results, the default OpenAPI Generator quickly loses track.
Because it doesn’t truly understand generics, it begins duplicating models for every endpoint:
ServiceResponseUserPageResponse
, ServiceResponseOrderListResponse
, ServiceResponseProductSliceResponse
…
Each redeclaring the same envelope again and again — only with a different inner type.
You’ll notice this if:
- Your generated client contains dozens of near-identical
ServiceResponse*
classes. -
getData()
loses strong typing or requires casting and manual mapping. - Adding a single field (like
meta
orrequestId
) triggers a full regeneration cycle.
💡 In short: what started as a clean, uniform response pattern quietly turns into a maintenance nightmare once generics and pagination come into play.
🟥 Before — One Wrapper Per Endpoint (Duplication)
With the default OpenAPI Generator, returning a generic envelope like:
@PostMapping
public ResponseEntity<ServiceResponse<CustomerDto>> createCustomer(@Valid @RequestBody CustomerCreateRequest req) { ... }
@GetMapping("/{customerId}")
public ResponseEntity<ServiceResponse<CustomerDto>> getCustomer(@PathVariable Integer customerId) { ... }
@GetMapping
public ResponseEntity<ServiceResponse<Page<CustomerDto>>> getCustomers(...) { ... }
…produces a separate full model per operation on the client side — each re-declaring the same envelope fields:
public class ServiceResponseCustomerDto {
private CustomerDto data;
private Meta meta; // serverTime, sort, etc.
}
public class ServiceResponsePageCustomerDto {
private PageCustomerDto data; // page, size, totalElements, content<T>...
private Meta meta;
}
This is technically type‑safe, but it creates real problems at scale:
-
Model explosion: one wrapper class per endpoint (
ServiceResponseFoo
,ServiceResponseBar
, …) -
Envelope drift: adding or changing a field like
meta
forces noisy regeneration -
Nested generics pain: paged/sliced responses (
ServiceResponse<Page<T>>
) multiply variants
Net effect: dozens of near‑identical classes that differ only by inner type — maintenance overhead with zero business value.
🟩 After — One Generic Base + Thin Typed Shells
We configure generation so every endpoint wrapper becomes a tiny class extending a single generic base.
Example (generated):
public class ServiceResponseCustomerDto
extends ServiceClientResponse<CustomerDto> {}
public class ServiceResponsePageCustomerDto
extends ServiceClientResponse<Page<CustomerDto>> {}
Reusable generic base (client‑side):
public class ServiceClientResponse<T> {
private T data;
private ClientMeta meta;
public T getData() { return data; }
public ClientMeta getMeta() { return meta; }
}
Meta + Page primitives:
public record ClientMeta(Instant serverTime, List<ClientSort> sort) {}
public record Page<T>(List<T> content, int page, int size,
long totalElements, int totalPages,
boolean hasNext, boolean hasPrev) {}
Errors are handled separately via RFC 9457 (ProblemDetail) and surfaced as a ClientProblemException
— no more mixing success envelopes with error details.
✅ No duplication
✅ Strong typing survives nesting (Page<T>
)
✅ One evolution point for { data, meta }
⚙️ How We Taught the Generator
The fix only required two small customizations — one at generation time, and one at template time.
Step 1 — Runtime Introspection in Springdoc
Instead of manually tagging schemas, we built an AutoWrapperSchemaCustomizer
that introspects every controller method at startup.
It scans all ResponseEntity<ServiceResponse<T>>
and ResponseEntity<ServiceResponse<Page<T>>>
return types and automatically registers composed OpenAPI schemas:
ServiceResponseCustomerDto:
allOf:
- $ref: '#/components/schemas/ServiceResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/CustomerDto'
x-api-wrapper: true
x-data-container: Page
x-data-item: CustomerDto
Behind the scenes:
- Collects all controller methods via
RequestMappingHandlerMapping
- Extracts generic type
T
usingResponseTypeIntrospector
- Registers composed schemas for each wrapper
- Adds vendor extensions like
x-api-wrapper
,x-data-container
, andx-data-item
- Detects nested containers like
Page<T>
automatically
This makes the OpenAPI spec fully aware of nested generics — no manual annotations, no per‑endpoint hacks.
Step 2 — Teach Mustache to Render Wrappers
Once the schema is enriched, the Mustache layer takes over.
A minimal partial (api_wrapper.mustache
) turns every x-api-wrapper
model into a thin, typed shell:
{{! Generics-aware thin wrapper }}
import {{commonPackage}}.ServiceClientResponse;
{{#vendorExtensions.x-data-container}}
import {{commonPackage}}.{{vendorExtensions.x-data-container}};
{{/vendorExtensions.x-data-container}}
public class {{classname}} extends ServiceClientResponse<
{{#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}}
> {}
Then model.mustache
conditionally includes the partial:
{{#vendorExtensions.x-api-wrapper}}
{{>api_wrapper}}
{{/vendorExtensions.x-api-wrapper}}
Finally, configure your OpenAPI Generator Maven Plugin to use the custom templates and wire everything together.
✅ Result
The generator now produces thin, fully type‑safe wrappers:
- No duplicated envelope fields
- No manual template edits
- Full support for nested generics (e.g.,
ServiceResponse<Page<T>>
)
public class ServiceResponsePageCustomerDto
extends ServiceClientResponse<Page<CustomerDto>> {}
Cleaner diffs, smaller models, and less boilerplate.
🧭 Takeaways
✅ No duplicated fields — every client wrapper reuses the same envelope.
✅ Strong typing survives nesting — Page<T>
and deeper layers stay intact.
✅ Single evolution point — tweak { data, meta }
once, all clients inherit.
✅ Proven in practice — CRUD + pagination tested end‑to‑end with MockWebServer.
Together, these small tweaks transform OpenAPI Generator into a first‑class citizen of type‑safe microservice development.
⚙️ Adapter Interface — Unifying All Generics
To simplify usage, we introduce a client adapter interface that consolidates all wrappers into reusable, type‑safe methods:
public interface CustomerClientAdapter {
ServiceClientResponse<CustomerDto> createCustomer(CustomerCreateRequest request);
ServiceClientResponse<CustomerDto> getCustomer(Integer customerId);
ServiceClientResponse<Page<CustomerDto>> getCustomers(
String name, String email, Integer page, Integer size,
SortField sortBy, SortDirection direction);
ServiceClientResponse<CustomerDto> updateCustomer(Integer customerId, CustomerUpdateRequest request);
ServiceClientResponse<CustomerDeleteResponse> deleteCustomer(Integer customerId);
}
Usage remains clean:
ServiceClientResponse<CustomerCreateResponse> res =
customerControllerApi.createCustomer(request);
CustomerCreateResponse created = res.getData();
Exactly what we wanted — a thin, typed shell over one reusable generic base.
🧩 When (and When Not) to Use
✅ Use it when your APIs share a consistent envelope and you want to:
- Keep client models compact and DRY.
- Preserve compile‑time typing end‑to‑end.
- Evolve
{ data, meta }
once across all services.
⚙️ Skip it when each endpoint has radically different shapes — in that case, abstraction costs more than it saves.
📘 Full Working Reference
The complete implementation — including the Spring Boot service, generated client, custom templates, and CRUD examples — is available here:
🌐 GitHub Repository
📘 Adoption Guides (GitHub Pages)
What’s inside:
- Unified
{ data, meta }
response envelope - Nested generics support (
ServiceResponse<Page<T>>
) - RFC 9457 error handling (Problem Details for HTTP APIs)
- MockWebServer integration tests
- Auto‑registration of wrapper schemas (server)
- Generics‑aware template overrides (client)
🚀 v0.7.0 — The Nested Generics Support
This release introduces full support for nested generics in OpenAPI Generator — no more duplicated wrappers, no more boilerplate.
It’s the moment where OpenAPI Generator finally learned to handle ServiceResponse<Page<T>>
structures natively, producing clean, type-safe client models that mirror real production APIs.
This isn’t just an incremental tweak — it’s a practical milestone toward maintainable, generics-aware client generation across microservices.
🔮 What’s Next
The next step is to publish the core modules as standalone artifacts — so teams can adopt generics‑aware OpenAPI support with a single dependency.
-
io.github.bsayli:openapi-generics-autoreg
→ server‑side: auto‑registers wrapper schemas via Springdoc. -
io.github.bsayli:openapi-generics-templates
→ client‑side: generates thin, type‑safe wrappers.
Once released, any project using a ServiceResponse<T>
envelope can instantly benefit:
✅ Consistent schema registration across all services.
✅ Clean, generics‑aware clients with zero boilerplate.
✅ Drop‑in adoption via Maven or Gradle.
By extracting these modules, the reference repository stays as a living showcase — while real‑world teams gain a plug‑and‑play path to type‑safe, production‑ready OpenAPI clients.
Generics were never the problem.
The tools just needed to learn to speak their language — and now, they finally do.
Top comments (0)