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>
Enter fullscreen mode

Exit fullscreen mode

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>>
Enter fullscreen mode

Exit fullscreen mode

default OpenAPI generators lose semantic intent.

They don’t understand generics — so they duplicate the envelope per endpoint:

ServiceResponseCustomerDto
ServiceResponsePageCustomerDto
ServiceResponseOrderDto
Enter fullscreen mode

Exit fullscreen mode

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 meta causes 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>
Enter fullscreen mode

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

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

Exit fullscreen mode

public record Meta(
  Instant serverTime,
  List<Sort> sort
) {}
Enter fullscreen mode

Exit fullscreen mode

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



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()
Enter fullscreen mode

Exit fullscreen mode

produces client models such as:

class ServiceResponsePageCustomerDto {
  PageCustomerDto data;
  Meta meta;
}
Enter fullscreen mode

Exit fullscreen mode

Every endpoint yields a new envelope class.

Problems:

  • model explosion
  • envelope drift
  • noisy diffs

Generated client before customization — duplicated wrappers




🟩 After — Thin Wrappers over a Shared Contract

Instead, generated models become thin shells:

public class ServiceResponsePageCustomerDto
    extends ServiceResponse<Page<CustomerDto>> {}
Enter fullscreen mode

Exit fullscreen mode

Key difference:

  • no client‑side base class
  • no duplicated envelope fields
  • wrappers merely bind generic parameters

The envelope lives once, in api-contract.

Generated client after customization — thin generic wrappers




⚙️ How the Generator Learned Generics



1️⃣ Server‑Side Schema Enrichment

The server uses a Springdoc OpenApiCustomizer that:

  • inspects controller return types
  • detects ServiceResponse<T> and ServiceResponse<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
Enter fullscreen mode

Exit fullscreen mode

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

Exit fullscreen mode

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

Exit fullscreen mode

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
Enter fullscreen mode

Exit fullscreen mode

With extension support:

ProblemExtensions
ErrorItem
Enter fullscreen mode

Exit fullscreen mode

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

Exit fullscreen mode

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.



Source link