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: the example system, a distributed SQL proxy — a set of small Go services that sit in front of PostgreSQL and add query routing, connection pooling, and automated failover. It’s a single Go module organized under the standard cmd/, internal/ layout. The concepts are pure Go; the example system just gives us vivid, production-grade code 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.

internal/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/internal/foo/bar/baz"
func main() { _ = baz.Hello() } // "baz" is the package name, not the path

The convention holds almost everywhere in the codebase. internal/parser/keywords.go declares package parser, and cmd/platformctl/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 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 internal/constants/service.go the service-name constants are exported so any package can reference them:

internal/constants/service.go
package constants
// Service names for telemetry, logging, and service registration.
const (
// ServiceGateway is the name of the gateway service.
ServiceGateway = "gateway"
// ServicePooler is the name of the pooler service.
ServicePooler = "pooler"
// ...
)

Contrast that with internal/parser/keywords.go, which keeps its lookup table unexported because nothing outside the parser should reach it directly:

internal/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 the whole tree:

go.mod
module github.com/example/platform
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 internal/constants is imported as github.com/example/platform/internal/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). Large single-module Go projects like Kubernetes work the same way.

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

The canonical result is a three-group import block: standard library, then third-party, then the github.com/example local group last. internal/gateway/manager.go is a clean example:

internal/gateway/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/example/platform/internal/preparedstatement"
gatewaymanagerpb "github.com/example/platform/internal/pb/gatewaymanager"
gatewaymanagerdatapb "github.com/example/platform/internal/pb/gatewaymanagerdata"
"github.com/example/platform/internal/gateway/handler"
"github.com/example/platform/internal/gateway/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 .../internal/pb/gatewaymanager declares a package you’d otherwise reference as gatewaymanager.SomeType everywhere. This codebase aliases these consistently with a <name>pb suffix at the import site:

gatewaymanagerpb "github.com/example/platform/internal/pb/gatewaymanager"

Now call sites read gatewaymanagerpb.GatewayManagerServer, 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:

  • Directoryinternal/
    • Directoryconfig/
      • Directoryinternal/ subpkgs: registry, sync, value
    • Directoryasthelpergen/
      • Directoryinternal/
    • Directoryservenv/
      • Directoryinternal/ subpkg: mux
    • Directorypooler/
      • Directoryinternal/

internal/config/registry.go imports its own internal subpackage:

internal/config/registry.go
import (
"github.com/spf13/viper"
"github.com/example/platform/internal/config/internal/sync"
)

Only code under internal/config/... may import .../config/internal/sync. If internal/pooler 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, service packages cannot depend on cmd or other services, shared utilities cannot depend on any in-repo code outside themselves — is wider than that, so it is enforced at lint time with depguard in .golangci.yml. The directory contract:

Directory Rule
./cmd/... Commands — can depend on anything
./internal/<service>/... Service code — cannot depend on cmd/ or other services
./internal/... (shared) Shared code — cannot depend on cmd/ or services
./internal/tools/... Generic utilities — cannot depend on any repo code outside tools/

A few real rules:

  • parser-isolation — files under internal/parser/** may not import anything from the shared packages except parser itself, so the parser can be lifted out as a standalone library:
    .golangci.yml
    parser-isolation:
    list-mode: lax
    files: ["**/internal/parser/**"]
    deny:
    - pkg: "github.com/example/platform/internal"
    desc: "parser can only depend on itself ..."
    allow:
    - $gostd
    - github.com/example/platform/internal/parser
    You can verify the rule holds: the only in-repo paths the parser imports are .../parser and .../parser/ast.
  • tools-isolationinternal/tools/** denies github.com/example/platform and only allows $gostd plus .../internal/tools.
  • Per-service isolation (pooler-isolation, orch-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 internal/parser/keywords.go:

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

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

cmd/pooler/plugin_topo.go
package main
import (
_ "github.com/example/platform/internal/plugins/topo"
)

The aggregator blank-imports each implementation:

internal/plugins/topo/plugins.go
// Package topo provides a central place to import all topology plugins.
package topo
import (
_ "github.com/example/platform/internal/topo/etcdtopo"
_ "github.com/example/platform/internal/topo/memorytopo"
)

And each implementation’s init() self-registers into a global registry:

internal/topo/etcdtopo/store.go
func init() {
servenv.OnParse(registerEtcdTopoFlags)
topo.RegisterFactory(topo.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

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

//go:build ruleguard

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

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. internal/config/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.)

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

Directory Contents Package May depend on
cmd/* binaries, CLIs (cobra) package main anything
internal/<service>/* per-service logic (gateway, pooler, …) named shared code, tools, pb (not cmd, not other services)
internal/* (shared) shared code (parser, errx, pgprotocol, …) named tools, pb (not cmd, not services)
internal/tools/* generic, codebase-agnostic utilities named only $gostd + other tools
internal/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.

  1. A file at internal/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/example/platform/internal/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 internal/pooler not import internal/config/internal/sync, and what kind of failure is it?

    Answerinternal/ packages are importable only by code rooted at the parent of internal/ — here internal/config/.... pooler 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/example/platform/internal/plugins/topo" from cmd/pooler/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()' internal/topo/ and trace which init() functions register factories. Then follow the blank-import chain from cmd/orchestrator/plugin_topo.go through internal/plugins/topo/plugins.go to the topo.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/example/platform/internal/[a-z]*' internal/parser --include='*.go' | sort -u. Explain what the result proves and why the parser is kept a standalone library.

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

  4. List the build-constrained files with grep -rln '//go:build' . --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.