Porting a TypeScript monolith to Go without behavioural drift
I wrote the Node build, then moved it to Go myself. That sounds easier — until you notice that “I remember how it behaves” is not an acceptance criterion.
- Go
- Migration
- Testing
Moving a service that real customers depend on to another language is not a syntax exercise. By week two the shape of the work is clear: the hard part is proving the new build produces the old results, while the old build keeps running.
My case came with what looked like an advantage: I had written the original Node build myself. No unfamiliar code, no guessing at someone else's intent. Which is exactly how the trap changes shape — the semantics lived in my head, and the author's memory is the worst acceptance criterion available when the system writes directly to customer money.
Start with the pure parts
I did not port endpoints first. I ported the logic with no I/O first: data formatting, numeric rounding, field encryption, formula parsing. That is the only layer where two languages can be compared absolutely.
For encryption the bar was byte-identical output:
// Go ciphertext must match Node's createCipheriv byte for byte,
// checked against a known-answer vector generated with openssl.
func TestFieldEncryptorMatchesNode(t *testing.T) { /* ... */ }
For the formatting layer I ran the TypeScript original through ts-node and diffed case by case — including JSON key order, because something downstream read by position.
Cron is where the bugs live
"Which job is due" is always more subtle than it looks: time priority, value priority, timezone normalisation, fallback paths. This is where I wrote the densest tests, and the only place I found a genuine behavioural difference between the two implementations.
Repositories are tested with pgxmock and Redis with miniredis, so CI needs no live infrastructure and the feedback loop is measured in seconds.
The cutover has to be reversible
The Go build ran beside the Node one on the same server on different ports, with an admin toggle to switch backends. The cutover was therefore validated against real traffic, and reverting took seconds rather than being one anxious rollback.
What I got wrong at the start
I let handlers return 501 naming the owning service, as a map of domain boundaries. The idea was right; I kept that state for too long. A route that answers 501 for weeks tells nobody whether it is close to ready. Next time I would port vertically, one domain at a time, until that domain serves real traffic — and only then open the next one.
One more: I started by trusting the parts I remembered best. The only genuine behavioural differences I found sat exactly there — in the cron logic I was confident I knew by heart.