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

Update (Apr 2026): Platform shipped v1.0.0 to Maven Central. See the repository for adoption guides.

How a Spring Boot experiment evolved into a contract-driven, generics-aware OpenAPI client setup — 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 around generics.

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.


🔓 BYOE / BYOC — Integrating with Your Existing Contracts

The platform does not require adopting a predefined response model.

Instead of forcing a new contract, it integrates with what you already have.

BYOE — Bring Your Own Envelope

If your system already uses a custom response wrapper:

ApiResponse<T>
Enter fullscreen mode Exit fullscreen mode

you can plug it into the pipeline without migration.

Server-side configuration:

openapi-generics:
  envelope:
    type: io.example.contract.ApiResponse
Enter fullscreen mode Exit fullscreen mode

Client-side configuration (build-time):

<additionalProperties>
  <additionalProperty>
    openapi-generics.envelope=io.example.contract.ApiResponse
  </additionalProperty>
</additionalProperties>
Enter fullscreen mode Exit fullscreen mode

The generator preserves your envelope instead of introducing a new one.

Important constraints (fail-fast enforced):

  • must be a concrete class
  • must declare exactly one type parameter
  • must contain exactly one direct payload field of type T
  • nested generic payloads (e.g. ApiResponse<Page<T>>) are not supported

Violations are detected at startup/build time — not silently ignored.


BYOC — Bring Your Own Contract

If your DTOs are already defined in a shared module:

io.example.contract.CustomerDto
Enter fullscreen mode Exit fullscreen mode

you can instruct the generator to reuse them:

<additionalProperties>
  <additionalProperty>
    openapi-generics.response-contract.CustomerDto=io.example.contract.CustomerDto
  </additionalProperty>
</additionalProperties>
Enter fullscreen mode Exit fullscreen mode

No duplication. No re-generation.


What this changes

Instead of adapting your system to the generator:

the generator aligns with your existing contracts.

  • no model rewrites
  • no contract duplication
  • no migration pressure

ServiceResponse<T> is the default — not a requirement.

Your contract remains the source of truth.


🧱 The Canonical Contract (openapi-generics-contract)

We extract the response model into a standalone module:

io.github.blueprint-platform:openapi-generics-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 (Contract vs Default Behavior)

Shape Behavior Description
ServiceResponse<T> Contract-aware Default envelope
ServiceResponse<Page<T>> Contract-aware Nested pagination support
YourEnvelope<T> Contract-aware BYOE (custom envelope)
Other shapes Default Handled by OpenAPI Generator

The platform guarantees deterministic behavior only for the contract-aware shapes.


🟥 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

🟩 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 duplicated envelope fields
  • wrappers only bind generic parameters
  • the envelope lives once, in the contract

⚙️ How the Generator Learned Generics

1️⃣ Server-Side Schema Enrichment

A Springdoc OpenApiCustomizer:

  • detects contract shapes
  • registers wrapper schemas
  • emits semantic vendor extensions
x-api-wrapper: true
x-api-wrapper-datatype: CustomerDto
x-data-container: Page
x-data-item: CustomerDto
Enter fullscreen mode Exit fullscreen mode

OpenAPI now describes contract semantics, not implementation details.


2️⃣ Generics-Aware Templates

{{#vendorExtensions.x-api-wrapper}}
  {{>api_wrapper}}
{{/vendorExtensions.x-api-wrapper}}
Enter fullscreen mode Exit fullscreen mode
public class {{classname}} extends {{vendorExtensions.x-envelope-type}}<...> {
}
Enter fullscreen mode Exit fullscreen mode

No duplication. Just deterministic binding.


⚠️ Error Handling

Error handling is not enforced by the platform.

Two common patterns:

Pattern A — Separate error protocol (default):

Success → ServiceResponse<T>
Error   → ProblemDetail (RFC 9457)
Enter fullscreen mode Exit fullscreen mode

Pattern B — Envelope-based errors (BYOE):

Success → YourEnvelope<T>
Error   → YourEnvelope<T>
Enter fullscreen mode Exit fullscreen mode

The generator preserves whatever contract the service defines.


🧠 Adapter Boundary (Consumer Side)

Consumers should not depend on generated APIs directly.

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
  • client usage stays stable across regeneration

🧠 Design Guarantees

This setup provides:

  • one shared response contract
  • no duplicated envelopes
  • deterministic generation
  • contract-aligned clients
  • support for custom envelopes and external DTOs

This is not a demo.

It is a reference implementation — now on Maven Central.


🔮 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.

Top comments (0)