Building Real Programs

Lesson 1 of 5

Packages, Modules & Layout

"Every module is characterized by its knowledge of a design decision which it hides from all others." — David L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules (1972)

Everything so far has been about making code work. This Part is about what a codebase needs beyond that — and it opens with the decision that shapes every other one, because it is the hardest to change later.

Parnas wrote the sentence above in 1972, arguing against the way everyone was decomposing systems at the time: by processing step, so that each module was one box on the flowchart. His alternative was to split by what varies — each module owns a decision that might change, and hides it, so a change to that decision touches one place. Fifty years of software architecture is largely rediscovery of that paragraph, and Go's package system is one of the more direct implementations of it.

Go adds an unusual amount of enforcement. A package is a compilation unit with a genuine boundary: identifiers starting with a capital letter are visible outside it and the rest are not, checked by the compiler rather than by convention. Import cycles are a compile error, not a code smell. And internal/ makes "this is ours, not yours" a rule the toolchain enforces against people who have never read your documentation.

The big question

Most languages let you organise code however you like and enforce almost none of it. Go rejects an import cycle at compile time, hides every lowercase identifier, and refuses to build if a stray import appears. Why be strict about structure and relaxed about almost everything else?

Because structure is the part you cannot fix later. A bad function is rewritten in an afternoon by one person. A package boundary in the wrong place is depended on by forty files, and moving it is a coordinated change across every one of them — which is why, in practice, nobody does, and the boundary calcifies. Go's strictness is a bet that the cost of being told "no" early is smaller than the cost of a dependency graph that has quietly become a cycle. The forbidden import cycle is the clearest case: it is not a stylistic objection, it is the compiler telling you two packages are actually one.

A package is a boundary, not a folder

The most common structural mistake in Go is grouping by kind — a models package, a handlers package, a utils package — which produces boundaries that no design decision lives behind.

The test that works is Parnas's: what does this package know that nobody else needs to? A store package knows how records are persisted. A billing package knows what a charge is worth. Those hide decisions. A models package hides nothing — it is a folder of types every other package must import, so it becomes a hub that everything depends on and that can never change.

That leads directly to the second question, which is which way the arrows point.

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Store exports a concrete type. The consumer declares the one-method interface it actually uses, and Store satisfies it without knowing the interface exists — which is what Part I's chapter on implicit satisfaction was building toward, seen now as an architectural tool rather than a language curiosity.

This inverts the habit most people bring from other languages, where an interface is declared next to its implementation and the consumer imports it. Three things follow from doing it Go's way.

The dependency arrow points from the consumer to the concrete type, and never back. Store imports nothing from describe's package, so there is no cycle to create.

The interface is as small as the caller needs. getter has one method because that is all describe uses. A producer-side interface always grows to the union of everything anyone might want, and then every implementation must satisfy all of it.

Test doubles need no coordination. fakeGetter satisfies getter by having the right method. Nobody had to plan for it.

Hence the proverb: accept interfaces, return structs. Return the concrete type so callers get everything; accept the narrowest interface so callers can substitute.

Everyday analogy

A well-designed package is a shop counter. Customers see a menu and hand over requests; the kitchen, the suppliers, the rota and the recipes are behind it. What is on the counter is a promise, and what is behind it is yours to change — that is the whole of Parnas's point, and the whole of what a capital letter means in Go.

A models or utils package is a shop with no counter and no kitchen — just shelves in the middle of the room that everyone reaches into. Nothing is hidden, so nothing can change: every rearrangement is visible to every customer, and in a codebase that means every change is a change to somebody's imports. The absence of a boundary feels like flexibility right up until the first time you need to move something.

What runs when your package is loaded

Before main executes, Go initialises every package it depends on, and the order is more precisely defined than most people realise.

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Package-level variables are initialised in dependency order, not source order: total is written first and initialised last, because it needs the other two. Go works this out from the reference graph, and a cycle among package variables is a compile error for the same reason an import cycle is.

Then every init() runs, in source order, and only then does main start. Across packages, imports are fully initialised before the importer, so by the time your init runs, everything you import is ready.

All of which is precisely specified, and mostly a reason to use less of it.

init() is code that runs before anyone asks for it. It cannot take arguments, cannot return an error, and cannot be called from a test to set up state or skipped to avoid it. A failure inside one leaves panic or log.Fatal as the only options, which turns a recoverable configuration problem into a process that cannot start.

The legitimate uses are narrow: registering something with a registry (database/sql drivers, image format decoders), building a lookup table that cannot be a literal, and compiling a package-level regexp.MustCompile. What does not belong is anything that can fail for reasons outside your source code — reading files, opening connections, parsing environment variables. Those want an explicit New that returns (*T, error), so the caller decides when it happens and what to do when it does not work.

The same argument applies to package-level vars doing real work. var db = mustConnect() runs at import time, in a test binary, in a go vet run, in a build tool that only wanted your type definitions — and there is no way to opt out.

A little history

Go spent its first six years without a dependency-management story, and the result was chaos: go get fetched whatever was at HEAD of a repository, so builds were not reproducible and there were no versions at all. The community built godep, then glide, then dep as an official experiment — each vendoring dependencies into the repository because there was no alternative.

Modules arrived in Go 1.11 (2018) and became the default in 1.16, and they made two choices that still generate argument. The first is semantic import versioning: a v2 or later module must include the major version in its import path, so example.com/lib/v2 is a genuinely different path from example.com/lib. It is ugly, and it means a program can import v1 and v2 simultaneously without the diamond conflicts that plague other ecosystems — a real problem traded for a visible wart. The second is minimal version selection: the build uses the oldest version satisfying all requirements, not the newest compatible one. Every other package manager does the opposite, and MVS's payoff is that adding a dependency cannot silently upgrade an unrelated one — builds change only when you change them. Both decisions optimise for reproducibility over convenience, which is the same trade as the rest of this chapter.

Modules, versions, and the surface you commit to

A module is a collection of packages versioned together — the unit you publish and depend on. One go.mod at its root names it and lists its requirements:

// Not runnable here — the playground compiles a single file, with no module.
module github.com/acme/billing

go 1.22

require (
    github.com/google/uuid v1.6.0
    golang.org/x/sync v0.7.0
)

The module path is also the import prefix, so a package in internal/ledger is imported as github.com/acme/billing/internal/ledger. That coupling of identity to location is why the path is worth choosing carefully: changing it later is a breaking change for everyone.

What matters day to day is not the file but what it commits you to — because once tagged v1.0.0, every exported identifier in every non-internal package is API you have promised to keep.

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

That is Part III's error-surface argument arriving as a versioning argument. ErrNotFound is a commitment; errBackendUnreachable can be renamed, split or deleted in a patch release because it is unreachable by name. The capital letter is the whole difference.

Three module facts worth carrying:

internal/ is enforced by the compiler. A package under internal/ may only be imported by code rooted at that directory's parent. It is the one way to publish a module while keeping most of it genuinely private, and it is the right default for anything you are not ready to promise.

Minimal version selection means your build does not drift. The version you get is the oldest that satisfies every requirement, so adding one dependency never silently upgrades another. Upgrades happen when you run go get -u, and not otherwise.

go.sum is not a lock file. It records cryptographic hashes of the exact module contents used, so a tampered or re-tagged dependency fails the build. go.mod decides versions; go.sum verifies bytes.

The engineer's notebook

Three questions when adding a package.

What decision does it hide? If the answer is "it holds the structs" or "it has the helpers", there is no boundary and you have made a folder. Name the thing that could change behind it.

Which way do the imports point, and could they ever point back? Draw it. If two packages need each other, they are one package, and the compiler will tell you so at the worst possible moment.

Am I ready to keep every capital letter in it? Exported identifiers in a tagged module are permanent. Start things lowercase, put them under internal/, and export when a caller genuinely needs them — the one thing you cannot do later is take it back.

The layout arguments worth ignoring

Go has no official project layout, which has produced more argument than the language deserves. The genuinely load-bearing conventions are short:

// Not runnable here — this is a directory tree, not a program.
billing/
├── go.mod                 // module root
├── cmd/
│   ├── api/main.go        // one binary per directory under cmd/
│   └── worker/main.go
├── internal/              // compiler-enforced: importable only within this module
│   ├── ledger/
│   └── postgres/
├── ledger.go              // the module's own public API, at the root
└── ledger_test.go         // tests live beside the code they test

cmd/ holds executables, one directory per binary, and each main should be thin — parse flags, build dependencies, call into a package that does the work. A main package cannot be imported, so anything it contains cannot be tested from elsewhere or reused.

The technique that makes that concrete is worth its own block, because it costs three lines and changes what is testable:

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

run takes its arguments and its output as parameters rather than reaching for os.Args and os.Stdout directly, and it returns an error rather than calling os.Exit. That is the entire trick: a test can pass a strings.Builder and assert on the bytes, and can assert on the error without the test binary exiting. main keeps the two things that genuinely cannot be tested — reading the real argument list and setting the exit status — and nothing else.

internal/ is a real mechanism, not a naming convention, and it is the most under-used thing in Go project structure. Everything starts there; things graduate out when you decide to support them.

Tests live beside the code, in the same directory, named _test.go. There is no parallel test tree.

Then there is pkg/, which is the argument. It comes from a popular unofficial "standard layout" repository and means "the public bits" — which is exactly what internal/ already expresses, in reverse and with compiler enforcement. It adds a path segment to every import and communicates nothing the absence of internal/ did not. The Go team has never used it in the standard library or their own projects. Skip it, and skip most of what that repository recommends: it describes conventions from large enterprise codebases as though they were requirements, and applying them to a service with four packages produces a directory tree deeper than the code is tall.

The real rule is that structure should follow the code, not precede it. Start flat, in one package at the module root. Split when a boundary becomes obvious — when you notice a set of files that change together and know something the rest does not. A codebase of eleven files in one package is easy to reorganise; the same code pre-split across nine packages you designed on day one is not.

In the real world

The import cycle is the structural failure that actually stops work, and it always arrives the same way. A user package and an order package are fine for months. Then a feature needs an order to know about the user who placed it, and the user to know their order history, and the compiler refuses: import cycle not allowed. It is not a warning that can be suppressed, and it usually lands mid-sprint on a change that looked trivial.

The instinctive fixes are both bad. Merging the packages surrenders the boundary and tends to snowball, since the merged package now sits between things that were previously independent. Extracting a types or common package that both import moves the types somewhere with no owner and no hidden decision, and that package becomes a magnet — everything ends up there, everything imports it, and it can never change.

The fix that works is the one this chapter opened with: the cycle means your boundary is wrong. Usually one direction is real and the other is convenience. An order genuinely needs a user ID; the user's order history is a query, and it belongs to whichever package owns queries — often a third package that imports both and is imported by neither. Go refusing to compile is the language telling you a design fact early, and the teams that treat it as a design prompt rather than an obstacle end up with better boundaries than the ones who reach for common.

Package-level state is a dependency you cannot see

A package-level variable holding real state is a dependency that never appears in a signature, and it is the quiet reason a lot of Go code is hard to test.

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Clock is the whole technique: name the dependency as a type, take it in the constructor, default it when the caller does not care. The production path is unchanged and the test path becomes possible — and the signature now says what this type needs, which package-level state never does.

This generalises to almost everything usually made global. A logger, a database handle, a config struct, an HTTP client, a metrics registry: each is a dependency, and each is better as a struct field set by a constructor. The costs of the global version are consistent — tests cannot run in parallel because they share it, initialisation order becomes load-bearing, and nothing in any signature reveals the coupling.

The standard library's own use of package-level state is instructive here, because it is mostly a warning. http.DefaultServeMux and http.DefaultClient are globals, and the advice from the Go team is to avoid both: DefaultServeMux means any imported package can register a route on your server, and DefaultClient has no timeout, so a hung request hangs forever. Both exist because they made the tutorial shorter, and both are things production code replaces.

Common Mistakes

  1. Packages named by kind — models, utils, helpers, common. They hide no decision, so they become hubs that everything imports and nothing can change.

  2. Declaring an interface next to its implementation. Producer-side interfaces grow to the union of every caller's needs. Let the consumer declare the narrow interface it uses.

  3. Returning an interface from a constructor. Callers lose access to everything the concrete type offers. Accept interfaces, return structs.

  4. Doing real work in init() or a package-level var. It runs before anyone asked, cannot fail gracefully, and cannot be skipped by a test. Use New that returns (*T, error).

  5. Exporting by default. Every capital letter in a tagged module is permanent. Start lowercase, start under internal/, and export when a caller actually needs it.

  6. Not using internal/. It is compiler-enforced privacy across an entire subtree, and it is the cheapest way to keep options open.

  7. Adopting pkg/. It duplicates what the absence of internal/ already says, adds a path segment to every import, and is used by neither the standard library nor the Go team.

  8. Splitting into packages before the boundaries are known. Eleven files in one package are easy to reorganise; nine packages designed on day one are not.

  9. Fixing an import cycle with a types package. It relocates the problem into a package with no owner, which then becomes a magnet that everything imports. The cycle means the boundary is wrong.

  10. Package-level state instead of injected dependencies. It is a dependency invisible in every signature, it blocks parallel tests, and it makes initialisation order load-bearing.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What makes an identifier visible outside its package, and where should an interface be declared?
Model answer — A leading capital letter. Store and Get are visible to importers; store and get are not, and it is enforced by the compiler rather than by convention — which is also why encoding/json cannot see unexported fields. The interface goes with the consumer, not the implementation. So a package that needs to read from something declares its own small interface — often one method — and any concrete type with that method satisfies it implicitly, without importing anything or declaring that it implements it. That keeps the interface as narrow as the caller actually needs, and it means the dependency points from the consumer to the concrete type rather than the other way round, so there is no cycle to create. The related rule I would follow is accept interfaces, return structs: my constructor returns *Store so callers get every method, and functions take the smallest interface they can. And I would start identifiers lowercase and export them only when something outside genuinely needs them, because an exported name in a released module is a promise.
Mid
You get import cycle not allowed between your user and order packages. What do you do?
Model answer — Treat it as a design signal rather than an obstacle, because the compiler is telling me the boundary is in the wrong place. The two instinctive fixes are both bad. Merging the packages surrenders the boundary and tends to snowball, since the merged package now sits between things that were independent. Extracting a types or common package that both import moves the types somewhere with no owner and no hidden decision, and that package becomes a magnet — everything ends up in it, everything imports it, and it can never change. What I would actually do is work out which direction is real. Usually one is: an order genuinely needs a user ID, while the user's order history is a query, and a query belongs to whichever package owns queries — often a third package that imports both and is imported by neither. If both directions really are needed, then the two packages are one package and I should say so. The underlying test is Parnas's: what decision does each package hide? If I cannot name one, I have a folder rather than a boundary, and folders produce cycles because there is nothing to keep the dependencies pointing one way.
Senior
How would you structure a new Go service, and what would you tell someone who wants to start from the "standard project layout"?
Model answer — Flat, in one package at the module root, with a thin main under cmd/, and I would split when a boundary becomes obvious rather than designing one up front. Structure should follow the code: eleven files in one package are easy to reorganise, and the same code pre-split across nine packages I guessed at on day one is not — the boundaries end up wrong and the imports calcify around them. I would put nearly everything under internal/, because that is compiler-enforced privacy across a whole subtree, and it means I have promised nothing until I deliberately move something out. On the popular layout repository, I would say it is not official, the Go team does not use it, and pkg/ in particular expresses exactly what the absence of internal/ already expresses, while adding a segment to every import path. Its deeper problem is that it presents conventions from large enterprise codebases as requirements, so a service with four packages ends up with a directory tree deeper than the code is tall. What I would keep from it is the parts that are real Go conventions anyway — cmd/ for binaries, internal/ for privacy, tests beside the code. And I would spend the structural effort on dependency direction instead: consumer-side interfaces, constructors that take their dependencies, and no package-level state, because those are what make a boundary hold once several people are working behind it.
Company lens — how the same topic gets asked
micro1Practical: restructure a small service so its packages have real boundaries and one-way imports.
GooglePrecision: initialisation order, what internal/ enforces, and why semantic import versioning exists.
MetaReview: spot the producer-side interface and the package-level state, and say what each costs.
AmazonDesign: lay out a new service, and defend what you export versus what stays internal.

Hands-on

Hands-on

Four exercises in API shape. Exercise 2 is the one worth internalising — a constructor with functional options, which is how Go packages add configuration without breaking callers. Note that these run in one file, so the compiler cannot enforce the package boundary; the discipline is yours.

Go — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Summary

  • A package should hide a decision, in Parnas's sense. If you cannot name what it hides, you have made a folder — which is why models, utils and common become hubs that can never change.
  • Declare interfaces where they are consumed, as small as the caller needs. Producer-side interfaces grow to the union of everyone's requirements.
  • Accept interfaces, return structs. Callers keep every method of the concrete type, and can still substitute at the boundary.
  • Package variables initialise in dependency order, then every init() in source order, then main.
  • Avoid real work in init() and package-level vars. It runs before anyone asked, cannot return an error, and cannot be skipped by a test. Prefer New() (*T, error).
  • Every exported identifier in a tagged module is permanent. Start lowercase, start under internal/, and export when a caller genuinely needs it.
  • internal/ is compiler-enforced privacy across a whole subtree, and it is the most under-used mechanism in Go project structure.
  • Skip pkg/. It says what the absence of internal/ already says, adds a path segment, and neither the standard library nor the Go team uses it.
  • An import cycle means the boundary is wrong, not that you need a types package — extracting one relocates the problem into a package nobody owns.
  • Package-level state is a dependency invisible in every signature. Name it as a type, take it in the constructor, default it when the caller does not care.

Structure decides where code lives; the next chapter decides whether it works. Go's testing story is unusually good, and unusually plain — no assertion library, no annotations, no separate test tree, just a _test.go file and functions taking *testing.T. The next chapter is table-driven tests and the tooling around them: subtests and what t.Run buys you, t.Parallel and the trap inside it, testing.TB for shared helpers, golden files, fuzzing, and the reason go test -race belongs in every CI pipeline in the language.

Editorial reflection

Go's package system makes one decision almost no other mainstream language makes: visibility is a property of the name, not a declaration. There is no public, no private, no export — a capital letter is the entire mechanism. It is the kind of choice that sounds like a gimmick and turns out to be load-bearing, because it makes the boundary visible at every use site rather than only at the declaration. Reading unfamiliar code, you can tell what is API and what is internal without looking anything up, and that is worth more than the flexibility of finer-grained modifiers.

The cost is that there are exactly two levels, and sometimes you want a third. internal/ is the patch, and it is a good one — but it operates on directories rather than declarations, so expressing "visible to these two packages and no others" means arranging your directory tree to say it. Java's package-private and Rust's pub(crate) are more precise. Go's answer is that the imprecision is the point: a system with four visibility levels invites people to use all four, and the resulting graph is one nobody can hold in their head.

What Go got unambiguously right is refusing import cycles. Every language that permits them has codebases where the dependency graph is a fiction, and the compiler will not tell you, so nobody finds out until a refactor. Making it a hard error is unpopular exactly when it fires and correct every time — it converts an architectural problem that would otherwise be discovered in year three into a compile error in week two. That is the same trade as this whole Part: pay a small, visible, immediate cost to avoid a large, invisible, deferred one.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.