DEV Community

Cover image for Goalchemy: Write Your SDK Once in Go, Ship It in Seven Languages
Eugene Yakhnenko
Eugene Yakhnenko

Posted on

Goalchemy: Write Your SDK Once in Go, Ship It in Seven Languages

Why semantics and runtimes, not syntax, keep SDKs apart, and what changes when you write them down as a spec.

Syntax is not the problem

This idea has been living rent free in my head for a long time.

Most mainstream languages share the same skeleton: functions to group work, and if, for and while to steer it. Sure, every language has its quirks. C++ has friend classes, C# has delegates, Python uses indentation to open a scope. You get the idea. Syntax is the easy part.

The jungle is everything underneath. How values are copied or shared. What an integer is: Python's are unbounded, JavaScript's are doubles, Java has no unsigned types. How strings are encoded: UTF-8 in Go and Rust, UTF-16 in Java, C# and JavaScript. How errors travel: exceptions in some languages, returned values in others. Integer overflow wraps in Go and Java, panics in Rust debug builds, and is undefined behavior in C.

Then come the standard library and the execution model. The same operation has different arguments, different error models and different defaults everywhere. One ecosystem reaches for async by default, another for blocking calls, and JavaScript in the browser can't read a file at all. There is no consensus, and there never will be.

So porting code from one language to another is never mechanical. And if you want the result to feel idiomatic, you have to buy into each language's uniqueness on its own terms.

Ten SDKs, not one

Now imagine you are a platform engineer and your product needs SDKs in ten languages.

All those differences mean you are not maintaining one SDK in ten languages. You are maintaining ten SDKs. In practice that looks like one reference implementation that gets the attention, and nine ports that slowly drift: their own bugs, their own reading of edge cases, often one engineer who really understands each one.

The cost grows with languages × features, and parity depends on discipline. Shared test vectors and interop jobs help, but they don't change the slope. Every new feature is still paid for once per language, and every new language is still paid for once per feature.

What I wanted was a way to change the unit of work itself, so that adding a feature or a language stops multiplying.

Goalchemy

What if that isn't the only way?

Goalchemy is an open source transpiler, licensed under Apache 2.0. You write your SDK's logic once, in a restricted subset of Go, and it generates Go, TypeScript, Python, Java, C#, Rust and C packages with the same observable behavior. It is still in alpha, but it is already carrying real work.

Go makes a good source language. It is small and explicit, and the standard tooling hands you a fully type-checked program for free. Most importantly, a Goalchemy program is just ordinary Go: no new syntax, no custom parser. It builds, runs and tests with the standard Go toolchain, and that native run becomes the oracle every other target is measured against.

The subset is deliberately strict. Reflection, unsafe and anything else outside the language spec is rejected at compile time with a stable diagnostic code and a one-line remedy. Deciding what not to support turned out to be the highest-leverage design work in the whole project. Every construct left out is a semantic that never has to be made identical seven times.

What is supported behaves like Go on every target. int and uint are always 64-bit with Go's wrapping rules. Floats keep their IEEE precision, signed zero and NaN included. Strings are immutable byte sequences. Structs and arrays copy by value, even on hosts that share objects by default. Errors are values, and panics, defer and recover follow Go's rules.

Narrowing the language tames part of the jungle. The compiler lowers your program to an intermediate representation that makes evaluation order, copies and aliasing explicit, so no target ever has to guess. That still leaves the standard library and the execution model.

But what about the runtime?

This is where Goalchemy does its real work.

Goalchemy replaces the Go standard library with packages that keep the standard names, so your code still reads and runs as ordinary Go. They come in two kinds:

  • Pure logic (strings, strconv, bytes, sort, encodings) is written once in the Goalchemy subset and compiled along with your program. Nothing to port, nothing to drift.
  • Capabilities that reach the host (crypto, HTTP, clocks, sync) have a small native implementation per target, each one backed by a contract.

The same goes for the language runtime itself: integer division, string slicing, map lookup and channel send are all runtime functions with one implementation per target and a contract.

The contract is plain data. It describes the signature, the behavior on nil, bounds, overflow and errors, and a list of concrete test cases with inputs and expected outputs. Every target ships a small harness that runs those cases, and a runtime function only counts as supported when the same cases pass everywhere, byte for byte.

Goalchemy keeps its own library small on purpose. Anything specific to your domain belongs in your SDK, not in Goalchemy. So it gives you the same framework it uses internally: declare a capability with a Go signature, write its contract, implement it natively per target, and it gets checked exactly like Goalchemy's own.

"Hold on," you might say. "I still maintain those capabilities myself, in every language."

Right you are. But look at what changed.

Your SDK's actual logic, the part that encodes your product, now exists exactly once, and it reads like everyday Go. What you maintain per language is a short list of small, isolated host functions. And each of them comes with a spec that every language has to satisfy, case by case. A behavior isn't supported because someone wrote the TypeScript version and it looked right. It's supported when the cases pass on all seven targets. Adding a capability becomes "write the contract and its cases, then make seven small files pass them."

The languages check each other

Contracts prove individual functions. Real programs need more, because the bugs that matter usually show up only when operations combine.

Since every Goalchemy program is valid Go, the test suite runs each program natively and on every target, and the output must match exactly. On top of that, a fuzzer generates random, type-valid programs and runs them the same way. When one language disagrees with the rest, a reducer shrinks the program down to a minimal reproducer, and it becomes a permanent regression test on every target.

That is the part I find genuinely beautiful. Your runtime functions aren't just unit tested. The collection of languages becomes its own oracle. Any one target that drifts gets outvoted by the other six and by native Go, automatically, long before a customer finds it.

The same thinking extends to concurrency. Rather than mapping goroutines onto seven different async and threading models that can never be tested for parity, every target runs the same deterministic cooperative scheduler, with channels, select and context included. Any interleaving can be reproduced from a seed, in any language.

Proving it on a real SDK

A transpiler is only as good as the work it can carry. Goalchemy's motivating workload, and its first real test, is OpenTDF: encrypt and decrypt SDKs in all seven languages, interoperable in both directions with the official SDKs through a live key access server.

This is not a thin REST wrapper. It involves segmented authenticated encryption, integrity hashes, key wrapping, signed manifests, ZIP containers and an authenticated key rewrap protocol. Get any detail subtly wrong and another SDK can't open your files. That made it a good test: if the approach could hold up here, it could hold up for most SDKs.

I sequenced the work so that I never had to debug two unknowns at once. First I confirmed the official SDKs interoperated with each other. Then I built the SDK logic once in the Go subset and verified it natively against the live server. Only after that did I transpile it, and run the full matrix: every target encrypts files the official SDKs can read, and decrypts files they produce.

The SDK logic exists once. Domain pieces like the ZIP container live in the SDK, and the per-language work is a small set of host adapters, each one held to its contract.

How fast is it?

Parity would mean little if the generated SDKs were too slow to use. They aren't. Here is the median time for a full encrypt and decrypt round trip through a real key access server, in milliseconds:

SDK 1 MiB 10 MiB 50 MiB
Official OpenTDF Go 36.28 88.27 217.77
Official OpenTDF Web (Node) 216.69 932.65 4444.67
Generated Go 72.83 83.18 266.32
Generated TypeScript (Node) 96.57 199.28 750.11
Generated Java 87.70 142.18 401.28
Generated C# 190.50 211.22 537.33
Generated Python 141.06 186.54 691.28
Generated Rust 61.66 127.14 614.88
Generated C 148.24 254.38 555.77

Every generated SDK beats the official Web SDK on Node at every size. Generated TypeScript, the closest like-for-like comparison, is nearly six times faster at 50 MiB. Generated Go stays close to the hand-written Go SDK, and even edges it out at 10 MiB.

The comparison isn't perfectly level. The official Go SDK reuses a client initialized before timing, while the generated SDKs' setup and session key generation are timed, which accounts for most of the gap at 1 MiB. The official Web SDK's per-decrypt RSA key generation is timed. Each cell pools 15 measurements across three fresh processes after warmup, and every archive was also checked by the official Go SDK. The numbers were taken with Goalchemy v0.2.1; the full methodology is in the opentdf-sdk repository.

What it costs

None of this is free, and it's worth being honest about the trade.

  • Goalchemy is young. It's in alpha and still needs work. Adopting it today means betting on a project that is still maturing, and ideally helping it get there.
  • The runtime library has gaps. fmt, encoding/json, net/url, file access and richer time handling are on the roadmap but not there yet. Until they land, an SDK provides what it needs as its own capabilities.
  • Generated code is readable, but isn't what a native expert would write. That's acceptable behind a small, hand-written SDK façade.
  • Performance is strong, not hand-tuned. A native expert can still win in places, such as small payloads where setup cost dominates.
  • Making libraries feel native in every ecosystem is a long tail.

What I'd take away from it

  • Change the slope, not the point. A cost that recurs with every feature and every language deserves a structural fix, not more diligence.
  • The verification system is the product. Most of the design effort went into how I would know it was right, not into the compiler itself.
  • Scope by rejection. Clear, actionable "no"s were the most valuable decisions I made.
  • Write semantics down as data, then let the implementations compete against it.
  • Delegate with acceptance gates. Much of the implementation was done by delegated workers, including AI coding agents. Mechanical definitions of done made that safe.

If you maintain the same logic in more than three languages, you are already paying for drift, whether you measure it or not. Goalchemy is open source. Try it on a small piece of your SDK, run one of the examples on all seven targets, or help close the gaps on the roadmap.

Links

  • Goalchemy: the transpiler, its runtime library and contracts.
  • opentdf-sdk: the OpenTDF SDK in seven languages, generated with Goalchemy, with benchmarks and methodology.
  • OpenTDF: the open protocol and platform the SDK implements.

Top comments (0)