Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 40

    API Design, Versioning and Contracts

    A partner's nightly job pulled TechLearningPro orders, stored the JSON, and generated invoices. Checkout renamed 'total' to 'amountMinor' in a release it called minor, because the

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    API Design, Versioning and Contracts

    Advanced System Design & Software Architecture · Lesson 40 · Microservices and Enterprise Architecture

    Lesson Overview

    A partner's nightly job pulled TechLearningPro orders, stored the JSON, and generated invoices. Checkout renamed total to amountMinor in a release it called minor, because the OpenAPI diff "only changed a field name" and the partner's contact had agreed in a meeting. The job wrote zero-value invoices until finance noticed. The gateway from API Gateway and Backend for Frontend was healthy. Payment's Service from lesson 39 was healthy. The bytes on the public path had changed their meaning, and nothing in the partner's pipeline had a copy of the expectation.

    This lesson designs that public order API: resources, pagination, errors, compatibility, versioning, and the tests that fail the provider's build when a consumer's expectation breaks. API Design for Services, REST in Microservices, API Versioning, and Contract Testing introduce the vocabulary. The work here is the policy TechLearningPro will enforce: URI major versions for the partner API, additive changes inside a version, problem documents instead of { "success": false }, opaque cursors instead of offset pages, and a consumer-driven contract that runs against the provider. Internal service calls stay on the ports from lesson 38 and do not grow a public version for every refactor.

    An OpenAPI document is necessary and it is not a contract with a consumer. It is the provider's claim. A generated client will compile against the claim and still break when a required field appears or an enumeration gains a value the consumer's switch does not handle. Pact, on the JVM, lets the consumer publish the interactions it actually depends on. The provider verification replays them. Spring Cloud Contract, by contrast, starts from the provider's stubs. Both tools are real. They point in opposite directions, and picking the wrong direction is how a green stub build ships a partner-breaking payload.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Model the public order resource so it exposes saga state and snapshotted money without leaking another service's tables.
    2. Choose cursor pagination, problem documents, and idempotency rules that match lessons 27, 32, and 37.
    3. Classify a change as compatible or breaking, including the special case of a new enumeration value.
    4. Compare URI versions, date or header versions, and vendor media types, and defend URI major versions for the partner API.
    5. Apply expand-and-contract so a field rename does not require a flag day.
    6. Explain why an OpenAPI file does not prove a consumer can read the payload, and what a Pact verification adds.
    7. State the limits of contract tests: they do not prove seat allocation, capture-once, or authorization.

    Prerequisites

    • Lesson 34's gateway and BFF. Partners and the web app enter there. This lesson is the representation behind the route, not another gateway product.
    • Lesson 32's order saga states and lesson 37's idempotency key. Idempotency and Exactly-Once Processing defined the key. The API publishes how a caller sends it and what a conflict status means.
    • Lesson 27's rejection of large offsets. Database Design and Indexing for Scale showed why OFFSET walks rows the client will throw away. The API must not reintroduce that walk as ?page=.
    • Lesson 38's rule that payment is not a public service. The public resource is the order. Capture status is a field or a problem type on that resource.
    • Spring MVC and Spring Framework 6's ProblemDetail. You will bind it, not invent an error envelope.

    Detailed Core Concepts

    The resource is the shopper's order, not the database

    GET /v1/orders/{orderId} returns the shopper-facing order: id, tenant, state from the saga, lines with snapshotted amounts, currency, and the payment id. It does not return a provider payload, a seat-row lock, or a join across schemas. State values are the ones lesson 32 named: started, seats_reserved, payment_uncertain, payment_captured, completed, compensated, compensation_failed. Partners that only understand pending and paid will mis-bill a compensated order. The contract lists the states. Adding a state is a compatibility event, discussed below, not a silent string.

    A capture is not a new public microservice. POST /v1/orders starts a saga. The body carries the course id, the idempotency key, and the price the client believes it is paying. Checkout recomputes or accepts the snapshot under the rule in lesson 38. The response is the order representation and a Location. A decline is not an HTTP 500. It is an order in a terminal compensated state, or a problem document whose type URI is https://api.techlearningpro.com/problems/payment-declined, with 409 or 422. HTTP 402 exists in the status registry and is a poor signal: client libraries treat it as a generic failure. Prefer a documented problem type over a rare status code nobody branches on. HTTP 503, with Retry-After when you know a delay, means the dependency is unavailable and the caller may retry a read or an idempotent start. It does not mean the card was declined.

    Authorization is part of the contract. The caller sees orders for the tenant the token grants. A sequential numeric id is an enumeration tool; order ids are UUIDs. A response that includes another learner's email because the DTO reused an internal entity is a broken contract even if the JSON schema matches. The representation is a type you wrote for the outside, not OrderEntity with @JsonIgnore sprinkled until the tests pass.

    Pagination that matches the index

    Order history is GET /v1/orders?limit=20&cursor=. The cursor is opaque. It encodes the sort key you already index, typically (created_at, order_id) from lesson 27, and the service resumes with a keyset predicate. limit has a maximum of 100. Offset and page number are not accepted on this resource. They invite a partner to ask for page 400 of a tenant with a million rows, and the database will walk those rows. A partner who needs a bulk extract uses an export job, not a tighter loop on page.

    Cursors are stable for a sort. If you change the sort without changing the version, old cursors fail. Return 400 with a problem type cursor-invalid and let the client restart at the first page. Do not return an empty list. Empty means "no more orders," and a partner will stop a backfill early.

    Errors are documents

    RFC 9457 problem details, application/problem+json, is the error representation. Spring Framework 6 builds it with ProblemDetail. Boot maps exceptions to that shape when spring.mvc.problemdetails.enabled=true.

    java
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
    HttpStatus.CONFLICT,
    "Idempotency key was reused with a different request body");
    problem.setType(URI.create("https://api.techlearningpro.com/problems/idempotency-conflict"));
    problem.setTitle("Idempotency conflict");
    problem.setProperty("idempotencyKey", key);
    return ResponseEntity.of(problem).build();

    type is a stable identifier, not a prose sentence. Clients branch on type. They may log detail, which is for humans and may change. Do not put the only copy of a machine-readable reason inside detail. Validation failures use 400 and a type such as invalid-body, with field errors in an extension property. 401 means unauthenticated. 403 means authenticated and not allowed to see this order. 404 means the order is not visible to this caller; do not use a different code for "exists but not yours," or you have built an oracle.

    Unknown fields in requests are rejected or ignored by a written rule. Jackson's default is to ignore unknown properties. For a POST that moves money, failing unknown fields is safer: a client that misspelled amountMinor should not create a free order because the amount was dropped. Configure the request reader to fail on unknown properties for the command body. Responses remain tolerant: clients ignore fields they do not understand.

    Compatibility

    Inside a major version the provider may:

    • Add an optional JSON field on a response.
    • Add an endpoint.
    • Widen an accepted input (accept a new optional query parameter).
    • Add a problem type for a case that used to be a generic 500, if existing clients already treat unknown 4xx as failure.

    The provider may not, without a new major version:

    • Remove or rename a field clients were promised.
    • Change a field's type or unit (total in major units versus amountMinor).
    • Turn an optional request field into a required one.
    • Change the meaning of a state string.
    • Tighten a constraint (maximum limit from 100 to 10) without a version, if clients already send 100.

    Enumerations are the trap. A consumer that switches on state and has no default will throw when it sees payment_uncertain. Adding a state is additive for a tolerant reader and breaking for a closed one. The partner API's contract therefore says readers must tolerate unknown states by treating them as pending and preserving the raw string. The Pact interaction that matters is not only the happy state. Include one interaction whose state value is an unknown token, and verify the consumer test handles it. If you cannot get partners to do that, a new state is a breaking change and belongs in a new major version. Write which rule you are enforcing. TechLearningPro enforces tolerant state handling in its own BFF and treats a new state as compatible there. For the external partner API, a new state is breaking until the partner's contract test shows a default branch. That is stricter on purpose. External clients are not revised on your release morning.

    Money stays in minor units and a currency code. Floats are not a compatible alternative. A field named total that is sometimes a string and sometimes a number is already broken.

    Versioning strategies

    StrategyWhat the client pinsStrengthCost
    URI /v1/ordersThe pathVisible in logs, gateway routes, and cachesA breaking change needs a new path and a second implementation
    Header or date, API-Version: 2026-11-06A headerOne URL, fine-grained behavior pins, the way large public APIs evolveEvery cache key must Vary on the header; missing headers need a default you will regret
    Vendor media type application/vnd.techlearningpro.order.v1+jsonAcceptExplicit per resourceVerbose, awkward for browsers and for partners who only set Content-Type: application/json
    Query ?version=1A parameterEasy to typeEasy to forget, easy to cache wrong, easy to leak into analytics as if it were a filter

    TechLearningPro's partner API uses URI major versions. The gateway already routes on path, partners cache responses, and support can read an access log without decoding a header. Compatible changes stay on /v1. /v2 appears only for a breaking change, and /v1 remains for a published sunset window of eighteen months. The Sunset header (RFC 8594) carries the date on /v1 responses once /v2 exists. A Deprecation header is used in the IETF HTTPAPI draft sense; do not assume a client honors it. The human-readable schedule lives in the API description and in the partner email. Both versions run in the same process unless a strangler cut says otherwise. Business rules must not drift: a tax calculation that differs between v1 and v2 is two products. Share the domain service. Map at the edge.

    Header dating is the right alternative if you ever need many small behavior pins on one URL. It is not what you bolt on beside /v1 "just in case." Two schemes at once mean a request can be v1 in the path and 2027 in the header. Reject that combination in review.

    Internal checkout-to-payment calls are not versioned in the URL. They use the Java port and, once remote, a single JSON document that evolves by expand-and-contract. The number of callers is one, and you deploy it. Spending a public versioning policy on that hop is how internal refactors become frozen APIs.

    Expand and contract

    To rename total to amountMinor without a flag day:

    1. Add amountMinor while still writing total. Deploy the provider. This is compatible.
    2. Deploy consumers to read amountMinor, falling back to total if they must handle both during the window.
    3. Confirm, from contract tests and access logs, that no supported consumer reads only total.
    4. Stop writing total in a major version, or stop writing it after the sunset of the minor change you announced. For a true rename on a public API, step 4 is /v2, and /v1 keeps total until sunset.

    Skipping to step 4 because "we updated our BFF" is the invoice incident. The BFF is not the partner job.

    OpenAPI records the claim

    springdoc-openapi (org.springdoc:springdoc-openapi-starter-webmvc-ui) publishes the spec at /v3/api-docs and a UI at /swagger-ui/index.html. Annotate the handler you actually ship:

    java
    @GetMapping("/v1/orders/{orderId}")
    @Operation(operationId = "getOrder", summary = "Fetch one order for the caller's tenant")
    ResponseEntity<OrderResponse> getOrder(@PathVariable UUID orderId) { /* ... */ }

    The spec is generated from the controller and from @Schema on OrderResponse, or maintained as YAML if you want the spec to lead the code. Pick one source of truth. A hand-written YAML file that drifts from the controller is a second fiction. In CI, fail the build when the generated spec differs from the committed spec, so a renamed field is a diff a human sees.

    OpenAPI does not execute the partner's parser. It will happily describe a required field the partner has never sent, and your build will stay green. It also does not prove authorization: a 200 in the spec is not a test that another tenant's id returns 404.

    Consumer-driven contracts

    A consumer-driven contract is an artifact the consumer produces, stating the interactions it relies on. The provider's pipeline verifies them against a running provider. Pact JVM does this. The consumer test uses PactConsumerTestExt and @Pact:

    java
    @ExtendWith(PactConsumerTestExt.class)
    @PactTestFor(providerName = "order-api")
    class OrderConsumerPactTest {
    @Pact(consumer = "partner-invoicer")
    RequestResponsePact getCompletedOrder(PactDslWithProvider builder) {
    return builder
    .given("order 6f1c0b2e-1e2a-4a7c-9c1d-0a0b0c0d0e0f is completed")
    .uponReceiving("a request for that order")
    .path("/v1/orders/6f1c0b2e-1e2a-4a7c-9c1d-0a0b0c0d0e0f")
    .method("GET")
    .willRespondWith()
    .status(200)
    .body(new PactDslJsonBody()
    .uuid("id")
    .stringValue("state", "completed")
    .integerType("amountMinor", 4900)
    .stringValue("currency", "USD"))
    .toPact();
    }
    }

    stringValue pins the example. integerType requires a JSON number and records the example 4900. The pact file is published to a broker or stored where the provider build can see it. The provider test uses @Provider("order-api"), @ExtendWith(PactVerificationInvocationContextProvider.class), a @TestTemplate that calls context.verifyInteraction(), and a @State method that seeds the completed order. The state name is part of the contract. A state the provider cannot seed is a conversation, not a skipped test.

    This is the opposite direction from Spring Cloud Contract, which starts from provider-written contracts and generates stubs for consumers. Stubs help a consumer build when you own both sides and want to work offline. They do not tell you that the partner invoicer reads amountMinor. Use Pact, or an equivalent consumer-written interaction, for the partner boundary. Use provider stubs only as a convenience inside the company, and do not confuse a stub that returns canned JSON with verification.

    Contract tests miss whole classes of bugs on purpose. They do not run two concurrent checkouts against one seat. They do not prove the idempotency key blocks a double capture. They do not prove tenant isolation unless an interaction says "caller B asks for caller A's order" and expects 404. Add that interaction. Otherwise the contract is a schema check with extra steps. Lesson 32's tests remain the tests for the saga. Contract Testing shows the mechanic. The gap you must not leave is the authorization interaction and the unknown-state interaction.

    What the gateway owns

    The gateway routes /v1/** and later /v2/** to the order service, terminates partner auth, and applies the rate limit from Rate Limiting, Throttling and Load Shedding. It does not rewrite total into amountMinor. Transformation in the gateway hides breakage until the gateway config drifts from both versions. The service owns the representation. The BFF for the first-party web app may trim fields for a screen. That trimming is a consumer, and it gets its own pact as consumer web-bff. It is not an excuse for the partner API to return the internal entity.

    Architecture and Design Diagrams

    mermaid
    flowchart LR
    partner[Partner invoicer]
    bff[Web BFF]
    gw[Gateway]
    orders[Order API in checkout]
    pact[Pact broker or pact files]
    partner -->|GET /v1/orders/id| gw
    bff -->|GET /v1/orders/id| gw
    gw --> orders
    partner -->|publishes interactions| pact
    bff -->|publishes interactions| pact
    pact -->|provider verification| orders

    The gateway forwards. It does not version the body. Both consumers publish expectations. The provider build verifies both. A partner who will not publish a pact is covered by the stricter rule: their documented major version changes only by sunset, and a rename is a /v2.

    mermaid
    sequenceDiagram
    participant C as Consumer CI
    participant B as Pact broker
    participant P as Provider CI
    participant API as Order API
    C->>API: mock from the pact, assert the parser
    C->>B: publish pact
    P->>B: fetch pacts for order-api
    P->>API: seed @State, replay GET /v1/orders/id
    API-->>P: 200 and body
    P-->>P: fail the build on mismatch

    The consumer build fails if its own parser cannot read the mock it described. The provider build fails if the live controller cannot satisfy the pact. Neither build replaces the saga concurrency test.

    Technical Explanation

    Idempotency on the public POST

    POST /v1/orders requires an Idempotency-Key header. The header is an IETF draft in the HTTPAPI working group, widely implemented, and not a finished standard. Document it in the OpenAPI description as a required header rather than pretending a final RFC number exists. The same key and the same body return the original order and status. The same key and a different body return 409 with type idempotency-conflict. A missing key returns 400. Lesson 37's unique constraint is the enforcement. The API's job is to make the rule visible and to return the problem type the partner's pact expects. A retry of a capture that the partner thought timed out must hit this path, not a second saga.

    Caching and Vary

    GET responses may be cached by the partner and by any cache you operate. Cache-Control: private, max-age=0 is a sane default for an order that changes as the saga moves. If you ever version with a header, add Vary: API-Version. Without it, a cache can serve a v2 body to a v1 client. URI versioning puts the version in the path, so the cache key differs without Vary. That is one of the reasons the partner API uses paths. Do not also accept API-Version on /v1 and ignore it. Either the header is the scheme or it is rejected.

    Compatible field, incompatible unit

    A release adds taxMinor and keeps amountMinor as the pre-tax snapshot. That is compatible if old clients ignore taxMinor. It is incompatible if you silently change amountMinor to mean tax-inclusive. The name did not change and the invoices did. Contract tests that only check "property is an integer" stay green. The pact example value, and a provider state "order with tax," should assert the number the consumer will add up. Put an assertion in the consumer test that amountMinor + taxMinor equals the invoice line under the documented rule. Schema presence is not arithmetic.

    How many versions you can stand

    Two majors is the budget. A third means a security fix is three patches, and the saga mapping will drift. Eighteen months after /v2 is generally available, /v1 returns 410 with a problem type version-sunset except for tenants on an explicit extension. Extensions are rows in a table, not a quiet continuation for everyone. Count callers of /v1 from access logs before you build /v2, so you know who must move. A version nobody calls is not free. It is a code path that still starts sagas.

    Authorization interaction

    java
    @State("order exists for a different tenant")
    void otherTenant() {
    // seed an order owned by tenant A; the replayed request authenticates as tenant B
    }

    The matching interaction expects 404 and a problem body, not the order with the email redacted. Redaction bugs leak through "almost right" JSON. 404 is the contract. The provider state must use the same authentication mechanism as production, not a test filter that skips the tenant check. A pact that injects a header X-Test-User unknown to production verifies a fiction.

    What Spring will do if you let it

    Jackson will serialize a Java double in a way that surprises finance. The response record uses long amountMinor and String currency. ProblemDetail extension properties appear as extra JSON fields; do not put secrets or raw provider bodies in them. springdoc will expose every endpoint on the controller. Restrict the UI and /v3/api-docs on the public gateway if partners should see a curated spec you publish, not the internal actuator-adjacent surface. The curated spec is a build artifact from the same generation step, filtered to /v1/**.

    Practical Implementation or Design Walkthrough

    Problem

    Design the partner order API for invoice jobs and the first-party BFF, with one breaking rename planned (total to amountMinor) and a requirement that a double-submitted create does not start two sagas.

    Requirements

    • GET /v1/orders/{id} returns state, minor-unit amounts, currency, and lines. Unknown tenants get 404.
    • History uses an opaque cursor and a maximum limit of 100.
    • Errors are application/problem+json with stable type URIs.
    • POST /v1/orders requires Idempotency-Key.
    • A field addition is not a new version. A rename is /v2, with /v1 under a sunset.
    • Provider CI fails if the partner pact or the BFF pact fails.
    • The gateway does not rewrite bodies.

    Steps

    1. Define OrderResponse as a record in the API package, not as the JPA entity. Map from the saga and the line snapshot.
    2. Enable problem details. Map decline, idempotency conflict, unknown cursor, and not-found to type URIs. Add pacts for each status you promise.
    3. Implement keyset history. Reject page and offset with 400 so a mistaken client fails loudly.
    4. Commit the generated OpenAPI and fail CI on drift. Publish partner-facing docs from that file.
    5. Add the consumer pact for the completed order, the unknown-state case for the BFF, and the cross-tenant 404. Wire provider verification into the order-service pipeline with @State methods that seed real rows through the repository, not through a fake controller.
    6. Plan the rename as expand-and-contract on /v1 only if every consumer's pact reads the new field. Otherwise ship /v2, emit Sunset on /v1, and keep one domain service behind both mappers.
    7. Confirm payment has no public route. The partner sees paymentId and state, not the provider client.

    Failure handling

    A partner sends a body with an unknown property: 400, type invalid-body, no saga row. A partner retries the same key after a timeout: the original order, even if the first request ended in payment_uncertain. Do not start a second capture because the first response was lost. A cursor from a removed sort: 400, type cursor-invalid. A removed major version: 410, type version-sunset.

    Validation

    • Pact verification fails when amountMinor is removed, in a test commit you revert.
    • Two POSTs, one key, same body: one saga row. Same key, different amount: 409.
    • Tenant B receives 404 for tenant A's order id, and the body does not contain the email.
    • GET with limit=1000 is rejected or clamped by a documented rule. Pick reject, so the client's bug is visible.
    • Access log for the gateway shows the path version and the consumer id, and no rewrite filter on the body.
    • OpenAPI drift job fails when a field is renamed without a spec commit.

    Enterprise Real-World Scenarios

    Scenario 1: The zero-value invoices

    Context. The rename shipped inside /v1 over a weekend. The partner job used a generated client from an older spec and bound a missing total as zero. Problem details were not in the response; the HTTP status was 200.

    Design. The rename moves to /v2. /v1 keeps total until sunset. The partner pact requires amountMinor only on the v2 provider name, or on a separate consumer. The v1 pact still requires total. Provider CI cannot drop total from v1 while that pact is in force. Finance's reconciliation compares amountMinor to the saga snapshot for a sample of orders each night, so a unit change fails an audit even if JSON types match.

    What you refuse. A gateway rewrite that puts total back for "legacy." The rewrite will drift from tax rules, and two representations of one order will disagree. Keep the mapper in the service, next to the domain type, covered by the v1 pact.

    Scenario 2: A helpful { "success": false }

    Context. An internal client library treats any HTTP 200 as payable and interprets a boolean. A new endpoint copies that envelope so the mobile app can ship faster. A decline and a validation error both return 200. The saga has already released the seat, and the app shows success because it never read the boolean.

    Design. Commands that fail do not return 200. Declines carry a problem type. The BFF maps that type to the screen. The pact for the BFF expects 409 and the type URI, and the BFF test fails if it treats 200 with a body flag as the only signal. REST in Microservices is enough to reject this envelope. The incident is what you get when the status code is decorative.

    Best Practices

    • Publish a representation, not a table. Snapshot money and expose saga states you are prepared to support.
    • Use problem documents with stable type URIs. Keep human text in detail.
    • Paginate history with opaque cursors and a hard maximum limit. Reject offset on this API.
    • Require Idempotency-Key on POST /v1/orders, and return 409 when the body changes under a key.
    • Make compatible changes by addition. Treat new enum values as breaking for external clients unless their contract shows a default.
    • Version the partner API in the URI, one breaking major at a time, with Sunset and a deadline. Do not also honor a version header on the same route.
    • Evolve internal ports by expand-and-contract. Do not mint a public version for a single caller you deploy.
    • Generate OpenAPI from the code you ship, or generate the code from the spec, and fail on drift.
    • Verify consumer-written pacts in the provider pipeline, including authorization and error types.
    • Keep body mapping in the service. Leave the gateway to auth, routing, and rate limits.
    • Cap supported majors at two. Count real callers before you open a new one.
    • Fail unknown fields on money commands. Ignore unknown fields on responses you send, and require consumers to do the same.
    • Do not log full order bodies that contain learner emails at info level on the partner path.

    Common Mistakes and Anti-Patterns

    Versioning every release. /v1.3.4 in the path freezes every change, including compatible ones, and multiplies deployments. Major versions are for breaks. Everything else stays on the current major.

    OpenAPI as the only test. The spec describes a 200. The partner needed a field you did not mark required, and their generator dropped it. The provider build stayed green. A pact that lists the field would have failed verification the day you removed it.

    Offset pagination copied from a sample. ?page=50&size=20 looks friendly and becomes a sequential scan. The partner will automate it. Give them a cursor or an export.

    Status 200 with an error flag. Clients, caches, and gateways all assume 200 means the operation happened. Declines and conflicts have their own status and a problem type.

    Breaking change hidden in a "minor" rename. total to amountMinor changes the invoices. It is a new major version unless every supported consumer has already moved, which you know from pacts and logs, not from a meeting.

    Gateway transformation. A filter that reshapes JSON for each partner creates a third implementation of tax math. Map in the service. Route in the gateway.

    Contract tests that skip auth. Every interaction uses a superuser test filter. The first real tenant bug is a leak the pact cannot see. Seed @State with tenants and call through the real filter.

    Internal versioning theater. /v3/internal/payment between checkout and payment, with no second consumer, slows the port refactor from lesson 38. Expand and contract the one document. Spend versioning budget where strangers call you.

    Troubleshooting and Design Review

    SymptomEvidenceLikely causeAction
    Partner invoices at zero200 responses, missing field, old clientRename inside the same majorRestore the field on /v1 or ship /v2 and keep /v1
    Provider CI green, partner downNo consumer pact in the pipelineOpenAPI-only checkVerify the partner interactions on each provider build
    History endpoint saturates the databaseOFFSET in the statementPage number APIReplace with a keyset cursor and a max limit
    Client shows success after a declineHTTP 200, success: falseDecorative statusReturn 409 and a problem type; pact the BFF against it
    Cached v2 body served to a v1 clientVary missing, header versioningVersion not part of the cache keyURI version, or add Vary and stop mixing schemes
    Cross-tenant read succeedsResponse contains another emailPact used a test filterVerify through production auth; expect 404
    Three majors, one fixRelease checklistNo sunsetEnforce the eighteen-month cutoff and 410
    Double charge on partner retrySecond saga rowMissing or ignored idempotency keyRequire the header; unique constraint; pact the replay

    Design review: Which consumers exist, and which artifact states their expectations? What change requires a new major version, and who still calls the old one? Where is the idempotency key required, and what is the status when the body differs? Does history use a cursor the index can serve? Can a caller outside the tenant observe that an order exists? Which component is allowed to change the JSON, and is the gateway on that list? What does a new saga state do to external switch statements?

    Interview Questions and Answers

    Q1. Why is the OpenAPI document not enough to call the order API a contract?

    It is the provider's description of paths, schemas, and status codes. springdoc can generate it from the controller, and CI can fail on drift. A consumer can still break when a field is renamed, a unit changes, or a new state appears, while the provider's tests keep passing against the updated spec. A contract in the sense that protects the partner is an expectation the consumer publishes and the provider verifies, such as a Pact interaction for GET /v1/orders/{id} that requires amountMinor and state. API Versioning and Contract Testing split those ideas across lessons. Here they are one release policy: spec for the catalog of the API, pacts for the consumers you can break.

    Q2. When is a change allowed to stay on /v1?

    When existing consumers keep working without a code change. Adding an optional response field qualifies, if consumers ignore unknown fields. Adding an endpoint qualifies. Renaming total, changing its unit, making a request field required, or removing a state does not. A new state value stays on /v1 only for consumers whose tests show a default branch. For external partners, TechLearningPro treats a new state as a major change until that evidence exists. The BFF, which you deploy and whose pact includes an unknown state token, may accept it as compatible.

    Q3. Why URI versions for partners, instead of a date header?

    Partners cache, the gateway routes on paths, and access logs show the version without a custom parser. A date or API-Version header can express finer pins, and it demands Vary so caches do not mix bodies. Query-parameter versions are easy to omit and easy to cache incorrectly. Vendor media types are precise and awkward for the clients you actually have. One scheme only: /v1 and, when a break is real, /v2. Header versioning on the same path is rejected so a request cannot claim two versions.

    Q4. How do you rename a money field without a flag day?

    Expand and contract. Add amountMinor beside total, deploy the provider, move consumers, prove it with pacts and logs, then remove total only on a new major or after an announced sunset of the old field. /v1 keeps the old field until that sunset. The domain service stays single. Mappers at the edge differ. A gateway rewrite that synthesizes total from the new field is a second mapper in the place least covered by the saga tests.

    Q5. What does the provider's Pact verification actually do, and what does it skip?

    It loads interactions the consumer published, seeds data in @State methods, and replays the HTTP calls against the running order API. A mismatch fails the provider build before production. It does not replace lesson 32: two buyers, one seat, is not an interaction. It does not prove idempotency unless an interaction repeats a key. It does not prove tenant isolation unless a state authenticates as the wrong tenant and expects 404. Spring Cloud Contract's stubs are provider-written and help internal consumers build offline. They are not a substitute for the partner's pact.

    Q6. How should POST /v1/orders behave on a timeout and a retry?

    The caller sends Idempotency-Key. The same key and body return the original order, including when the first attempt is still payment_uncertain. A different body under that key is 409 with type idempotency-conflict. A missing key is 400. The header is a widely implemented IETF draft; document it in OpenAPI instead of citing a final RFC that does not exist. The unique constraint from lesson 37 is what makes the retry safe. The status codes are what make the rule testable in a pact.

    Q7. Why reject offset pagination if partners ask for page numbers?

    OFFSET makes the database read and discard every preceding row. Lesson 27 already refused that for the learner's own history query. A partner with a script will request deep pages and hold connections. The API offers an opaque cursor over (created_at, order_id) and a maximum limit of 100, and it returns 400 for page or offset so the mistake is visible. Bulk history is an export, not a loop. An invalid cursor is 400 with type cursor-invalid, not an empty page, because empty means the backfill is finished.

    Q8. A decline comes back as HTTP 200 and { "success": false }. What breaks?

    Caches, gateways, and client helpers treat 200 as success. The app that only checks the status code will show a paid course after the saga has compensated. Return a 4xx with ProblemDetail: type https://api.techlearningpro.com/problems/payment-declined, title and detail for humans. Reserve 503 and Retry-After for "the dependency is unavailable," which a client may retry if the POST is idempotent. Do not spend 402 on this. Clients do not handle it reliably, and a problem type is the branch you can pact.

    Q9. Who is allowed to change the JSON, the gateway or the service?

    The service. It owns the saga and the snapshot, and both /v1 and /v2 mappers should sit next to that domain type so tax math cannot diverge. The gateway authenticates the partner, routes the path, and applies rate limits. A transformation filter becomes an untested third implementation. The BFF may omit fields for a screen because it is a consumer with its own pact, not because the partner payload should be the entity.

    Q10. How many major versions will you run, and how do you delete one?

    Two. A third means every fix lands three times and the state machine will drift. When /v2 is general, responses on /v1 carry Sunset from RFC 8594. After the published window, eighteen months in this policy, /v1 returns 410 and type version-sunset, with exceptions stored as explicit grants rather than as a silent default. Before opening /v2, count callers from logs so the sunset list is real. API Versioning lists the techniques. The operational limit is the part you add so the techniques do not accumulate.

    Q11. What should a contract test include that schema tests usually omit?

    An unknown state token for any consumer you claim is tolerant. A cross-tenant 404 whose body does not contain another learner's email. The idempotency replay and the idempotency conflict. A problem type for a decline. An assertion on money units, not only on JSON types, so a tax-inclusive change to amountMinor fails the consumer's arithmetic. Those interactions are still small. They are the ones that match the incidents this API has already had.

    Hands-On Exercises

    Exercise 1: Partner order contract

    Problem. Publish /v1 for a partner invoicer and the web BFF. Plan the retirement of a field named total.

    Tasks.

    1. Write the GET and POST shapes, including idempotency and the problem types for decline, conflict, and not-found.
    2. Specify cursor pagination and the response for an obsolete cursor.
    3. Decide whether the rename can stay on /v1. If not, describe /v2, Sunset, and what stays shared in the domain layer.
    4. List the Pact interactions, including cross-tenant access and an unknown state for the BFF.

    Deliverables. A short OpenAPI fragment, the version decision, and the interaction list with expected status codes.

    Exercise 2: Review a breaking "minor"

    Problem. A pull request renames total to amountMinor on /v1, updates the spec, and adds a gateway rewrite that copies the new field back to total for "legacy."

    Tasks.

    1. Say which consumers the rewrite hides, and which bug it will cause when tax rules change.
    2. Replace the rewrite with an explicit compatibility plan.
    3. Name the CI check that should have failed.

    Rubric.

    AreaWeight
    Representation, problem types, idempotency25%
    Cursor pagination and limits15%
    Compatible versus breaking, URI version policy25%
    Consumer-driven verification versus OpenAPI-only20%
    Gateway left as a router; auth interaction included15%

    Lesson Summary

    The partner order API publishes saga state and snapshotted minor units, pages with opaque cursors, and fails with problem documents whose type URIs stay stable. Compatible changes are additions. Renames, unit changes, and new states for external clients are a new URI major version, sunset with RFC 8594, capped so you never carry three implementations of money. Idempotency-Key on create is how a lost response avoids a second saga.

    OpenAPI is the catalog of that promise. Pact interactions, verified in the provider pipeline, are the consumers' side of it, including 404 across tenants and the decline problem type. Spring Cloud Contract stubs serve a different direction and do not replace those interactions. The gateway routes and authenticates. It does not reshape the body.

    Lesson 41 takes the payment call that sits behind this API and puts a timeout, a retry budget, a bulkhead, and a circuit breaker around it, so a slow provider becomes payment_uncertain or a clean 503 instead of a thread pile-up. The public status codes you chose here are the ones that breaker must not rewrite into a fake success.

    Further Reading

    Ready to mark this lesson complete?Track your journey across the entire course.