Versioning APIs in Quarkus: Four Strategies Every Java Developer Should Know
Practical strategies for evolving your Java services without breaking clients.
APIs are living contracts. As our applications grow, their APIs must evolve too. New features arrive, old fields become obsolete, and entire data structures may need rethinking. But breaking existing client integrations is not an option. That’s where API versioning comes in.
In this tutorial, we’ll build a Quarkus project that demonstrates four common strategies for versioning REST APIs:
URL path versioning
Query parameter versioning
Header-based versioning
Content negotiation with custom media types
Along the way, we’ll write clean DTOs, document our endpoints with OpenAPI, add helpful error handling, and validate everything with automated tests.
Project Setup
Let’s start by scaffolding a new Quarkus project. Open your terminal and run:
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=quarkus-versioning-tutorial \
-DclassName="com.example.versioning.ProductResource" \
-Dpath="/products" \
-Dextensions="rest-jackson,smallrye-openapi"
cd quarkus-versioning-tutorial
This gives us a fresh Quarkus app with Quarkus REST + JSON support, and OpenAPI documentation. Even if there’s not a lot of code, you can find the project on my Github repository.
Defining DTOs
Our example API will manage products. To simulate API evolution, we’ll create two versions of a Product
DTO:
ProductV1.java
package com.example.versioning.dto;
public record ProductV1(String id, String name, String description) {}
ProductV2.java
package com.example.versioning.dto;
public record ProductV2(String id, String name, String description, boolean inStock) {}
Notice the additional inStock
field in V2. That’s the kind of change that requires careful versioning.
1. URL Path Versioning
This is the most explicit strategy: clients hit /v1/products
or /v2/products
.
ProductResourceV1.java
package com.example.versioning.resources;
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV1;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/v1/products")
@Produces(MediaType.APPLICATION_JSON)
public class ProductResourceV1 {
@GET
public Response getProducts() {
List<ProductV1> products = List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers."));
return Response.ok(products).build();
}
}
ProductResourceV2.java
package com.example.versioning.resources;
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV2;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/v2/products")
@Produces(MediaType.APPLICATION_JSON)
public class ProductResourceV2 {
@GET
public Response getProducts() {
List<ProductV2> products = List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true));
return Response.ok(products).build();
}
}
Test it:
./mvnw quarkus:dev
curl http://localhost:8080/v1/products | jq
curl http://localhost:8080/v2/products | jq
Pros
Clear and explicit for clients and humans.
Easy to test in a browser and with curl.
Plays well with caches, routers, and CDNs.
Swagger/OpenAPI grouping is straightforward.
Cons
Duplicates URLs and often code paths.
Can lead to routing sprawl over time.
Harder to deprecate gracefully if many clients bookmark old paths.
Use when: Public APIs and broad client bases that need clarity and simple tooling.
Domain Versioning (a.k.a. Hostname Versioning)
Another flavor of URI-based versioning is to put the version into the hostname instead of the path. Instead of calling:
https://api.example.com/v1/products
you would call:
https://apiv1.example.com/products
From the API client’s perspective, the version is baked into the domain name itself.
Implementation in Quarkus
Quarkus doesn’t need special code changes for this strategy. Your resource class stays the same as in URL path versioning. What changes is how you configure your reverse proxy, load balancer, or DNS.
For example:
apiv1.example.com
points to version 1 of your Quarkus service.apiv2.example.com
points to version 2 of your service (which might even be deployed as a separate Quarkus application).
This is often handled at the infrastructure level (Kubernetes Ingress, OpenShift Routes, or API Gateway), rather than inside the Quarkus codebase.
Pros
Same as path versioning.
Can route to a completely different server or codebase.
Good for major, incompatible rewrites living as separate deployments.
Cons
Same as path versioning.
Clients may need to change security settings (CORS, certificates, firewall allowlists).
Use when: You want independent deployments per version and expect large breaking changes.
2. Query Parameter Versioning
Here, the base URL stays the same, but we add ?v=1
or ?v=2
.
QueryVersionedProductResource.java
package com.example.versioning.resources;
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV1;
import com.example.versioning.dto.ProductV2;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products-query")
@Produces(MediaType.APPLICATION_JSON)
public class QueryVersionedProductResource {
@GET
public Response getProducts(@QueryParam("v") @DefaultValue("1") String version) {
return switch (version) {
case "1" -> Response.ok(List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers."))).build();
case "2" -> Response.ok(List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true))).build();
default -> Response.status(Response.Status.BAD_REQUEST)
.entity("Unsupported version: " + version)
.build();
};
}
}
Test it:
curl "http://localhost:8080/products-query?v=1" | jq
curl "http://localhost:8080/products-query?v=2" | jq
Pros
Simple to implement in a single resource.
Allows a sensible default version.
Easy for internal tools and quick experiments.
Cons
Weaker cache friendliness than path or domain.
Easy for clients to forget or omit the parameter.
Feels less “contractual” and can be abused for ad‑hoc switches.
Use when: Internal tools and dashboards; not ideal for production‑grade public APIs.
3. Header-Based Versioning
Keep the URL clean and push version info into a header.
HeaderVersionedProductResource.java
package com.example.versioning.resources;
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV1;
import com.example.versioning.dto.ProductV2;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products-header")
@Produces(MediaType.APPLICATION_JSON)
public class HeaderVersionedProductResource {
@GET
public Response getProducts(@HeaderParam("X-API-Version") @DefaultValue("1") String version) {
return switch (version) {
case "1" -> Response.ok(List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers."))).build();
case "2" -> Response.ok(List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true))).build();
default -> Response.status(Response.Status.BAD_REQUEST)
.entity("Unsupported API version: " + version)
.build();
};
}
}
Test it:
curl -H "X-API-Version: 1" http://localhost:8080/products-header | jq
curl -H "X-API-Version: 2" http://localhost:8080/products-header | jq
Pros
Keeps URLs clean and stable.
Treats version as metadata, which fits HTTP semantics.
Works well in controlled environments with programmatic clients.
Cons
Invisible in the browser; harder to discover and test manually.
Requires client tooling to set headers correctly.
Can be overlooked by proxies if not configured.
Use when: Internal services and partner APIs where clients can reliably send headers.
4. Content Negotiation (Media Types)
The most RESTful approach: clients request application/vnd.myapi.v1+json
or application/vnd.myapi.v2+json
.
MediaTypedProductResource.java
package com.example.versioning.resources;
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV1;
import com.example.versioning.dto.ProductV2;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
@Path("/products-media")
public class MediaTypedProductResource {
@GET
@Produces("application/vnd.myapi.v1+json")
public Response getProductsV1() {
return Response.ok(List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers."))).build();
}
@GET
@Produces("application/vnd.myapi.v2+json")
public Response getProductsV2() {
return Response.ok(List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true))).build();
}
}
Test it:
curl -H "Accept: application/vnd.myapi.v1+json" http://localhost:8080/products-media | jq
curl -H "Accept: application/vnd.myapi.v2+json" http://localhost:8080/products-media | jq
Pros
Leverages HTTP as intended with
Accept
andContent-Type
.Supports multiple representations without changing routes.
Scales well for hypermedia and complex resource representations.
Cons
Higher learning curve for clients.
Custom media types are less intuitive.
Testing and documentation require more discipline.
Use when: You need strict REST semantics and multiple representations of the same resource.
5. Date Versioning (a.k.a. “Point-in-Time Versioning”)
Instead of numbering your API versions, you key them by a specific date. A client can say: “Give me the API as it was on 2024-05-01.” This is sometimes called point-in-time versioning or dynamic date versioning.
The idea is simple: every change to the API is associated with an effective date. Clients specify their desired version using a date, typically in a header like X-API-Version: 2024-05-01
.
Implementation in Quarkus
You’d parse the version header, compare the date against supported release dates, and route the request accordingly:
@GET
@Path("/products-date")
@Produces(MediaType.APPLICATION_JSON)
public Response getProducts(@HeaderParam("X-API-Version") String date) {
if ("2024-05-01".equals(date)) {
return Response.ok(List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers.")
)).build();
} else if ("2024-07-01".equals(date)) {
return Response.ok(List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true)
)).build();
}
return Response.status(Response.Status.BAD_REQUEST)
.entity("Unsupported API date version: " + date)
.build();
}
In production, this mapping would be data-driven (from a configuration file or database), not hardcoded.
Pros
Very precise — clients can pin to the exact API state they integrated with.
Useful in regulated industries where reproducibility matters (e.g., finance, healthcare).
Allows smoother migrations by letting clients upgrade at their own pace.
Cons
More complex to manage: you must track many dates over time.
Testing becomes harder — each date is effectively a new API version.
Can lead to “version sprawl” if not carefully governed.
When to use it: only in enterprises with strong compliance or regulatory needs, or when clients demand reproducibility down to specific release dates.
Making It Production-Ready
Building versioned endpoints is just the first step. In real-world projects, you need more than working code. You need reliability, clarity, and maintainability. That means documenting your versions so consumers know what to expect, handling errors gracefully when clients request unsupported versions, and backing it all with automated tests to prevent regressions. Let’s look at how to harden our Quarkus API so it’s ready for production use.
OpenAPI Documentation
Quarkus automatically generates an OpenAPI specification for your API. When using URL path versioning, you can document the different versions clearly. Navigate to http://localhost:8080/q/swagger-ui
to see it in action.
You can enhance this documentation using MicroProfile OpenAPI annotations.
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/v1/products")
@Tag(name = "Products (V1)", description = "Legacy product operations")
public class ProductResourceV1 {
@GET
@Operation(summary = "Get all products", description = "Returns products in v1 format.")
public Response getProducts() { /* ... */ }
}
Centralized Exception Handling
What happens when a client requests a version that doesn't exist? Don't just return a generic 400 or 404. Provide a helpful error message using a custom ExceptionMapper
.
UnsupportedVersionException.java
package com.example.versioning.exceptions;
public class UnsupportedVersionException extends RuntimeException {
public UnsupportedVersionException(String message) {
super(message);
}
}
VersionExceptionMapper.java
package com.example.versioning.exceptions;
import java.util.Map;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class VersionExceptionMapper implements ExceptionMapper<UnsupportedVersionException> {
@Override
public Response toResponse(UnsupportedVersionException exception) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "Unsupported API version requested.",
"message", exception.getMessage(),
"supportedVersions", new String[] { "1", "2" }))
.build();
}
}
Now, if your code throws an UnsupportedVersionException
, the client will get a clear, structured error response.
Automated Tests
Writing tests for your versioning strategy is crucial. Quarkus's integration with RestAssured makes this a breeze.
VersioningTest.java
(in src/test/java
)
package com.example.versioning;
package com.example.versioning;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.containsString;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
public class VersioningTest {
@Test
public void testPathVersioning() {
given()
.when().get("/v1/products")
.then().statusCode(200)
.body(containsString("description"));
given()
.when().get("/v2/products")
.then().statusCode(200)
.body(containsString("inStock"));
}
@Test
public void testHeaderVersioning() {
given()
.header("X-API-Version", "2")
.when().get("/products-header")
.then().statusCode(200)
.body(containsString("inStock"));
}
@Test
public void testContentNegotiation() {
given()
.accept("application/vnd.myapi.v2+json")
.when().get("/products-media")
.then().statusCode(200)
.body(containsString("inStock"));
}
}
Run tests with:
./mvnw test
Securing Your Versioned APIs
Versioning keeps your clients happy, but security keeps your business safe. No matter which versioning strategy you choose, every API must enforce consistent authentication and authorization rules across versions. A forgotten endpoint in an old version can easily become a weak spot.
Authentication
Quarkus provides several options out of the box:
JWT (JSON Web Tokens): Lightweight, standard, and perfect for stateless APIs.
OAuth2 / OpenID Connect: Best choice for enterprise integrations, especially when using Keycloak or another identity provider.
Mutual TLS: For highly secure, service-to-service communication.
Authorization
Use @RolesAllowed
, @DenyAll
, or @PermitAll
to control access per version. This ensures, for example, that sensitive fields introduced in a new version are not exposed to unauthorized users.
Transport Security
Always expose versioned APIs over HTTPS. When using domain versioning, ensure every subdomain has valid TLS certificates.
Deprecation and Legacy Risks
Old versions shouldn’t mean weaker security. If you deprecate a version, disable its authentication credentials at the same time. Attackers love forgotten endpoints.
Rule of thumb: Every API version must meet the same security standards as your latest one. No exceptions.
Choosing the Right Strategy
There is no single “best” way to version an API. The right choice depends on your audience, the type of changes you expect to make, and the governance model inside your organization. For public APIs, URL path versioning is usually the safest option. It is explicit, easy for developers to discover, and plays nicely with browsers, caches, and documentation tools. If you want to completely separate incompatible versions of the same service, domain versioning can be even more powerful. By assigning each version its own subdomain, you can route traffic to entirely different deployments and even run them in parallel. The trade-off is that clients need to update DNS and often reconfigure security settings such as SSL certificates or CORS rules.
For internal or partner APIs, other strategies are often more attractive. Header-based versioning keeps URLs clean and stable, treating version information as metadata. This works well when clients are strictly programmatic and can easily set headers. Content negotiation pushes this even further by using the standard HTTP Accept
and Content-Type
headers to express versions through custom media types. It is arguably the most “RESTful” option and a good fit when the same resource needs multiple representations, but it requires more discipline from both developers and clients. Query parameter versioning, while simple and quick to implement, is usually better left for internal dashboards and tooling. It lacks strong caching behavior and is too easy for clients to omit accidentally, which makes it less reliable for production-grade APIs.
There are also specialized cases such as date-based or point-in-time versioning. Instead of using numbers, clients specify the API state they expect by sending a date. This approach can guarantee reproducibility and is especially valuable in regulated industries like finance or healthcare. However, it introduces significant complexity, since every new release creates another point in time that must be supported and tested. Without strict governance, this can quickly spiral into version sprawl.
The most important rule is not which strategy you pick, but that you apply it consistently across your entire API surface. An organization that mixes different strategies will confuse its clients and create unnecessary maintenance overhead. Decide early, document your choice clearly, and enforce it in both code and infrastructure.
API versioning may seem like a small detail, but it’s what separates brittle services from reliable platforms. Quarkus gives you the tools to implement it cleanly, whichever path you choose.
Happy coding with Quarkus!