go-layout — layout, naming & API surface
The Go community consensus is minimalism; resist imported ceremony. Naming and the shape of an exported signature are part of the API — they are as reviewable as the code.
Layout
internal/is the one true consensus. Packages underinternal/cannot be imported from outside the module subtree — use it to keep implementation private while exporting a small surface.- Start flat; grow as needed. A new module is often one package at the root. Add
cmd/<binary>/main.gowhen there are multiple binaries andinternal/<pkg>/when privacy is needed — not before.golang-standards/project-layoutis community-made, explicitly not official and contested; don't treat its deep tree as a starting requirement. - No Java/C# transplants: no
*Manager/*Impl/*Factoryreflexes, no one-type-per-file rule, no interface-for-everything. - Hexagonal / ports-and-adapters / DDD is a tool, not a default — justified for larger services with real external-boundary complexity, overkill for a CLI or a small service.
mainowns process exit. Callos.Exit/log.Fatalonly inmain(ideally once, on the error from arun() errorfunction); everything else returns errors. A deeplog.Fatalskips deferred cleanup and makes the code path untestable. Same discipline forinit(): only cheap, deterministic setup — no I/O, no environment reads, no mutating global state; anything more is an explicit constructor called frommain.- Files: one package per directory;
package fooforfoo.go+foo_test.go; usepackage foo_testfor black-box tests that exercise only the exported API. - Imports in groups, standard library first, then other modules, then side-effect imports —
goimports/gofumptkeep the groups. A blank import (import _ "pkg") belongs in amainpackage or a test that needs the side effect, not in a library, where it silently changes every importer (Style Decisions, Import "blank"); the one library exception isimport _ "embed"in a file that uses the//go:embeddirective.revive(blank-imports, default rule set) reports a blank import outsidemainand test files unless a comment justifies it or it is thatembedcase, so a deliberate library blank import carries a comment saying why. Neverimport .— it hides where a name comes from;revive(dot-imports, default) flags it.
Naming
- Package names are part of the call site: short, lowercase, single word, no underscores or
camelCase. The caller writes
chi.NewRouter(), so don't stutter (chi.ChiRouter,bytes.BufferWrite). Avoidutil,common,helpers,basegrab-bags — name by what the package provides. MixedCaps, neverMAX_LENGTHorsnake_case— including constants, whatever the convention was in the language this code came from.- Initialisms keep a single case throughout:
userID,parseURL,HTTPServer,ServeHTTP— neveruserId,HttpServer. Mixed casing within one identifier is the tell of a translated name. - Name length tracks scope.
i,r,bufare correct in a five-line body; anything package-level, long-lived, or used far from its declaration earns a descriptive name. Longer is not better —idxbeatstheCurrentIndexIntoTheSlice. - Receiver names are a one- or two-letter abbreviation of the type (
c *Client,srv *Server), identical across every method on that type. Neverself,this, orme. - No
Getprefix on accessors:u.Name(), paired withu.SetName(…). A verb-like name is for something that acts; a noun-like name for something that returns a value. - Test doubles live in a
<pkg>testpackage and are named for behaviour, not mechanism —AlwaysDeclines, notMockCardProcessorImpl2.
Signatures & API surface
- Receiver type: pointer when the method mutates, when the receiver is large, or when the type
holds a
syncfield (copying a lock is a bug —go vetcopylocks). Value receivers for small immutable types. Be consistent within a type — don't mix pointer and value receivers. - Pass small fixed-size values directly.
*intto "avoid a copy" trades a machine word for an indirection plus aliasing risk. - No in-band errors. Return
(T, error)or(T, bool)— never-1,"", or anilthat means failure. A caller can forget to compare against a magic value; a second return value is harder to ignore, anderrchecksees it. - Named results only when they add information the types don't (
(n int, err error)), or when a deferred closure must assign to them (theClose-into-erridiom ingo-errors). Barereturnbelongs only in short functions. - Two option styles, chosen by how often callers pass options: an option struct as the final parameter when most callers set at least one field (self-documenting, grows compatibly); variadic functional options when most callers pass none. Don't erect a functional-options framework around two booleans.
- Accept interfaces, return concrete types. Define an interface in the package that consumes it, keep it to a method or three, and return the concrete type so callers get the full surface and new methods don't break them.
- Prefer synchronous signatures — let the caller add concurrency (→
go-concurrency). - Make the zero value useful where possible (
bytes.Buffer,sync.Mutexneed no constructor). If a type genuinely requires aNew…, the doc comment must say so. - Name the fields in a struct literal of a type from another package —
csv.Reader{Comma: ',', Comment: '#'}, never positional. The owner may add or reorder fields; a positional literal then breaks, or worse still compiles with the values in the wrong slots.go vet(composites) flags it. Positional stays fine for a small type of the current package, whose definition sits beside the use.
Doc comments
- Every exported identifier gets one, as a full sentence starting with the name:
// Serve accepts incoming connections on the listener.That phrasing is what makesgo docoutput and grep both work. - Package comment sits directly above
package xwith no blank line, exactly one per package, opening// Package x …. gofmtformats doc comments (since Go 1.19) — lists, headings, indented code blocks, and[Name]/[pkg.Name]doc links. Write that syntax and let the tool lay it out.- Document what the signature can't say: concurrency safety, who owns and must close a returned resource, whether cancellation leaves partial work behind, and which errors callers can branch on. Don't restate parameter names.
- Retire an exported name with a
Deprecated:paragraph, not by deleting it.
Sources
- Effective Go — https://go.dev/doc/effective_go
- Code Review Comments (Package/Variable/Receiver Names, Initialisms, Mixed Caps, In-Band Errors, Named Result Parameters, Pass Values, Interfaces, Doc Comments) — https://go.dev/wiki/CodeReviewComments
- Google Go Style Decisions (Import grouping, Import blank, Import dot, Field names, Getters, Receiver names, Initialisms) — https://google.github.io/styleguide/go/decisions; Best Practices (naming, option structs, documentation, test doubles, program initialization) — https://google.github.io/styleguide/go/best-practices
go vetanalyzers (composites,copylocks) — https://pkg.go.dev/cmd/vet; revive rules (blank-imports,dot-imports,exported,var-naming,receiver-naming) — https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md- Uber Go Style Guide (Exit in Main, Avoid init()) — https://github.com/uber-go/guide
- Doc comment syntax — https://go.dev/doc/comment;
internal/— https://pkg.go.dev/cmd/go#hdr-Internal_Directories
Decomposition inspired by samber/cc-skills-golang (MIT © 2026 Samuel Berthe); rules grounded in the sources above.