skip to content

How do you decide which packages go under internal/ in a repo other teams import?

level: principalimportance: should knowfreq 32%

answer

  1. one direction is cheap, the other is not
  2. you cannot enumerate your importers
  3. a real consumer today, not next quarter
  4. promote the contract, keep the plumbing
  5. refusing has a cost too: teams copy

basics

~20 s

Default everything to internal/ and promote a package out only for a consumer that exists today. An importable path is a support commitment you cannot withdraw quietly, so promotion is reviewed as an API change.

solid answer

~50 s

The asymmetry decides the policy. Moving a package into `internal/` later breaks every downstream build at once, and Go gives you no way to enumerate your importers, so you find out by being paged; moving one out costs nothing today and everything afterwards. So the default is `internal/`, and a package leaves only when a consumer exists now, the concept behind it is stable, and someone will own it for the repository's lifetime. What I promote is the contract — the types and the two or three entry points a consumer needs — not the plumbing behind them. Binaries stay under `cmd/`, where main packages are unimportable by construction. Promotion is a reviewed change with a named owner, and the platform reviewer can overrule me. The counter-pressure is real: lock everything down and teams copy or fork the code, so a refusal has to come with a path to yes.

go deeper

for a junior

Know the direction of the default: new packages start under internal/, and something becomes importable by other repositories only as a deliberate decision.

for a middle

Be able to explain the asymmetry — an exported import path cannot be withdrawn without breaking builds you cannot see, while keeping code under internal/ costs nothing today.

for a senior

Argue a specific case: which types you would promote, which plumbing stays behind, and what the migration would look like if the surface turned out to be wrong.

for a principal

Own the policy and its costs on both sides — the support bill for every exported package, the copying and forking that a too-small surface causes, and the review step that makes promotion visible before the first import lands.

## The decision is asymmetric, and that sets the default In Go an import path is the interface. Once `github.com/acme/platform/store` exists outside `internal/`, anyone in the organisation can import it, and you have no mechanism to find out who did — no registry, no callers list, nothing short of searching every repository you know about. Taking it back means moving it under `internal/`, which breaks every downstream build simultaneously with `use of internal package ... not allowed`, and each affected team needs its own migration. Going the other way is free at the moment you do it. That asymmetry is the whole argument for the default: **new code goes under `internal/`, and leaves only on purpose.** ## The four questions I ask before promoting a package 1. **Is there a consumer today?** Not a team that might want it next quarter — a repository with a branch that needs it now. Speculative surface is the most expensive kind, because it acquires importers before anyone has validated the shape. 2. **Is the concept stable?** A package whose name will still be right in two years can be supported. A package that is really 'the bits our handler happens to need' will be reshaped within months, and reshaping it in public costs everyone. 3. **Is this the contract or the plumbing?** Promote the types and the small number of entry points a consumer calls. Keep the retry policy, the encoding details and the internal client under `internal/`, so you can change how it works without changing what it promises. 4. **Who owns it?** Named owner, doc comments, a support commitment, and an answer to what happens when the consumer hits a bug at 3am. A package nobody will own should not be importable. ## What promotion actually costs Every exported package is a standing bill: questions, bug reports for use cases you did not design for, and a migration guide every time you change it. That last item is worth stating plainly to whoever is asking, because the requester usually only sees the one-line import they want to write. When the team owning the repository publishes five binaries and a shared surface, the surface is where most of the coordination cost lives, not the binaries. ## The counter-pressure, which is equally real A repository that exports nothing is not a success. Teams that cannot import the code will do one of three things: copy it, fork the repository, or reimplement it slightly differently. Now the organisation has three implementations of the same retry policy and the bug is fixed in one of them. So 'it stays in `internal/`' is only a defensible answer when it comes with a path: what you would need to see for it to be promoted, or what supported thing they should use instead. The judgement is picking the smallest surface that prevents copying. Usually that is far smaller than the requester asks for — often one type and one constructor rather than the six packages behind them. ## Where the cmd/ split fits The same question applies to programs. A `main` package cannot be imported by anyone, so each binary under `cmd/<name>/` is automatically outside the supported surface. That makes `cmd/` the safe place for anything that is a product rather than a library: operational tools, backfills, one-off migrations. The decision worth reviewing is not where they live but how many exist — five binaries mean five things to build, sign, release and page someone about, and a subcommand of an existing program is sometimes the cheaper answer. ## How I make the decision reviewable A pull request that moves a package out of `internal/` is an API-surface change, and I want it treated like one: a distinct review, an owner named in the doc comment, and a note in the changelog. This is worth the ceremony precisely because the toolchain will not warn anyone later — the promotion is silent, and its consequences arrive months afterwards as an inability to change the code. I also expect to be overruled sometimes. A platform or architecture reviewer who says the surface is too large, or that this belongs in a different repository entirely, is applying a view of the organisation's dependency graph that I do not have from inside one repository. The mechanism only works if that review actually happens before the first import lands, because after that the cost of reversing it is no longer mine alone. ## Migrating something that is already exported When a package should never have been public, the move is a project, not a commit: announce the new import path, ship both for a period, write the migration guide for downstream teams, track who has moved, and only then put the old path under `internal/`. Budget for the slowest team, and expect the guide to be the artefact people actually consume. Doing this once teaches a team more about default-`internal/` than any policy document.

  • A team wants a package exported for work they will start next quarter. What do you say?
    No for now, with a path to yes: promotion happens when the consumer exists, because a speculative surface acquires importers before the shape has been validated by anyone actually using it. I would offer to review their design against what is under `internal/` today, so the eventual surface is the one they need.
  • How do you move an already-exported package back under internal/?
    As a project, not a commit. Publish the replacement path, run both for a period, write the migration guide, track which teams have moved, and only then put the old path under `internal/` — where the break is immediate and total, since every downstream build fails at once.
  • What is the risk of keeping the exported surface too small?
    Teams copy the code or fork the repository, and the organisation ends up with several drifting implementations and a bug fixed in only one of them. A refusal is only defensible when it comes with a supported alternative or a stated condition for promotion.
  • Where do the repository's binaries sit in this policy?
    Under `cmd/<name>/` as main packages, which nobody can import, so they are outside the supported surface by construction. The decision worth reviewing there is how many binaries exist at all, since each is a separate thing to release and operate.

saying these in an interview costs you the question

  • Exports by default and expects consumers to be careful
  • Treats promoting a package as a routine refactor, not an API change
  • Cannot say why moving a package into internal/ later is expensive
  • Assumes importers can be found and notified reliably
  • Locks everything down with no path to a supported alternative
  • Promotes a whole subtree when one type and a constructor would do