A helper type your Go package exported by accident is now imported by three other teams. How do you shrink that surface?
answer
- exporting is a one-way door
- measure before you cut
- read the hits, do not count them
- replace the need, not the symbol
- close the door so it cannot leak again
basics
~10 sNot unilaterally. Find every real use, give those callers a narrow replacement, port them, then move the implementation behind internal/. Removing an exported name breaks importers' builds.
solid answer
~50 sExporting is a one-way door, so treat this as a migration, not a delete. Start by measuring: `go doc` on the package lists what you promise today, and a code search across the consuming repositories for the qualified identifier shows who uses it and how — usually most hits are one copied pattern and a couple are load-bearing. Then design a replacement for what those callers actually needed, which is normally far narrower than the type they reached for, and port the consumers yourself; a patch you send beats a deprecation you announce. When the last use is gone, move the implementation into a package under `internal/` so it cannot leak again, and delete the exported name — on a public module that removal belongs in a new major version. Then ask why it leaked: usually a helper was exported for a sibling package, which is `internal/`'s job.
code
text · 5 lines# what does the package promise right now?
$ go doc example.com/migrate
# who outside the module actually uses one of those names?
$ grep -rn --include='*.go' 'migrate\.SQLFile' ~/src/service-a ~/src/service-b ~/src/batchgo deeper
Understand the core fact: once another package imports an exported name, removing it stops that package from compiling. That is why exporting is treated as a commitment.
Be able to describe the order of work — measure the current surface, find the real uses, offer a replacement, then remove — and to say why an internal/ package prevents the same leak recurring.
Show that you would read the call sites rather than count them, design the replacement around what callers needed, and carry the migration into the consuming repositories yourself instead of announcing it.
Argue the economics: the bill was incurred at export time, prevention costs one review comment, and removal on a public module means a major version. Decide who signs off on that spend.
## Why this is hard An exported identifier is not a fact about your code, it is a promise to other people's builds. Once three teams import `migrate.SQLFile`, deleting it does not simplify your package — it breaks three build pipelines. So the work is not a refactor, it is a migration with other people in it, and the sequencing matters more than the code. ## Step 1: find out what you actually promised Before touching anything, get the real list. `go doc example.com/migrate` prints the package's exported symbols with their doc comments — that output *is* your API, and reading it in one sitting is often the first time anyone has. Expect surprises: helper types, a constant that was exported to be used in a test, a struct field raised to upper case for a one-off. ## Step 2: find out what is actually used A code search across the consuming repositories, per identifier, is the diagnostic that decides everything after it. Grep for the qualified name — `migrate.SQLFile` — across every repository that imports the module, and read the hits rather than counting them. Typical outcome: - most hits are the same call, copied between services, doing something your package could do for them in one line; - one or two hits use the type in a way you never imagined, and those are the ones that will fight you; - some hits are dead code, or tests of dead code. That distribution changes your plan. Thirty mechanical uses of one pattern means you write one replacement and send thirty small patches. Three genuinely different uses means three conversations. ## Step 3: replace the need, not the symbol The mistake is to offer the same thing under a different name. Ask what each caller was trying to do. If they exported-type-juggled their way to "parse these files and tell me the order", the replacement is a function that answers exactly that, returning a small result type you are happy to own. The new API is usually smaller than the accidental one, because you are designing for observed needs rather than exposing an implementation. ## Step 4: move the callers In an organisation where you can see the consuming repositories, send the patches. This is the step teams skip in favour of announcing an intention and waiting, and waiting is how a two-week migration becomes a two-year one. Do the work; the diff is usually tiny in each repository, and you are the person who understands both sides. ## Step 5: close the door behind you When the last use is gone: - move the implementation into a package under `internal/`, so the same leak cannot happen again by a future edit; - delete the exported name from the outer package; - if the module is public and versioned, the removal is a breaking change and belongs in a new major version path, not a patch release. Inside a single organisation with a monorepo and one build, the coordination you already did is the release process. An intermediate step that buys time: leave an exported alias or a thin exported wrapper in the outer package while the implementation lives under `internal/`. Existing call sites keep compiling, new code cannot reach the internal package, and you delete the alias when the last caller is gone. ## Step 6: fix the cause Almost every accidental export has the same origin: a helper was raised to upper case so that a *sibling package of your own* could use it. That is exactly the problem `internal/` solves — an internal package can export freely without promising anyone. Ask, at review time, of every newly capitalised name: who outside this module is meant to call this? If the answer is nobody, the letter goes back down or the code goes under `internal/`. ## What to tell the person who wants to just delete it They are right about the goal and wrong about the cost. The surface is genuinely too large and shrinking it is genuinely valuable — a smaller API is less to document, less to keep working, less to test, and less to reason about when changing the implementation. But the bill for exporting was incurred at the moment of export, and the only way to pay it down is the migration above. The cheap version of this work is prevention: it costs one review comment before the merge, and weeks afterwards.
- The consumers are in repositories you cannot send patches to. What changes?The sequencing stays, the timeline does not. You publish the replacement first, keep the old name working, and the removal waits for a new major version path — which importers adopt on their own schedule. Until then you are supporting both, and that cost is the argument for exporting less next time.
- How do you stop the surface from growing back?Make the exported set visible. Review newly capitalised names as a category, ask which external caller needs each one, and default new helpers into a package under internal/ where your own code can use them without promising anything.
- Is there a fast way to see whether an exported symbol is used at all?Search the consuming repositories for the qualified name, and check your own package too. A symbol used only by your own code and its tests is a free win: unexport it, or move it under internal/, and nobody outside notices.
saying these in an interview costs you the question
- Deletes the exported name and calls it the consumers' problem
- Counts search hits without reading what they do
- Re-exports the same type under a nicer name
- Announces a removal instead of porting the callers
- Assumes a patch release can drop an exported symbol
- Ships the replacement without moving the implementation under internal/