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 
metaorrequestId) 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 
metaforces 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 
TusingResponseTypeIntrospector - 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)