APIs rarely fail because of performance or scalability.
They fail because someone changes a field, renames a property, or “cleans up” a response — and downstream consumers discover it at the worst possible moment.
For teams building internal or public APIs, safe evolution is the real challenge. You want to ship improvements continuously without coordinating every change across demos, clients, and integrations. Stripe solved this problem years ago with a versioning model that scales. This article shows how to implement the same approach in Java using Quarkus.
The Problem: Implicit Change Is Still a Breaking Change
Most APIs evolve implicitly.
A field is renamed.
A response gets richer.
An enum gains a new value.
Even when changes are reasonable, consumers are forced to adapt immediately. In practice, this leads to brittle demos, frozen APIs, or long-lived technical debt.
Stripe’s core insight was simple: clients must opt into change.
An API version is not a deployment detail. It is a contract.
The Stripe Model, Condensed
Stripe uses date-based API versions such as 2023-10-16 or 2024-03-15.
Key properties of this model:
- Clients explicitly pin a version.
- New behavior is introduced in new versions only.
- Old versions keep working indefinitely.
- Internally, the platform evolves continuously.
The important architectural detail is that versioning is handled at the edge. Core business logic does not branch on versions.
Architecture Overview
The pattern looks like this:
Client
→ Version Router
→ Request Adapter (per version)
→ Canonical Model
→ Business Logic
← Canonical Result
← Response Adapter (per version)
← Versioned Response
Responsibilities are cleanly separated:
- Version router detects the requested version.
- Adapters translate between versioned schemas and a canonical model.
- Business logic only sees the canonical representation.
This keeps versioning complexity out of the core.
Quarkus Setup (Minimal)
The example uses Quarkus REST with Jackson:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
A request filter reads the version header and stores it in a request-scoped context:
@Provider
@ApplicationScoped
public class ApiVersionFilter implements ContainerRequestFilter {
@Inject VersionContext context;
@Override
public void filter(ContainerRequestContext req) {
String version = req.getHeaderString("X-API-Version");
context.set(version != null ? version : "2024-09-01");
}
}
From this point on, version information is available to the request pipeline.
Canonical Model First
The canonical model represents the current understanding of the domain:
public class CanonicalPayment {
public String id;
public BigDecimal amount;
public String method;
public PaymentStatus status;
}
This model evolves deliberately.
Adapters absorb the cost of backward compatibility.
Adapters: Where Versioning Lives
Each API version has adapters that translate to and from the canonical model.
Example: a legacy version that only supports amount:
@ApplicationScoped
public class PaymentV1Adapter implements RequestAdapter<PaymentV1> {
@Override
public String version() {
return "2023-10-16";
}
@Override
public CanonicalPayment toCanonical(PaymentV1 req) {
CanonicalPayment p = new CanonicalPayment();
p.amount = req.amount();
p.method = "CARD";
return p;
}
}
Newer versions add fields without affecting older clients.
Response adapters perform the inverse transformation.
Version Routing Without Conditionals
Adapters are registered via CDI and selected dynamically:
public RequestAdapter<?> resolve(String version) {
return adapters.stream()
.filter(a -> a.version().compareTo(version) <= 0)
.max(Comparator.comparing(RequestAdapter::version))
.orElseThrow();
}
The key idea:
the closest compatible adapter wins.
No if(version == ...) logic in the resource layer.
The Resource Stays Stable
The REST endpoint itself remains version-agnostic:
@POST
@Path("/payments")
public Response create(JsonNode body) {
RequestAdapter<?> adapter = registry.requestAdapter(version());
CanonicalPayment canonical = adapter.toCanonical(body);
CanonicalPayment result = service.create(canonical);
Object response = registry.responseAdapter(version())
.fromCanonical(result);
return Response.ok(response).build();
}
The public API stays stable even as versions accumulate.
Verification: What This Buys You
With this structure:
Old demos keep working.
New clients opt into new behavior explicitly.
Business logic stays readable.
Version removal becomes a controlled operation.
Most importantly, change becomes intentional.
Production Notes
A few things experienced teams should watch for:
Keep validation at version boundaries, not in the canonical core.
Treat adapters as long-lived compatibility code.
Track version usage metrics early.
Plan deprecation windows before adding new versions.
Avoid mixing REST stacks; stick to Quarkus REST consistently.
This pattern is not free, but it scales far better than ad-hoc branching.
Further Reading
This article is part of The Main Thread, a publication focused on modern Java architecture, real-world systems, and production-grade engineering.
Read the full version here:
https://www.the-main-thread.com/p/quarkus-stripe-api-versioning-adapter-java-tutorial
Because modern Java deserves better content.

Top comments (0)