An API change reaches people who do not deploy on your schedule. A field disappears, a type changes, or an existing operation acquires a different meaning. Your Java service compiles. Their client fails.
Versioning gives clients a way to select a contract while you introduce another one. In this tutorial, we will implement four selectors in Quarkus: a URL path, a query parameter, a custom header, and an Accept media type. The examples share a small product model so you can compare the request and response behavior directly.
Check compatibility before adding a version
Adding a response field does not automatically require a new API version. Many JSON clients ignore unknown properties. Strict validators and some generated clients may reject them. Removing a field, changing its type, or changing its meaning is more likely to break consumers.
The example adds inStock to demonstrate two representations. Whether that addition needs a version in your application depends on the published contract and actual clients. Establish that first; supporting another version has a maintenance cost.
| Change | Evidence to check |
|---|---|
| Add an optional response field | Unknown-property handling in existing clients |
| Rename or remove a field | Callers and contract tests for the old field |
| Change a type or nullability | Deserialization and validation behavior |
| Change an operation's meaning | Client assumptions and business rules |
Start with the Java project
The complete example uses Java 21 and pins Quarkus 3.25.4. Those versions describe the example rather than the newest supported release. Start from that directory for a reproducible baseline, or scaffold an application with rest-jackson and smallrye-openapi using the Quarkus version your project supports.
The samples below include Vary headers for header-selected representations. Apply those changes if you start from an older copy of the repository.
Define the DTOs under src/main/java/com/example/versioning/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) {}
Keep both representations separate from your persistence entity. In a larger application, both resource versions can call the same business service and map its result to the selected response contract.
1. Put the version in the path
Clients request /v1/products or /v2/products. Each resource declares one response shape.
resources/ProductResourceV1.java
package com.example.versioning.resources;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
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)
@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() {
List<ProductV1> products = List.of(
new ProductV1("p1", "Laptop", "A powerful laptop for developers."));
return Response.ok(products).build();
}
}
resources/ProductResourceV2.java
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();
}
}
Start the application and compare the responses:
./mvnw quarkus:dev
In another terminal:
curl --fail http://localhost:8080/v1/products
curl --fail http://localhost:8080/v2/products
The first response has id, name, and description. The second also has inStock. Paths are easy to discover, route, and document. They can also multiply resource classes as versions accumulate. Keep shared business logic below the resource layer so a bug fix reaches every supported version.
2. Put the version in a query parameter
The URL stays /products-query; ?v=1 or ?v=2 selects the representation. This resource defaults to version 1 when the parameter is absent.
package com.example.versioning.resources;
import java.util.List;
import com.example.versioning.dto.ProductV1;
import com.example.versioning.dto.ProductV2;
import com.example.versioning.exceptions.UnsupportedVersionException;
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 -> throw new UnsupportedVersionException("Unsupported version: " + version);
};
}
}
curl --fail 'http://localhost:8080/products-query?v=1'
curl --fail 'http://localhost:8080/products-query?v=2'
curl --include 'http://localhost:8080/products-query?v=99'
Query parameters are part of the target URI, so this approach can work with HTTP caching. Check that your CDN or gateway includes v in its cache key; configurations that discard the parameter can serve the wrong representation. RFC 9111 defines the cache-key and Vary behavior.
Choose and document the default deliberately. Changing an omitted parameter from version 1 to version 2 can break a client that never selected a version explicitly.
3. Put the version in a header
Here the client sends X-API-Version. The resource uses the same two DTOs and an explicit default:
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."))).header("Vary", "X-API-Version").build();
case "2" -> Response.ok(List.of(
new ProductV2("p1", "Laptop", "A powerful laptop for developers.", true))).header("Vary", "X-API-Version").build();
default -> Response.status(Response.Status.BAD_REQUEST)
.entity("Unsupported API version: " + version)
.build();
};
}
}
curl --fail -H 'X-API-Version: 1' http://localhost:8080/products-header
curl --fail -H 'X-API-Version: 2' http://localhost:8080/products-header
Vary: X-API-Version tells a cache that the request header affects the selected response. Your gateway also needs to forward the header and honor the variation. Header selection works well when clients can reliably set headers, though an ordinary browser URL does not make the selected version visible.
4. Select a media type with Accept
Custom media types let the client select a representation of the same resource with the standard Accept header:
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."))).header("Vary", "Accept").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))).header("Vary", "Accept").build();
}
}
curl --fail -H 'Accept: application/vnd.myapi.v1+json' http://localhost:8080/products-media
curl --fail -H 'Accept: application/vnd.myapi.v2+json' http://localhost:8080/products-media
curl --include -H 'Accept: application/xml' http://localhost:8080/products-media
In the pinned example, the last request returns 200 with a fallback representation. Declaring @Produces does not establish strict rejection of every unsupported Accept value here. If your contract requires 406, add explicit negotiation or rejection and test that path. The success responses include Vary: Accept. Also test wildcard and omitted headers; document the behavior your clients will receive.
Accept describes the response representation the client can receive. Content-Type describes the representation sent in a request body. A write API may need a compatibility policy for both.
Return a clear unsupported-version error
The query resource throws UnsupportedVersionException. This mapper turns it into a structured 400 response. The header sample returns its own 400 response; to use the same mapper there, replace its default branch with a throw as well.
package com.example.versioning.exceptions;
public class UnsupportedVersionException extends RuntimeException {
public UnsupportedVersionException(String message) {
super(message);
}
}
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();
}
}
For public APIs, keep the error shape consistent across selectors. List supported versions and give clients enough information to correct the request without exposing internal details.
Test the old contract as well as the new one
The repository includes Quarkus REST Assured tests. Run them with:
./mvnw test
Add assertions for the contract you intend to preserve. Checking that a response contains the text inStock is weaker than checking its value and confirming that version 1 still has its original shape:
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.hasKey;
// Inside a @QuarkusTest test method:
given().when().get("/v1/products").then()
.statusCode(200)
.body("[0].id", equalTo("p1"))
.body("[0]", not(hasKey("inStock")));
given().when().get("/v2/products").then()
.statusCode(200)
.body("[0].inStock", equalTo(true));
Exercise all four selectors, omitted versions, unknown versions, and unsupported media types. Check the response media type and relevant Vary headers. For an existing API, run consumer contract tests against the old representation too.
Plan discovery and retirement
SmallRye OpenAPI exposes the generated schema at /q/openapi; Swagger UI is available at /q/swagger-ui in development. Use tags and operation descriptions to distinguish versions. Header and query parameters need clear documentation of supported values and defaults. See the Quarkus OpenAPI guide.
A hostname can also select an independently deployed service. A date can label a contract release in a header. Neither changes the underlying compatibility problem. A date selects API behavior; it does not reproduce historical data or guarantee that every dependency behaves as it did on that date.
Keep authentication and authorization checks across every supported version. Deprecation announces that clients should migrate. Retirement stops the old contract after the announced migration period. Do not revoke shared credentials merely because one API version is deprecated; enforce retirement through the version route and a documented access policy.
Choose the selector your clients can support
| Selector | Practical advantage | Detail to decide |
|---|---|---|
| Path | Visible and easy to route | Shared implementation and retirement of old paths |
| Query | Simple selection on one endpoint | Default version and gateway cache key |
| Header | Stable URL for programmatic clients | Header forwarding, documentation, and Vary
|
| Media type | Standard response negotiation | Supported types, wildcard behavior, and Vary
|
I would start with the client contract and existing gateway conventions. Then choose one consistent selector for the API surface, keep the business logic shared, and test the old representation whenever the implementation changes.
Which compatibility failure has cost your team more time: a field change, a changed default, or an undocumented behavioral change?
Adapted from my original Main Thread article with AI assistance for editing. Cover illustration generated with AI.
Top comments (0)