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
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:
- Model the public order resource so it exposes saga state and snapshotted money without leaking another service's tables.
- Choose cursor pagination, problem documents, and idempotency rules that match lessons 27, 32, and 37.
- Classify a change as compatible or breaking, including the special case of a new enumeration value.
- Compare URI versions, date or header versions, and vendor media types, and defend URI major versions for the partner API.
- Apply expand-and-contract so a field rename does not require a flag day.
- Explain why an OpenAPI file does not prove a consumer can read the payload, and what a Pact verification adds.
- 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
OFFSETwalks 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.
javaProblemDetail 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 (
totalin major units versusamountMinor). - 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
| Strategy | What the client pins | Strength | Cost |
|---|---|---|---|
URI /v1/orders | The path | Visible in logs, gateway routes, and caches | A breaking change needs a new path and a second implementation |
Header or date, API-Version: 2026-11-06 | A header | One URL, fine-grained behavior pins, the way large public APIs evolve | Every cache key must Vary on the header; missing headers need a default you will regret |
Vendor media type application/vnd.techlearningpro.order.v1+json | Accept | Explicit per resource | Verbose, awkward for browsers and for partners who only set Content-Type: application/json |
Query ?version=1 | A parameter | Easy to type | Easy 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:
- Add
amountMinorwhile still writingtotal. Deploy the provider. This is compatible. - Deploy consumers to read
amountMinor, falling back tototalif they must handle both during the window. - Confirm, from contract tests and access logs, that no supported consumer reads only
total. - Stop writing
totalin 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/v1keepstotaluntil 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
mermaidflowchart LRpartner[Partner invoicer]bff[Web BFF]gw[Gateway]orders[Order API in checkout]pact[Pact broker or pact files]partner -->|GET /v1/orders/id| gwbff -->|GET /v1/orders/id| gwgw --> orderspartner -->|publishes interactions| pactbff -->|publishes interactions| pactpact -->|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.
mermaidsequenceDiagramparticipant C as Consumer CIparticipant B as Pact brokerparticipant P as Provider CIparticipant API as Order APIC->>API: mock from the pact, assert the parserC->>B: publish pactP->>B: fetch pacts for order-apiP->>API: seed @State, replay GET /v1/orders/idAPI-->>P: 200 and bodyP-->>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+jsonwith stable type URIs. POST /v1/ordersrequiresIdempotency-Key.- A field addition is not a new version. A rename is
/v2, with/v1under a sunset. - Provider CI fails if the partner pact or the BFF pact fails.
- The gateway does not rewrite bodies.
Steps
- Define
OrderResponseas a record in the API package, not as the JPA entity. Map from the saga and the line snapshot. - Enable problem details. Map decline, idempotency conflict, unknown cursor, and not-found to type URIs. Add pacts for each status you promise.
- Implement keyset history. Reject
pageandoffsetwith 400 so a mistaken client fails loudly. - Commit the generated OpenAPI and fail CI on drift. Publish partner-facing docs from that file.
- 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
@Statemethods that seed real rows through the repository, not through a fake controller. - Plan the rename as expand-and-contract on
/v1only if every consumer's pact reads the new field. Otherwise ship/v2, emitSunseton/v1, and keep one domain service behind both mappers. - Confirm payment has no public route. The partner sees
paymentIdand 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
amountMinoris 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.
GETwithlimit=1000is 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
typeURIs. Keep human text indetail. - Paginate history with opaque cursors and a hard maximum limit. Reject offset on this API.
- Require
Idempotency-KeyonPOST /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
Sunsetand 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
| Symptom | Evidence | Likely cause | Action |
|---|---|---|---|
| Partner invoices at zero | 200 responses, missing field, old client | Rename inside the same major | Restore the field on /v1 or ship /v2 and keep /v1 |
| Provider CI green, partner down | No consumer pact in the pipeline | OpenAPI-only check | Verify the partner interactions on each provider build |
| History endpoint saturates the database | OFFSET in the statement | Page number API | Replace with a keyset cursor and a max limit |
| Client shows success after a decline | HTTP 200, success: false | Decorative status | Return 409 and a problem type; pact the BFF against it |
| Cached v2 body served to a v1 client | Vary missing, header versioning | Version not part of the cache key | URI version, or add Vary and stop mixing schemes |
| Cross-tenant read succeeds | Response contains another email | Pact used a test filter | Verify through production auth; expect 404 |
| Three majors, one fix | Release checklist | No sunset | Enforce the eighteen-month cutoff and 410 |
| Double charge on partner retry | Second saga row | Missing or ignored idempotency key | Require 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.
- Write the
GETandPOSTshapes, including idempotency and the problem types for decline, conflict, and not-found. - Specify cursor pagination and the response for an obsolete cursor.
- Decide whether the rename can stay on
/v1. If not, describe/v2,Sunset, and what stays shared in the domain layer. - 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.
- Say which consumers the rewrite hides, and which bug it will cause when tax rules change.
- Replace the rewrite with an explicit compatibility plan.
- Name the CI check that should have failed.
Rubric.
| Area | Weight |
|---|---|
| Representation, problem types, idempotency | 25% |
| Cursor pagination and limits | 15% |
| Compatible versus breaking, URI version policy | 25% |
| Consumer-driven verification versus OpenAPI-only | 20% |
| Gateway left as a router; auth interaction included | 15% |
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
- RFC 9457, Problem Details for HTTP APIs, and Spring Framework
org.springframework.http.ProblemDetail.spring.mvc.problemdetails.enabled. - RFC 8594, the
Sunsetheader. - OpenAPI 3.0 specification; springdoc-openapi
springdoc-openapi-starter-webmvc-ui(/v3/api-docs). - Pact JVM consumer JUnit 5 (
PactConsumerTestExt,@Pact,PactDslJsonBody) and provider JUnit 5 (@Provider,@State,PactVerificationInvocationContextProvider). - Spring Cloud Contract documentation, for provider-driven stubs and how they differ from consumer-driven pacts.
- IETF HTTPAPI draft for the
Idempotency-Keyheader. Treat it as a draft you document, not as a finished standard. - This course: API Gateway and Backend for Frontend, Database Design and Indexing for Scale, Idempotency and Exactly-Once Processing, Distributed Transactions and Saga Pattern.
- Primers: API Design for Services, API Versioning, Contract Testing, REST in Microservices.