DEV Community

Cover image for We Made OpenAPI Generator Think in Generics
Barış Saylı
Barış Saylı

Posted on • Edited on • Originally published at Medium

We Made OpenAPI Generator Think in Generics

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 or requestId) 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(...) { ... }
Enter fullscreen mode Exit fullscreen mode

…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;
}
Enter fullscreen mode Exit fullscreen mode

Generated client before customization — duplicated wrappers

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>> {}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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 }

Generated client after customization — thin generic wrappers


⚙️ 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
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • Collects all controller methods via RequestMappingHandlerMapping
  • Extracts generic type T using ResponseTypeIntrospector
  • Registers composed schemas for each wrapper
  • Adds vendor extensions like x-api-wrapper, x-data-container, and x-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}}
> {}
Enter fullscreen mode Exit fullscreen mode

Then model.mustache conditionally includes the partial:

{{#vendorExtensions.x-api-wrapper}}
  {{>api_wrapper}}
{{/vendorExtensions.x-api-wrapper}}
Enter fullscreen mode Exit fullscreen mode

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>> {}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Usage remains clean:

ServiceClientResponse<CustomerCreateResponse> res =
    customerControllerApi.createCustomer(request);
CustomerCreateResponse created = res.getData();
Enter fullscreen mode Exit fullscreen mode

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)