Packages, Modules & Imports
This is the first language chapter. We’ll learn how Go organizes code — packages, a module, visibility, and imports — by reading a real distributed-systems codebase: multigres (“Vitess for Postgres”), a single-module Go monorepo whose source lives under go/. The concepts are pure Go; multigres just gives us vivid, production-grade examples to point at.
If you haven’t seen the repo map yet, the orientation has it.
The package clause is not the directory name
Section titled “The package clause is not the directory name”Every .go file begins with a package <name> clause. The compiler treats all files in one directory as one package, and they must all declare the same package name. That name is what other code types to reference the package’s identifiers — and it is not required to match the directory name, though by convention it usually does.
// directory is "baz"package baz // conventional: package name == last path segment
func Hello() string { return "hi" }A caller imports the path but references the package name:
import "example.com/m/go/foo/bar/baz"
func main() { _ = baz.Hello() } // "baz" is the package name, not the pathThe convention holds almost everywhere in the codebase. go/common/parser/keywords.go declares package parser, and go/cmd/multigres/main.go declares package main. The main package is special: it is the only package name that produces an executable (it must contain a func main()), and every binary under go/cmd/* is a package main.
Exported vs unexported: capitalization is the only rule
Section titled “Exported vs unexported: capitalization is the only rule”Go has no public/private/protected keywords. An identifier (type, function, method, field, constant, variable) is visible outside its package if and only if its first letter is uppercase. Lowercase means package-private — reachable from any file in the same package, invisible everywhere else.
package widget
type Gadget struct { // exported type Name string // exported field — visible to importers count int // unexported field — only this package can touch it}
func New() *Gadget { return &Gadget{} } // exported constructorfunc reset() {} // unexported helperThis is used constantly. In go/common/constants/service.go the service-name constants are exported so any package can reference them:
package constants
// Service names for telemetry, logging, and service registration.const ( // ServiceMultigateway is the name of the multigateway service. ServiceMultigateway = "multigateway" // ServiceMultipooler is the name of the multipooler service. ServiceMultipooler = "multipooler" // ...)Contrast that with go/common/parser/keywords.go, which keeps its lookup table unexported because nothing outside the parser should reach it directly:
// keywordLookupMap provides fast keyword lookup by name.var keywordLookupMap map[string]*KeywordInfokeywordLookupMap (lowercase k) cannot be named from any other package; callers go through the exported LookupKeyword function instead. This is the encapsulation boundary — pick uppercase only for the API you intend to support.
The module: one go.mod, one path prefix
Section titled “The module: one go.mod, one path prefix”A module is a versioned collection of packages with a single go.mod at its root. This codebase is one module covering everything under go/:
module github.com/multigres/multigres
go 1.25.1Two facts follow from those three lines:
- The module path is the import prefix for all internal code. A package living at
go/common/constantsis imported asgithub.com/multigres/multigres/go/common/constants. Import path = module path + the directory path relative to the module root. go 1.25.1sets the language/toolchain version. Features and standard-library behavior are gated on this; a file using a newer construct than the module declares will not build.
The rest of go.mod is require blocks listing dependencies. Direct dependencies (something in go/ imports them) sit plainly; transitive ones carry a // indirect marker:
require ( github.com/davecgh/go-spew v1.1.2-... // indirect gopkg.in/yaml.v3 v3.0.1)Because it is a single module, there is no per-directory go.mod; the architectural boundaries you’d express with separate modules elsewhere are instead enforced by internal/ and lint rules (covered below).
Import grouping and the goimports local-prefix convention
Section titled “Import grouping and the goimports local-prefix convention”gofmt sorts imports alphabetically within each blank-line-separated group but does not decide the groups. goimports additionally manages a “local prefix” group, configured here in .golangci.yml:
formatters: enable: - gofumpt - goimports settings: goimports: local-prefixes: - github.com/multigresThe canonical result is a three-group import block: standard library, then third-party, then the github.com/multigres local group last. go/services/multigateway/manager.go is a clean example:
import ( "context" "time"
"google.golang.org/grpc" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/multigres/multigres/go/common/preparedstatement" multigatewaymanagerpb "github.com/multigres/multigres/go/pb/multigatewaymanager" multigatewaymanagerdatapb "github.com/multigres/multigres/go/pb/multigatewaymanagerdata" "github.com/multigres/multigres/go/services/multigateway/handler" "github.com/multigres/multigres/go/services/multigateway/handler/queryregistry")Import aliasing for generated protobuf packages
Section titled “Import aliasing for generated protobuf packages”A generated protobuf package’s declared name is fixed by the code generator and is often verbose or non-obvious. The path .../go/pb/multigatewaymanager declares a package you’d otherwise reference as multigatewaymanager.SomeType everywhere. This codebase aliases these consistently with a <name>pb suffix at the import site:
multigatewaymanagerpb "github.com/multigres/multigres/go/pb/multigatewaymanager"Now call sites read multigatewaymanagerpb.MultiGatewayManagerServer, and the pb suffix signals “this is a generated wire type” at a glance. The same convention produces querypb, topodatapb, etc.
internal/ packages: compiler-enforced visibility
Section titled “internal/ packages: compiler-enforced visibility”A package whose import path contains a path element named internal/ can be imported only by code rooted at internal/’s parent directory. Anything outside that subtree gets a compile error, not a warning. This is Go’s one structural-visibility mechanism beyond capitalization.
example.com/m/foo/internal/secret // importable by foo/... and foo/internal/...example.com/m/bar/baz.go // CANNOT import foo/internal/secret -> build failsThis is used to wall off implementation detail. Real internal/ directories in the tree:
Directorygo/
Directorytools/
Directoryviperutil/
Directoryinternal/ subpkgs: registry, sync, value
- …
Directoryasthelpergen/
Directoryinternal/
- …
Directorycommon/
Directoryservenv/
Directoryinternal/ subpkg: mux
- …
Directoryservices/
Directorymultipooler/
Directoryinternal/
- …
go/tools/viperutil/registry.go imports its own internal subpackage:
import ( "github.com/spf13/viper"
"github.com/multigres/multigres/go/tools/viperutil/internal/sync")Only code under go/tools/viperutil/... may import .../viperutil/internal/sync. If go/services/multipooler tried to, the build would fail with use of internal package ... not allowed. That guarantee lets maintainers refactor internal/sync freely without breaking external callers.
depguard: architecture the language cannot express
Section titled “depguard: architecture the language cannot express”internal/ only encodes parent/child visibility. The codebase’s dependency direction — cmd can depend on anything, services/common cannot depend on cmd or other services, tools cannot depend on any in-repo code outside tools — is wider than that, so it is enforced at lint time with depguard in .golangci.yml. The directory contract:
| Directory | Rule |
|---|---|
./go/cmd/... | Commands — can depend on anything |
./go/services/... | Service code — cannot depend on cmd/ or other services |
./go/common/... | Shared code — cannot depend on cmd/ or services/ |
./go/tools/... | Generic utilities — cannot depend on any repo code outside tools/ |
A few real rules:
parser-isolation— files undergo/common/parser/**may not import anything fromgo/commonexcept parser itself, so the parser can be lifted out as a standalone library:You can verify the rule holds: the only.golangci.yml parser-isolation:list-mode: laxfiles: ["**/go/common/parser/**"]deny:- pkg: "github.com/multigres/multigres/go/common"desc: "parser can only depend on itself within go/common ..."allow:- $gostd- github.com/multigres/multigres/go/common/parsergo/common/...paths the parser imports are.../parserand.../parser/ast.tools-isolation—go/tools/**deniesgithub.com/multigres/multigres/goand only allows$gostdplus.../go/tools.- Per-service isolation (
multipooler-isolation,multiorch-isolation, …) — each service package is importable only by its own package and itscmd. use_modern_packages— deniesmath/rand$, forcingmath/rand/v2.
Package-level var/const and init()
Section titled “Package-level var/const and init()”Package-level const ( ... ) and var ( ... ) blocks declare values scoped to the whole package, initialized before any init() runs. A const block can use iota to build an auto-incrementing enum:
type Color intconst ( Red Color = iota // 0 Green // 1 Blue // 2)This codebase uses exactly this in go/common/parser/keywords.go:
type KeywordCategory intconst ( UnreservedKeyword KeywordCategory = iota // 0 ColNameKeyword // 1 TypeFuncNameKeyword // 2 ReservedKeyword // 3)init() is a special function: no parameters, no return value, run once per package after all package-level variables are initialized and before main (or before any exported identifier of the package is first used). You may declare multiple init() functions, even across files in the same package. They’re used here to build derived state that is awkward to express as a single declaration. The keyword lookup map:
func init() { keywordLookupMap = make(map[string]*KeywordInfo, len(Keywords)) for i := range Keywords { keywordLookupMap[Keywords[i].Name] = &Keywords[i] }}Keywords is a package-level slice; init() turns it into an O(1) lookup map after the slice is fully initialized. A second hand-written example builds reverse maps in go/tools/viperutil/config.go, inverting handlingNamesToValues into handlingValuesToNames and a sorted handlingNames.
The blank-import plugin pattern (why init() matters here)
Section titled “The blank-import plugin pattern (why init() matters here)”A blank import — import _ "path" — imports a package solely for its side effects: its package-level vars get initialized and its init() functions run, but none of its identifiers are made available. This codebase uses it to register pluggable backends without the registering code being referenced directly.
Trace the topology-plugin chain. The command blank-imports an aggregator:
package main
import ( _ "github.com/multigres/multigres/go/common/plugins/topo")The aggregator blank-imports each implementation:
// Package topo provides a central place to import all topology plugins.package topo
import ( _ "github.com/multigres/multigres/go/common/topoclient/etcdtopo" _ "github.com/multigres/multigres/go/common/topoclient/memorytopo")And each implementation’s init() self-registers into a global registry:
func init() { servenv.OnParse(registerEtcdTopoFlags) topoclient.RegisterFactory(topoclient.DefaultTopoImplementation, Factory{})}The result: main never names etcdtopo or RegisterFactory, yet by the time main runs, the etcd factory is registered. The import edges sequence the registration, and dropping a backend is a one-line edit to the blank-import list rather than a code change in every command.
Build constraints
Section titled “Build constraints”Two mechanisms select which files compile for a given target.
1. //go:build lines placed before the package clause and followed by a blank line. The Go toolchain only requires that the constraint precede the package clause (and be separated from it by a blank line) — its position relative to the license comment varies across the tree: servenv_unix.go puts it on line 1 above the license, while rules.go and topo_test.go (below) put it after the license block. Three flavors appear here:
//go:build !windowsgo/common/servenv/servenv_unix.go (and pprof_unix.go) compile only on non-Windows.
//go:build ruleguardgo/tools/ruleguard/rules.go compiles only when the ruleguard tag is set — which .golangci.yml does via run.build-tags: [ruleguard]. The file exists purely for lint-time rule definitions and is invisible to a normal go build.
//go:build integrationgo/test/endtoend/topo_test.go is gated behind the integration tag so slow end-to-end tests are excluded from the short test run and only run by the integration test target.
2. GOOS/GOARCH filename suffixes require no //go:build line — the suffix itself is the constraint. go/tools/viperutil/internal/sync/sync_darwin_test.go is compiled only on macOS, and a sibling sync_linux_test.go only on Linux, purely because of the _darwin/_linux suffix.
External test packages (package foo_test)
Section titled “External test packages (package foo_test)”A _test.go file may declare a package named foo_test instead of foo. This compiles as a separate package living in the same directory, and it can reference only the exported API of foo. Two reasons to use it: it tests the package as a real consumer would, and it breaks import cycles when a test needs a helper package that itself imports foo. (Dozens of such files exist in this tree.)
package sync_testThe monorepo layout under go/
Section titled “The monorepo layout under go/”Putting it together, the single module is organized by responsibility, with the dependency direction enforced top-down by depguard:
| Directory | Contents | Package | May depend on |
|---|---|---|---|
go/cmd/* | binaries, CLIs (cobra) | package main | anything |
go/services/* | per-service logic (multigateway, multipooler, …) | named | common, tools, pb (not cmd, not other services) |
go/common/* | shared code (parser, mterrors, pgprotocol, …) | named | tools, pb (not cmd, not services) |
go/tools/* | generic, codebase-agnostic utilities | named | only $gostd + other tools |
go/pb/* | generated protobuf | named (aliased <name>pb) | — |
Knowing this table tells you, before reading a single line, what a file is allowed to import — and therefore roughly what layer it lives in.
Checkpoints
Section titled “Checkpoints”-
A file at
go/common/foo/bar.godeclarespackage foo. What import path do other packages use, and what identifier do they type in code?Answer
They import the pathgithub.com/multigres/multigres/go/common/foo(module path + directory), and reference its identifiers via the package namefoo(e.g.foo.Thing). Path and name are independent; here they happen to match the last path segment. -
Why can
go/services/multipoolernot importgo/tools/viperutil/internal/sync, and what kind of failure is it?Answer
internal/packages are importable only by code rooted at the parent ofinternal/— herego/tools/viperutil/....multipooleris outside that subtree, so it is a compile error (“use of internal package … not allowed”), enforced by the compiler and not suppressible with//nolint. -
You delete the line
_ "github.com/multigres/multigres/go/common/plugins/topo"fromcmd/multipooler/plugin_topo.go. It still compiles. What breaks?Answer
The blank import existed only for side effects: it triggered theinit()chain that callstopoclient.RegisterFactory. Without it, no topo backend registers, and the service fails at runtime when it tries to construct the configured topo implementation. -
import "math/rand"compiles cleanly. Why does CI reject it?Answer
Theuse_modern_packagesdepguard rule in.golangci.ymldeniesmath/rand$, requiringmath/rand/v2. depguard runs at lint time, so the code compiles but fails the lint gate.
Exercises
Section titled “Exercises”-
Run
grep -rn 'func init()' go/common/topoclient/and trace whichinit()functions register factories. Then follow the blank-import chain fromgo/cmd/multiorch/plugin_topo.gothroughgo/common/plugins/topo/plugins.goto thetopoclient.RegisterFactorycall. In three sentences, explain why this indirection is used instead of callingRegisterFactorydirectly frommain. -
Open the
parser-isolationrule in.golangci.yml, then rungrep -rho 'github.com/multigres/multigres/go/common/[a-z]*' go/common/parser --include='*.go' | sort -u. Explain what the result proves and why the parser is kept a standalone library. -
Run
find go -type d -name internal. Pickgo/tools/viperutil/internal/syncand use grep to show that no package outsidego/tools/viperutilimports it. Then state the exact compile error you’d get ifgo/services/multipoolertried to import it. -
List the build-constrained files with
grep -rln '//go:build' go --include='*.go'and also find the_darwin/_linuxsuffixed files. Categorize each by what selects it (OS via//go:build, OS via filename suffix, or a custom tag likeruleguard/integration) and state which lint or test target activates the custom-tag ones.