Why we commit generated code to our SDK repos
We ship SDKs in six languages: Python, TypeScript, Go, Java, C#, and Ruby. Keeping six clients in sync with one API — consistent naming, consistent types, consistent behavior — is a coordination problem that could eat as much engineering as the API itself. Our answer is to generate as much as possible from the OpenAPI spec, and then do something that reliably raises eyebrows in code review: commit the generated code to git.
The two layers
Every smplkit SDK is the same sandwich:
Layer 1: the generated client, produced by running a generator against the OpenAPI spec. It handles HTTP, authentication headers, request and response serialization, error handling at the HTTP level, and the typed model classes. Nobody writes this code and nobody maintains it; it’s regenerated whenever the spec changes.
Layer 2: the hand-crafted wrapper — written by humans, reviewed by humans, unit-tested by humans. This is the surface customers actually call: client.flags.get("checkout-v2"), client.config.resolve("service-config"). It owns everything the generator can’t know about: WebSocket connections for live updates, caching, context management, SDK configuration.
Customers only ever see Layer 2. Layer 1 lives under a path whose name is the documentation — src/smplkit/_generated/ in Python.
Why the generated code is in git
The conventional practice is to generate at build time and keep the artifacts out of version control. We do the opposite, on purpose.
Generate-at-build adds dependencies to every build: the generator tool, the spec, sometimes a network call to fetch the spec. Each one is a new way for CI to fail in a manner that’s miserable to debug. But the bigger cost is invisibility: when the spec changes, the generated client changes, and with build-time generation that diff happens somewhere no reviewer ever looks.
Generated code that’s committed gets blamed, diffed, and bisected like any other code. When a spec change produces surprising generated output — which happens — it shows up in an ordinary PR review instead of surfacing later.
Picking generators
For Python we use openapi-python-client, after evaluating the standard Java-based openapi-generator and datamodel-code-generator. It produces idiomatic Python with Pydantic v2 models, runs without a JVM, and its output is clean enough that we’re not embarrassed to commit it — which, given the previous section, is an actual requirement.
For TypeScript, Go, Java, C#, and Ruby we use openapi-generator-cli with language-specific templates. Output quality varies by language; the wrapper layer exists partly so customers never find out by how much.
Python is the reference, because drift already happened
We have a rule: the Python SDK is the reference implementation, and every other SDK is the Python SDK ported to that language’s idiom.
The rule exists because we shipped the first five SDKs without it, and they drifted — different method names for the same concept, different error-handling behavior, showcases exercising different code paths. Subtle stuff, but realigning them was costly enough that we wrote the rule down.
In practice: before implementing any SDK feature in Ruby or TypeScript, look at how Python does it and do that. Genuine idiom differences — Ruby’s ? suffix on boolean methods, Go’s error returns — are allowed, but each deviation gets documented explicitly in an ADR. It sounds rigid. It’s also why shipping a feature across six SDKs is one design plus five mechanical ports instead of six designs.
One configuration story
All six SDKs resolve configuration through the same chain:
- Constructor arguments (highest priority)
- Environment variables (
SMPLKIT_API_KEY,SMPLKIT_ENVIRONMENT,SMPLKIT_SERVICE) - A profile file —
~/.smplkit, INI format,[default]or a named profile - Built-in defaults (lowest priority)
The profile file is our ~/.aws/credentials: put the API key and default environment there once, and every test script and example stops needing them. Because every SDK has the same options with the same names (translated to each language’s casing convention), one documentation example covers all six languages:
# Python — reads SMPLKIT_API_KEY from environment, or ~/.smplkit profile
client = SmplClient(environment="production", service="my-svc")
Versioning, including the part where we game it
Nobody sets version numbers by hand. semantic-release derives them from conventional commits: feat: bumps minor, fix: bumps patch, a BREAKING CHANGE: footer bumps major. On merge to main, CI computes the version, updates the manifest, tags, and publishes to PyPI, npm, Maven Central, NuGet, or RubyGems.
And now the confession: at the moment, every SDK commit is a fix:. Not because every change fixes something — because the platform is pre-general-availability, and we refuse to publish a 2.0.0 of anything before a customer exists to care. So we constrain the commit types and keep every SDK in the patch range. We are, deliberately and with a straight face, feeding semantic-release a version of events that keeps it calm. The constraint lifts when we open for business.
When the spec changes
Regeneration is automatic. The service whose spec changed emits a GitHub Actions repository_dispatch event; each SDK repo has a workflow that responds by fetching the new spec, running the generator, committing to a regen/ branch, and opening a pull request. A human reviews and merges.
The human step matters: a field that went from required to optional, a renamed type, an added enum value — the generator won’t flag any of these as interesting, and every one of them can require wrapper changes. The diff review is where that judgment happens, which is also the strongest argument for the diff being visible at all.
What we’d revisit
We don’t test the generated layer. The wrapper has 100% coverage; the generated client is treated as a black box from a trusted tool. Reasonable, until a generator bug ships something subtly wrong and the first test is a customer.
Drift detection is still manual. “Python is the reference” has no automated enforcement — nothing fails CI if the Ruby SDK grows a method the Python SDK doesn’t have. We’re relying on discipline, which works until it doesn’t.
No pre-release channel. We’d like to publish alpha SDK versions for new API features before they stabilize. Semantic-release plus conventional commits supports it; we haven’t built the release-candidate publishing workflow.