How Stripe-Style API Versioning in Quarkus Saves Your Demos and Your Sanity
A practical, end-to-end guide to building date-based API adapters, version routers, and backward-compatible responses in modern Java.
Not long ago, I was preparing a demo for a conference. The demo relied on an internal API one of my DevRel colleagues had built. We use each other’s services all the time; it’s part of the fun of working in a team where everyone experiments and ships small ideas quickly.
Except on that particular morning, the API had changed.
Not dramatically. Not maliciously. Just enough that the request my demo was sending no longer matched what the service expected. A field had been renamed. Another one removed.
Meanwhile, I was staring at a broken demo, wondering when the change happened and why nobody had mentioned it in the chat.
It wasn’t anyone’s fault, really.
It was just version drift.
The silent killer of demos, integrations, and the occasional developer mood.
That moment reminded me of something Stripe figured out early on: if you want to keep evolving an API without breaking people downstream, the API must never implicitly change. Clients should opt into new behavior. Not discover it during a live demo.
That’s what this article is about.
Quarkus gives us everything we need to build a Stripe-style versioning model: Date-based versions, clean adapters, a stable canonical model, and request/response transformation pipelines that let your API evolve safely for years. In this tutorial, we’ll build a fully runnable example that shows how to do exactly that.
Because if there’s one thing better than a fast, modern Java stack, it’s a future version of your API that doesn’t sabotage your demo five minutes before you go on stage.
What you’ll build
A minimal Payments API with three schema generations:
V1 (2023-10-16):
{ “amount”: 100.00 }V2 (2024-03-15):
{ “amount”: 100.00, “method”: “CARD” }V3 (2024-09-01): adds async confirmation fields in the response
Adapters translate each version to/from a CanonicalPayment model used by the service.
Project bootstrap
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=api-adapter-demo \
-DclassName="com.example.api.PaymentResource" \
-Dpath="/payments" \
-Dextensions="rest-jackson,smallrye-openapi,hibernate-validator"
cd api-adapter-demoWe explicitly use rest-jackson because the Quarkus REST stack was renamed; this is the current, stable path for JSON over HTTP in Quarkus. Don’t trust AI assistants wanting you to scaffold Quarkus apps with anything else.
If you don’t feel like following the steps in this tutorial, just take a look at the companion Github repository and grab it from there.
application.properties
src/main/resources/application.properties
quarkus.http.port=8080
# Default API version if client didn’t send X-API-Version:
api.default-version=2024-09-01Version detection and routing
We’ll capture X-API-Version and store it in a request-scoped context. If missing, we use a configurable default (mimics “account default” in Stripe).
src/main/java/com/example/version/VersionContext.java
A @RequestScoped CDI bean that:
Holds the API version for the current request
Provides thread-safe access during request processing
Ensures each request has its own version context
package com.example.version;
import jakarta.enterprise.context.RequestScoped;
@RequestScoped
public class VersionContext {
private String version;
public String get() {
return version;
}
public void set(String version) {
this.version = version;
}
}src/main/java/com/example/version/ApiVersionFilter.java
A JAX-RS request filter that:
Reads the X-API-Version header from incoming requests
Falls back to a configurable default (api.default-version, defaulting to 2024-09-01)
package com.example.version;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.ext.Provider;
/**
* Reads X-API-Version and stores it in VersionContext.
*/
@Provider
@ApplicationScoped
public class ApiVersionFilter implements ContainerRequestFilter {
@ConfigProperty(name = “api.default-version”, defaultValue = “2024-09-01”)
String defaultVersion;
@Inject
VersionContext ctx;
@Override
public void filter(ContainerRequestContext requestContext) {
String v = requestContext.getHeaderString(”X-API-Version”);
ctx.set(v != null && !v.isBlank() ? v : defaultVersion);
}
}Canonical model and service
Your canonical model is what the business logic understands. It should be free from versioning quirks.
src/main/java/com/example/domain/PaymentStatus.java
package com.example.domain;
public enum PaymentStatus {
AUTHORIZED, CONFIRMED, PENDING, FAILED
}src/main/java/com/example/domain/CanonicalPayment.java
package com.example.domain;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public class CanonicalPayment {
public String id;
public BigDecimal amount;
public String method; // CARD, SEPA, PAYPAL, etc.
public PaymentStatus status;
public OffsetDateTime createdAt;
public CanonicalPayment() {
}
public CanonicalPayment(String id, BigDecimal amount, String method,
PaymentStatus status, OffsetDateTime createdAt) {
this.id = id;
this.amount = amount;
this.method = method;
this.status = status;
this.createdAt = createdAt;
}
}src/main/java/com/example/service/PaymentService.java
Business logic:
create(CanonicalPayment req): Creates a payment
Generates a UUID, sets default method if missing, initializes status to AUTHORIZED
Works with the canonical model, not versioned DTOs
package com.example.service;
import java.time.OffsetDateTime;
import java.util.UUID;
import com.example.domain.CanonicalPayment;
import com.example.domain.PaymentStatus;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PaymentService {
public CanonicalPayment create(CanonicalPayment req) {
// In real life: fraud checks, ledger writes, async webhooks, etc.
String id = UUID.randomUUID().toString();
return new CanonicalPayment(
id,
req.amount,
req.method == null ? “CARD” : req.method,
PaymentStatus.AUTHORIZED,
OffsetDateTime.now());
}
}Versioned DTOs
Define DTOs per version. Keep them small and explicit.
src/main/java/com/example/api/v1/PaymentV1.java
package com.example.api.v1;
import java.math.BigDecimal;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
public record PaymentV1(
@NotNull @DecimalMin(”0.01”) BigDecimal amount) {
}src/main/java/com/example/api/v2/PaymentV2.java
package com.example.api.v2;
import java.math.BigDecimal;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record PaymentV2(
@NotNull @DecimalMin(”0.01”) BigDecimal amount,
@NotBlank String method) {
}src/main/java/com/example/api/v3/PaymentV3Response.java
package com.example.api.v3;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* V3 returns more metadata and async confirmation hints.
*/
public record PaymentV3Response(
String id,
BigDecimal amount,
String method,
String status,
OffsetDateTime createdAt,
boolean confirmationRequired
) {}
Adapter contracts and registry
Adapters translate between versioned DTOs and the canonical model.
src/main/java/com/example/adapter/RequestAdapter.java
Converts versioned requests to CanonicalPayment
version(): Returns the version date (e.g., “2023-10-16”)
requestType(): Returns the request DTO class
toCanonical(T request): Transforms to canonical model
package com.example.adapter;
import com.example.domain.CanonicalPayment;
public interface RequestAdapter<T> {
String version(); // ex: “2023-10-16”
Class<T> requestType(); // ex: PaymentV2.class
CanonicalPayment toCanonical(T request);
}src/main/java/com/example/adapter/ResponseAdapter.java
Converts CanonicalPayment to versioned responses
version(): Returns the version date
responseType(): Returns the response DTO class
fromCanonical(CanonicalPayment model): Transforms to versioned response
package com.example.adapter;
import com.example.domain.CanonicalPayment;
public interface ResponseAdapter<R> {
String version();
Class<R> responseType();
R fromCanonical(CanonicalPayment model);
}A small registry resolves adapters for a given date string. We’ll choose the closest adapter on or before the requested date. This is a pragmatic approach you can refine (e.g., map exact dates only).
src/main/java/com/example/adapter/AdapterRegistry.java
Discovers all adapters via CDI Instance injection
Selects adapters based on version and target type
Uses date-based version comparison (finds the latest adapter ≤ requested version)
Supports backward compatibility by selecting the most recent compatible adapter
package com.example.adapter;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
@ApplicationScoped
public class AdapterRegistry {
private final List<RequestAdapter<?>> requestAdapters;
private final List<ResponseAdapter<?>> responseAdapters;
@Inject
public AdapterRegistry(Instance<RequestAdapter<?>> reqs, Instance<ResponseAdapter<?>> resps) {
this.requestAdapters = reqs.stream().toList();
this.responseAdapters = resps.stream().toList();
}
public <T> RequestAdapter<T> requestAdapterFor(String version, Class<T> targetTypeHint) {
LocalDate v = LocalDate.parse(version);
@SuppressWarnings(”unchecked”)
Optional<RequestAdapter<T>> best = (Optional<RequestAdapter<T>>) (Optional<?>) requestAdapters.stream()
.filter(a -> LocalDate.parse(a.version()).compareTo(v) <= 0)
.sorted(Comparator.comparing(a -> LocalDate.parse(a.version()), Comparator.reverseOrder()))
.findFirst();
return best.orElseThrow(() -> new IllegalArgumentException(”No RequestAdapter for version “ + version));
}
public <R> ResponseAdapter<R> responseAdapterFor(String version, Class<R> targetTypeHint) {
LocalDate v = LocalDate.parse(version);
@SuppressWarnings(”unchecked”)
Optional<ResponseAdapter<R>> best = (Optional<ResponseAdapter<R>>) (Optional<?>) responseAdapters.stream()
.filter(a -> LocalDate.parse(a.version()).compareTo(v) <= 0)
.sorted(Comparator.comparing(a -> LocalDate.parse(a.version()), Comparator.reverseOrder()))
.findFirst();
return best.orElseThrow(() -> new IllegalArgumentException(”No ResponseAdapter for version “ + version));
}
}Concrete adapters (V1, V2, V3)
src/main/java/com/example/adapter/v1/PaymentV1Adapter.java
package com.example.adapter.v1;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import com.example.adapter.RequestAdapter;
import com.example.adapter.ResponseAdapter;
import com.example.api.v1.PaymentV1;
import com.example.domain.CanonicalPayment;
import com.example.domain.PaymentStatus;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PaymentV1RequestAdapter implements RequestAdapter<PaymentV1> {
@Override
public String version() {
return “2023-10-16”;
}
@Override
public Class<PaymentV1> requestType() {
return PaymentV1.class;
}
@Override
public CanonicalPayment toCanonical(PaymentV1 request) {
return new CanonicalPayment(
null, // id created by service
request.amount(),
“CARD”, // default method in V1
PaymentStatus.PENDING, // initial
OffsetDateTime.now());
}
}
@ApplicationScoped
class PaymentV1ResponseAdapter implements ResponseAdapter<PaymentV1> {
@Override
public String version() {
return “2023-10-16”;
}
@Override
public Class<PaymentV1> responseType() {
return PaymentV1.class;
}
@Override
public PaymentV1 fromCanonical(CanonicalPayment model) {
// V1 only exposes amount back
BigDecimal amt = model.amount == null ? BigDecimal.ZERO : model.amount;
return new PaymentV1(amt);
}
}src/main/java/com/example/adapter/v2/PaymentV2Adapter.java
package com.example.adapter.v2;
import java.time.OffsetDateTime;
import com.example.adapter.RequestAdapter;
import com.example.adapter.ResponseAdapter;
import com.example.api.v2.PaymentV2;
import com.example.domain.CanonicalPayment;
import com.example.domain.PaymentStatus;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PaymentV2RequestAdapter implements RequestAdapter<PaymentV2> {
@Override
public String version() {
return “2024-03-15”;
}
@Override
public Class<PaymentV2> requestType() {
return PaymentV2.class;
}
@Override
public CanonicalPayment toCanonical(PaymentV2 request) {
return new CanonicalPayment(
null,
request.amount(),
request.method(),
PaymentStatus.PENDING,
OffsetDateTime.now());
}
}
@ApplicationScoped
class PaymentV2ResponseAdapter implements ResponseAdapter<PaymentV2> {
@Override
public String version() {
return “2024-03-15”;
}
@Override
public Class<PaymentV2> responseType() {
return PaymentV2.class;
}
@Override
public PaymentV2 fromCanonical(CanonicalPayment model) {
return new PaymentV2(model.amount, model.method);
}
}src/main/java/com/example/adapter/v3/PaymentV3Adapter.java
package com.example.adapter.v3;
import java.time.OffsetDateTime;
import com.example.adapter.RequestAdapter;
import com.example.adapter.ResponseAdapter;
import com.example.api.v2.PaymentV2; // V3 reuses V2 request but adds V3 response features
import com.example.api.v3.PaymentV3Response;
import com.example.domain.CanonicalPayment;
import com.example.domain.PaymentStatus;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PaymentV3RequestAdapter implements RequestAdapter<PaymentV2> {
@Override
public String version() {
return “2024-09-01”;
}
@Override
public Class<PaymentV2> requestType() {
return PaymentV2.class;
}
@Override
public CanonicalPayment toCanonical(PaymentV2 request) {
return new CanonicalPayment(
null,
request.amount(),
request.method(),
PaymentStatus.PENDING,
OffsetDateTime.now());
}
}
@ApplicationScoped
class PaymentV3ResponseAdapter implements ResponseAdapter<PaymentV3Response> {
@Override
public String version() {
return “2024-09-01”;
}
@Override
public Class<PaymentV3Response> responseType() {
return PaymentV3Response.class;
}
@Override
public PaymentV3Response fromCanonical(CanonicalPayment model) {
boolean confirmationRequired = model.status == PaymentStatus.AUTHORIZED;
return new PaymentV3Response(
model.id,
model.amount,
model.method,
model.status.name(),
model.createdAt,
confirmationRequired);
}
}Resource: version-aware endpoint
We’ll accept generic JSON and let the registry pick the right request adapter based on the current version. Then we choose a response adapter for the same version so each caller gets the format they expect.
src/main/java/com/example/api/PaymentResource.java
package com.example.api;
import com.example.adapter.AdapterRegistry;
import com.example.adapter.RequestAdapter;
import com.example.adapter.ResponseAdapter;
import com.example.api.v1.PaymentV1;
import com.example.api.v2.PaymentV2;
import com.example.api.v3.PaymentV3Response;
import com.example.domain.CanonicalPayment;
import com.example.service.PaymentService;
import com.example.version.VersionContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/payments”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {
@Inject VersionContext versionContext;
@Inject AdapterRegistry registry;
@Inject PaymentService service;
@Inject ObjectMapper mapper;
@POST
public Response createPayment(@Valid JsonNode body) throws Exception {
String version = versionContext.get();
// Pick the best request adapter for this version.
// We support V1 and V2 request shapes across all versions.
RequestAdapter<?> reqAdapter = pickRequestAdapter(version);
// Deserialize into the adapter’s expected request type
Object typedReq = mapper.treeToValue(body, reqAdapter.requestType());
CanonicalPayment canonical = adaptToCanonical(reqAdapter, typedReq);
// Business logic
CanonicalPayment created = service.create(canonical);
// Transform response for the same version
Object versionedResponse = pickAndTransformResponse(version, created);
return Response.ok(versionedResponse).build();
}
// ---- Helpers
private RequestAdapter<?> pickRequestAdapter(String version) {
// Try V2 first (has method), fallback to V1
try { return registry.requestAdapterFor(version, PaymentV2.class); }
catch (Exception ignore) { /* fallback */ }
return registry.requestAdapterFor(version, PaymentV1.class);
}
package com.example.api;
import com.example.adapter.AdapterRegistry;
import com.example.adapter.RequestAdapter;
import com.example.adapter.ResponseAdapter;
import com.example.api.v1.PaymentV1;
import com.example.api.v2.PaymentV2;
import com.example.api.v3.PaymentV3Response;
import com.example.domain.CanonicalPayment;
import com.example.service.PaymentService;
import com.example.version.VersionContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/payments”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {
@Inject
VersionContext versionContext;
@Inject
AdapterRegistry registry;
@Inject
PaymentService service;
@Inject
ObjectMapper mapper;
@POST
public Response createPayment(@Valid JsonNode body) throws Exception {
String version = versionContext.get();
// Pick the best request adapter for this version.
// We support V1 and V2 request shapes across all versions.
RequestAdapter<?> reqAdapter = pickRequestAdapter(version);
// Deserialize into the adapter’s expected request type
Object typedReq = mapper.treeToValue(body, reqAdapter.requestType());
CanonicalPayment canonical = adaptToCanonical(reqAdapter, typedReq);
// Business logic
CanonicalPayment created = service.create(canonical);
// Transform response for the same version
Object versionedResponse = pickAndTransformResponse(version, created);
return Response.ok(versionedResponse).build();
}
// ---- Helpers
private RequestAdapter<?> pickRequestAdapter(String version) {
// Try V2 first (has method), fallback to V1
try {
return registry.requestAdapterFor(version, PaymentV2.class);
} catch (Exception ignore) {
/* fallback */ }
return registry.requestAdapterFor(version, PaymentV1.class);
}
@SuppressWarnings({ “unchecked”, “rawtypes” })
private CanonicalPayment adaptToCanonical(RequestAdapter adapter, Object typedReq) {
return adapter.toCanonical(typedReq);
}
private Object pickAndTransformResponse(String version, CanonicalPayment created) {
// V3 returns richer response type; otherwise V2 or V1
try {
ResponseAdapter<PaymentV3Response> v3 = registry.responseAdapterFor(version, PaymentV3Response.class);
return v3.fromCanonical(created);
} catch (Exception ignore) {
}
try {
ResponseAdapter<PaymentV2> v2 = registry.responseAdapterFor(version, PaymentV2.class);
return v2.fromCanonical(created);
} catch (Exception ignore) {
}
ResponseAdapter<PaymentV1> v1 = registry.responseAdapterFor(version, PaymentV1.class);
return v1.fromCanonical(created);
}
}Why accept JsonNode? Because the resource signature stays stable while adapters own the request shape. This keeps your endpoint simple even as versions multiply.
Why versioned APIs often avoid JsonNode in the public signature
OpenAPI docs become unclear.
Consumers expect typed contracts.
Generic JSON breaks Swagger-UI examples (You’ll see this below)
Clients lose code-generation benefits.
Adapters are powerful, but be aware of the downsides if you operate them in front of DTOs.
Tests
We’ll verify all three versions with different payloads and response shapes. And yes, mark the day in the calendar, I have written tests for this tutorial :)
src/test/java/com/example/api/PaymentResourceTest.java
package com.example.api;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.isA;
import static org.hamcrest.Matchers.notNullValue;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
public class PaymentResourceTest {
@Test
void v1_request_and_response() {
given()
.header(”X-API-Version”, “2023-10-16”)
.contentType(”application/json”)
.body(”{\”amount\”: 12.34}”)
.when()
.post(”/payments”)
.then()
.statusCode(200)
// V1 response only has “amount”
.body(”amount”, equalTo(12.34f))
.body(”$”, aMapWithSize(1));
}
@Test
void v2_request_and_response() {
given()
.header(”X-API-Version”, “2024-03-15”)
.contentType(”application/json”)
.body(”{\”amount\”: 99.99, \”method\”: \”CARD\”}”)
.when()
.post(”/payments”)
.then()
.statusCode(200)
.body(”amount”, equalTo(99.99f))
.body(”method”, equalTo(”CARD”));
}
@Test
void v3_request_v2_shape_but_richer_response() {
given()
.header(”X-API-Version”, “2024-09-01”)
.contentType(”application/json”)
.body(”{\”amount\”: 50.00, \”method\”: \”SEPA\”}”)
.when()
.post(”/payments”)
.then()
.statusCode(200)
.body(”id”, notNullValue())
.body(”status”, anyOf(equalTo(”AUTHORIZED”), equalTo(”PENDING”)))
.body(”confirmationRequired”, isA(Boolean.class));
}
@Test
void default_version_when_header_missing() {
given()
.contentType(”application/json”)
.body(”{\”amount\”: 10.00, \”method\”: \”CARD\”}”)
.when()
.post(”/payments”)
.then()
.statusCode(200)
// Defaults to application.properties -> 2024-09-01 (V3 response)
.body(”id”, notNullValue())
.body(”confirmationRequired”, isA(Boolean.class));
}
}Run and verify
Start dev mode:
./mvnw quarkus:devTry the three versions:
# V1: old clients (no method)
curl -s -X POST http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'X-API-Version: 2023-10-16' \
-d '{"amount": 12.34}' | jq
{
“amount”: 12.34
}
# V2: adds method
curl -s -X POST http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'X-API-Version: 2024-03-15' \
-d '{"amount": 99.99, "method": "CARD"}' | jq
{
“amount”: 99.99,
“method”: “CARD”
}
# V3: richer response, async hints
curl -s -X POST http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'X-API-Version: 2024-09-01' \
-d '{"amount": 50.00, "method": "SEPA"}' | jq
{
“id”: “3511a1a5-edfc-42da-a8ec-870b0c9446e3”,
“amount”: 50.0,
“method”: “SEPA”,
“status”: “AUTHORIZED”,
“createdAt”: “2025-11-27T09:36:36.80728+01:00”,
“confirmationRequired”: true
}Run tests:
./mvnw testOpenAPI and Swagger:
If you do not like the untyped API, you can add example request documentation to the PaymentResource.
@POST
public Response createPayment(
@RequestBody(
description = “Versioned payment request”,
content = @Content(
mediaType = “application/json”,
examples = {
@ExampleObject(
name = “V1”,
value = “”“
{
“amount”: 10.00
}
“”“
),
@ExampleObject(
name = “V2”,
value = “”“
{
“amount”: 10.00,
“method”: “CARD”
}
“”“
),
@ExampleObject(
name = “V3”,
value = “”“
{
“amount”: 10.00,
“method”: “SEPA”
}
“”“
)
}
)
)
@Valid JsonNode body) throws Exception {
// same content
}
}Production notes
Metrics: Count version usage with a filter and tag by version; use Micrometer/Prometheus if needed.
Logs: Include version in structured logs for debugging transformations.
Faster selection: If you have dozens of versions, precompute a
NavigableMap<LocalDate, AdapterSet>at startup.Validation: Keep version-specific validation in DTOs and business rules in services.
Deprecation: Send
DeprecationandSunsetheaders for old versions, and cut adapters only after the grace period and strong telemetry.
Common pitfalls
Leaking version concerns into core. If your domain model knows about version fields, you’ve lost the separation.
Single resource per version. Tempting, but it explodes maintenance. Keep one resource, push differences into adapters.
Inconsistent stack. Mixing
quarkus-resteasy-*withquarkus-rest-*will cause duplicate provider errors (Quarkus).
Where to go next
Generate versioned OpenAPI examples using
@Schemaand sample payloads per header value.Add account defaults: a simple table mapping API keys → default version.
Support per-request overrides: we already do with
X-API-Version.Introduce webhooks and async flows in V3+ to mirror real payments.
You now have a complete, runnable Quarkus app that demonstrates Stripe-style version pinning with adapters. Extend it with real storage, metrics, and deprecation headers when you roll this pattern into production.
Ship it.




I am constantly amazed by the quality and quantity of posts on the Main Thread. Well done!
Super thorough guide. The adapter pattern for version translation is elegant, but I really appreciate that you callout the OpenAPI tradeoff explicitly. Accepting JsonNode keeps the resource stable across versions, but losing typed contracts in swagger docs is a real pain point for API consumers who rely on codgen tools.