A company hands you a Go codebase nobody in-house can read. The team is strong in its main language, but Go is an island. And the code runs in production, every night, with a customer consuming the API.
I am in that situation right now. Import tools that read partner files, scheduled batches, an API consumed by a third party. Zero possible reviewer. For every line I change, nobody can tell me it is wrong.
You can survive it. But not on instinct. With a protocol.
TL;DR: without a reviewer, your safety net is the existing behaviour. You capture it before touching anything: characterization tests, golden files for the imports, a frozen contract for the API. CI becomes the reviewer. Every commit explains its why to someone who does not read Go. And documentation is a deliverable, not a bonus.
This article is for developers inheriting code with no safety net, and for CTOs handing a Go island to a freelancer. The protocol is written for a closed perimeter: tools, batches, one API. The end of the article covers what changes when the island is a microservice.
The situation is more common than people admit
A company picks Go for one precise need: a fast import tool, a service that must hold the load. One developer builds it, often alone. Then they leave, or change teams. The code stays, it works, and nobody dares touch it. Until the day a change is needed.
That is not the company's fault. It is the normal life cycle of a technical island. The problem is the moment someone has to go back in, without the author's eyes, without anyone's eyes.
My rule number one: the danger is not in the code you are going to write. It is in what you are going to break without knowing.
Day 1: the map before the territory
Before reading a single function, I list. The entry points, with go list ./... and the main packages. The dependencies, with go mod graph. The scheduled jobs, with their times. What comes in, what goes out, and who consumes what.
Then the tools that read for me. go vet and staticcheck for the obvious mistakes. govulncheck for known vulnerabilities in dependencies. They do not replace a reviewer, but they never get tired.
That first day produces no line of code. It produces one page: what the system does, and where it hurts. The tech lead can read that page, even without Go.
Characterization tests: capture what is, not what should be
A characterization test does not check that the code is right. It records what it does today. The term comes from Michael Feathers, in "Working Effectively with Legacy Code". The idea is twenty years old and has not aged a day.
For an import tool, that is a golden file. A real input file, anonymized. The output file the tool produces today, stored as is under testdata/. The test replays the import and compares byte for byte.
var update = flag.Bool("update", false, "rewrite golden files on purpose")
func TestImportGolden(t *testing.T) {
for _, tc := range goldenCases(t) { // one case per testdata/*.in
got := runImport(t, tc.input)
goldenPath := filepath.Join("testdata", tc.name+".golden")
if *update {
os.WriteFile(goldenPath, got, 0o644) // go test -update
continue
}
want := readFile(t, goldenPath)
if diff := cmp.Diff(string(want), string(got)); diff != "" {
t.Fatalf("behaviour changed for %s:\n%s", tc.name, diff)
}
}
}
If today's output contains a bug, the golden file contains the bug. On purpose. The day you fix it, the test goes red, you update the golden file in the same commit, and the diff shows exactly what changed. A documented bug beats a surprise bug.
For a command-line tool, testscript does the same thing at shell level. A script that runs the command with input files, and compares the output. The full behaviour, flags included, captured in ten lines.
Freeze the API contract before touching anything
The API is consumed by a third party. That is the point where a regression becomes an incident at someone else's place. I freeze it before the first change.
Concretely, one test per route that records the current response: status code, useful headers, body, for a real set of requests. Same golden principle, over HTTP, with httptest. Then I derive a contract document in OpenAPI format, from what the code really does, not from what people believe.
I get that document validated by the API's consumer. That is the only reviewer that counts for this part: the one who breaks if I am wrong. Once validated, the contract is a border. Everything inside is frozen. Everything outside, I can change.
CI is your reviewer
With no human to review, CI has to say no in my place. It runs on every push: build, go vet, staticcheck, the golden tests, the contract tests, govulncheck. And go mod tidy -diff, so no dependency moves silently.
It runs before I have written a single line of feature. The order is what matters: CI first, code second. A CI that arrives later only protects the end of the project.
And deployments never happen from my laptop. From CI, from a tag, with a rollback written before the first production release. The first deployment of code you do not know is prepared like an operation.
Write the review you cannot receive
The tech lead does not read Go. But they read plain language, and they know the business better than I do. So every PR starts with a paragraph they can review. What it changes for the user, what it does not change, and how I verified it.
Small commits, one subject per commit. A refactor never mixes with a feature. A bug found along the way becomes a note and a test, not a fix slipped into another diff.
Decisions that commit the future, I write in a short file, one per page: the context, the option chosen, what I ruled out. That is an ADR. In six months, whoever takes over will know why, not just what.
What I refuse to do
No rewrite. Code that has run for years is right about details I do not know yet. Replacing it means losing those details all at once.
No framework change, no dependency bumps for comfort. Only security updates, and one at a time.
No opportunistic cleanup. An ugly function that works stays ugly until a real need touches it. Every change must be justified by a requested evolution or a red test. Without that, it is free risk.
What if the island is a microservice?
Everything above assumes a closed perimeter. Files in, files out, one API. A microservice has no such luck. Its behaviour is what it exchanges with its neighbours: HTTP or gRPC calls, messages on a queue, a shared database. The protocol stays. Every step changes shape.
The map no longer comes from go list. It comes from production traces and logs: who calls this service, whom it calls, which message topics it reads and writes. A service whose neighbours you do not know is not mapped.
The golden file becomes a recording of exchanges. You capture real requests and real responses from the neighbouring services, anonymized, and replay them against stand-ins. Heavy dependencies, database or queue, run in test containers with Testcontainers. The test then says: for these inputs, the service produced these calls and these messages, in this order.
The contract to freeze is twofold. What the service exposes, as with the monolith's API. And what it consumes: the neighbours' responses and the message formats. A change on either side breaks silently.
And here, you do have reviewers: the neighbouring teams. They do not read your Go, but they review your contract, and that is the one that matters. Formalize it with consumer-driven contract tests. Each neighbour writes what it expects from you, your CI checks it on every push.
The release becomes the real review. Deploy the new version next to the old one, on a share of the traffic, and compare outputs before switching. In microservices, rolling back code is easy. Rolling back a message already published in a new format is not. Your rule: the service reads the old format and the new one for the whole transition.
Documentation is a deliverable, not a bonus
On the last day of the assignment, the code will be orphaned again. So I prepare the exit from the entrance. An operations guide: how it runs, where the logs are, what to do when an import fails at three in the morning. A development guide: how to run the tests, update a golden file, deploy. And the list of decisions.
The exit test is simple. A developer who does not know the project must be able to ship a small fix in one day, with these documents and the CI. If not, the assignment is not finished.
The checklist
Capture the behaviour, freeze the contract, let CI say no.
- [ ] Day 1: the system map before any change (inputs, outputs, batches, consumers)
- [ ]
go vet,staticcheck,govulncheckas a first read - [ ] Golden tests for every import tool, on real anonymized data
- [ ]
testscriptfor command-line tools - [ ] One test per API route that records the current response
- [ ] An OpenAPI contract derived from the real code, validated by the consumer
- [ ] CI in place before the first feature,
go mod tidy -diffincluded - [ ] A paragraph readable without Go at the top of every PR
- [ ] One ADR per structural decision
- [ ] No rewrite, no cleanup without a need or a red test
- [ ] The rollback written before the first deployment
- [ ] Microservice: the map comes from production traces, not from
go list - [ ] Microservice: contract frozen on both sides, exposed and consumed, checked by contract tests
- [ ] Microservice: new version in parallel on a share of the traffic, both formats read during the transition
- [ ] Operations and development guides delivered, tested by someone who does not know the project
What to remember
Without a reviewer, discipline replaces the second pair of eyes. Capture the behaviour, freeze the contract, let CI say no, write for the person who does not read the code.
It is not slower. It is what lets you move fast without breaking something nobody will know how to fix.
Got an orphaned Go island in your company? Let's talk.
Sources: Michael Feathers, "Working Effectively with Legacy Code" (2004) · testscript documentation · go-cmp documentation · net/http/httptest · Go 1.23, go mod tidy -diff · Architecture Decision Records · method applied on a current assignment, client details deliberately left out
Top comments (1)
Nowadays you could use AI to do some review work, so you gain that extra pair of eyes.