Evolutionsdkclient librariesergonomicsforward compatibilitycodegen

SDK Design: The Contract's User Interface

payments.create({...}) versus hand-rolled HTTP is the visible part. The invisible part is what the SDK owns on behalf of every consumer — retries with idempotency keys, pagination iterators, typed errors, timeouts — and how it is built to survive the API evolving underneath it. A strict SDK turns your safe changes into their crashes.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What should the client library do for every consumer — and how must it be built so the API can keep evolving underneath it?
Consumers
Application developers who will never read your HTTP docs if the SDK is good enough — plus, transitively, every end user of their code. The SDK's defaults become the de-facto behavior of your entire consumer base.
The promise
A well-designed SDK guarantees that the easy way is the correct way: retries are safe by default, pagination is complete by default, errors are branchable by type — and none of it breaks when the API adds a field or an enum value.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

From transport to interface

The distance between client.request("POST", "/v1/payments", body) and payments.create(params) is not sugar — it is who holds the contract knowledge. With raw transport, every consumer re-derives paths, shapes, error meanings and retry rules from the docs, each with their own bugs; the contract is re-implemented N times. A real SDK is the contract *encoded once, by the people who wrote it*: typed methods per operation, typed models per resource, and — the part that matters most — the behavioral clauses turned into code.

The behavioral clauses are where SDKs earn their keep, because they are exactly what consumers get wrong alone (Documentation Is Part of the Contract can state them; the SDK can enforce them). Retries with exponential backoff and jitter, on retryable failures only, with an Idempotency Keys: The Mechanism key generated per logical operation and reused across attempts — safe retry-by-default instead of N hand-rolled retry loops, half of them double-charging. Pagination as an iterator, so "the first page is all the data" bugs become impossible (Pagination: Choosing How Lists End). Errors mapped from the wire taxonomy to typed exceptions consumers can branch on — CardExpiredError, not string-matching a message (An Error Taxonomy Clients Can Branch On). Timeouts with sane defaults, Request-Id surfaced on every error for support (Request IDs: The Contract's Correlation Clause), rate-limit headers translated into waiting (The Rate-Limit Contract).

Every consumer re-implements the contract
1// consumer code, repeated at N companies:
2const res = await fetch(`${BASE}/v1/payments`, {
3 method: 'POST',
4 headers: { Authorization: `Bearer ${key}` },
5 body: JSON.stringify({ amount, currency }),
6})
7if (!res.ok) {
8 // retry? is that safe? (this is a POST…)
9 // parse the error? which shape?
10 throw new Error('payment failed: ' + res.status)
11}
12// no idempotency key — the retry someone
13// adds during the next incident double-charges
The contract, encoded once by its authors
1const payment = await client.payments.create(
2 { amount: 1999, currency: 'EUR', source },
3 // SDK: generates an Idempotency-Key, retries
4 // retryable failures with backoff + jitter,
5 // reusing the SAME key across attempts
6)
7
8// errors arrive as the taxonomy, typed:
9try { … } catch (err) {
10 if (err instanceof CardExpiredError) collectNewCard()
11 else if (err instanceof RateLimitError) await err.retryAfter()
12 else throw err // err.requestId for support
13}

The good version is not shorter because of syntax — it is shorter because retry safety, idempotency, error taxonomy and correlation are implemented once, correctly, by the team that defined them, instead of N times by teams that each read half the docs.

Built to survive the API evolving

Here is where SDK design joins this module: the SDK is a *compiled snapshot of the contract*, deployed into codebases you cannot touch, on upgrade cycles you do not control. Every forward-compatibility clause the API declares must be *implemented* in the SDK, or the clause is fiction. The contract says clients tolerate unknown response fields (Backward Compatibility: The Real Rules) — so generated models must preserve-and-ignore unknown keys, never throw on them, and strict-deserialization defaults in the target language must be explicitly disarmed. The contract says enums are extensible (Enum Evolution: The New Value That Broke Old Clients) — so generated enum types must decode unknown values into a usable representation (a raw-value case, a wrapper type) instead of crashing, which is precisely where naive codegen in strict languages fails first.

This is the quiet catch in Schema-First vs Code-First codegen: the generator's defaults decide whether your entire SDK fleet is forward-compatible, and most generators' defaults are strict. Audit the generated deserialization path per language against one test: *serve a response with three unknown fields and one unknown enum value; the SDK must succeed*. Run that test in CI against every SDK you ship (see Testing the Contract, Not Just the Code) — it is the mechanical form of the tolerant-reader clause, and it is what makes "adding a field is safe" true for your actual consumer base rather than your ideal one.

SDK versioning then has its own layer: semver over the *SDK surface*, which is correlated with but not identical to the API's compatibility rules. An additive API change is a minor SDK release; a breaking SDK change (renamed method, changed types) can exist without any API change at all — and because old SDK versions keep calling the current API for years, the API's deprecation windows must account for the SDK-upgrade lag, and old-but-supported SDKs must keep working against the evolving API for as long as their pinned behavior is served (Versioning: What a Version Even Promises's date-pinning interacts well here: the SDK version can pin the API behavior date it was built against).

API change × SDK build quality: who breaks
API changeNaive strict SDKForward-compatible SDK
New response field shipsDeserialization throws; whole response lost — a *safe* change became an outageField ignored (and ideally preserved for re-serialization); nothing happens
New enum value returnedEnum decode crashes, or maps to a wrong default caseDecoded as unknown-value case; consumer's documented safe-handling path runs (see Enum Evolution: The New Value That Broke Old Clients)
Field deprecatedNothing — consumers keep using it, invisible to the Removing Fields Without Removing Consumers burn-down@deprecated annotation → IDE strikethrough + build warnings; optionally, read telemetry
New endpoint addedAbsent until manual SDK workRegenerated from spec in the next minor release (see OpenAPI: Describing the Contract, Not Designing It)
Breaking API change (new major)Silent runtime failures on old SDKsMajor SDK release; old majors keep working against the still-served old surface (see API Migration: Running the Change End to End)

Idiomatic per language, honest about its costs

An SDK is judged in the consumer's language, not yours. Async models must be native (promises in TypeScript, context in Go, asyncio-compatible in Python); naming follows the language's casing and conventions, mapped mechanically from the wire names; errors integrate with the language's idiom (exception hierarchies where those are native, error values where those are). This is why pure template-driven codegen plateaus at "technically usable": the common structure comes from the spec, but idiom needs per-language handwork — which is also the honest budgeting fact: every supported language is a product with its own tests, docs, release train and issue queue. Two excellent SDKs beat six abandoned ones; the languages you do not support get raw HTTP plus great docs, which is a legitimate tier, not a failure (Documentation Is Part of the Contract carries them).

Two design boundaries keep SDKs trustworthy. Thin over clever: the SDK encodes the contract — transport, auth plumbing, retries, types, pagination — and stops before business logic, client-side caching with invented semantics, or "helpful" validation that duplicates and then contradicts the server's (Validation Errors: Feedback, Not Verdicts is the server's job; a stricter SDK than server rejects valid requests, a looser one converts clean 422s into confusing successes-then-failures). Every clever behavior is contract surface you now maintain across all languages forever. Observable by default: hooks for logging and tracing, Request-Id on every error, and an SDK-version header on every request — that last one is Consumer-Driven Evolution: Telemetry Before Breakage's cheapest, highest-value input, shipped for free with every call.

  • Own by default: auth header plumbing, retries with backoff + jitter + idempotency keys, timeouts, pagination iterators, typed errors, rate-limit waiting, Request-Id surfacing, SDK-version header.
  • Generate: models, method stubs and deprecation annotations from the governed spec — then verify unknown-field and unknown-enum tolerance per language in CI.
  • Hand-craft per language: idiom, async model, error integration, the getting-started page — the parts templates cannot see.
  • Refuse: business logic, speculative caching, client-side validation beyond types, and any behavior the API contract does not define — the SDK is the contract's interface, not a second contract.

Key points

  • An SDK is the contract encoded once by its authors: typed operations and models, plus the behavioral clauses — retries, idempotency, pagination, error taxonomy — that consumers get wrong when left alone.
  • The SDK's defaults become your consumer base's de-facto behavior: safe-retry-by-default with reused idempotency keys prevents more double-charges than any docs page.
  • The SDK is a compiled snapshot of the contract living in codebases you cannot touch — every forward-compatibility clause must be implemented in it, or the clause is fiction.
  • Audit generated deserialization against the one test that matters: unknown fields and unknown enum values must not throw; strict codegen defaults silently break your safe-change list.
  • SDK semver tracks the SDK surface, not the API: additive API changes are minor releases, and deprecation windows must absorb SDK-upgrade lag measured in years.
  • Fewer, better languages: each SDK is a product; thin-over-clever and an SDK-version header on every request keep the fleet maintainable and the telemetry flowing.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → SDKs: generates clients for six languages from the spec with default settings; strict deserialization ships in every one of them, untested against novelty.
  2. 2
    Consumers → SDKs: adopt happily — typed models, autocomplete; nobody hand-writes HTTP anymore, so nobody's code path tolerates anything the types do not name.
  3. 3
    API → change: a new optional response field ships — top of the safe list, no version bump, exactly as Backward Compatibility: The Real Rules allows.
  4. 4
    SDKs → production: three of six languages' deserializers throw on the unknown key; whole responses are lost across the consumer fleet simultaneously.
  5. 5
    Team → aftermath: pins the API in place while six SDK patches roll out on consumer upgrade schedules — the tooling meant to enable evolution now vetoes it.
What breaks
  • Safe API changes become fleet-wide consumer crashes when SDK deserialization is strict — and the blast radius is every consumer on the affected SDK versions at once.
  • Abandoned language SDKs strand consumers on old contract snapshots: they miss new endpoints, keep using deprecated fields invisibly, and hold deprecation windows hostage.
  • Clever SDK behavior (caching, extra validation, silent fallbacks) forks the contract: the API's documented behavior and the SDK's actual behavior diverge, and consumers debug the gap.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Encode the behavioral contract in the SDK core once — retries with idempotency-key reuse, backoff + jitter, timeouts, pagination iterators, typed error taxonomy — so the easy path is the safe path in every language.
  • • Make forward compatibility a tested property: CI serves unknown fields and unknown enum values to every SDK on every build; strict-by-default codegen settings are explicitly overridden and audited.
  • • Version SDKs by their own surface (semver), pin them to spec versions, flow `deprecated:` annotations into IDE warnings, and send an SDK-version header on every request.
  • • Support few languages as real products, keep the SDK thin (no business logic, no invented semantics), and give unsupported languages first-class HTTP docs instead of an abandoned client.
Observe in production
  • • Segment API traffic and error rates by the SDK-version header: breakage after a deploy that clusters on old SDK versions is a compatibility clause failing in the field (see [[api-metrics]]).
  • • Track SDK-version distribution per consumer — the upgrade-lag histogram is the empirical input for every deprecation window and removal date you will set.
  • • Watch SDK issue trackers as contract telemetry: recurring "how do I retry / paginate / handle this error" issues mean the SDK's defaults are not carrying the clause they should.
Evolve without breaking
  • • A spec-generated, forward-compatible SDK fleet makes additive evolution nearly free: regenerate, release a minor, and unknown-tolerant old versions keep working untouched (see [[openapi]]).
  • • Deprecations flow through the SDK as annotations and changelogs — the IDE strikethrough is the highest-conversion deprecation channel you have (see [[deprecation]] and [[removing-fields]]).
  • • Breaking API changes ship as major SDK releases whose migration is mostly the SDK's own diff: a good SDK converts an [[api-migration]] from re-integration into a guided upgrade.
What it costs
  • • Every supported language is a permanent product — tests, docs, releases, issues; the honest choice between two great SDKs and six rotting ones costs you checkbox-list adoption points.
  • • Unknown-tolerant deserialization trades away some compile-time strictness consumers like: the compiler stops guaranteeing exhaustiveness, and the safety moves into documented runtime handling (see [[enum-evolution]]).
  • • The SDK layer adds its own failure surface — bugs, version skew and language quirks that are yours to debug even when the API behaved perfectly; raw HTTP had no such middleman.

Misconceptions

Claim
“An SDK is a thin convenience wrapper — generate it and move on.”
Reality
The generated part is the least valuable part. Retry safety, idempotency-key reuse, pagination completeness, typed errors and forward-compatible deserialization are the SDK's actual job, and none of them fall out of a template with default settings.
Claim
“Typed strictness is what makes an SDK good — reject anything unexpected.”
Reality
Strictness against the *current* snapshot makes the SDK a time bomb: the API's safest evolution moves (new fields, new enum values) become deserialization crashes. Good SDKs are strict about what they send and tolerant about what they receive.
Claim
“More language SDKs means more adoption, so support everything.”
Reality
An outdated SDK is worse than none — it strands consumers on old contract snapshots and misrepresents your API. Support the languages you can run as products; give the rest excellent HTTP documentation.

Apply it