Skip to content

When to Export a Go Symbol

Adityo Guni Waluyo

A test migration into external packages doubles as an API audit: three doors between public-path rewrites, real exports, and export_test.go.

TL;DR

Moving tests out of a package forces every internal function through one of three doors: rewrite via public paths, make a real export, or use export_test.go. A supposedly test-only commit exposed four importer functions worth exporting, revealing hidden API value. Test migrations double as free API audits, with zero exports signaling a healthy public interface.

While reviewing the diff of an importer test move, I stopped at something odd: four production files changed inside a commit that was supposed to move test files only. Function names inside the data-import module changed, and their callers in several other files were adjusted along with them.

My first guess was simple: moving tests would not touch production code. The earlier batches proved exactly that, zero exported symbols, every test rewritten through public paths. I expected this batch to be the same. Instead, four functions got real exports.

storeMediaFile          -> StoreMediaFile
isDuplicateEntry        -> IsDuplicateEntry
buildFullDescription    -> BuildFullDescription
moduleMap               -> ModuleMap

Every caller in production code was updated at once.

The export rule and its perpetual contract

The mechanics are strict and simple. According to the Go language specification, an identifier is exported when its first character is a Unicode uppercase letter and it is declared at package block, field-name, or method-name level [1]. Everything else stays unexported, invisible to other packages.

Technical ability is not a reason. The official Go blog recommends minimizing the exported interface: users quickly come to depend on every exported type, function, variable, and constant, and that becomes an implicit contract you must honor in perpetuity or risk breaking their programs [2]. Google's style guide completes the picture: types used only internally should stay unexported [3]. One capital letter at the front of a name opens a door that is hard to close again.

This is where the black-box constraint from the test migration works as an audit tool. Once tests move into an external package, every internal function a test uses needs an answer: which door does it enter through? There are three doors, and the choice reveals the function's real API value.

The interesting case was on the integration side. The old integration helpers reached into an unexported database field straight from the importer struct. The new version injects the connection through a test-setup utility that receives the database handle explicitly, and media seeding was replaced with a direct insert. Tests no longer depend on struct details, and the struct is free to change without breaking tests.

Three decision doors

Door one: rewrite the test through an existing public path. That is what happened in nearly every migration batch. Handler tests drive the real router, small helper tests get covered as soon as the public Create and Get versions run. Zero production changes, and that is the sign of a healthy public API.

Door two: a real export, because the function has API value beyond tests. The four importer functions belong here. Duplicate detection that handles database deadlock cases, description sanitization that closes an XSS hole, media file storage with path-traversal guarding. Logic like this stands on its own and deserves to be part of the module's public interface, production callers included.

Door three: the export_test.go file as the last-resort valve. When an internal is needed only by tests and worthless to other callers, the sanctioned pattern is re-exporting that symbol from a test file inside the package, the way the bufio package in the standard library does it [4]. The intent is honest and concentrated in one auditable place.

The unplanned audit

Comparing batches makes the lesson sharp. A batch with zero exports means the public paths were already enough. A batch needing four exports means valuable logic had been hiding in there. The need to export appears exactly where a function has real value outside tests, not where tests are lazy.

I will not claim those four new names are the final best shape of that API; that verdict only comes from production use. What is certain: every export now exists for a documented reason visible in the diff, not by accident of test access. I will read the next test migration as a free API audit, something that usually needs a dedicated review session. When the audit finds nothing, that is good news too: the public API was healthy all along.

Sources

Related articles