Build CoreOrdered learning track

HTTP API Versioning for Internal Services

Learn Java Microservices Communication - Part 036

HTTP API versioning for Java microservices: compatibility lifecycle, additive and breaking changes, version placement, OpenAPI versioning, expand-contract migration, deprecation, consumer telemetry, Java implementation patterns, and governance.

16 min read3007 words
PrevNext
Lesson 3696 lesson track18–52 Build Core
#java#microservices#communication#http-api+4 more

Part 036 — HTTP API Versioning for Internal Services

API versioning is not about choosing between:

/v1/orders

and:

Accept: application/vnd.example.orders+json;version=1

That is only the visible syntax.

The real problem is compatibility.

A service evolves.

Consumers evolve at different speeds.

Deployments are staggered.

Rollbacks happen.

Generated clients lag.

Old workers keep running.

Batch jobs call old endpoints.

A versioning strategy answers:

How can the provider change without breaking consumers that are still correct according to the old contract?

For internal microservices, versioning is not decoration.

It is operational risk management.


1. Contract Is More Than JSON Shape

An HTTP API contract includes:

Contract areaExamples
Endpoint addresspath, method, host, gateway route
Request shapefields, types, requiredness, validation rules
Response shapefields, nullability, enum values, pagination model
Status code semantics200, 201, 202, 400, 409, 412, 429, 503
Error bodyproblem type, error code, field violations
Headersidempotency, trace, precondition, pagination, rate limit
Behaviorside effects, ordering, dedup, consistency
Performance envelopetimeout budget, page size, payload size
Security assumptionsauth requirements, scope checks, tenant isolation
Observabilityoperation name, trace semantics, metric labels
Deprecation rulessunset, replacement, support window

Breaking a consumer does not require changing JSON.

You can break a consumer by:

  • changing status code,
  • changing retryability,
  • tightening validation,
  • changing default sort order,
  • removing an error code,
  • adding a mandatory header,
  • changing pagination token meaning,
  • making a previously synchronous command asynchronous,
  • changing authorization requirements,
  • changing idempotency semantics,
  • changing timeout envelope.

Versioning must govern behavior, not just schema.


2. The First Rule: Avoid Versions When Additive Compatibility Is Enough

The best API version is often no new version.

If the change is backward-compatible, evolve the same endpoint.

Examples of usually compatible changes:

ChangeUsually compatible?Notes
Add optional response fieldYesConsumers must ignore unknown fields
Add optional request fieldYesServer must preserve old behavior when absent
Add new endpointYesExisting consumers unaffected
Add new enum valueRiskyMany generated clients break or map badly
Add pagination metadataUsually yesIf old fields preserved
Add new error detail extensionYesIf old error code/status preserved
Increase max page sizeUsually yesCould affect memory if clients depend on old size
Add rate-limit headersYesUnless required for correctness

The provider should optimize for:

compatible evolution first,
major version only when compatibility is impossible or unsafe.

Versioning every small change is not maturity.

It is often contract design failure.


3. Breaking Change Taxonomy

A breaking change is any change that can make an existing correct consumer fail.

3.1 Structural breaking changes

Examples:

  • remove response field,
  • rename field,
  • change type,
  • make optional request field required,
  • remove endpoint,
  • remove status code behavior,
  • change error format.

3.2 Semantic breaking changes

Examples:

  • POST /case-escalations used to be sync, now returns 202 Accepted,
  • GET /cases default sort changes from createdAt desc to updatedAt desc,
  • DELETE used to soft-delete, now hard-deletes,
  • retrying same idempotency key used to replay success, now returns 409,
  • 409 used to mean domain conflict, now means duplicate-in-progress.

Semantic breaks are more dangerous because schema diff tools may not detect them.

3.3 Operational breaking changes

Examples:

  • timeout envelope changes from 200 ms to 3 seconds,
  • max page size reduced from 500 to 50,
  • endpoint begins returning 429,
  • compression required,
  • request body limit reduced,
  • endpoint moved behind a different gateway with different auth.

3.4 Security breaking changes

Examples:

  • new scope required,
  • tenant resolution header changes,
  • service account no longer allowed,
  • response now redacts fields required by old consumers.

Security changes may be necessary, but they are still compatibility events.


4. Change Classification Matrix

Use a matrix before every API change.

ChangeCompatible?Needs new major version?Needs consumer comms?
Add optional response fieldYesNoUsually no
Add required response fieldUsually yesNoMaybe
Remove response fieldNoYes or deprecate firstYes
Rename fieldNoYes or add new + deprecate oldYes
Change field typeNoYesYes
Add optional request fieldYesNoMaybe
Add required request fieldNoYes or phased defaultingYes
Tighten validationOften noMaybeYes
Loosen validationUsually yesNoMaybe
Add enum valueRiskyMaybeYes
Remove enum valueNoYesYes
Change status codeOften noMaybeYes
Add new error codeRiskyMaybeYes
Remove error codeNoYesYes
Change pagination tokenNoYes or dual tokenYes
Change sorting defaultNoMaybeYes
Make sync command asyncNoYesYes
Add idempotency requirementNo for old clientsNeeds phased rolloutYes

This table is intentionally conservative.

Internal consumers often use generated clients, strict deserializers, enums, switch statements, and assumptions that are not visible in OpenAPI.


5. Where to Put the Version

There are several approaches.

5.1 URI path version

GET /v1/cases/CASE-100
GET /v2/cases/CASE-100

Pros:

  • obvious,
  • easy gateway routing,
  • easy logs/metrics,
  • easy documentation,
  • works well with generated clients.

Cons:

  • version becomes part of resource address,
  • can encourage over-versioning,
  • hard to version only one operation.

Good for internal service APIs where operational clarity matters.


5.2 Header version

GET /cases/CASE-100
X-Api-Version: 2

Pros:

  • clean paths,
  • version negotiation possible,
  • route can remain stable.

Cons:

  • less visible,
  • easier to forget,
  • gateway/routing/metrics need header-aware setup,
  • caching/proxy behavior must be carefully configured.

Good when gateway and tooling are mature.


5.3 Media type version

Accept: application/vnd.example.case.v2+json

Pros:

  • aligns with representation versioning,
  • can version response shape independently.

Cons:

  • more complex,
  • less familiar to many internal teams,
  • generated client tooling may be less convenient.

Good for public APIs or representation-heavy APIs.


5.4 Query parameter version

GET /cases/CASE-100?api-version=2

Pros:

  • easy to test manually,
  • visible.

Cons:

  • weak semantic fit,
  • can pollute business query parameters,
  • can interact poorly with caching and logs.

Use sparingly.


5.5 Hostname version

https://case-v1.internal
https://case-v2.internal

Pros:

  • strong operational isolation,
  • easy traffic splitting.

Cons:

  • heavy,
  • duplicates infrastructure,
  • not ideal for many APIs.

Good for platform-level migrations or incompatible runtime stacks.


For most Java internal microservices:

  1. Prefer backward-compatible evolution without endpoint version bump.
  2. Use path major version for rare breaking API families.
  3. Keep minor/patch changes in OpenAPI info.version, artifact version, and changelog.
  4. Do not create /v2 for every additive field.
  5. Do not keep old major versions forever without ownership and telemetry.
  6. Route /v1 and /v2 explicitly through gateway/service mesh.
  7. Track consumers per operation and version.

Example:

GET /v1/cases/{caseId}
GET /v2/cases/{caseId}

OpenAPI:

openapi: 3.2.0
info:
  title: Case Service Internal API
  version: 2.4.1
paths:
  /v2/cases/{caseId}:
    get:
      operationId: getCaseV2

Important distinction:

VersionMeaning
Path major version /v2Breaking API family version
OpenAPI info.versionVersion of the API description/document
Java artifact versionVersion of generated/client library
Service deployment versionRuntime build currently deployed

These are related but not identical.

Do not conflate them.


7. Expand-Contract Migration

The safest way to introduce breaking changes is not a big bang.

Use expand-contract.

Example: rename ownerId to assigneeId.

Bad migration:

{
  "assigneeId": "U-123"
}

Old consumers expected:

{
  "ownerId": "U-123"
}

Safer migration:

Phase 1:

{
  "ownerId": "U-123",
  "assigneeId": "U-123"
}

Phase 2:

  • consumers migrate to assigneeId,
  • provider tracks field usage if possible,
  • OpenAPI marks ownerId deprecated.

Phase 3:

  • remove ownerId only in /v2,
  • keep /v1 until sunset.

8. Parallel Version Runtime Patterns

8.1 Controller duplication

api/
  v1/
    CaseControllerV1.java
    CaseResponseV1.java
  v2/
    CaseControllerV2.java
    CaseResponseV2.java
application/
  GetCaseUseCase.java
domain/
  Case.java

Good:

  • explicit,
  • easy to reason about,
  • clean generated docs.

Bad:

  • duplicated controller code,
  • can diverge.

Use when versions differ materially.


8.2 Shared application use case, versioned adapters

This is the recommended default.

The domain/application layer should not become versioned just because HTTP representations are versioned.

Version at the edge.


8.3 Single controller with conditional response

@GetMapping("/cases/{id}")
public ResponseEntity<?> getCase(
    @PathVariable String id,
    @RequestHeader("X-Api-Version") int version
) {
    CaseView view = useCase.get(id);

    if (version == 1) {
        return ResponseEntity.ok(v1Mapper.toResponse(view));
    }

    return ResponseEntity.ok(v2Mapper.toResponse(view));
}

This can work for small differences.

But it becomes messy when behavior diverges.

Avoid large if (version) logic inside core business flows.


9. Java DTO Versioning

Do not expose domain objects directly.

Bad:

public record Case(
    String id,
    CaseStatus status,
    String internalRiskScore,
    String regulatoryRoutingHint
) {}

Good:

public record CaseResponseV1(
    String id,
    String status,
    String ownerId
) {}

public record CaseResponseV2(
    String id,
    String status,
    String assigneeId,
    String lifecycleState
) {}

Mapper:

public final class CaseHttpMapper {
    public CaseResponseV1 toV1(CaseView view) {
        return new CaseResponseV1(
            view.id(),
            view.status().externalCode(),
            view.assigneeId()
        );
    }

    public CaseResponseV2 toV2(CaseView view) {
        return new CaseResponseV2(
            view.id(),
            view.status().externalCode(),
            view.assigneeId(),
            view.lifecycleState().externalCode()
        );
    }
}

The versioned DTO is a stability boundary.

It protects consumers from internal refactoring.


10. Versioning Request Commands

Versioning commands is more sensitive than versioning queries.

A query shape change can break reads.

A command shape change can break side effects.

Example v1:

{
  "caseId": "CASE-100",
  "targetQueue": "FRAUD_REVIEW"
}

Example v2:

{
  "caseId": "CASE-100",
  "assignment": {
    "queue": "FRAUD_REVIEW",
    "priority": "HIGH"
  },
  "reasonCode": "SUSPICIOUS_ACTIVITY"
}

Rules:

  • old command semantics must remain stable,
  • idempotency key scope should include operation version,
  • request fingerprint must include command version,
  • validation changes require rollout care,
  • error codes must remain meaningful.

Dedup scope example:

tenant_id + caller_id + operation_name + major_version + idempotency_key

Do not allow /v1 and /v2 commands to collide on the same dedup key unless intentionally designed.


11. Versioning Error Models

Errors are part of the contract.

Bad versioning:

{
  "message": "Invalid request"
}

Then later:

{
  "error": {
    "code": "CASE_NOT_FOUND"
  }
}

This breaks clients.

Better:

{
  "type": "https://errors.example.internal/case-not-found",
  "title": "Case not found",
  "status": 404,
  "detail": "Case CASE-100 was not found.",
  "extensions": {
    "code": "CASE_NOT_FOUND",
    "retryable": false
  }
}

When evolving errors:

ChangeCompatibility
Add new extension fieldUsually compatible
Change type URIBreaking if clients depend on it
Change statusOften breaking
Remove codeBreaking
Add new codeRisky
Change retryabilityBreaking operationally

Versioning errors matters because clients often encode retry, fallback, alerting, and user messaging based on error codes.


12. Versioning Pagination

Pagination is a contract.

Breaking changes:

  • changing token format without dual support,
  • changing default sort order,
  • changing page size limits,
  • changing whether deleted records appear,
  • changing stable ordering guarantee,
  • changing filter semantics.

Safer approach:

GET /v1/cases?pageToken=old-token
GET /v2/cases?pageToken=new-token

Or include token version inside an opaque token:

{
  "v": 2,
  "sort": ["createdAt", "id"],
  "last": ["2026-07-05T01:00:00Z", "CASE-100"]
}

Then sign/encrypt/encode it.

Consumers should not parse page tokens.

Providers should not silently reinterpret old tokens under new semantics.


13. Versioning and OpenAPI

OpenAPI describes HTTP APIs in a machine-readable way.

Use it as a compatibility artifact.

Recommended repository structure:

api/
  openapi/
    case-service-v1.yaml
    case-service-v2.yaml
  changelog/
    2026-07-05-add-assignee-id.md

Use OpenAPI for:

  • generated client compatibility,
  • documentation,
  • diffing,
  • contract tests,
  • stub generation,
  • API review,
  • governance gates.

But remember:

OpenAPI describes a lot, not everything.

It may not fully capture:

  • timeout guarantees,
  • retry semantics,
  • idempotency behavior,
  • consistency model,
  • authorization nuance,
  • side effects,
  • operational constraints.

Put those in descriptions, extensions, ADRs, and tests.

Example extension:

paths:
  /v1/case-escalations:
    post:
      operationId: createCaseEscalation
      x-communication-policy:
        idempotencyRequired: true
        retryableByClient: true
        timeoutBudgetMs: 500
        sideEffects:
          - create escalation
          - write audit event
          - publish CaseEscalated

14. Compatibility Gates

A mature service should not rely on humans to spot breaking changes manually.

Useful gates:

GatePurpose
OpenAPI diffDetect structural contract breaks
Consumer contract testsVerify real consumer expectations
Generated client compile testCatch enum/type/nullability breaks
Stub replay testsVerify old client against new provider
Canary consumer trafficObserve live compatibility
Deprecation usage telemetryKnow who still uses old version
Error/status code regression testsPreserve operational behavior

CI flow:

Do not merge API changes that accidentally break known consumers.


15. Consumer Telemetry

Versioning without consumer telemetry becomes guesswork.

At minimum, record:

  • API version,
  • operation ID,
  • caller service,
  • client library version,
  • response status,
  • error code,
  • idempotency use,
  • deprecated field/endpoint usage if detectable.

Example metric dimensions:

http.server.requests{
  service="case-service",
  api_version="v1",
  operation="getCase",
  caller="workflow-service",
  status="200"
}

Deprecation dashboard:

APIVersionCallerRequests/dayLast seenOwner
Case APIv1workflow-service130k2026-07-05Case Platform
Case APIv1reporting-job9k2026-07-05Data Platform
Case APIv2portal-service240k2026-07-05Portal

Do not sunset an internal API until you know who still calls it.


16. Deprecation and Sunset

A deprecation lifecycle:

Deprecation notice should include:

  • what is deprecated,
  • replacement endpoint,
  • migration guide,
  • compatibility differences,
  • deadline,
  • owner,
  • support channel,
  • risk of non-migration.

HTTP can include deprecation-related headers where appropriate, but internal systems should not depend only on headers.

Use:

  • API catalog,
  • Slack/Teams announcements,
  • ADR,
  • generated client warnings,
  • dashboard,
  • gateway policy,
  • owner escalation.

17. Versioning Generated Clients

Generated clients are useful but can hide compatibility issues.

Rules:

  1. Generated code is not the domain boundary.
  2. Wrap generated clients in a service-owned client adapter.
  3. Version generated artifacts explicitly.
  4. Compile known consumers against new generated clients before rollout.
  5. Do not force all consumers to upgrade for additive server changes.
  6. Avoid generated enum exhaustiveness traps.

Generated enum trap:

switch (status) {
    case OPEN -> ...
    case CLOSED -> ...
}

If provider adds:

SUSPENDED

Old client may fail.

Safer:

default -> handleUnknownStatus(status);

OpenAPI enum changes are not always harmless in generated clients.


18. Versioning Status Codes and Retries

Changing status codes can change client behavior.

Example:

Old:

503 Service Unavailable
Retry-After: 1

New:

500 Internal Server Error

A client may stop retrying.

Example:

Old:

409 Conflict

Meaning: duplicate request in progress, retry with same idempotency key.

New:

409 Conflict

Meaning: domain conflict, do not retry.

Same code, different semantics.

This is a semantic breaking change.

Define error type and retryability explicitly.

{
  "type": "https://errors.example.internal/request-in-progress",
  "status": 409,
  "extensions": {
    "code": "REQUEST_IN_PROGRESS",
    "retryable": true
  }
}

19. Versioning Security Behavior

Security changes can be compatible at the API shape level and breaking at runtime.

Examples:

  • endpoint now requires case:read:sensitive,
  • service-to-service caller must send audience-specific token,
  • tenant header no longer accepted,
  • field-level redaction added.

Security migration strategy:

  1. add telemetry for who would fail under new rule,
  2. run in shadow/evaluation mode,
  3. notify owners,
  4. add support for new credential/scope,
  5. require new behavior for new version,
  6. enforce old version only after migration window or emergency exception.

Do not surprise internal consumers with security breaks unless there is an active incident or compliance requirement.


20. Versioning and Deployment

API versioning must survive deployment realities.

During rolling deploy:

old pod + new pod + old client + new client

All combinations may exist.

Compatibility matrix:

ClientServerMust work?
old clientold serverYes
old clientnew serverYes during compatible rollout
new clientold serverOnly if rollout ordered carefully
new clientnew serverYes

For backward-compatible provider-first rollout:

  1. deploy server that accepts old and new shape,
  2. deploy clients using new shape,
  3. observe migration,
  4. remove old shape in later version.

For client-first rollout, the old server must already tolerate the new request shape, which is often not true.

Provider-first is usually safer for additive server changes.


21. Internal API Versioning Policy Template

# API Versioning Policy

## Scope
Applies to service-to-service HTTP APIs owned by this service.

## Compatibility
The service may add optional response fields, optional request fields, new endpoints, and new error extensions without major version bump.

## Breaking Changes
The following require a new major API version or explicit migration plan:
- removed or renamed fields
- changed field types
- newly required request fields
- changed status code semantics
- changed error code semantics
- changed idempotency behavior
- changed pagination token semantics
- changed authorization requirements
- changed side effects

## Version Placement
Major versions are represented in the path: `/v1`, `/v2`.

## Deprecation
Deprecated versions require:
- replacement endpoint
- migration guide
- consumer telemetry
- owner notification
- sunset date
- exception process

## OpenAPI
Each major version has a separate OpenAPI document.

## Governance
API changes require:
- OpenAPI diff
- provider tests
- relevant consumer contract tests
- migration ADR for breaking changes

22. Java Implementation Layout

Recommended layout:

com.example.casecommunication
  api
    v1
      CaseControllerV1.java
      CaseResponseV1.java
      CreateEscalationRequestV1.java
      ProblemTypesV1.java
    v2
      CaseControllerV2.java
      CaseResponseV2.java
      CreateEscalationRequestV2.java
      ProblemTypesV2.java
  application
    GetCaseUseCase.java
    CreateEscalationUseCase.java
  domain
    Case.java
    Escalation.java
  infrastructure
    persistence
    messaging

Controller v1:

@RestController
@RequestMapping("/v1/cases")
public final class CaseControllerV1 {
    private final GetCaseUseCase getCaseUseCase;
    private final CaseApiMapper mapper;

    @GetMapping("/{caseId}")
    public CaseResponseV1 getCase(@PathVariable String caseId) {
        CaseView view = getCaseUseCase.get(caseId);
        return mapper.toV1(view);
    }
}

Controller v2:

@RestController
@RequestMapping("/v2/cases")
public final class CaseControllerV2 {
    private final GetCaseUseCase getCaseUseCase;
    private final CaseApiMapper mapper;

    @GetMapping("/{caseId}")
    public CaseResponseV2 getCase(@PathVariable String caseId) {
        CaseView view = getCaseUseCase.get(caseId);
        return mapper.toV2(view);
    }
}

One use case.

Two edge representations.

This keeps versioning out of the domain model.


23. Testing Version Compatibility

Test categories:

TestPurpose
v1 endpoint testEnsure v1 behavior preserved
v2 endpoint testEnsure new behavior works
cross-version mapper testEnsure same domain state maps correctly
OpenAPI snapshot testDetect accidental contract drift
old client integration testVerify old generated client still works
error compatibility testPreserve status/error semantics
deprecation telemetry testEnsure caller/version metrics emitted

Example mapper test:

@Test
void mapsCaseToV1WithoutLeakingV2Fields() {
    CaseView view = new CaseView(
        "CASE-100",
        CaseStatus.OPEN,
        "U-123",
        LifecycleState.UNDER_REVIEW
    );

    CaseResponseV1 response = mapper.toV1(view);

    assertThat(response.id()).isEqualTo("CASE-100");
    assertThat(response.ownerId()).isEqualTo("U-123");
}

Example OpenAPI diff gate:

current main branch OpenAPI
vs
pull request OpenAPI

fail if:
- response field removed
- request field made required
- status code removed
- operation removed
- schema type changed

Human review is still needed for semantic changes.


24. Anti-Patterns

24.1 Versioning every deploy

Bad:

/v2026-07-05/cases
/v2026-07-06/cases

This creates consumer chaos.

Deployment version is not API version.


24.2 No version, no compatibility policy

Bad:

"Internal API, we can change it anytime."

Internal APIs often have more hidden consumers than public APIs.

No policy means accidental production coupling.


24.3 Shared DTO library as versioning strategy

A shared Java DTO library can make compile-time reuse easier.

It can also create lockstep deployments.

If every consumer must upgrade the DTO library before the provider changes, you have converted network compatibility into dependency hell.

Prefer wire contracts plus consumer-owned adapters.


24.4 Versioning only the URL but not behavior

/v2 is meaningless if:

  • error codes change unpredictably,
  • old semantics are not documented,
  • generated client is not versioned,
  • gateway routes are mixed,
  • telemetry cannot distinguish callers.

24.5 Removing old version without usage data

If you cannot answer:

Who still calls /v1?

You are not ready to remove /v1.


25. Decision Model

Use this flow:

The discipline:

Do not version because it is easy. Version because compatibility requires a new contract boundary.


26. The Real Lesson

HTTP API versioning is a lifecycle.

It includes:

  • compatibility classification,
  • OpenAPI governance,
  • deployment sequencing,
  • generated client management,
  • consumer telemetry,
  • deprecation,
  • sunset,
  • operational rollback.

A top-tier internal API does not merely expose /v1.

It can explain:

  1. what changes are safe,
  2. what changes are breaking,
  3. how consumers migrate,
  4. how old versions are supported,
  5. how removal is proven safe.

That is the difference between an endpoint and a platform contract.


References

Lesson Recap

You just completed lesson 36 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.