Go · lesson 17 of 25
Packages and Modules in Go
Go packages and modules run for real: a multi-package module, exported names, the internal/ rule, replace and go.work, import cycles, and go doc output.
By SkillByExample · Updated
A package is a directory of .go files compiled together. A module is a tree of packages with a go.mod at its root, and its module path prefixes every import path inside it. This lesson builds a small two-package module, then breaks the visibility rules on purpose to see what the toolchain says.
A module with several packages
The module example.com/shop has a reusable money package, a private internal/tax package and a command in cmd/shop.
module example.com/shop
go 1.24
// Package money represents amounts as integer cents.
package money
import "fmt"
// Cents is an amount of money in hundredths of a unit.
type Cents int64
// FromFloat converts 12.34 to 1234 cents, rounding to the nearest cent.
func FromFloat(f float64) Cents { return Cents(round(f * 100)) }
// String formats c as 12.34.
func (c Cents) String() string { return fmt.Sprintf("%d.%02d", c/100, c%100) }
func round(f float64) int64 { return int64(f + 0.5) } // unexported
package tax
import "example.com/shop/money"
// Add returns c plus pct percent tax, rounded down to the cent.
func Add(c money.Cents, pct int) money.Cents {
return c + c*money.Cents(pct)/100
}
package main
import (
"fmt"
"example.com/shop/internal/tax"
"example.com/shop/money"
)
func main() {
price := money.FromFloat(19.99)
fmt.Println("price:", price)
fmt.Println("with 20% tax:", tax.Add(price, 20))
}
$ find . -name '*.go' | sort
./cmd/shop/main.go
./internal/tax/tax.go
./money/money.go
$ go run ./cmd/shop
price: 19.99
with 20% tax: 23.98
$ go list ./...
example.com/shop/cmd/shop
example.com/shop/internal/tax
example.com/shop/money
$ go vet ./...
Import paths are the module path plus the directory: example.com/shop/money. The package name is what follows package and is what you type at call sites (money.FromFloat); by convention it matches the last path element. ./... means “every package under here”, so one go vet ./... checks the whole module, and go list shows that the module contains three packages. fmt.Println used the String method, so the price prints as 19.99 rather than 1999.
Exported and unexported names
A name starting with an upper-case letter is exported; anything else is visible only inside its own package. A second module that uses shop locally (via a replace directive) can’t reach the lower-case helper:
module example.com/client
go 1.24
require example.com/shop v0.0.0
replace example.com/shop => ../raw_shop
package main
import (
"fmt"
"example.com/shop/money"
)
func main() {
fmt.Println(money.FromFloat(2.5))
fmt.Println(money.round(2.5))
}
$ go run .
# example.com/client
./main.go:11:20: name round not exported by package money
The compiler names the problem precisely. There are no public/private keywords in Go: case is the whole visibility system, and it applies to types, functions, methods, constants, variables and struct fields alike.
The replace line points the example.com/shop requirement at a directory on disk instead of downloading it, which is how you develop two modules side by side. (A go.work workspace, shown in the practice section, does the same without editing go.mod.)
internal/ packages
Any package under a directory named internal can only be imported by code rooted at the parent of that directory. cmd/shop inside the module may import example.com/shop/internal/tax; another module may not:
module example.com/client2
go 1.24
require example.com/shop v0.0.0
replace example.com/shop => ../raw_shop
package main
import (
"fmt"
"example.com/shop/internal/tax"
"example.com/shop/money"
)
func main() {
fmt.Println(tax.Add(money.FromFloat(10), 5))
}
$ go run .
package example.com/client2
main.go:6:2: use of internal package example.com/shop/internal/tax not allowed
This is enforced by the go command, not by convention. Put everything that isn’t part of your module’s promised API under internal/, and you can rename or delete it later without breaking anyone.
Import cycles
Package a importing b while b imports a is rejected outright.
module example.com/cycle
go 1.24
package a
import "example.com/cycle/b"
func Hello() string { return "a+" + b.Name() }
package b
import "example.com/cycle/a"
func Name() string { return "b" }
func Both() string { return a.Hello() }
$ go build ./...
package example.com/cycle/a
imports example.com/cycle/b from a.go
imports example.com/cycle/a from b.go: import cycle not allowed
Packages are compiled and initialised in dependency order, which needs a graph without cycles. The usual fixes are moving the shared code into a third package both can import, or having one side accept an interface instead of importing the other package.
Documentation comments
A comment directly above a declaration is its documentation, and go doc reads it from source — including your own packages:
$ go doc ./money
package money // import "example.com/shop/money"
Package money represents amounts as integer cents.
type Cents int64
func FromFloat(f float64) Cents
$ go doc ./money Cents.String
package money // import "example.com/shop/money"
func (c Cents) String() string
String formats c as 12.34.
The convention is that a doc comment is a full sentence starting with the name it documents. Unexported names (round) don’t appear in go doc output, and the // Package money ... comment becomes the package summary that pkg.go.dev shows.
Naming conventions
| Convention | Example | Avoid |
|---|---|---|
| Short, lower-case, single word | money, tax, http | moneyUtils, tax_calc |
| Don’t repeat the package name | money.Cents, tax.Add | money.MoneyCents, tax.AddTax |
| No catch-all names | strutil, slug | util, common, helpers |
| Directory matches package name | money/ holds package money | money/ holding package currency |
Callers always write the package name, so tax.Add reads well while tax.AddTax stutters.
Practice
1. Add an exported Sub(other Cents) Cents method to money.Cents and use it in cmd/shop to print a 5.00 discount from the price.
Solution
module example.com/shop
go 1.24
package money
import "fmt"
type Cents int64
func FromFloat(f float64) Cents { return Cents(int64(f*100 + 0.5)) }
func (c Cents) Sub(other Cents) Cents { return c - other }
func (c Cents) String() string { return fmt.Sprintf("%d.%02d", c/100, c%100) }
package main
import (
"fmt"
"example.com/shop/money"
)
func main() {
price := money.FromFloat(19.99)
fmt.Println(price.Sub(money.FromFloat(5)))
}
$ go run ./cmd/shop
14.99
The method is exported because it starts with S; cmd/shop sees it through the money import.
2. Break the cycle in raw_cycle: keep a importing b, and remove b’s dependency on a.
Solution
module example.com/cycle
go 1.24
package a
import "example.com/cycle/b"
func Hello() string { return "a+" + b.Name() }
func Both() string { return Hello() } // moved here from b
package b
func Name() string { return "b" }
package main
import (
"fmt"
"example.com/cycle/a"
)
func main() { fmt.Println(a.Both()) }
$ go run .
a+b
Both needed both packages, so it moved to the one that already imports the other. The dependency graph is now a line: main → a → b.
3. Instead of a replace directive, use a workspace: create a client module with a plain require, then go work init it together with raw_shop.
Solution
module example.com/client3
go 1.24
package main
import (
"fmt"
"example.com/shop/money"
)
func main() { fmt.Println("workspace:", money.FromFloat(7.5)) }
$ cd .. && go work init ./raw_shop ./raw_p3 && cat go.work
go 1.24.7
use (
./raw_p3
./raw_shop
)
$ cd raw_p3 && go run .
workspace: 7.50
go.work lists the modules to build together, and imports between them resolve to the local directories. The client’s go.mod didn’t need a require for shop at all. Workspaces are meant for local development, so go.work is usually not committed.