← All posts

How We Ship Type-Safe SDK Clients for Every Language We Support

We support six programming languages in our SDKs: Python, TypeScript, Go, Java, C#, and Ruby. Keeping all six in sync with the API, with consistent naming, consistent types, and consistent behavior, is a coordination problem that could easily consume as much engineering time as building the API itself.

Our answer: generate as much as possible from the OpenAPI spec. The generated code handles HTTP mechanics, request serialization, and response deserialization. Hand-crafted wrapper code sits on top, providing the ergonomic API that customers actually use.

The Two-Layer Architecture

Every smplkit SDK has the same structure:

Layer 1: Generated client. Auto-generated from the OpenAPI spec using openapi-generator-cli. This code handles HTTP, authentication headers, request/response serialization, error handling at the HTTP level, and typed model classes. We don’t write or maintain this code — it’s produced by running a generator command against the spec.

Layer 2: Hand-crafted wrapper. Written by humans, reviewed by humans, covered by unit tests written by humans. The wrapper provides the API surface that customers actually call: client.flags.get("checkout-v2"), client.config.resolve("service-config"). It handles business logic that the generated client doesn’t know about: WebSocket connections for live updates, caching, context management, SDK configuration.

Customers only see Layer 2. Layer 1 is an implementation detail, committed to the repo under a path that signals “don’t touch this” (e.g., src/smplkit/_generated/ in Python).

Why Generated Code Is Committed

One question comes up often: why commit generated code? Why not generate it at build time?

Our experience is that committed generated code is more reliable in practice. Generate-at-build-time adds a build step dependency — the generator tool, the OpenAPI spec, a network call to fetch the spec if it’s hosted remotely. These dependencies can fail in CI in ways that are hard to debug. More importantly, they obscure diffs: when the spec changes, you want to see exactly what changed in the generated client, in your normal PR review. If the code is generated at build time, that diff isn’t visible.

Generated code that’s committed gets blamed, diffed, and bisected like any other code. This transparency catches cases where a spec change produced unexpected generated code — which happens.

The OpenAPI-Python-Client Choice

For Python, we use openapi-python-client as the generator. We evaluated the standard openapi-generator (the Java-based tool that covers many languages) and datamodel-code-generator.

openapi-python-client generates idiomatic Python with Pydantic v2 models, which is what we want. It’s pure Python, so running it doesn’t require a JVM. Its output is clean enough that we’re not embarrassed to commit it.

For TypeScript, Go, Java, C#, and Ruby, we use the standard openapi-generator-cli with language-specific templates. The generated output quality varies by language, but in all cases the wrapper layer abstracts away anything ugly.

The Python SDK as the Reference Implementation

We have a principle: the Python SDK is the reference implementation. Every other SDK is “the Python SDK, ported to this language’s idiom.”

When we shipped the first five SDKs, without this principle, they drifted in subtle ways — different method names for the same concept, different error handling behavior, showcases that exercised different code paths. Realigning them was costly.

The principle means: before implementing any SDK feature in Ruby or TypeScript, look at how Python does it. Implement the same behavior. If you need to deviate because of a genuine language idiom difference (Ruby’s ? suffix for boolean methods, Go’s error return pattern), document the deviation explicitly in an ADR.

This sounds rigid but it pays off. When we ship a new feature that requires SDK changes, we implement it once in Python, then port it to each other language following the established pattern. The porting is mechanical because the structure is identical.

Configuration Hierarchy

All SDKs follow the same configuration resolution chain:

  1. Constructor arguments (highest priority)
  2. Environment variables (SMPLKIT_API_KEY, SMPLKIT_ENVIRONMENT, SMPLKIT_SERVICE)
  3. Profile file (~/.smplkit in INI format, section [default] or a named profile)
  4. Built-in defaults (lowest priority)

The profile file is the equivalent of ~/.aws/credentials or ~/.npmrc for smplkit. Developers can put their API key and default environment in the profile and omit it from every test script and example.

Consistency in configuration is important for documentation. We can show one example of setting up the client and it applies to every language:

# Python — reads SMPLKIT_API_KEY from environment, or ~/.smplkit profile
client = SmplClient(environment="production", service="my-svc")

Every SDK has the same options with the same names (translated to the language’s casing convention). This makes cross-language documentation straightforward.

Semantic Release and Version Management

We don’t set version numbers manually. Versions are derived from git commit history by semantic-release. Conventional commits drive bumps:

  • feat: commits produce minor version bumps
  • fix: commits produce patch bumps
  • BREAKING CHANGE: footers produce major bumps

The result: the CI pipeline, on merge to main, determines the new version, updates the package manifest, creates a git tag, and publishes to the language’s registry (PyPI, npm, Maven Central, NuGet, RubyGems). No manual version management, no “forgot to bump the version” commits.

We’re currently constraining all SDK commits to fix: type — no feat: bumps — while the platform is pre-general-availability. This keeps versions in the patch range and avoids publishing any 2.0.0 before customers are live. When we open for business, we’ll relax this constraint.

The Regeneration Workflow

When the API spec changes (a new endpoint, a modified request body, a new response field), the regeneration workflow runs automatically:

  1. The service that changed emits a spec-changed signal via a GitHub Actions repository_dispatch event.
  2. Each SDK repo has a workflow that responds to this event: it fetches the new spec, runs the generator, commits the result to a regen/ branch, and opens a pull request.
  3. A human reviews the PR, verifies the wrapper layer still works with the new generated client, and merges.

The PR review step exists because generated code changes can have behavioral implications the generator doesn’t flag. A field that changed from required to optional, a renamed type, an added enum value — these all require human judgment about whether the wrapper layer needs updates.

What We’d Revisit

Test coverage for generated clients. We have 100% coverage on the wrapper layer. We don’t test the generated client code — we treat it as a black box from a trusted tool. This is reasonable but it means bugs in the generator’s output are invisible until a customer hits them.

Drift detection between SDKs. We have the “Python is reference” principle, but no automated enforcement. Nothing currently fails CI if the Ruby SDK has a method that the Python SDK doesn’t have. We’re relying on discipline, which works until it doesn’t.

Preview/alpha SDK registrations. We’d like to be able to publish pre-release SDK versions for new API features before they’re stable. The semantic-release + conventional commits approach supports this, but we haven’t set up the release candidate publishing workflow yet.

smplkit SDKs are a two-layer stack: auto-generated HTTP clients derived from the OpenAPI spec, wrapped by hand-crafted code that provides the ergonomic API surface. Python is the reference implementation; other languages port from it.