skip to content

How do you decide a Go package's exported surface when many teams import it and unexporting later breaks them?

level: principalimportance: nice to knowfreq 28%

answer

  1. the two directions do not cost the same
  2. adding is free, removing is not
  3. answer the use case, not the identifier
  4. under-exporting has its own bill: forks
  5. the surface diff needs a named reviewer

basics

~20 s

Export the smallest set of names that lets consumers do their job, and treat every capital letter as a promise you cannot withdraw cheaply. Start names unexported, export on a concrete request, and review the exported diff at every release.

solid answer

~50 s

The decision rests on an asymmetry: adding an exported name later is additive and breaks nobody, while removing one breaks every importer's build and, for a published module, costs a major version. So bias toward under-exporting and let demand pull names out, one justified request at a time. When a team asks for something, ask what they are trying to do rather than granting the identifier they named, and expose the narrowest thing that serves it, because a helper type handed over today is a type whose shape you owe them forever. Prefer exporting behaviour over representation where you expect the representation to move, and export plain fields where the type really is data. The counter-pressure is real and you must weigh it: an under-exported package gets forked, copied or worked around, and then you have several implementations instead of the one you were protecting. Finally, make the surface a reviewed artefact — a diff of `go doc -all` between tags, with an owner who can say no.

go deeper

for a junior

Know that exporting a name means other teams may start depending on it, and that this is a decision to raise in review rather than settle alone.

for a middle

Be ready to explain why adding an exported name is cheap and removing one is expensive, and to spot a type that entered the surface only because an exported function returns it.

for a senior

Show that you answer export requests by use case and expose the narrowest thing that serves it, and that you can name the concrete cost of getting it wrong in either direction.

for a principal

Own the policy: a default-closed surface with a fast path to open, a named reviewer for the surface diff at each release, and an explicit position on when reclaiming a name is worth a major version to your consumers.

## What makes this a judgment call rather than a rule Go gives you two visibility states and no way to say "exported to these three packages". So every name is either invisible to your consumers or permanently available to all of them, and the two directions cost wildly different amounts: - **Adding** an exported name is additive. Every existing importer keeps compiling; nobody is forced to care. - **Removing** one breaks every importer at compile time, and for a module consumed outside your build it means a major version, which under Go's module rules changes the import path itself. An asymmetry that steep has an obvious dominant strategy: when in doubt, do not export. You can always say yes later, and saying yes later is free. What you cannot do is take it back. ## The failure mode on the other side Under-exporting is not automatically safe, and a principal-level answer has to say so. A package that refuses to expose what consumers genuinely need produces predictable damage: teams copy the code into their own repositories, vendor a fork, reimplement it slightly differently, or route around you with reflection and struct copying. Now you have four implementations of the thing you were guarding, no way to fix a bug in all of them, and no visibility into who depends on what. That is a worse outcome than having exported a type. So the posture is not minimalism for its own sake. It is: default closed, with a fast and genuinely available path to open, and someone whose job is to answer those requests quickly. A month-long wait for an export request is what turns a well-scoped package into a forked one. ## How to answer a request to export something When a team asks you to export an internal type, the request is usually stated as an identifier and should be answered as a use case. Ask what they are building. Three outcomes are common: 1. **A narrower function serves them.** They asked for the internal builder; what they needed was one call that does the thing. Export the call. Your representation stays yours. 2. **They genuinely need the type.** Export it deliberately: give it a doc comment that says what is guaranteed, add a test that pins the behaviour they are relying on, and record that this is now supported. 3. **They are working around a gap somewhere else.** The export would paper over a design problem that belongs in another package. Fix that instead. What you should not do is export the identifier they named because refusing feels obstructive. The identifier is your representation; the use case is their requirement, and only one of those two is your problem to satisfy. ## Behaviour versus representation Exporting a function or method commits you to *what happens*. Exporting a struct field or a concrete type commits you to *how it is shaped*. For anything you expect to reimplement — a storage layout, a cache, a wire representation — export the behaviour and keep the shape unexported, so the reimplementation is invisible. For a type that genuinely is data, such as a decoded payload or a configuration struct, exported fields are the kinder API and the Go standard library uses them freely. Remember that a type can join your surface without you ever exporting it directly: if an exported function returns it or takes it as a parameter, it is in the contract. Reviewing signatures, not just declarations, is part of the job. ## Making it operational - **Review the surface, not only the diff.** At release time, diff `go doc -all` against the previous tag. That diff is the API change, and it deserves a named reviewer. - **Say what stability means.** Go has no language-level marker for "experimental", so it is documentation plus convention: an explicit note on the package or the name, or an unstable API kept in a package of its own so importing it is a visible decision. - **Watch for under-export signals.** Copies of your code turning up in consumer repositories, a backlog where every request is "please make X public", or consumers using reflection to reach your internals — each is evidence that the closed default has stopped being free. - **Decide who pays for a major version.** Reclaiming a name costs consumers real migration work. Whether that is worth spending is an organisational call, not a technical one, and it should be made by someone who will also be answering for the migration.

  • Why is adding an exported name safer than removing one?
    An addition is invisible to existing importers: their code still compiles and behaves the same. A removal fails their build, and for a module they upgrade independently it forces a major version, which in Go changes the import path. That asymmetry is the whole argument for defaulting to unexported.
  • A team asks you to export an internal helper type. What is the defensible response?
    Find out what they are building before answering. Often a narrow exported function serves the use case and keeps your representation private. If they really need the type, export it deliberately with documentation and a test that pins what they depend on. Exporting the identifier they named, unexamined, is how surfaces sprawl.
  • What signals that you have under-exported?
    Copies or forks of your code appearing in consumer repositories, a request backlog where every item is "make X public", and consumers reaching your internals through reflection or duplicated structs. At that point the closed default is no longer free: you have several implementations to fix instead of one.
  • How do you signal that an exported name is not stable yet?
    Go has no language-level marker, so it is documentation and placement. Say so plainly in the doc comment, or put the unstable API in a separate package so importing it is a visible decision a reviewer can see. Whatever you choose, be consistent, because consumers will treat silence as a promise.

saying these in an interview costs you the question

  • Exports everything so consumers are never blocked
  • Treats minimal surface as an absolute with no cost
  • Assumes an exported name can be quietly dropped in a patch release
  • Grants the identifier a team asked for without asking why
  • Leaves the exported surface unreviewed at release time