Modules, Packages and Internal Boundaries in Go
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
In Go a package is the compilation unit and a module is the versioning unit. They are different things, and conflating them produces errors like package example.com/hello/greet is not in std.
Before you start
go version # go1.24.x
Modules versus packages
- Module: a set of packages described by a
go.modfile at its root. It has one import-path prefix and one version. - Package: the
.gofiles in a single directory. A directory holds exactly one package name.
mkdir hello && cd hello
go mod init example.com/hello
go.mod:
module example.com/hello
go 1.24
The import path is the module path plus the directory:
hello/
go.mod module example.com/hello
main.go package main
greet/
greet.go package greet
internal/
store/
store.go package store
// main.go
package main
import (
"fmt"
"example.com/hello/greet"
"example.com/hello/internal/store"
)
func main() {
fmt.Println(greet.Hello("world"))
_ = store.New()
}
What gets exported
An identifier is exported — visible to other packages — when it starts with an uppercase letter. Lowercase stays inside the package. There is no public or private keyword.
package greet
// exported
func Hello(name string) string { return "Hello, " + name }
// package-private
func normalize(name string) string { return name }
internal/ is a boundary the compiler enforces: only packages rooted at the parent of internal can import it. example.com/hello/internal/store can be imported from anywhere under example.com/hello/...; an outside module that tries gets a compile error. That is the right place to hide implementation details in a published library — no documentation convention can be ignored by accident.
Package naming
- Lowercase, singular, no underscores:
http,strings,user. - Avoid
util,common,helpers. They attract unrelated code forever. - Read well at the call site:
user.New()beatsusers.UserNew(). - Directory and package names usually match. When they do not, the
packageclause wins (internal/storedeclaringpackage storagecompiles) — but it makes every reader look twice.
Managing dependencies
go get github.com/google/uuid@latest # add a dependency (writes go.mod and go.sum)
go get github.com/google/uuid@v1.6.0 # pin a version
go mod tidy # drop unused deps, fill in missing ones
go mod download # prefetch into the module cache
go list -m all # every module in the build
go mod graph | grep uuid # who requires it
go.sum records checksums for each module and must be committed. The require lines in go.mod are the result of minimal version selection:
require (
github.com/google/uuid v1.6.0
golang.org/x/sync v0.10.0 // indirect
)
// indirect means nothing in your code imports it directly; it comes from a dependency. Do not hand-edit those lines — go mod tidy maintains them.
Go 1.24 also added the tool directive, so command-line tools your project depends on can be tracked the same way:
go get -tool golang.org/x/tools/cmd/stringer@latest
go tool stringer -type=Status
The version then lives in go.mod instead of a comment in a Makefile.
How versions are chosen
Go uses minimal version selection: when several modules require different versions of the same module, the build uses the highest of those minimums. Two consequences:
- Upgrading one dependency can silently raise others. Read the
go mod tidydiff. - To force a version, raise it explicitly with
go get module@versionrather than reaching forreplace.
What replace is for
replace points a module at a local path or a different version. It exists for local development:
replace example.com/lib => ../lib
Caveats:
replaceis not transitive. Your library’s replace directive does not apply to projects that import it.- Decide deliberately whether it should be committed; delete debug-only ones.
- It does not resolve version conflicts, it only hides them from you.
Vendoring
go mod vendor # copy dependencies into vendor/
go build -mod=vendor
Useful where the module proxy is unreachable, or where the team must audit the source of every dependency. The cost is a bigger repository and a re-vendor step on every upgrade. When vendor/ exists Go uses it by default, so keep it consistent with go.mod.
Workspaces for several modules
When one repository holds multiple modules, go.work links them by local path:
go work init ./api ./lib
go work use ./tools
go.work is usually not committed (local paths differ per developer), and CI builds straight from the go.mod files.
Import cycles
Go forbids package-level cycles; the compiler reports import cycle not allowed. Three ways out:
- Extract a shared package for the types both sides need, such as
model. - Depend on an interface instead of an implementation — the caller declares what it needs, the other package satisfies it.
- Merge them if they really are one responsibility.
// package a no longer imports b; it declares the capability it needs
package a
type Fetcher interface {
Fetch(id string) (string, error)
}
func Run(f Fetcher) error { _, err := f.Fetch("1"); return err }
Common mistakes
1. Two package names in one directory
store/
a.go package store
b.go package storage
The compiler says found packages store and storage. One name per directory.
2. main not at the module root
Use go run ./cmd/server, not go run .. A module can hold several main packages, each under cmd/<name>/.
3. Relative import paths
import "./greet" does not work in module mode. Use the full module path.
4. Skipping go mod tidy
Deleted code leaves stale requirements behind, and a new import fails the build until the module is added.
5. Private repositories
Set GOPRIVATE and credentials, or the proxy will try to fetch and fail:
go env -w GOPRIVATE=git.example.com/*
6. Package-level variables as “global config”
They leak state between tests and between requests. Pass a struct explicitly instead.
7. Treating v0 as stable
v0.x carries no compatibility promise. Tag v1.0.0 when the API has settled.
Summary
go.moddefines a module, directories define packages, and the import path is module path plus directory.- Uppercase exports;
internal/is enforced by the compiler. - Commit
go.sum; rungo mod tidyroutinely. - Control versions with
go get module@vX.Y.Z; keepreplacefor local work. - Break import cycles by extracting shared types or depending on interfaces.
Next: testing and benchmarking with only the standard library.