What counts as a breaking change

A new API version is expensive. It means a second contract to document, test and operate, and every consumer has to plan a migration. In microservice API versioning, treat a version bump as the last resort, reserved for changes that genuinely break consumers. Deciding that requires a precise definition of "breaking". Without one, teams either bump versions for harmless additions or ship real breaks under the same version number. No choice among api versioning strategies fixes either mistake.

A change is breaking if a consumer that was correct yesterday fails or misbehaves today without changing any code. Breaks fall into two groups:

Category Example change Why consumers break
Schema Removing or renaming a field Deserializers fail, or code reads a null it never expected
Schema Changing a type or unit (string to integer, seconds to milliseconds) Parsing fails, or a value parses fine and is silently wrong by a factor of 1,000
Behavioral Tighter validation Requests that used to succeed are now rejected
Behavioral Changed defaults An omitted parameter now produces different results
Behavioral Changed error codes or status semantics Retry, fallback and alerting logic takes the wrong branch
Behavioral Changed pagination or ordering guarantees Clients skip or duplicate records and deduplicate incorrectly

Behavioral breaks are the more dangerous group. The schema stays identical, so a schema diff shows nothing and the generated client still compiles. Consumers find out in production, often through bad data rather than an error. Any observable behavior that consumers can reasonably depend on is part of the contract, even if it is not written in the schema.

How API versioning works

API versioning is a set of practices, not a single feature. In a microservice system it works in layers, and the rest of this article covers each one:

  1. A declared contract defines what consumers may rely on: schema, status codes, ordering, defaults and error semantics.
  2. Compatibility rules decide whether a change can ship under the current version. Additive changes ship in place. Consumers are expected to read tolerantly.
  3. Automated checks in CI compare each proposed change against the released contract and against the contracts consumers have published, and fail the build on incompatible changes.
  4. An explicit version is introduced only when a change cannot be made additively. The version travels in the request (path, header or media type) so the provider knows which contract the caller expects.
  5. Routing and translation let one deployment serve several versions. Adapters at the edge convert old requests and responses to and from a single internal model.
  6. Deprecation and sunset retire the old version once measured traffic shows that every identified consumer has migrated.

Each layer reduces how often the next one is needed. Good contracts and tolerant readers make most changes non-breaking. CI catches the breaks that slip through. Explicit versions handle what remains.

Declare the contract first, then apply Semantic Versioning

Semantic Versioning gives version numbers a fixed meaning, but only relative to something. The specification's first rule is that software using it must declare a public API. Without a declared contract, nobody can say whether a change is "incompatible," so the number carries no information. For a service, the contract is an OpenAPI document, a Protobuf or Avro schema, or written guarantees about ordering, pagination, error codes and idempotency. It should exist before the first consumer integrates, not be reconstructed later from whatever the code happens to do.

With a contract in place, the mapping onto a service is straightforward:

  • MAJOR: removing or renaming a field, tightening validation, changing a status code or altering semantics consumers rely on.
  • MINOR: new optional fields, endpoints or enum values that the contract already says clients must tolerate.
  • PATCH: fixes that bring behavior back in line with the documented contract.

Most network APIs expose only the major version to clients, as in /v2/ or a v2 media type. Minor and patch versions are meant to be safe to receive without any action, so routing on them would add complexity and give consumers nothing. They belong in changelogs and schema registries.

Formal versioning matters most when consumers are numerous, external or unknown, because you cannot coordinate an upgrade with them. A small internal system with two consumers owned by the same team can often change both sides in one deploy.

SemVer conveys intent but enforces nothing. Teams often ship a behavioral break, such as a changed default or a reordered result, as a minor or patch release because the schema did not change.

Who should use explicit versioning

Explicit, formal versioning pays off when you cannot change the provider and all of its consumers at the same moment. That is usually true for:

  • Public and partner APIs. You don't control consumers' release schedules, and some of them you don't know about at all.
  • Platform and shared services inside a larger organization, such as identity, payments or catalog services called by many teams with separate roadmaps and deploy pipelines.
  • Clients you cannot force to update, such as mobile apps, desktop software, firmware and on-premises installations. Old builds keep calling your API long after you ship a new one.
  • Event producers whose messages are stored and replayed. A consumer may read data written under an old schema months later, so compatibility has to hold across time, not just across a deploy.
  • Teams in regulated or contractual settings where consumers are promised a support window for a given contract.

In these settings, the discipline described here (declared contracts, compatibility rules, CI checks and a deprecation policy) is what lets the provider keep shipping without waiting on every consumer.

When to avoid it

Explicit versions add a second contract to run, so avoid them when the cost isn't justified:

  • One team owns both sides. If the provider and every consumer live in the same repository or are deployed together, change both in one commit and one release. A version would only preserve code you are about to delete.
  • The change can be made additively. A new optional field, endpoint or enum value (when consumers read tolerantly) does not need a new version. Bumping anyway trains consumers to ignore version numbers and multiplies the versions you operate.
  • The API is still experimental. Before the first external consumer integrates, label the API as unstable or preview rather than publishing v1, v2 and v3 in quick succession.
  • You cannot identify or measure consumers. Without per-consumer, per-version traffic data you can never retire an old version, so every new version becomes permanent. Fix observability before adding versions.
  • The change is a bug fix that restores documented behavior. That is a patch, even if some consumer depended on the bug. Communicate it, but don't fork the contract for it.

Avoiding explicit versions does not mean avoiding compatibility discipline. The contract, tolerant readers and CI checks still apply.

Evolving without a version bump: additive change and tolerant readers

Most changes don't need a new version if producers and consumers both follow a few rules.

On the producer side, change by adding. A new optional field, a new endpoint or a new operation leaves every existing request and response valid. Changing the type or meaning of an existing field does not, so put the new behavior somewhere new. Never reuse a removed field name or enum value for something else. A consumer still running old code will read the new data with the old meaning, and nothing will fail loudly. State in the contract that responses are extensible, so consumers know that new fields and enum values can appear at any time.

On the consumer side, apply the tolerant reader principle. Read only the fields you use and ignore the rest. Don't depend on field order. Map unknown enum values to an explicit fallback, such as an "unknown" branch, instead of throwing. A strict deserializer undoes all of this. If a JSON mapper is set to fail on unknown properties, or a generated client uses a closed enum, a harmless new field becomes an error in the consumer. The producer's change was compatible, and the consumer's configuration turned it into a break.

Additive evolution has costs. Fields you can never remove pile up. When everything is optional, the schema no longer tells you what a valid message looks like. Tolerance can also hide a change the consumer needed to act on, such as a new status that means "do not ship." Even so, when consumers read tolerantly, make additive change the default. Keep explicit versions for changes that can't be made by adding.

import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

public class OrderClient {
    // UNKNOWN is the explicit fallback for statuses this client has never seen.
    enum Status { PENDING, SHIPPED, @JsonEnumDefaultValue UNKNOWN }

    // Declare only the fields this consumer actually uses.
    static class Order { public String id; public Status status; }

    public static void main(String[] args) throws Exception {
        // The producer added a "carrier" field and a new status value. Both are additive.
        String json = "{\"id\":\"ord-1042\",\"status\":\"ON_HOLD\",\"carrier\":\"ups\"}";

        // Jackson's defaults are strict: unknown properties and unknown enum values both throw.
        ObjectMapper strict = new ObjectMapper();
        try {
            strict.readValue(json, Order.class);
        } catch (Exception e) {
            System.out.println("strict client broke: " + e.getClass().getSimpleName());
        }

        // Tolerant reader: skip fields we don't know, map unknown enum values to UNKNOWN.
        ObjectMapper tolerant = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true);
        Order order = tolerant.readValue(json, Order.class);

        // The fallback is a deliberate decision. Do not ship an order we can't interpret.
        switch (order.status) {
            case PENDING -> System.out.println(order.id + ": waiting");
            case SHIPPED -> System.out.println(order.id + ": shipped");
            case UNKNOWN -> System.out.println(order.id + ": unrecognized status, holding for review");
        }
    }
}

The same additive change, a new field plus a new enum value, makes Jackson's default strict ObjectMapper throw, while a tolerant reader ignores the unknown field and sends the unknown status to an explicit, safe fallback branch.

URI, header or media type: choosing a versioning mechanism

Some changes cannot be expressed as additions. When a change is genuinely incompatible, the version has to travel in the request, and each placement shifts cost to a different party.

URI path (/v2/orders) is visible in every log line and browser bar. Caches key on it without configuration, and a gateway can route on a path prefix. The drawback is granularity: a breaking change to one resource tends to produce /v2 for the whole API, and clients hard-code base URLs, so migrating means editing code rather than configuration.

A custom request header keeps URLs stable and allows per-resource versions. Clients forget to send it, so you need a defined default. It is also harder to debug, because the version does not appear in access logs unless you log that header explicitly.

Media-type versioning uses Accept content negotiation as defined in RFC 9110. It fits HTTP semantics best, but it costs the most in tooling and client effort. Responses must carry Vary: Accept, and a cache or proxy that ignores it can serve one version to a client that asked for another. A custom header has the same caching risk unless responses vary on that header.

Mechanism Visible in logs Cache-safe by default Version granularity Client effort
URI path Yes Yes Whole API Low
Custom header No No (needs Vary) Per resource Medium
Media type No No (needs Vary: Accept) Per representation High

For public APIs with many consumers, use the URI path. Its predictability matters more than its coarseness. For internal APIs with a few consumers you can coordinate with, a header works well. Choose media types only when you control the clients and the caching layer.

Alternatives to explicit versioning

Explicit version identifiers are one option among several. The alternatives below trade the cost of running parallel contracts for other costs, and many teams combine them.

  • Additive-only evolution ("versionless" APIs). Never make a breaking change. Add new fields and endpoints, mark old ones deprecated in the contract, and remove them only after measured usage reaches zero. This works well when consumers read tolerantly, at the price of accumulated fields. GraphQL APIs commonly follow this model, using the schema's @deprecated directive and per-field usage tracking instead of version numbers.
  • New resources instead of new versions. When one resource needs an incompatible shape, publish it under a new name (for example, a new endpoint for a redesigned search) and leave the old one alone. This keeps the change local instead of bumping the whole API.
  • Lockstep deployment. When a provider and its consumers share a repository and release pipeline, make the breaking change in both places in one atomic change. This removes versioning entirely, but only works while ownership and deployment stay coupled.
  • Expand, migrate, contract without a version. The parallel-change pattern described later can carry a breaking change across services with no version identifier at all: add the new shape, move consumers, then remove the old shape.
  • Schema registries with compatibility modes for event-driven systems. Instead of versioning endpoints, the registry checks each new message schema against earlier ones under a chosen rule (backward, forward or both) and rejects incompatible schemas at publish time.
  • Capability negotiation. Clients declare which optional behaviors they support, for example through a request parameter or header, and the provider enables new behavior only for clients that opt in. This suits gradual rollouts but adds conditional logic to the provider.

None of these removes the need for a declared contract and CI checks. They change where the cost of an incompatible change lands.

Catching breaking changes in CI with contract tests and schema diffing

Whichever mechanism you choose, it only protects consumers if breaking changes are caught before they ship. Code review is not a dependable filter. A renamed JSON field or a tightened enum looks harmless in a diff, and reviewers rarely know every caller. Move detection into CI so a failing build does the job.

Consumer-driven contract tests. Each consumer records the requests it sends and the response fields it actually reads, then publishes that as a contract. The provider's pipeline replays every published contract against its build. If a change removes a field one consumer depends on, the provider's build fails and names that consumer. A runtime incident would usually leave you to work that out yourself.

Schema diffing. Compare the proposed OpenAPI or .proto schema against the last released version, and fail the build on incompatible changes such as removed fields, changed types or new required parameters. This needs no consumer cooperation, so it covers callers you don't know about.

For Protocol Buffers, the wire format identifies fields by number, not by name. If you reuse a number, old clients will decode new data as the wrong field without any error. When you remove a field, add its number and name to a reserved statement, as in the example below. The compiler will then reject any later attempt to reuse them.

Both checks have limits. Contract tests only cover consumers who have published contracts, so external and unknown callers can still break. When many teams must update contracts before a provider can release, the tests become a coordination bottleneck. Even so, a CI failure costs one author a few minutes. The same breakage found in production pulls several teams into an incident.

// order.proto (proto3)
syntax = "proto3";

package shop.orders.v1;

message Order {
  // Field 2 was `string customer_email` and field 5 was
  // `int32 discount_percent`. Both were removed.
  // Reserving their numbers stops anyone from reusing them. Old clients
  // still decode tag 2 as a string and tag 5 as an int32, so a new field
  // on either number would be silently misread.
  reserved 2, 5;

  // Reserving the old names keeps them from being reintroduced with
  // different meaning. It also protects JSON and text-format consumers,
  // which match fields by name rather than by number.
  reserved "customer_email", "discount_percent";

  string id = 1;
  string customer_id = 3;
  repeated LineItem items = 4;

  // New fields always take a fresh number, never a reserved one.
  // Writing `string promo_code = 5;` would fail to compile.
  string promo_code = 6;
}

message LineItem {
  string sku = 1;
  int32 quantity = 2;
}

A proto3 message that reserves the numbers and names of removed fields, so the compiler rejects any later attempt to reuse them and old clients can't misread the data.

Routing versions and migrating without forking the codebase

Once a second major version exists, you have to decide where requests get split. Routing at the gateway (by path prefix, header or media type) keeps services unaware of versions, but it tends to lead teams into deploying a separate build for each version. Routing inside the service lets one deployment serve every version, which is usually the better outcome.

The pattern that scales is one internal model with adapters at the edge. A v1 request is upgraded to the current domain model on the way in, and the response is converted back to the v1 shape on the way out. Business logic exists once, so a bug fixed there is fixed for every version. Retiring v1 then means deleting an adapter, not a branch of the codebase.

Expand, migrate, contract

When a change spans services or data stores, roll it out as a parallel change. First, expand: add the new field, endpoint or column next to the old one, and have the provider write to both. Next, migrate: backfill the data and move consumers over one at a time. Last, contract: when traffic on the old shape reaches zero, remove it. Each step ships and rolls back on its own, so no single release has to coordinate several teams.

Translation layers have costs too. Adapters pile up as versions accumulate, and each conversion adds latency. The bigger risk is semantic drift. If one status field becomes two in the new version, converting back for old consumers forces a lossy choice, and that choice is where subtle bugs come from. Test every adapter against the old contract as well as the new one.

   v1 client                v2 client
       |                        |
       v                        v
+--------------------------------------------+
| Gateway: route by path, header, media type |
+--------------------------------------------+
       |                        |
+------|------------------------|------------+
|      v     one deployment     v            |
|  +-----------+          +-----------+      |
|  | v1 adapter|          | v2 native |      |
|  | req -> new|          | (no-op)   |      |
|  | resp -> v1|          |           |      |
|  +-----------+          +-----------+      |
|        \                      /            |
|         v                    v             |
|     +------------------------------+       |
|     | Current internal domain model|       |
|     | Business logic, written once |       |
|     +------------------------------+       |
+--------------------|-----------------------+
                     v
          [ Data store: old + new shape ]

EXPAND ----------> MIGRATE ----------> CONTRACT
add new shape,     backfill, move      old traffic = 0,
provider writes    consumers one       remove old shape
to both            at a time
(each step ships and rolls back on its own)

Requests for each version enter one deployment, where edge adapters translate them to a single internal model, while expand, migrate and contract roll out cross-service changes as independently reversible steps.

Deprecation, sunset and the cost of running parallel versions

Deprecation runs in a fixed order. Announce the change with a migration guide. Mark the old version as deprecated in the docs and in every response, for example with Deprecation and Sunset headers that clients can log or alert on. Measure who still calls it. Set a sunset date. Remove it.

The measuring step is where most retirements fail. You can't retire a version whose callers you can't identify. Require a client identity, such as a credential, mTLS identity or mandatory client header, and record request counts per consumer per version. "Traffic on v1 is down 90%" doesn't tell you whether the remaining 10% is a forgotten cron job or your payments service.

Delay has a real cost because each live version adds work that compounds:

Illustrative estimate (assumed numbers, not a benchmark)

Annual hours per extra version = (weekly test/CI upkeep × 52) + (incidents/yr × hours each) + (security patches/yr × hours each)

With assumed inputs of 3 h/week, 12 incidents at 4 h and 8 patches at 6 h, one version costs 156 + 48 + 48 = 252 hours. Two legacy versions cost about 500 hours, roughly a quarter of an engineer-year at 2,000 h/year. Documentation drift is not included.

Sunset dates will slip when a critical consumer can't migrate in time. Plan for that in the policy before it happens. Grant exceptions only in writing, with a new date and a named owner on the consumer side. Decide ahead of time who can enforce removal, typically an API governance group or the platform lead. Without that authority, every deadline becomes a negotiation.

Keeping parallel versions running is a legitimate choice, but treat it as a cost you agree to pay on purpose. Don't let it become the default.

How a versioning discipline saves engineering time

The time savings come from moving work earlier and doing it once, rather than from versioning itself. Each practice in this article removes a specific kind of rework:

  • Fewer versions to run. A precise definition of "breaking," plus tolerant readers, lets most changes ship in place. Every version you don't create is one you never have to test, patch, document or retire.
  • Breaks found by one person, not many. Schema diffing and contract tests turn a production incident involving several teams into a failed build that the change's author fixes before merging.
  • No guessing who is affected. A failing consumer contract names the consumer. Per-consumer traffic metrics show who still needs to migrate, so nobody has to search logs or send organization-wide emails to find callers.
  • Fixes applied once. With a single internal model and edge adapters, a bug fix or security patch lands in one place for every version, instead of being backported across forked branches or separate deployments.
  • Independent releases. Expand, migrate and contract lets the provider and each consumer ship on their own schedules, so no release waits for a coordinated cutover across teams.
  • Bounded legacy cost. A deprecation policy with measured traffic and enforced sunset dates puts a limit on how long each old version consumes maintenance time.

Illustrative estimate (assumed numbers, not a benchmark)

Assume a breaking change that reaches production costs 3 engineers 4 hours each to diagnose, roll back and coordinate a fix (12 hours), while the same change caught by a CI check costs its author 0.5 hours. If CI catches an assumed 6 such changes a year, that is 6 × (12 − 0.5) = 69 hours of incident work avoided, before counting the effect on consumers' own users. Your inputs will differ, and the useful exercise is plugging in your own incident history.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to microservice api versioning, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.