Blog API And SDK Design

API And SDK Design / Aug 30, 2026

One API, Several Clients: When SDK Generators Benefit From a Contract IR

Learn when a contract intermediate representation can keep generated SDKs consistent while allowing Promise, streaming, and richer typed clients to evolve independently.

By Virillio Code Editorial
One central contract diagram fans into several distinct client artifacts while preserving the same colored semantic spine.

An API contract is supposed to be the shared truth. Yet SDK generation often turns it into a fork point: one generator emits a Promise client, another emits a typed effect client, a third produces documentation, and each gradually accumulates its own rules for names, errors, pagination, streaming, validation, and wire formats.

At first, direct generation looks simplest:

Authoritative API contract → Generator A → Client A Authoritative API contract → Generator B → Client B

The problem appears when the API means more than a collection of paths. A client needs to know which values are encoded on the wire, which are decoded domain values, how an error is represented, whether an operation streams, and what transport metadata must survive. If every generator interprets those details independently, drift is not an accident. It is the default outcome.

One possible boundary is a small, normalized representation between the authoritative contract and each emitted SDK.

Generate each SDK from a shared semantic contract, not from a separate interpretation of the API.

Virillio Code Editorial calls that representation a Contract IR: an intermediate representation that records public operation semantics without prematurely choosing every runtime behavior. This is our architecture term, not a requirement or named construct in OpenAPI or Smithy.

Not every generator needs this extra layer. If one contract maps directly to one client surface, or an existing generator already resolves all target semantics consistently, an IR may be unnecessary ceremony. It earns its place when several emitters otherwise repeat—and risk diverging on—the same interpretation work.

What an interface description gives you—and what it does not

OpenAPI describes an HTTP interface in a programming-language-agnostic form. Smithy makes a related point in a different ecosystem: an interface model can be transformed into several kinds of artifacts, and Smithy projections can adapt a model for particular consumers. Those standards demonstrate model-driven generation; they do not require the Contract IR layer proposed here.

But an interface description is not automatically the right public shape for every SDK.

Consider a fictional museum-catalog operation that returns an artifact identifier, an acquisition date, and a continuation value. A lightweight Promise client might preserve all three encoded values as strings. A validated client might decode the identifier into a domain-specific type, represent the acquisition date with its runtime's date-time type, and keep the continuation value opaque. A documentation emitter still needs to explain that the continuation value is passed unchanged when requesting the next catalog page. A shared IR can record the operation identity, encoded and decoded projections, and pagination fact once while leaving each public surface idiomatic.

A streaming operation presents the same kind of choice: one runtime might expose an async iterator and another a stream abstraction. Both can remain faithful to the service without sharing identical surface syntax.

The mistake is to treat fidelity as identical surface syntax. Fidelity means preserving the contract's meaning: transport behavior, request and response shape, error semantics, and operation identity. The public value model may still differ by runtime.

A proposed three-layer Contract IR model

The following Virillio Code Editorial model keeps that distinction explicit. It is a design aid, not a universal compiler architecture.

  • Layer: Authoritative contract — Owns: Routes, operations, schemas, errors, codecs, and transport semantics — Should not decide: How every language presents values to its users
  • Layer: Contract IR — Owns: Normalized endpoint structure, encoded and decoded projections, operation metadata, and generation facts — Should not decide: Promise vs. stream vs. effect execution model
  • Layer: Runtime-specific emitter — Owns: Method names, public types, validation policy, and runtime interpreter — Should not decide: The server's private implementation or a new version of transport semantics

The contract remains authoritative. The IR does not replace it or create a second hand-maintained schema. It is a compile-time representation that makes the useful facts explicit once so several emitters can consume them consistently.

The emitter remains free to choose an idiomatic public surface. That is what makes a shared IR more useful than a shared generated type package.

For example:

  • A Promise emitter can expose plain, wire-shaped structural values and direct rejections.
  • A richer runtime can expose decoded domain values and integrate validation with its own error and cancellation model.
  • A documentation emitter can use the same operation metadata to explain parameters, failures, and streaming behavior.

Each is allowed to be native to its audience while staying traceable to the same contract.

Why direct generation tends to drift

There are three predictable failure modes when every SDK generator reads the API contract independently.

1. Semantic rules get copied, then diverge

One generator learns how to represent optional query parameters. Another develops its own rule for the same issue. Later, one changes how it handles a nullable response or a non-2xx error. Both remain locally reasonable, but they no longer agree.

In a multi-emitter setup, a Contract IR can centralize semantic interpretation. Emitters can consume a resolved fact such as “this operation has a cursor-shaped continuation input” rather than each rediscovering it from raw schema nodes.

2. Shared types become a lowest-common-denominator trap

Teams sometimes solve drift by publishing one generated type package for every client. This can be useful, but it becomes restrictive when one target needs wire values and another needs decoded values. The shared package then either leaks runtime-specific concepts everywhere or loses useful semantic information to stay generic.

Sharing the IR instead preserves a common source without requiring every consumer to share the same public types.

3. Server implementation leaks into client generation

Generation is safest when clients depend on a public contract and transport-neutral semantics, not on server internals. An SDK should not need the database layer, runtime scheduler, or server-only middleware implementation merely to know how to call an endpoint.

This is also a useful dependency test: if an SDK emitter must import server behavior to generate a client, the public contract may be underspecified.

What belongs in the IR

The IR should contain only information that an emitter needs to faithfully produce a public client. Typical examples include:

  • Stable operation identity and grouping.
  • HTTP method, path, parameters, request body, and response variants.
  • Encoded wire projection and decoded semantic projection when those differ.
  • Declared error cases and their transport mapping.
  • Streaming and pagination metadata.
  • Documentation, deprecation, and explicit consumer-facing naming annotations.
  • Provenance back to the authoritative contract for diagnostics and tests.

The important word is *semantic*. An IR should not merely mirror the source syntax in a different JSON shape. It should resolve the things an emitter would otherwise guess.

It should also avoid becoming a private implementation dump. A client generator rarely needs handler layout, storage choices, internal feature flags, or any other server-private detail. The IR is a boundary, not an escape hatch.

How to keep the layers honest

An IR justifies its complexity only if it is verified from both directions.

First, test that the IR preserves the public contract. A changed route, codec, error, or streaming behavior should show up in the generated representation predictably.

Second, test that each emitted client behaves equivalently at the transport boundary. “Equivalent” does not mean identical public objects. It means that the same request is serialized correctly, the same response class is understood, and the same declared failure remains observable.

Finally, make differences between emitters deliberate. If one client validates runtime values and another trusts structural TypeScript types, record that as an emitter policy. It should be an intentional ergonomic tradeoff, not an accidental gap.

A practical adoption path

You do not need to redesign every SDK generator in one pass.

  1. Identify the repeated semantic logic across generators: errors, pagination, naming, wire-to-domain conversion, and streams are common starting points.
  2. Compile those facts into a narrow read-only IR from the existing authoritative contract.
  3. Move one emitter to the IR and compare its transport behavior with the current implementation.
  4. Add a second emitter only after the first proves that the IR contains enough information and no server-only dependency.
  5. Treat the IR as generated build output or an internal compiler model, never as a second source humans edit by hand.

The transition is successful when adding a new target no longer requires reinterpreting the whole API from scratch.

What we are learning building Virillio Code

At Virillio Code, the client-generation work is still in development. Our current lesson is that a public API benefits from a stable semantic center when it serves substantially different consumers. A network client, an in-process host, and different language or runtime surfaces may legitimately make different ergonomic choices; the proposed IR is one way to keep their contract interpretation aligned.

That is the purpose of a Contract IR: not to erase runtime differences, but to make them safe, explicit, and independently evolvable.

Sources and further reading

  • OpenAPI Specification — A language-agnostic HTTP interface description can let consumers understand a service without its implementation.
  • Smithy 2.0 specification — An interface model can be transformed into client, server, and other artifacts.
  • Smithy projections — A model can be filtered or modified for specific consumer audiences.

Editorial disclosure

This article was substantially researched, drafted, and revised with AI through the Virillio Code editorial workflow. Virillio Code publishes the final text under its editorial byline, and the supporting primary sources are linked above.