Skip to content

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.

go/foo/bar/baz.go
// 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 path

The 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 constructor
func reset() {} // unexported helper

This is used constantly. In go/common/constants/service.go the service-name constants are exported so any package can reference them:

go/common/constants/service.go
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:

go/common/parser/keywords.go
// keywordLookupMap provides fast keyword lookup by name.
var keywordLookupMap map[string]*KeywordInfo

keywordLookupMap (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.

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/:

go.mod
module github.com/multigres/multigres
go 1.25.1

Two facts follow from those three lines:

  1. The module path is the import prefix for all internal code. A package living at go/common/constants is imported as github.com/multigres/multigres/go/common/constants. Import path = module path + the directory path relative to the module root.
  2. go 1.25.1 sets 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:

go.mod
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:

.golangci.yml
formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- github.com/multigres

The 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:

go/services/multigateway/manager.go
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 fails

This 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:

go/tools/viperutil/registry.go
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:

DirectoryRule
./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 under go/common/parser/** may not import anything from go/common except parser itself, so the parser can be lifted out as a standalone library:
    .golangci.yml
    parser-isolation:
    list-mode: lax
    files: ["**/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/parser
    You can verify the rule holds: the only go/common/... paths the parser imports are .../parser and .../parser/ast.
  • tools-isolationgo/tools/** denies github.com/multigres/multigres/go and only allows $gostd plus .../go/tools.
  • Per-service isolation (multipooler-isolation, multiorch-isolation, …) — each service package is importable only by its own package and its cmd.
  • use_modern_packages — denies math/rand$, forcing math/rand/v2.

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 int
const (
Red Color = iota // 0
Green // 1
Blue // 2
)

This codebase uses exactly this in go/common/parser/keywords.go:

go/common/parser/keywords.go
type KeywordCategory int
const (
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:

go/common/parser/keywords.go
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:

go/cmd/multipooler/plugin_topo.go
package main
import (
_ "github.com/multigres/multigres/go/common/plugins/topo"
)

The aggregator blank-imports each implementation:

go/common/plugins/topo/plugins.go
// 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:

go/common/topoclient/etcdtopo/store.go
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.

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 !windows

go/common/servenv/servenv_unix.go (and pprof_unix.go) compile only on non-Windows.

//go:build ruleguard

go/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 integration

go/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.

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.)

go/tools/viperutil/internal/sync/sync_darwin_test.go
package sync_test

Putting it together, the single module is organized by responsibility, with the dependency direction enforced top-down by depguard:

DirectoryContentsPackageMay depend on
go/cmd/*binaries, CLIs (cobra)package mainanything
go/services/*per-service logic (multigateway, multipooler, …)namedcommon, tools, pb (not cmd, not other services)
go/common/*shared code (parser, mterrors, pgprotocol, …)namedtools, pb (not cmd, not services)
go/tools/*generic, codebase-agnostic utilitiesnamedonly $gostd + other tools
go/pb/*generated protobufnamed (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.

  1. A file at go/common/foo/bar.go declares package foo. What import path do other packages use, and what identifier do they type in code?

    AnswerThey import the path github.com/multigres/multigres/go/common/foo (module path + directory), and reference its identifiers via the package name foo (e.g. foo.Thing). Path and name are independent; here they happen to match the last path segment.

  2. Why can go/services/multipooler not import go/tools/viperutil/internal/sync, and what kind of failure is it?

    Answerinternal/ packages are importable only by code rooted at the parent of internal/ — here go/tools/viperutil/.... multipooler is outside that subtree, so it is a compile error (“use of internal package … not allowed”), enforced by the compiler and not suppressible with //nolint.

  3. You delete the line _ "github.com/multigres/multigres/go/common/plugins/topo" from cmd/multipooler/plugin_topo.go. It still compiles. What breaks?

    AnswerThe blank import existed only for side effects: it triggered the init() chain that calls topoclient.RegisterFactory. Without it, no topo backend registers, and the service fails at runtime when it tries to construct the configured topo implementation.

  4. import "math/rand" compiles cleanly. Why does CI reject it?

    AnswerThe use_modern_packages depguard rule in .golangci.yml denies math/rand$, requiring math/rand/v2. depguard runs at lint time, so the code compiles but fails the lint gate.

  1. Run grep -rn 'func init()' go/common/topoclient/ and trace which init() functions register factories. Then follow the blank-import chain from go/cmd/multiorch/plugin_topo.go through go/common/plugins/topo/plugins.go to the topoclient.RegisterFactory call. In three sentences, explain why this indirection is used instead of calling RegisterFactory directly from main.

  2. Open the parser-isolation rule in .golangci.yml, then run grep -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.

  3. Run find go -type d -name internal. Pick go/tools/viperutil/internal/sync and use grep to show that no package outside go/tools/viperutil imports it. Then state the exact compile error you’d get if go/services/multipooler tried to import it.

  4. List the build-constrained files with grep -rln '//go:build' go --include='*.go' and also find the _darwin/_linux suffixed files. Categorize each by what selects it (OS via //go:build, OS via filename suffix, or a custom tag like ruleguard/integration) and state which lint or test target activates the custom-tag ones.