open atlas
↑ Back to track
Go, zero to senior GO · 05 · 03

Package design: names as API, internal/, dependency direction, and the cycle that blocks your hotfix

Package boundaries as API design: the package name is the prefix of every export, internal/ as the visibility tool, accept interfaces and return structs, why util/common rot, and how cyclic-import pressure exposes a wrong dependency direction — with a refactoring case study.

GO Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The incident was nearly over. Refunds were double-posting, the cause was a two-line guard missing in billing, and the on-call engineer had the fix typed in eleven minutes. Then the build failed: import cycle not allowed: billing -> customer -> billing. The cycle was not in the diff — it had been latent for two years, armed by a models package every service imported, a util package with ninety-one exported functions, and a recent “small” change that made customer call a billing helper directly. The compiler had simply never been asked to walk that path until the hotfix touched both packages. Untangling enough of the graph to compile took fifty minutes; the incident review allocated more time to package structure than to refunds. Nobody had designed the layout — it had accreted, one convenient import at a time, until the architecture diagram everyone believed in and the import graph the compiler enforced were two different documents.

When you are done here, you will be able to spot the latent cycle before it arms — and run the four-move refactor that opens it without touching production behaviour.

The package name is the first word of your API

Every exported identifier is read with its package name attached: http.Server, json.Marshal, time.Duration. That makes the package name part of the API, and it drives two rules. First, name packages for what they provide, not what they contain: billing, retry, pdf — a noun describing the capability. Names like models, helpers, types, interfaces, common describe a kind of code, which means everything in the codebase eventually qualifies, which is how a package becomes a gravity well that everyone imports and that imports everyone — the models package in the Hook armed the cycle precisely because both billing and customer needed its types. Second, design identifiers to read with the prefix: http.Server, not http.HTTPServer; billing.Invoice, not billing.BillingInvoice. Stutter is not just cosmetic — it is a sign the author designed the identifier in isolation from the only way callers will ever see it. The test for a healthy package: can you state its purpose in one sentence that is not “miscellaneous things used by several services”? util fails that test by construction, and it fails operationally too: a ninety-one-function grab-bag has the union of all dependencies of all its functions, so importing it for util.Clamp drags in the AWS SDK someone needed for util.UploadJSON.

internal/ and the direction of dependencies

Go gives you exactly one visibility tool above the identifier level: a package under a directory named internal/ can be imported only by code rooted at internal/’s parent. svc/internal/auth is importable by anything under svc/, and by nothing outside it — enforced by the compiler, not by convention or review vigilance. Use it by default: every package starts in internal/ and is promoted out only when an external consumer earns it, because everything outside internal/ in a published module is API you support forever. The deeper discipline internal/ serves is dependency direction. Decide which packages are the core domain (billing, customer — pure logic, importing little) and which are the edges (HTTP handlers, queue consumers, storage adapters), and make every import arrow point from edge to core, never back. A domain package that imports net/http to return a handler-shaped error, or reads a config struct from the transport layer, has reversed an arrow — and reversed arrows are how cycles arm themselves: each one is harmless until a second reversed arrow closes the loop.

Quiz

Your module has svc/internal/auth. Which code can import it?

Accept interfaces, return structs

Go interfaces are satisfied implicitly, and that one property decides where interfaces should be declared: in the package that consumes them, not the package that implements them. billing needs customer data, so billing declares the two-method CustomerSource it actually needs; the customer package satisfies it without importing billing, without even knowing it exists. Compare the Java-shaped reflex — an interfaces package both sides import — which recreates the gravity-well problem and couples every implementor to a fat, speculative contract. The complementary rule is to return concrete structs: a constructor returning *Client lets you add methods forever without breaking anyone, while returning an interface freezes the method set (every addition breaks third-party implementations) and hides the type’s documentation. Together the two rules keep arrows pointing the right way and contracts minimal:

// Package billing — the consumer declares the contract it needs.
type CustomerSource interface {
	Customer(ctx context.Context, id string) (Customer, error)
}

// Customer is billing's own view: three fields it uses, not customer's 40-field struct.
type Customer struct {
	ID      string
	Country string
	VATID   string
}

func NewInvoicer(src CustomerSource) *Invoicer { // accept the interface…
	return &Invoicer{src: src}                   // …return the concrete struct
}

// Package customer never imports billing. The wiring layer (cmd/serve)
// imports both and adapts: billing.NewInvoicer(customeradapter.New(repo)).

Note the size of the interface: one method. Consumer-side interfaces stay small because they encode one consumer’s needs; provider-side interfaces bloat because they speculate about everyone’s.

Case study: untangling billing ⇄ customer

Back to the Hook’s graph. The repo had models (all shared structs), util (ninety-one functions), and service packages importing both plus each other. The refactor that fixed it ran in four moves, none of which changed behaviour. One: dissolve models — each struct moved to the package that owns its lifecycle (Invoice into billing, Customer into customer); packages that needed another’s data got their own narrow view-struct or a parameter list, because sharing a type is coupling, and most “shared” types were used for two fields. Two: break the cycle with a consumer-side interfacecustomer’s call into a billing helper became a billing.CustomerSource implementation the wiring layer connects, flipping the arrow’s direction. Three: dissolve util — each function moved next to its only caller (sixty-two of ninety-one had exactly one), genuinely shared ones formed real packages with real names: retry, clock, money. Four: fence the tree with internal/ — everything not consumed by another service moved under it, so the next convenient-but-wrong import became a compile error instead of a review argument. Together, these four moves are a mechanical sequence: dissolve gravity wells, flip one cyclic arrow with an interface, scatter utility functions to their owners, and fence the result. None of them requires a rewrite, and none changes observable behaviour — they only move responsibilities to the packages that should own them. The metric that proved it worked was not elegance: the change amplification dropped — a git log survey showed the median PR before touched 3.4 packages; after, 1.6. Cycle pressure is the smell to act on early: the moment you want an import the compiler refuses, the design is telling you a responsibility lives in the wrong place — the cycle error is the symptom, never the disease.

Quiz

billing needs one function from customer, but importing it creates billing -> customer -> billing. What is the structurally sound fix?

Recall before you leave
  1. 01
    Why do models/util/common packages reliably rot, and what is the dissolution recipe from the case study?
  2. 02
    Explain accept-interfaces-return-structs: where the interface is declared, why implicit satisfaction makes that possible, and why returning an interface from a constructor hurts.
Recap

Package design in Go is API design, because the language gives packages teeth: the name prefixes every export, internal/ is compiler-enforced visibility, and import cycles are flatly refused. Name a package for the capability it provides — billing, retry, money — and design identifiers to read with the prefix (http.Server, never http.HTTPServer). Names that describe a kind of code (models, util, common, helpers) admit everything, so they grow into gravity wells with the union of everyone’s dependencies, imported by all and eventually importing back — which is how the Hook’s hotfix met import cycle not allowed eleven minutes into an incident. Default packages into internal/, where the compiler limits imports to the tree rooted at its parent, and promote to public only what external consumers earn. Keep dependency arrows pointing one way, edge to domain: handlers and storage adapters import billing; billing imports neither. When the domain needs a collaborator, it declares a small consumer-side interface — implicit satisfaction means the implementor never knows — and the wiring layer in cmd/ adapts the two together; constructors accept those interfaces and return concrete structs so the type can grow methods without breaking anyone. The case-study recipe: dissolve shared-type packages into owning packages with narrow view-structs, flip the cyclic arrow with a consumer-side interface, scatter util to its callers, fence with internal/ — and watch change amplification, not aesthetics, to know it worked. Cycle pressure is the cheapest design review you will ever get; the compile error is the symptom, the reversed arrow is the disease. Now when you see a models or util package growing past thirty exports, treat it as an armed grenade: measure change amplification, find which arrows are reversed, and start with the smallest interface that opens the loop.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.