Lindsay Edwards

Isolate the runtime, codegen the contract

On this page

There is a Python machine learning service that I deliberately kept out of the JavaScript workspace. On purpose, with some satisfaction, because the alternative was worse.

The service drags in torch and CUDA and the whole heavyweight dependency tree that comes with them. If that lived inside the JS monorepo, every developer would install it, every build would reason about it, and the two ecosystems would be chained to each other’s version choices forever.

The isolation was the right call#

So I did not do that. The Python service sits on its own, reached only over HTTP through a thin bridge package on the JS side. The JS install never pulls a gigabyte of ML dependencies it will never run. The build graph never has to think about CUDA. And the two ecosystems version independently, which is the part I would fight to keep, because upgrading torch has nothing to do with bumping a JS framework and the two should never block each other.

If you have ever watched a polyglot repo where one language’s dependency tree poisons the other’s install times, you know why this matters. The boundary was an HTTP call, and an HTTP call is a lovely, clean seam. Each side stays in its own world.

That much I got right, and I would do it again.

What the seam quietly cost#

Here is the part I got wrong, and did not notice until the shapes drifted.

Nothing enforced the contract. The request and response shapes were hand-mirrored. On the Python side they were Pydantic models. On the JS side they were TypeScript interfaces. Two hand-written descriptions of the same messages, living in two different files, in two different languages, with nothing connecting them.

Which means they agree only as long as two humans keep them agreeing. Add a field on the Python side and forget the TypeScript one, and nothing complains. Not the compiler, not the install, not the build. The types still pass on each side in isolation, because each side is internally consistent. They are just consistent about different things.

A boundary that both sides describe by hand is a boundary that will drift. The only question is whether you find out in a review or in production.

The failure, when it comes, is the worst kind. It is not a crash at build time where you would want it. It is a runtime surprise, on a field somebody renamed weeks ago, surfacing on a request you were not looking at. For a user that lands as a feature that simply stops working, and for you as a bug that will not reproduce, because each side looks perfectly correct on its own.

Isolate the toolchains, share the contract#

The lesson I took is narrow and I think it is right: isolating a heavyweight runtime is good, and having no shared contract across the boundary is a separate mistake that just happened to ride along with it. Do not talk yourself out of the isolation because of the drift. Fix the drift.

The shape of the messages should be defined once, in a language-neutral schema, and generated into both sides. OpenAPI, JSON Schema, protobuf, the specific tool matters less than the property: one source of truth, and both the Pydantic models and the TypeScript interfaces produced from it. Rename a field in the schema and both sides regenerate, or one side fails to build. Either way the drift becomes visible at the moment you cause it, not a fortnight later.

So the two rules sit side by side.

Isolate the runtime, so the ML service’s weight never leaks into the JS ecosystem and the two version on their own schedules.

Codegen the contract, so the seam that separates them cannot silently disagree about what is crossing it.

I had the first one. I have both now.

Keep reading