DEV Community

Cover image for Evolve a REST API Without Breaking Clients: Four Java Strategies
Markus
Markus

Posted on Originally published at the-main-thread.com AI-assisted

Evolve a REST API Without Breaking Clients: Four Java Strategies

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) {}
Enter fullscreen mode Exit fullscreen mode

ProductV2.java

package com.example.versioning.dto;

public record ProductV2(String id, String name, String description, boolean inStock) {}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Start the application and compare the responses:

./mvnw quarkus:dev
Enter fullscreen mode Exit fullscreen mode

In another terminal:

curl --fail http://localhost:8080/v1/products
curl --fail http://localhost:8080/v2/products
Enter fullscreen mode Exit fullscreen mode

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);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode

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();
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
curl --fail -H 'X-API-Version: 1' http://localhost:8080/products-header
curl --fail -H 'X-API-Version: 2' http://localhost:8080/products-header
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)