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.

Go 1.24

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.mod file at its root. It has one import-path prefix and one version.
  • Package: the .go files 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() beats users.UserNew().
  • Directory and package names usually match. When they do not, the package clause wins (internal/store declaring package storage compiles) — 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 tidy diff.
  • To force a version, raise it explicitly with go get module@version rather than reaching for replace.

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:

  • replace is 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:

  1. Extract a shared package for the types both sides need, such as model.
  2. Depend on an interface instead of an implementation — the caller declares what it needs, the other package satisfies it.
  3. 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.mod defines 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; run go mod tidy routinely.
  • Control versions with go get module@vX.Y.Z; keep replace for local work.
  • Break import cycles by extracting shared types or depending on interfaces.

Next: testing and benchmarking with only the standard library.