Everything we have built in this module (the REST contracts from 03-01, the event envelope and payloads from 03-02, Inventory's .proto and the GraphQL schema from 03-03) has something in common: they are contracts between teams that are going to evolve. The Shopping Experience team will want to add attributes to products; the Orders team will want a new field in order.created; someone will decide that price as a bare number was a mistake and that it should carry a currency. Each of those changes can silently break a consumer nobody warned. In 02-01 we said the only acceptable coupling between services is contract coupling; this lesson explains how to manage that coupling so that a change in one service does not become an incident in another.

We will look at the contract as the boundary between teams, which changes are compatible and which are not, the tolerance rule (Postel) that avoids most breakages, the REST versioning strategies and TechCorp's choice, the lifecycle of a version (deprecation, coexistence, retirement), event versioning (the envelope's version field, compatible evolution, upcasting), Protobuf and GraphQL versioning, the design-first approach with OpenAPI and AsyncAPI, a conceptual introduction to consumer-driven contract tests, and a complete guided example: evolving GET /products first compatibly and then incompatibly, with its coexistence period and Orders' migration plan. Implementing the contract tests with Pact belongs to 04-05.

Contents

  1. The contract as the boundary between teams
  2. Compatible and incompatible changes
  3. The tolerance rule: tolerant consumer, conservative producer
  4. REST versioning strategies and TechCorp's choice
  5. Lifecycle of a version
  6. Event versioning
  7. Protobuf and GraphQL versioning
  8. Contract first: OpenAPI and AsyncAPI
  9. Consumer-driven contract tests (introduction)
  10. Guided example: evolving GET /products

  1. The contract as the boundary between teams

A contract is everything a consumer can legitimately depend on: the shape of the request and the response, the status codes, the fields and their types, the semantics of each one, the error codes (code), the headers, the ordering (or not) of events, the values of an enum. What is not the contract: the implementation, the database, the order of keys in the JSON, the text of detail, undocumented fields.

In microservices the contract is the only contact surface between teams, and that is why it concentrates two opposing tensions:

  • The producer (Catalog) wants to change its API when its business changes, without asking for permission.
  • The consumers (Orders, the mobile BFF, a partner's ERP) want nothing to change without notice, because every change is work and risk for them.

The context map from 02-03 tells us who is in charge in each relationship: in an open host service with a published language (Catalog → Orders), the producer publishes and consumers adapt, but with evolution rules that this lesson fixes; in customer-supplier (Customers → Orders), the consumer has a say in the contract; in partnership (Orders ↔ Inventory), they negotiate it together. In every case, the discipline is the same: compatible changes are made freely; incompatible ones require a new version and a coexistence period.

  1. Compatible and incompatible changes

A change is backward compatible if a consumer written against the previous version keeps working without being touched. It is the only property that matters.

Change Compatible? Why Example at TechCorp
Adding an optional field to the response Yes (if the consumer ignores the unknown) The old consumer does not read it Adding attributes to GET /products
Adding an optional field to the request Yes The old consumer does not send it and the server assumes the default POST /orders accepts an optional notes
Adding a new endpoint or method Yes Nobody was using it POST /orders/{id}/cancellation
Adding a value to an enum in the response Depends (formally incompatible) A consumer with an exhaustive switch on statuses breaks with a new value Adding OUT_FOR_DELIVERY to the order statuses: the front end that maps statuses to texts shows undefined
Adding a new event Yes Nobody is subscribed order.shipped
Renaming a field No The consumer reads the old name and gets undefined priceunitPrice
Changing the type of a field No price: 59.90price: {amount, currency} breaks any total += price The example in section 10
Removing a field or an endpoint No The consumer needs it Removing available from GET /products
Making an optional request field required No Previously valid requests start returning 400 Requiring country in shippingAddress
Making a required response field optional (nullable) No The consumer assumes it always comes name can now be null
Changing the semantics without changing the shape No, and it is the worst Nothing fails at compile time or in tests; the business fails price goes from "excluding VAT" to "including VAT"; total stops including shipping
Changing a status code or an error code No Clients switch on them OUT_OF_STOCK from 409 to 422
Changing the URL of a resource No Saved links and client code point at the old one /products/items
Tightening a validation No Requests that used to pass now fail Lowering the maximum number of lines from 100 to 50
Relaxing a validation Yes Nothing that passed stops passing Raising the maximum from 50 to 100

Mnemonic rule: adding is (almost always) compatible; removing, renaming, changing type or meaning is not. And the semantic change deserves special attention because no tool detects it: only communication between teams and contract tests do.

  1. The tolerance rule: tolerant consumer, conservative producer

The robustness principle (or Postel's law, from the TCP RFC): be conservative in what you send and liberal in what you accept. In APIs it translates into two disciplines that, together, make most changes compatible without versioning anything:

The consumer is tolerant (tolerant reader):

  • It ignores fields it does not know. It never validates "the JSON must have exactly these fields". That way, when Catalog adds attributes, Orders' productTranslator does not even notice.
  • It reads only what it needs. If Orders uses id, name, price and available, its code touches nothing else and depends on nothing else.
  • It does not depend on the order of fields, or of an object's keys, or of a list's elements unless the contract guarantees it.
  • It handles enums with a default case: an unknown status does not break the application; it is shown as is or a warning is logged.
  • It does not fail on absent optional fields: product.attributes ?? {}.

The producer is conservative:

  • It always sends what it promised, in the promised type, even if the value is empty ([], not absence; null only if the contract allows it).
  • It does not reuse names with another meaning.
  • It adds, it does not change. If it needs price with a currency, it adds detailedPrice and keeps price until it retires the version (section 10).
  • It documents what it adds (OpenAPI/AsyncAPI) the same day it deploys it.

Example of a tolerant reader in Orders' productTranslator (the ACL from 02-03):

// translators/productTranslator.js (orders-service)
// Converts Catalog's public JSON into Orders' internal model.
// It only touches the fields Orders needs; everything else is ignored (tolerant reader).
function toOrdersProduct(dto) {
  return {
    productId: dto.id,
    name: dto.name,
    unitPrice: Number(dto.price),             // Number() in case it ever arrives as a string
    available: dto.available !== false        // absent → we assume available; explicit false → not
  };
}

With this translator, Catalog can add ten fields without Orders changing a single line. What does not survive is price becoming an object: that is no longer tolerance, it is a new version.

  1. REST versioning strategies and TechCorp's choice

When the change is incompatible and necessary, you have to serve two versions at once for a while. Ways to indicate the version:

Strategy Example Advantages Drawbacks
In the URI GET /v1/products, GET /v2/products Visible, trivial to route (the gateway sends /v2/* wherever it wants), easy to test with curl, cacheable by URL "Pollutes" the URI (purists: the version is not part of the resource); duplicates documentation; tempts you to version the whole API for one endpoint
In the Accept header (content negotiation) Accept: application/vnd.techcorp.products.v2+json Clean, stable URIs; version per representation, not per API; lets you version a single resource Invisible in the URL (hard to debug and to cache); clients forget the header and receive the default version without knowing; header-based routing in the gateway
Custom header X-Api-Version: 2 Simple Non-standard; same invisibility problems
Query parameter GET /products?version=2 Easy to test Mixes version with filters; gets lost in links; uncommon
No version (compatible evolution only) Zero complexity No way out when an incompatible change is unavoidable

TechCorp's choice, aligned with majority practice:

  • Major version in the URI: /v1/products, /v2/products. Reason: it is the most visible, the easiest to route in the gateway from 03-04 (/api/v2/products → it can even go to a different deployment) and the one that produces the fewest silent errors in consumers (there is no "default version" that sneaks in by forgetting a header).
  • Major versions only. There is no /v1.2/: within /v1/ the API only evolves compatibly (section 2). A new version is a rare, planned event, not a routine increment.
  • The version is per service, not global: Catalog can be on /v2/ and Orders on /v1/. Each team versions its own contract.
  • All the contracts from 03-01 now carry /v1/: POST /v1/orders, GET /v1/products?ids=, GET /v1/customers/{id}, POST /v1/reservations. In the gateway: /api/v1/orders/*orders-service:3002/v1/orders/*. Until now we omitted it for clarity; from this lesson on it is part of the contract.

  1. Lifecycle of a version

A new version does not replace the previous one overnight: it coexists with it and retires it in an orderly fashion.

stateDiagram-v2
    [*] --> Active: publish /v2/
    Active --> Deprecated: announce retirement of /v1/ (Deprecation/Sunset headers)
    Deprecated --> Retired: Sunset date reached and traffic ~0
    Retired --> [*]: /v1/ responds 410 Gone

Stages and practices:

  1. Publication of v2. v1 remains active and unchanged. It is announced to the known consumers (internal channel, API changelog) with the migration guide.
  2. Deprecation of v1. v1 keeps working but warns in every response with two standard headers:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 28 Feb 2027 00:00:00 GMT
Link: <https://docs.techcorp.example/api/catalog/v2/migration>; rel="successor-version"

Deprecation (RFC 9745) says "this is going to be retired"; Sunset (RFC 8594) says when it will stop responding; Link rel="successor-version" points to where to migrate. Well-built clients log a warning when they see Deprecation. 3. Coexistence window. For TechCorp's internal consumers, a minimum of two deployment cycles of every affected team (in practice, 4-8 weeks). For external consumers (the partners' ERP), a minimum of 6 months by commercial contract. During the window, both versions are tested and deployed. 4. Usage metrics per version. The gateway (03-04) labels every request with its version and exposes a counter (requests_total{service="catalog",version="v1"}, in the format from 06-01). A version is not retired until its traffic is zero or the last consumers are identified and notified. Without metrics, retiring is guessing. 5. Retirement. /v1/* responds 410 Gone (not 404: the resource existed and was retired on purpose) with an RFC 7807 problem linking to v2. After a few weeks the code is deleted.

And a cost rule: every active version is code to maintain, test and deploy. Two versions at once is normal; three is a sign that retirements are not happening.

  1. Event versioning

Events are even more delicate contracts than APIs, for two reasons: the producer does not know who consumes (03-02), so it cannot notify anyone in particular, and events can sit in a queue (or a DLQ) for hours or days and be processed by a consumer newer or older than the producer that emitted them.

The tools:

  • The envelope's version field (02-05): { eventId, type, version: 1, occurredAt, payload }. It is the version of the payload schema for that event type. It is incremented only on incompatible payload changes.
  • Compatible payload evolution (same version): adding optional fields, adding values to lists. Tolerant consumers (section 3) notice nothing. Example: adding salesChannel: "web" | "mobile" to order.created is version: 1 with one more field.
  • Incompatible change → version: 2, and the producer publishes only the new one (publishing both would duplicate processing). Consumers must be able to read both while there are v1 events in circulation.
  • Upcasting in the consumer: upon receiving an event, the consumer runs it through a chain of functions that convert each old version to the next, up to the current one, and the rest of the code only knows the latest. It is the cleanest way to support N versions without if (version === 1) scattered throughout the handler.
// messaging/upcasters/orderCreated.js (in every consumer of order.created)
// Each function converts the payload from version N to N+1. They are applied in a chain.
const upcasters = {
  // v1 → v2: in v2 'total' became an object {amount, currency} and the lines carry 'currency'
  1: (payload) => ({
    ...payload,
    total: { amount: payload.total, currency: 'EUR' },
    lines: payload.lines.map(l => ({ ...l, unitPrice: { amount: l.unitPrice, currency: 'EUR' } }))
  })
  // 2: (payload) => ... when v3 exists
};

const CURRENT_VERSION = 2;

function normalizeOrderCreated(envelope) {
  let { version, payload } = envelope;
  while (version < CURRENT_VERSION) {
    const upcast = upcasters[version];
    if (!upcast) throw new Error(`Don't know how to convert order.created v${version}`);
    payload = upcast(payload);
    version++;
  }
  if (version > CURRENT_VERSION) {
    // A producer newer than me: if the payload is a compatible superset, carry on; if not, DLQ (03-02)
    console.warn('order.created with a version higher than the known one', { version, eventId: envelope.eventId });
  }
  return payload; // always in the CURRENT_VERSION shape
}

Inventory's handler calls normalizeOrderCreated(envelope) and always works with the v2 shape, whatever it receives.

  • When a new event instead of a new version. If the meaning changes, it is not a version: it is another event. order.created v2 is still "an order has been created" with another shape; if what you want to communicate is "the order has left the warehouse", that is order.shipped, even if the payload looks similar. Rule: same semantics and different shape → new version; different semantics → new type. A new event is also advisable when the payload changes so much that upcasting would mean inventing data the old producer never had.

And an operational rule that follows from the topology of 03-02: since the routing key is the type (order.created), the version does not go in the routing key. Using order.created.v2 as the routing key would break every consumer's bindings and force them to subscribe to each version: exactly the opposite of what we want.

  1. Protobuf and GraphQL versioning

Protocol Buffers (03-03) is designed for compatible evolution, with very specific rules:

  • What identifies a field in the binary is its number, not its name. Renaming product_id to product_ref is compatible (only the generated code changes); changing the number is not and reusing a deleted number is catastrophic: old messages would be interpreted with the new type/meaning.
  • Adding a field with a new number is compatible: old receivers ignore it, new ones see the default value in old messages.
  • Removing a field: it is deleted from the .proto and its number (and its name) are marked as reserved so that nobody reuses them.
  • Changing the type is only safe between wire-compatible types (int32/int64/bool, string/bytes with UTF-8); in practice, treat it as incompatible.
  • Incompatible service changes (an rpc's signature) → a new package (techcorp.inventory.v2) that coexists with the previous one.
message ReservationRequest {
  string order_id = 1;
  repeated ReservationLine lines = 2;
  int32 expires_in_seconds = 3;
  // 'string warehouse = 4' was removed in 2026-09: never reuse the number or the name
  reserved 4;
  reserved "warehouse";
  string sales_channel = 5;          // added in 2026-09, compatible
}

GraphQL (03-03) takes the opposite path to versioning: there are no schema versions. The philosophy is continuous evolution of the graph:

  • Adding types, fields, optional arguments and enum values is compatible (clients request only what they know: zero over-fetching means a new field reaches nobody who does not ask for it).
  • A field that must be retired is marked with @deprecated(reason: "..."); the clients' tools show it, and the server can measure exactly who still requests it (every query declares its fields), which makes retirement much safer than in REST.
  • Type changes are made by adding a new field (detailedPrice: Price!) and deprecating the old one.
type Product {
  id: ID!
  name: String!
  price: Float! @deprecated(reason: "Use detailedPrice; retired on 2027-02-28")
  detailedPrice: Price!
  currency: String! @deprecated(reason: "Included in detailedPrice")
}
type Price { amount: Float!, currency: String! }

  1. Contract first: OpenAPI and AsyncAPI

Writing the contract before the code (design-first, contract-first) changes the dynamic between teams: Catalog and Orders agree on the YAML for GET /v1/products in an hour, and from that moment one implements the server, the other the client (with a mock generated from the contract) and they meet at integration with the shape already agreed. The contract is also where a change is reviewed: a pull request against the YAML is the natural place for a consumer to say "this breaks me".

For REST we already saw OpenAPI 3 (03-01). For events there is its equivalent: AsyncAPI, which describes channels (in RabbitMQ, exchanges and routing keys), messages and their schemas. Fragment for order.created:

asyncapi: 3.0.0
info:
  title: TechCorp - Orders Events
  version: 1.2.0            # version of the DOCUMENT; each event's schema version goes in the envelope
servers:
  rabbitmq:
    host: rabbitmq:5672
    protocol: amqp
channels:
  orderCreated:
    address: order.created          # routing key on the techcorp.events exchange (03-02)
    messages:
      orderCreated:
        $ref: '#/components/messages/OrderCreated'
    bindings:
      amqp:
        is: routingKey
        exchange: { name: techcorp.events, type: topic, durable: true }
operations:
  publishOrderCreated:
    action: send
    channel: { $ref: '#/channels/orderCreated' }
    summary: Orders publishes this event upon accepting an order (POST /v1/orders → 202)
components:
  messages:
    OrderCreated:
      name: order.created
      contentType: application/json
      payload:
        $ref: '#/components/schemas/OrderCreatedEnvelope'
  schemas:
    OrderCreatedEnvelope:
      type: object
      required: [eventId, type, version, occurredAt, payload]
      properties:
        eventId: { type: string, example: evt-3f9c... }
        type: { type: string, const: order.created }
        version: { type: integer, example: 1 }
        occurredAt: { type: string, format: date-time }
        payload:
          type: object
          required: [orderId, customerId, customer, shippingAddress, lines, total]
          properties:
            orderId: { type: string, example: ord-88213 }
            customerId: { type: string, example: c-1024 }
            customer:
              type: object
              required: [email, name]
              properties:
                email: { type: string, format: email }
                name: { type: string }
            shippingAddress: { $ref: '#/components/schemas/Address' }
            lines:
              type: array
              minItems: 1
              items:
                type: object
                required: [productId, name, quantity, unitPrice]
                properties:
                  productId: { type: string }
                  name: { type: string }
                  quantity: { type: integer, minimum: 1 }
                  unitPrice: { type: number }
            total: { type: number, example: 79.70 }
            salesChannel: { type: string, enum: [web, mobile], description: "Added in v1 (compatible, optional)" }

How it is used: the document lives in the Orders repository (the producer), consumers read it to generate validators or stubs, and every change goes through review. Just as with OpenAPI, each service publishes its own AsyncAPI for the events it produces.

  1. Consumer-driven contract tests (introduction)

Documenting the contract does not guarantee honoring it. Consumer-driven contract tests (with Pact as the reference tool) close that gap:

  1. The consumer (Orders) writes, in its own tests, the interactions it expects from the producer: "when I request GET /v1/products?ids=p-501 I expect 200 with an object that has data[0].id, name (string), price (number) and available (boolean)". Only the fields it uses, not the whole response (tolerance, again).
  2. From those tests a pact file (JSON) is generated and published to a pact broker.
  3. In the producer's CI (Catalog), the pacts of all its consumers are downloaded and verified against the real service: if Catalog changes price to an object, Orders' pact fails in Catalog's CI, before deploying.

The valuable part: the producer knows exactly which fields each consumer uses (it can fearlessly retire what nobody pacts on) and an incompatible change is detected where it originates. They are implemented in 04-05; here it is enough to know they exist and that they are the safety net for everything above.

  1. Guided example: evolving GET /products

Initial situation (03-01, now with /v1/):

{ "data": [ { "id": "p-501", "name": "BT X200 Headphones", "price": 59.90, "currency": "EUR", "available": true } ], "notFound": [] }

Consumers: Orders (productTranslator: uses id, name, price, available), the mobile BFF (also uses imageUrl) and the ERP of two partners.

Step 1: add attributes (compatible)

The catalog in MongoDB already stores attributes (02-04) and the web wants to display them. Change: add an optional field to the response.

{ "id": "p-501", "name": "BT X200 Headphones", "price": 59.90, "currency": "EUR", "available": true,
  "attributes": { "color": "black", "connection": "Bluetooth 5.3", "batteryHours": 30 } }

Procedure: (1) pull request against Catalog's OpenAPI adding attributes as an optional object with additionalProperties; (2) consumers do nothing (tolerant readers: Orders ignores it, the BFF uses it whenever it wants); (3) it is deployed on /v1/; (4) the pacts of Orders and the BFF keep passing because they only check the fields they use. Cost for the other teams: zero. This is what 95% of changes should be.

Step 2: price goes from a number to an object {amount, currency} (incompatible)

TechCorp is going to sell in Portugal and the United Kingdom; a price without its currency attached is a source of errors, and currency as a sibling field gets forgotten. It is decided that price becomes { "amount": 59.90, "currency": "EUR" }. Changing a field's type is incompatible: Number(dto.price) in Orders would yield NaN and a partner's ERP would add up objects.

There is a compatible option that is considered first: add detailedPrice: {amount, currency} and keep the numeric price forever. It is what GraphQL would do. Catalog rejects it for two legitimate reasons: there would be two fields with the same data (a source of inconsistencies) and a numeric price without a currency is precisely the model that is to be forbidden. So v2.

Plan:

  1. Design of v2 (week 0): OpenAPI for /v2/products with price as an object and no loose currency; the opportunity is taken to make imageUrl required (another incompatible change grouped into the same version: major versions are expensive, better few and with several changes). Review with Orders, BFF and the partners.
  2. Implementation (weeks 1-2): Catalog serves /v1/ and /v2/ from the same code; internally the model is the new one and a backward translation layer generates the v1 shape (price: amount, currency). That way v1 is not a frozen code branch, but a view.
  3. Publication of v2 and deprecation of v1 (week 2): /v1/products starts responding with Deprecation: true, Sunset 6 months out (because of the external partners) and Link to the migration guide. The gateway adds the /api/v2/products/* route and labels the metrics by version.
  4. Migration of Orders (weeks 3-4), the critical internal consumer:
// translators/productTranslator.js — version that consumes /v2/products
function toOrdersProduct(dto) {
  return {
    productId: dto.id,
    name: dto.name,
    unitPrice: Number(dto.price.amount),
    currency: dto.price.currency,          // Orders starts storing the currency in order_lines (new column, optional)
    available: dto.available !== false
  };
}

The migration is: change the CATALOG_URL base from /v1 to /v2 (or the path in the client), update the translator, update Orders' pact against /v2/, and a schema migration in Orders (currency column, default EUR, compatible). It is deployed with the strategy from 05-04 and the error ratio of POST /orders is watched. 5. Migration of the mobile BFF (week 4) and formal notice to the partners (month 1) with the Sunset date. 6. Follow-up (months 2-6): the dashboard from 06-01 shows requests_total{version="v1"} going down. In month 5, one partner is still on v1: they are contacted directly (the per-client-token metrics say who it is). 7. Retirement (month 6): /v1/products responds 410 Gone with an RFC 7807 problem (code: VERSION_RETIRED, detail with the v2 URL). A month later the backward translation layer is deleted.

What made it possible for a type change in the most-used field of the API not to cause a single incident: a contract written and reviewed before coding, tolerant consumers that only depend on what they use, major version in the URI with two versions coexisting, deprecation headers, per-version metrics and pacts that would have failed in CI if anyone had skipped the order.

Common Mistakes and Tips

  • Strictly validating someone else's response ("reject if there are unknown fields"). It turns every new producer field into a consumer breakage. Tolerant reader always.
  • Changing semantics without changing shape. The most dangerous change because no automated test sees it. If price starts including VAT, it is a new version (or a new field), even if it is still a number.
  • Versioning out of habit (/v1.3/, /v1.4/). Every version is cost; within a major version only compatible evolution.
  • Putting the version in the events' routing key. It breaks every binding. The version goes in the envelope.
  • Publishing an event in two versions at once. It duplicates processing in every consumer. The new one is published; consumers upcast.
  • Reusing field numbers in Protobuf. reserved always when deleting.
  • Retiring a version without metrics. "Nobody uses v1" is a hypothesis until a counter confirms it.
  • Deprecation header without Sunset. A warning without a date moves nobody.
  • Contract written after the code. It goes stale in the first iteration and stops being useful for review. First the YAML, then the code, and CI checking that they match.
  • Documenting only what you return today without saying what is contract and what is not. Make it clear in OpenAPI/AsyncAPI that undocumented fields do not exist and that order does not matter.

Exercises

Exercise 1. Classify each proposed change to the Orders API as compatible or incompatible, indicate what the team would do (deploy on /v1/, add a field, new version) and which TechCorp consumer could break: (a) POST /v1/orders accepts a new optional field coupon; (b) GET /v1/orders/{id} stops returning history because it is expensive to compute; (c) the status PAID is renamed to CHARGED; (d) the status OUT_FOR_DELIVERY is added to the state machine; (e) total starts including shipping costs; (f) Idempotency-Key goes from required to optional.

Exercise 2. The Orders team wants to add to order.created a billing: { taxId, companyName } block for business customers (optional) and, in addition, change shippingAddress.country from a two-letter ISO code ("ES") to a full name ("Spain"). Decide for each change whether it is a new envelope version, and if so write the upcaster that Notifications (which prints the country in the email) would need to go from the old version to the new one. Then argue whether the second change should be made at all.

Exercise 3. An external partner consumes GET /v1/products from its ERP and, six weeks before the Sunset date, warns that it will not manage to migrate. Propose three options (with pros and cons) that TechCorp could offer without breaking the rule of "one active version per service in the long run", and say which one you would recommend.

Solutions

Solution 1.

Change Compatible? Action Who breaks if done wrong
a Optional coupon field in the request Yes Deploy on /v1/; document in OpenAPI Nobody
b Removing history from the response No Compatible alternatives: keep it and compute it on demand with ?include=history (new optional parameter, defaulting to with history so as not to break), or move it to GET /v1/orders/{id}/history while keeping the field until a v2 The web (shows the order timeline)
c Renaming PAIDCHARGED No (changes an enum value that clients compare against) Do not do it; if unavoidable, v2 Web, mobile BFF, any status switch
d Adding OUT_FOR_DELIVERY Formally incompatible (new enum value) Can be done on /v1/ if the contract already said "new statuses may appear; handle them with a default case" and consumers comply; notify and check the BFF and the web beforehand Front ends with an exhaustive status mapping
e total includes shipping No (semantic change) Add totalWithShipping and shippingCost as new fields and leave total as it was; or v2 Orders↔Payments (the amount to charge), the partners' ERP, accounting: and without any visible error
f Idempotency-Key becomes optional Yes (relaxing a validation) Deploy on /v1/ (although it is a bad design idea: 02-05 wants it required) Nobody breaks; a guarantee is lost

Solution 2.

  • Optional billing: compatible, same version: 1. Tolerant consumers ignore it; Notifications will be able to use it for the email whenever it wants. It is documented in the AsyncAPI.
  • country from "ES" to "Spain": incompatible (it changes the format/semantics of an existing field): version: 2. Notifications' upcaster:
const COUNTRY_NAMES = { ES: 'Spain', PT: 'Portugal', GB: 'United Kingdom', FR: 'France' };
const upcasters = {
  1: (payload) => ({
    ...payload,
    shippingAddress: { ...payload.shippingAddress, country: COUNTRY_NAMES[payload.shippingAddress.country] ?? payload.shippingAddress.country }
  })
};

Should it be done? No. The ISO code is the interoperable format (Customers uses it, the payment providers use it, the carriers use it); a name in one language is good for nothing but printing, and that is a presentation responsibility of Notifications (which can have its own table of names, or receive countryName as an additional compatible field). Compatibility would be broken, every consumer would be forced to upcast, and information (the code) would be lost for one party's convenience. The right answer to the Orders team: add an optional shippingAddress.countryName if it is really needed, and stay on version: 1.

Solution 3.

  1. Extend v1's Sunset for everyone (e.g. 3 more months). Pros: simple, no exceptions. Cons: keeps the cost of two versions for everyone because of a single consumer; sets a precedent.
  2. Per-client exception: v1 keeps responding only for that partner's token (the gateway routes by identity; for everyone else, 410). Pros: the window closes for everyone else; the cost is bounded; it pressures the partner with a new, firm date. Cons: exception logic in the gateway/service; it has to be removed afterwards.
  3. Temporary adapter for the partner: a small BFF (03-04) or middleware that translates v2 → v1 shape (price.amountprice) for its ERP, deployed by TechCorp or delivered to the partner as a library. Pros: Catalog retires v1 on schedule; the translation is trivial. Cons: it is one more component; only worthwhile if the translation is mechanical (here it is).

Recommendation: option 2 with a short, non-negotiable deadline, or option 3 if the commercial relationship justifies it and the translation is as simple as in this case. Option 1 penalizes everyone for the sake of one. In any case, per-version and per-client metrics are what allow the decision to be made with data rather than assumptions.

Conclusion

The contract is the only boundary between teams and, well managed, the one that lets each team deploy at its own pace. We have separated compatible changes (adding optional fields, endpoints, events) from incompatible ones (renaming, changing type, removing, tightening validations and, the most treacherous, changing semantics), and we have seen that the tolerance rule (a consumer that ignores the unknown, a producer that only adds) absorbs the vast majority of evolution without versioning anything. For the rest: major version in the URI (/v1/, /v2/) with compatible evolution within each, a lifecycle with Deprecation/Sunset, coexistence window and usage metrics before retiring; the envelope's version field and upcasting in consumers for events (and a new event when the meaning changes); field numbers and reserved in Protobuf; per-field @deprecated and no versions in GraphQL; contract first with OpenAPI and AsyncAPI; and consumer-driven contract tests as the safety net. The GET /products example walked the whole path, from adding attributes without anyone noticing to changing price to {amount, currency} with six months of coexistence and the orderly migration of Orders.

This concludes the communication module: we know how to design REST APIs with their contracts, codes and uniform errors; publish and consume events on RabbitMQ with at-least-once guarantees; when to turn to gRPC or GraphQL; what the API Gateway on port 8080 does and does not do and why BFFs exist; how services find and balance each other with Kubernetes DNS and health checks; and how all those contracts evolve without breaking anyone. What does not exist yet is the code of a complete service: so far we have written loose routes, publishers and consumers. In module 4 we will choose the concrete tools of the stack, build a microservice from scratch (project structure, configuration, startup, health), truly connect it to the other services and to RabbitMQ following these contracts, and give it unit, integration and contract tests with Pact. It starts with choosing technologies and tools.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved