Client-Side Adoption — Generate a Contract-Aligned Java Client
Generate a Java client that preserves contract semantics exactly as published — with progressive adoption, zero duplication, and no drift.
This is not a typical OpenAPI client setup.
It is a controlled, opt-in build-time pipeline where:
- OpenAPI is treated as input metadata, not authority
- the contract is preserved, not regenerated
- the output is deterministic when enabled
- the system is selectively bypassable when you need stock behavior
This guide focuses on four things:
- consuming OpenAPI as input
- executing the contract-aware build pipeline
- aligning the generated client with an external contract (BYOE / BYOC)
- using the generated client safely from your application
For server-side projection mechanics, see Server-Side Adoption. For internal pipeline mechanics, see Architecture.
Contents
- 60-second quick start
- What the client actually does
- Input: OpenAPI (not your contract)
- Minimal setup
- Progressive adoption modes
- Build pipeline (what really happens)
- Output: what gets generated
- Usage: how the client enters your system
- Common adoption pitfalls
- Verification
- Error handling
60-second quick start
You want:
- a type-safe client
- zero duplicated envelope models
- preserved generic response semantics across server, spec, and client
Do this:
1) Inherit the parent
<parent>
<groupId>io.github.blueprint-platform</groupId>
<artifactId>openapi-generics-java-codegen-parent</artifactId>
<version>1.0.2</version>
</parent>
2) Provide an OpenAPI document
Either a static file checked into the client module:
src/main/resources/your-api-docs.yaml
Or fetched from a running producer:
http://localhost:8084/customer-service/v3/api-docs.yaml
3) Configure the generator (minimal)
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<executions>
<execution>
<id>generate-client</id>
<phase>generate-sources</phase>
<goals><goal>generate</goal></goals>
<configuration>
<generatorName>java-generics-contract</generatorName>
<inputSpec>${project.basedir}/src/main/resources/your-api-docs.yaml</inputSpec>
<library>restclient</library>
<apiPackage>com.example.client.api</apiPackage>
<modelPackage>com.example.client.dto</modelPackage>
<invokerPackage>com.example.client.invoker</invokerPackage>
<configOptions>
<useSpringBoot3>true</useSpringBoot3>
<serializationLibrary>jackson</serializationLibrary>
<openApiNullable>false</openApiNullable>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
4) (Optional) Align with your own contract
Reuse your DTOs (BYOC):
<additionalProperties>
<additionalProperty>
openapi-generics.response-contract.CustomerDto=com.example.contract.CustomerDto
</additionalProperty>
</additionalProperties>
Add one openapi-generics.response-contract.<OpenAPI model name> property per externally provided DTO you want the generated client to reuse.
Use your own envelope (BYOE):
<additionalProperties>
<additionalProperty>
openapi-generics.envelope=com.example.contract.ApiResponse
</additionalProperty>
</additionalProperties>
5) Build
mvn clean install
That’s it.
Result
Default envelope:
public class ServiceResponseCustomerDto
extends ServiceResponse<CustomerDto> {}
public class ServiceResponsePageCustomerDto
extends ServiceResponse<Page<CustomerDto>> {}
Custom envelope (BYOE):
public class ApiResponseCustomerDto
extends ApiResponse<CustomerDto> {}
One envelope. Generics preserved. Same contract on the server, in the OpenAPI document, and in every generated wrapper.
Working references:
samples/spring-boot-3/customer-service-client(BYOC enabled) andsamples/spring-boot-4/customer-service-client(zero-configuration default flow). The two samples differ on purpose — they show that BYOE and BYOC are alignment inputs, not requirements.
What the client actually does
The client has one responsibility:
Convert an OpenAPI document into a contract-aligned Java client without redefining anything.
It does not:
- design models
- interpret business semantics
- introduce abstractions over your domain
It executes a deterministic transformation:
OpenAPI document → contract-aligned Java client
Same input, same output, every build.
Input: OpenAPI (not your contract)
Client generation always starts from an existing OpenAPI document.
curl http://localhost:8084/customer-service/v3/api-docs.yaml -o customer-api-docs.yaml
The critical distinction:
OpenAPI is input metadata, not the contract itself.
What this means in practice:
- Structure comes from OpenAPI — paths, operations, response shapes.
- Semantics come from contract types — your envelope and DTOs, either generated by default or sourced from your classpath via BYOE/BYOC.
The system never trusts OpenAPI to be the source of truth for type identity. It uses the document’s vendor extensions (x-api-wrapper, x-data-container, x-data-item, x-ignore-model) to reconstruct the original generic shape.
Spec freshness is part of the contract
A spec checked into source control is a snapshot. The platform cannot detect when the producer has moved on; if your build still passes against an old spec, the client is old too. Decide on a refresh cadence before this becomes a debugging story:
- Manual — fetch and commit the spec when the producer team announces a contract change.
- CI-driven — a scheduled job that re-fetches the spec, regenerates the client, and opens a PR if anything changed.
- Build-time fetch — pull the spec directly from a running producer during the build (works for local development, less so for offline CI).
The right choice depends on your release cadence and how tightly the producer and consumer are coupled. The platform takes no opinion — it just guarantees that whatever spec you feed it is reconstructed deterministically.
Minimal setup
You provide exactly two inputs. Everything else is handled by the platform.
1. Build-time orchestration (mandatory)
<parent>
<groupId>io.github.blueprint-platform</groupId>
<artifactId>openapi-generics-java-codegen-parent</artifactId>
<version>1.0.2</version>
</parent>
This is the entry point of the system. It provides:
- generator binding (
java-generics-contract) - the extract → patch → overlay template pipeline
- deterministic execution model
- automatic registration of generated sources for compilation
You do not need to manage any of these concerns yourself.
2. OpenAPI Generator plugin (your input surface)
You control the input and integration surface only. At minimum:
- the OpenAPI input (
inputSpec) - the generator (
java-generics-contract) - the HTTP client library (
library) - the Java package layout (
apiPackage,modelPackage,invokerPackage)
Everything else (template chain, contract behavior, wrapper logic) is handled by the parent.
Full reference configuration
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<executions>
<execution>
<id>generate-client</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<generatorName>java-generics-contract</generatorName>
<inputSpec>${project.basedir}/src/main/resources/your-api-docs.yaml</inputSpec>
<library>restclient</library>
<apiPackage>com.example.client.api</apiPackage>
<modelPackage>com.example.client.dto</modelPackage>
<invokerPackage>com.example.client.invoker</invokerPackage>
<configOptions>
<!-- Choose ONE depending on your runtime -->
<!-- Spring Boot 3 -->
<useSpringBoot3>true</useSpringBoot3>
<!-- Spring Boot 4 -->
<!-- <useSpringBoot4>true</useSpringBoot4> -->
<serializationLibrary>jackson</serializationLibrary>
<openApiNullable>false</openApiNullable>
</configOptions>
<!-- Optional: Bring Your Own Contract (external DTOs) -->
<!-- Add one property per externally provided OpenAPI model. -->
<!--
<additionalProperties>
<additionalProperty>
openapi-generics.response-contract.CustomerDto=com.example.contract.CustomerDto
</additionalProperty>
<additionalProperty>
openapi-generics.response-contract.AddressDto=com.example.contract.AddressDto
</additionalProperty>
</additionalProperties>
-->
<!-- Optional: Bring Your Own Envelope (BYOE) -->
<!--
<additionalProperties>
<additionalProperty>
openapi-generics.envelope=com.example.contract.ApiResponse
</additionalProperty>
</additionalProperties>
-->
<cleanupOutput>true</cleanupOutput>
<skipValidateSpec>false</skipValidateSpec>
<generateApiDocumentation>false</generateApiDocumentation>
<generateApiTests>false</generateApiTests>
<generateModelDocumentation>false</generateModelDocumentation>
<generateModelTests>false</generateModelTests>
</configuration>
</execution>
</executions>
</plugin>
What you control
- which OpenAPI spec is used
- which HTTP client is generated (
library) - package structure
- serialization strategy
- optional external contract mappings (BYOC)
- optional envelope override (BYOE)
What the platform owns
- generator internals
- template extraction, patching, and overlay
- wrapper generation rules
- vendor extension handling
- contract integrity checks performed at generation time
The generator is contract-driven, not schema-driven.
Reference implementations
For complete, working setups:
samples/
spring-boot-3/customer-service-client ← BYOC enabled
spring-boot-4/customer-service-client ← zero-configuration default flow
Both demonstrate:
- the minimal POM shape
- how the parent POM orchestration is inherited
- how generated sources are consumed downstream
The Spring Boot 3 sample additionally demonstrates BYOC against a shared customer-contract module; the Spring Boot 4 sample runs without BYOC or BYOE so you can see the default flow on its own.
Progressive adoption modes
The system separates two concerns that are often conflated:
- Modes control how the generator behaves.
- Alignment inputs control how the contract is resolved.
These are orthogonal. You can change one without affecting the other.
1. Build-time modes (execution behavior)
1.1 Contract-aligned mode (default)
<openapi.generics.skip>false</openapi.generics.skip>
Behavior:
- contract-aware generation is enabled
- wrapper classes are generated as type bindings (thin
extendsdeclarations) - generic structure is preserved
- OpenAPI is interpreted, not materialized class-for-class
This is the standard operating mode.
1.2 Compatibility mode (fallback)
<openapi.generics.skip>true</openapi.generics.skip>
Behavior:
- the contract-aware build pipeline is bypassed entirely
- the underlying
openapi-generator-maven-pluginruns with stock templates - models are generated directly from schemas
- generics may be flattened, envelopes may be duplicated per endpoint
Use this when:
- comparing outputs side-by-side
- isolating whether an issue originates upstream or in the contract layer
- migrating an existing client incrementally
openapi.generics.skip | Behavior |
|---|---|
false (default) | Contract-aware generation |
true | Standard OpenAPI Generator |
This single switch is the clean exit path. If you need stock generator behavior, flip openapi.generics.skip to true — the generator name stays as java-generics-contract, the parent stays inherited, and the pipeline simply skips its contract-aware steps. There’s no fork to unwind.
2. Contract alignment inputs (orthogonal to modes)
These do not change the execution mode. They tell the generator how to resolve the contract during generation.
2.1 BYOE — Bring Your Own Envelope
Define which envelope type represents your contract.
<additionalProperties>
<additionalProperty>
openapi-generics.envelope=com.example.contract.ApiResponse
</additionalProperty>
</additionalProperties>
Behavior:
- the generator resolves your envelope type explicitly
- generated wrappers extend your envelope
- no envelope class is generated client-side
- the system becomes envelope-agnostic — the same pipeline serves the default and BYOE cases
- custom envelope types are used as generated wrapper base classes, so they should provide a public no-argument constructor and public accessors for the envelope properties
Example output:
public class ApiResponseCustomerDto
extends ApiResponse<CustomerDto> {}
Constraints (validated at server startup; see server-side guide for details):
- must be a concrete class
- must declare exactly one type parameter
- must expose a single direct payload field of type
T
Scope: BYOE supports envelopes with a single direct generic payload (
YourEnvelope<T>). Nested forms likeYourEnvelope<Page<T>>are out of scope and fail fast at startup.
2.2 BYOC — Bring Your Own Contract
Reuse externally owned DTOs instead of regenerating them.
<additionalProperties>
<additionalProperty>
openapi-generics.response-contract.CustomerDto=com.example.contract.CustomerDto
</additionalProperty>
</additionalProperties>
Behavior:
- DTOs are resolved from your classpath, not generated
- no duplicate model is produced
- generated wrappers import your existing types directly
Example output:
public class ServiceResponseCustomerDto
extends ServiceResponse<CustomerDto> {}
The CustomerDto referenced here is your com.example.contract.CustomerDto — not a regenerated copy.
BYOC applies to payload types used inside generated wrappers, including nested generic structures such as ServiceResponse<Page<CustomerDto>>.
3. How they compose
Modes and alignment inputs are independent. A typical production setup uses both:
<additionalProperties>
<additionalProperty>
openapi-generics.envelope=com.example.contract.ApiResponse
</additionalProperty>
<additionalProperty>
openapi-generics.response-contract.CustomerDto=com.example.contract.CustomerDto
</additionalProperty>
</additionalProperties>
Result:
Envelope → external (BYOE)
DTO → external (BYOC)
Wrappers → generated (thin bindings)
4. Summary
Mode controls execution → openapi.generics.skip = false / true
Inputs control alignment → openapi-generics.envelope / response-contract.*
The system works because:
- execution is explicitly controlled
- contract ownership is externalizable
- generation remains deterministic
- adoption is fully reversible
The generator does not invent models. It resolves which envelope to use (BYOE), which DTOs to reuse (BYOC), and how to bind them deterministically.
Build pipeline (what really happens)
The parent POM orchestrates a five-stage pipeline during generate-sources. You do not invoke these stages yourself — they execute automatically when you inherit the parent.
OpenAPI spec (input)
│
▼
1. Extract upstream model.mustache from openapi-generator JAR
│ → maven-dependency-plugin
│ → output: target/effective-templates/Java/model.mustache
▼
2. Patch the upstream template with surgical regex insertions
│ → maven-antrun-plugin
│ → injects two replacements into the loop
│ → fails fast if upstream structure changed
▼
3. Extract platform templates from openapi-generics-java-codegen JAR
│ → maven-dependency-plugin
│ → output: target/codegen-templates/META-INF/openapi-generics/templates/
▼
4. Overlay platform templates onto the patched upstream
│ → maven-resources-plugin
│ → adds api_wrapper.mustache (the partial referenced by the patch)
▼
5. Run openapi-generator-maven-plugin
→ templateDirectory points at target/effective-templates/Java
→ generatorName = java-generics-contract → GenericAwareJavaCodegen
▼
Generated sources (contract-aligned)
The build-helper plugin then registers the generated sources for compilation. The whole pipeline is governed by a single switch:
<openapi.generics.skip>false</openapi.generics.skip>
When true, every contract-aware step is skipped and the build falls back to stock OpenAPI Generator behavior.
Why patch-then-overlay instead of just overlay?
A drop-in template directory is the obvious approach — but it freezes a snapshot of upstream model.mustache at the moment you copied it. As OpenAPI Generator evolves, your snapshot drifts further behind, and behavior changes silently.
The patch approach inverts this: upstream stays as the source of structure, and the platform injects only the generic-aware branch. If upstream restructures the `` loop in a way that the regex no longer matches, the build fails immediately:
OpenAPI template patch FAILED — upstream model.mustache structure changed.
The trade-off: occasional upstream-bump pain during major version updates, in exchange for durability against silent drift.
What changes vs standard OpenAPI generation
| Concern | Stock OpenAPI Generator | openapi-generics |
|---|---|---|
| Envelope schema | Materialized as a class per endpoint (ServiceResponseCustomerDto, ServiceResponseOrderDto, …) | Single shared envelope; wrappers are thin extends bindings |
| Generics | Flattened in generated wrappers | Preserved (extends ServiceResponse<Page<CustomerDto>>) |
| External DTOs | Regenerated from schema | Reused from classpath when BYOC is configured |
| Generated output | Schema-driven materialization | Contract-driven binding |
Pipeline guarantees
- single orchestrator (no plugin ordering issues)
- fixed execution flow (no runtime branching)
- deterministic output (same spec + same configuration → same generated code, byte-for-byte stable across builds)
- contract-driven, not schema-driven
- envelope-agnostic (default and BYOE share the same pipeline)
Output: what gets generated
The system does not generate envelope or contract DTOs in the traditional sense. It generates thin, contract-aligned wrapper types that bind OpenAPI responses back to your canonical contract.
From the OpenAPI document
The producer publishes wrapper schemas like:
ServiceResponseCustomerDto
ServiceResponsePageCustomerDto
Generated Java
Default envelope, single payload:
public class ServiceResponseCustomerDto
extends ServiceResponse<CustomerDto> {}
Default envelope, paginated payload:
public class ServiceResponsePageCustomerDto
extends ServiceResponse<Page<CustomerDto>> {}
Custom envelope (BYOE):
public class ApiResponseCustomerDto
extends ApiResponse<CustomerDto> {}
That is the entire body. The wrapper exists to give Jackson a concrete type to bind into; the structure lives entirely in the envelope and contract types it extends.
What is NOT generated
When the contract-aware pipeline is active:
ServiceResponse(or your BYOE envelope) — imported from the contract moduleMeta,Sort— imported from the contract modulePage— imported from the contract module- DTOs mapped via BYOC — imported from your existing modules
This is the structural reason the generated dto/ package looks small: it contains wrappers, not contract material.
Deterministic naming
Wrapper class names are derived from:
envelope simple name + container name (if any) + item simple name
Examples:
ServiceResponseCustomerDto
ServiceResponsePageCustomerDto
ApiResponseCustomerDto
ApiResponsePageCustomerDto
Same envelope + same payload + same supported container → same generated class name. Diffs are stable across builds.
Usage: how the client enters your system
Generated sources are added to your project automatically:
target/generated-sources/openapi/src/gen/java
These sources are not your domain layer. They are an integration boundary.
What you actually use
Application code depends on the contract shape, not the generated wrapper:
ServiceResponse<CustomerDto>
Or, with BYOE:
ApiResponse<CustomerDto>
This is the only type your business logic needs to reference. Generated wrapper classes (ServiceResponseCustomerDto, ServiceResponsePageCustomerDto, …) exist for transport — Jackson binds incoming JSON into them, and they extend the contract so your code reads them as the contract type.
How the client enters your system
Generated APIs are consumed through a controlled adapter boundary:
public interface CustomerClient {
ServiceResponse<CustomerDto> getCustomer(Long id);
}
Implementation delegates to the generated client:
@Service
public class CustomerClientImpl implements CustomerClient {
private final CustomerControllerApi api;
public CustomerClientImpl(CustomerControllerApi api) {
this.api = api;
}
@Override
public ServiceResponse<CustomerDto> getCustomer(Long id) {
return api.getCustomer(id);
}
}
Why this boundary matters
Generated code is a transport concern, not a domain concern. The adapter is where you translate between them.
Without this boundary, generated types leak into your application — binding your domain to OpenAPI output, generator conventions, and the transport itself.
With the adapter in place:
- Domain purity — your application depends on contract types, not generated artifacts
- Testability — business logic is tested against the contract, not the HTTP client
- Contract stability — generator updates do not ripple into domain code
- Replaceability — the underlying transport can change without touching the application
The adapter is not boilerplate. It is the seam between what your system means and how it talks to the outside.
Data flow through your system
Controller → Contract → Adapter → Generated Client → HTTP
Reference implementations
samples/
spring-boot-3/customer-service-consumer
spring-boot-4/customer-service-consumer
These demonstrate adapter-based integration, contract-first usage, and safe isolation of generated code.
Common adoption pitfalls
These aren’t system failures — they’re shapes that look right but produce surprising results during early adoption. The platform’s deterministic and fail-fast properties catch the most consequential issues at build time; the notes here help you recognize the rest sooner.
Generated wrapper types in domain code
Generated wrappers (ServiceResponseCustomerDto, ServiceResponsePageCustomerDto, …) are deserialization targets. They exist so Jackson has a concrete class to bind into — not to be referenced from business logic.
If you find yourself writing:
ServiceResponseCustomerDto response = api.getCustomer(id);
…in domain code, the adapter boundary is missing. The fix is structural: introduce an adapter that returns ServiceResponse<CustomerDto> (the contract type) and keep the wrapper class confined to the adapter implementation. See Usage — how the client enters your system.
Switching to stock generator behavior
If you want plain OpenAPI Generator output for a module — for comparison, debugging, or incremental migration — the clean path is the skip switch:
<openapi.generics.skip>true</openapi.generics.skip>
Keep the generator name (java-generics-contract) and the inherited parent. The pipeline detects the flag, bypasses its contract-aware steps, and the build runs against stock templates.
Changing the generator name to java while keeping the parent’s template overrides puts the build in an inconsistent state — patched templates pointed at by <templateDirectory> fed to a generator that doesn’t expect them. The skip switch avoids this entirely.
Custom templates in templateDirectory
The parent POM sets <templateDirectory> to the patched-and-overlaid output of the build pipeline. Pointing it elsewhere — for example, at a hand-curated src/main/resources/openapi-templates folder — disables the patch-then-overlay flow and replaces it with a frozen snapshot that drifts from upstream silently.
If you need a behavioral change in template output, the durable path is contributing the change upstream (to OpenAPI Generator itself or to openapi-generics-java-codegen’s overlay templates) rather than holding a local fork. Local template overrides accumulate technical debt against future generator upgrades.
BYOC mappings without the matching classpath dependency
A BYOC mapping like:
<additionalProperty>
openapi-generics.response-contract.CustomerDto=com.example.contract.CustomerDto
</additionalProperty>
…tells the generator to emit import com.example.contract.CustomerDto; instead of generating a local copy. For this to compile, com.example.contract.CustomerDto must be on the client module’s classpath — typically via a dependency on a shared *-contract module.
If the dependency isn’t there, the generated client builds wrappers like extends ServiceResponse<CustomerDto> against a class the compiler can’t find, and the build fails with cannot find symbol. The fix is to add the contract module as a dependency, not to remove the BYOC mapping.
Out-of-date OpenAPI spec
A spec checked into source control doesn’t refresh itself. If the producer team adds endpoints, changes wrapper shapes, or updates an envelope, your client still sees the old contract until you re-fetch the spec. The build will pass; the runtime will fail or, worse, silently miss new fields.
Pick a refresh strategy at adoption time — manual on producer announcements, scheduled CI fetch, or build-time fetch from a running producer — and document it alongside the client module. See Spec freshness is part of the contract.
Verification
After generation, verify the following:
1. Wrapper classes extend the correct contract type
Open target/generated-sources/openapi/src/gen/java/.../dto/. You should see thin classes like:
public class ServiceResponseCustomerDto
extends ServiceResponse<CustomerDto> {}
public class ServiceResponsePageCustomerDto
extends ServiceResponse<Page<CustomerDto>> {}
If wrappers are full classes with data, meta, getters, setters, and @JsonProperty annotations — the contract-aware pipeline did not run. Check that:
<generatorName>isjava-generics-contract(notjava)- the parent POM is correctly inherited
openapi.generics.skipis not set totrue
2. No envelope or contract-infrastructure classes are regenerated
The generated dto/ package should not contain:
ServiceResponse(or your BYOE envelope)Meta,SortPage- any DTO you mapped via BYOC
If any of these appear as standalone generated classes, the server-side projection did not stamp them as x-ignore-model: true. See server-side verification.
3. Generics are preserved
extends ServiceResponse<Page<CustomerDto>>
If you see flattened forms like extends ServiceResponsePageCustomerDto (with no generics) or duplicated envelope structures per endpoint — the build is in compatibility mode, not contract-aligned mode.
4. The client compiles against your contract
A successful mvn clean install confirms that:
- the generated wrappers reference types resolvable on the classpath
- BYOC mappings resolved to real classes (no
cannot find symbolerrors) - BYOE envelope, if configured, is on the classpath as a transitive dependency
If verification passes:
OpenAPI → Client → Contract alignment is correct.
Error handling
Error handling is not enforced by the client generator. It depends on the contract pattern your service publishes.
Two common patterns:
1. Separate error protocol (recommended for new services)
Success → ServiceResponse<T>
Error → ProblemDetail (RFC 9457)
The generated client deserializes successes into the wrapper type and errors into ProblemDetail. Your adapter layer decides how to surface them.
2. Envelope-based error model
Success → YourEnvelope<T>
Error → YourEnvelope<T> (errors carried inside the envelope)
The generated client deserializes both branches into the same wrapper type. Your adapter inspects the envelope and routes accordingly.
Key point
The generator does not define error semantics.
It preserves whatever contract the service exposes.
Choosing between the two patterns is a service-level decision, made on the producer side. See server-side error responses for the trade-offs.
Further reading
- Server-Side Adoption — what changes in your producer service
- Architecture — internal pipeline, vendor extension protocol, design decisions
- Compatibility & Support Policy — supported version matrix
- GitHub Discussions — design questions, edge cases, OAS 3.1 compliance