Your team publishes a Go library other teams import. How do you decide what belongs in the exported API, and what does each exported name commit the team to?
answer
- the exported set is a budget, not an inventory
- who, by name, needs this?
- adding is additive, removing is a migration
- our other package needs it has its own answer
- review the new capital letters as a category
basics
~20 sExport the smallest set a real caller needs; everything else goes under internal/. Each exported name commits the team to documenting, keeping and supporting it, because removal breaks importers while adding one later is safe.
solid answer
~50 sI treat the exported set as a budget the team pays for, not a description of what the code contains. The default is that a package exports its entry points and nothing else, with the implementation split into readable packages under `internal/`, where it can be reworked without coordinating with anyone. A name gets exported when a named caller has a use case now — not because it might be handy, and not because a sibling package needs it, which is exactly what `internal/` is for. The commitment is concrete: documentation, tests, bug reports from people I have never met, and a shape I cannot change without breaking their builds. The asymmetry drives the policy — adding later is additive and safe, removing is a coordinated migration or a new major version — so I make growth visible by reviewing newly capitalised names as their own category.
code
text · 9 lines$ go doc example.com/migrate
package migrate // import "example.com/migrate"
func Run(ctx context.Context, dir string, db *sql.DB) (Result, error)
type Result struct {
Applied int
Skipped int
}go deeper
Take away the direction of the asymmetry: adding an exported name later is safe, removing one is not, so packages start with as few as possible.
Be ready to name the concrete costs of an exported name — documentation, frozen shape, support, breaking removal — and to say where the rest of the code lives instead.
Show the working practice: a named caller and a use case before any export, a narrower entry point offered in place of an implementation type, and internal/ as the default home for new code.
Own the policy and its friction. Say who can approve an export, how growth stays visible at merge time, and how you answer a blocked partner team without freezing your implementation for years.
## The frame: a surface is a liability, not an inventory A package's exported names are not a summary of what it can do. They are the list of things the team has agreed to keep working for other people, indefinitely, on other people's schedules. That framing changes the question from "is this useful?" — almost everything is — to "are we willing to own this?" What one exported name actually costs: - **Documentation.** It appears in `go doc` output whether or not anyone wrote a comment, so either it is documented or your API contains something undocumented. - **Behaviour lock-in.** Callers depend on what it does, not only on its signature. Changing behaviour without changing the signature is the quiet way to break people. - **Shape lock-in.** Its type, parameters and results are frozen for as long as importers exist; the implementation behind it is constrained by whatever it exposes. - **Support.** Bug reports, questions and edge cases arrive from callers you never spoke to, using it in ways you did not design for. - **Removal cost.** Taking it back is a coordinated migration across consumers, or a new major version path for a public module. The cost is not proportional to the code behind the name. A three-line helper you export is roughly as expensive to own as a substantial type, because the cost is in the promise, not the implementation. ## The default: entry points out, everything else in A well-shaped library has a thin outer package — the handful of names that describe the job it does — over a large private one. In a schema-migration tool that might be a single `Run` function, a result type, and a couple of options; the file parsing, the ordering, the checksums and the execution live under `internal/`, exported freely among themselves and promised to nobody. The team keeps full freedom to restructure the interior, and callers get a surface they can read in a minute. The test for a candidate export is not utility, it is need: **which caller, by name, needs this, and for what?** "Someone might" is not an answer, and neither is "our other package uses it" — that is precisely the case `internal/` exists to serve. ## Using the asymmetry Adding an exported name later is additive: nobody's build breaks because a package gained a function. Removing one is not. Therefore, when you are unsure, the cheap error is to keep it unexported and be asked for it, and the expensive error is to export it and be stuck with it. Deliberately exporting less than you think is needed is the position that can be corrected; exporting more is the position that cannot. This also means the right answer to "can you export X?" is often "tell me what you are doing, and I will export something narrower that does it". You get to own an entry point designed for the need, rather than an implementation type someone will use in ways you cannot anticipate. ## Making growth visible and governed Surfaces grow one merge at a time, each addition locally reasonable. Practices that work: - **Review capitalisation as a category.** In review, look specifically at newly exported names and ask the caller question about each. This is the single highest-leverage habit, and it costs a comment. - **Name an owner.** One person, or a small group, owns the package's API and can say no. Without a designated owner every request gets a yes, because saying no is uncomfortable and the cost is deferred. - **Keep the API readable in one sitting.** If `go doc` output no longer fits on a screen, that is a signal worth discussing even when every individual name is defensible. - **Write an external test package.** A test file declared in the `_test` package variant of your package can only use the exported surface, which is an honest way to feel what callers get and to notice when something needed is missing — or when something exported is never used. - **Say what is not promised.** Document that everything under `internal/` may change without notice, so nobody negotiates for it later. ## Where the judgment gets uncomfortable The hard calls are organisational, not technical: - A partner team is blocked today and wants an implementation type exported. Exporting it unblocks them this afternoon and constrains your package for years. The good move is usually a narrower entry point shipped just as fast — the bad moves are both a flat no and a quick yes. - Your own second module needs a helper that lives under `internal/`. It cannot import it, so either the helper becomes a promise, or it moves into a third module whose purpose is to be depended on. Choosing to promise it should be a decision with a name on it, not a build error someone fixes at 6pm. - Someone proposes exporting things "for testability". Tests inside the package see everything already; a request to export for tests is usually a request to restructure the code instead. ## The one-line policy Export the least that lets a real caller do a real job today; put the rest under `internal/`; make every new capital letter a decision somebody made on purpose. Everything above is the argument for why that is worth the friction.
- A partner team is blocked and asks you to export an implementation type today. How do you answer?Ask what they are doing with it, then ship a narrower entry point that does exactly that, on the same timeline. A flat no leaves them blocked and a quick yes freezes your implementation; the narrow export unblocks them and keeps the type yours to change.
- How do you notice a surface that is growing without anyone deciding to grow it?Review newly capitalised identifiers as their own category in every diff, and keep the package's documented output small enough to read in one sitting. Growth is always a series of individually reasonable merges, so the check has to sit at merge time.
- Someone wants symbols exported so tests can reach them. Is that a good reason?Rarely. A test file in the same package already sees every unexported identifier, so the request usually means the test lives in the wrong place or the code needs splitting. Exporting for tests turns a testing problem into a permanent API commitment.
Every capital letter is a support contract signed on behalf of the team, and the customer chooses when it ends.
saying these in an interview costs you the question
- Exports anything that might be useful to somebody someday
- Treats the exported set as a description of the code
- Exports helpers so a sibling package can use them
- Assumes a removal is fine because nobody has complained
- Says the API can be trimmed later once callers exist
- Has no named owner able to refuse an export request