Day 9 — Packages & layout
Day 9 — Packages & layout
Stage I · ~3h (theory-heavy)
Goal: Split a module into multiple packages with clear boundaries, correct import paths, exported APIs, and an internal/ tree that cannot be imported by strangers.
A package is a directory of Go files sharing one package name. A module is the versioned unit with go.mod. Import paths start with the module path. Do not confuse folder names with package clause names—though they usually match.
Why this day exists
Single-file programs teach syntax. Real systems need:
- Separation of CLI wiring vs domain logic
- Importable libraries under test
- Prevention of accidental dependency cycles
internal/as an enforced privacy boundary
Day 9 is the last sequential-core packaging skill before the Stage I gate asks you to ship a multi-package CLI.
Theory 1 — Module path vs import path vs directory
~/lab/90daysofx/go/day09/ # directory on disk
go.mod # module example.com/day09
cmd/inv/main.go # package main → import path example.com/day09/cmd/inv
inventory/store.go # package inventory → example.com/day09/inventory
internal/parse/parse.go # package parse → example.com/day09/internal/parse
| Concept | Example |
|---|---|
| Module path | example.com/day09 |
| Package import path | example.com/day09/inventory |
| Package name (clause) | package inventory |
| Directory | .../day09/inventory |
Rules
- All files in a directory (not subdirs) are one package.
- The package clause is usually the directory’s base name.
package mainis special: builds an executable.
- You cannot have
package fooandpackage barin the same directory.
Theory 2 — Exported identifiers
package inventory
type Item struct { // exported type
SKU string // exported field
qty int // unexported field
}
func NewItem(sku string) *Item { // exported func
return &Item{SKU: sku}
}
func (i *Item) valid() bool { // unexported method
return i.SKU != ""
}Only identifiers starting with an uppercase letter are visible to importers.
Package doc
// Package inventory provides an in-memory stock store.
package inventoryThe comment immediately preceding the package clause documents the package (go doc).
Theory 3 — Imports
import (
"fmt" // stdlib
"example.com/day09/inventory" // local module package
)
func demo() {
it := inventory.NewItem("A1")
fmt.Println(it.SKU)
}Import forms
import "fmt"
import f "fmt" // rename
import . "fmt" // dot import — avoid in production
import _ "net/http/pprof" // side-effect import (init only)Init functions
func init() {
// runs before main, after package-level vars initialized
}Multiple inits allowed; order is constrained but prefer explicit setup over heavy init. Side-effect imports rely on init.
Theory 4 — internal/ directory rule
Any package under a path segment internal can only be imported by code rooted at the parent of that internal directory.
example.com/day09/internal/parse # OK import from example.com/day09/...
example.com/other/foo importing above # FORBIDDEN by compiler
Use internal/ for:
- Implementation details
- Shared helpers you do not want as public API
- CLI-only wiring helpers
Do not put everything in internal/ by reflex—export a small public surface for libraries you intend to reuse.
Theory 5 — Common layouts
Small CLI (recommended for Stage I)
module/
go.mod
cmd/<app>/main.go # thin main
<domain>/ # public library packages
internal/<helper>/ # private helpers
Multi-command
cmd/
inv/main.go
invctl/main.go
Share domain packages; keep mains thin.
Avoid early over-structure
| Premature | Prefer first |
|---|---|
pkg/ for everything |
Direct domain dirs at module root |
models/, utils/, helpers/ mega-packages |
Names that mean something (inventory, parse) |
| Deep 6-level trees | 2–3 levels until pain is real |
| Circular imports “solved” with interfaces everywhere | Rethink ownership; interfaces later |
pkg/ is seen in some monorepos; it is optional fashion, not a language requirement.
Theory 6 — Cycles and layering
Go forbids import cycles.
package a imports b
package b imports a // compile error
Layering sketch
cmd/inv → inventory → (stdlib only)
→ internal/parse
Domain should not import CLI packages. CLI may import domain.
Dependency direction test
Can this package be tested without starting a CLI?
If no, the boundary is wrong.
Theory 7 — Buildable packages vs tests
inventory/
store.go
store_test.go # package inventory — white box
# or store_export_test.go with package inventory_test — black box
Day 16 covers table tests deeply. Today: put library code outside main so tests can import it.
go test ./...
go list ./...Worked examples bank
Example A — Thin main
// cmd/inv/main.go
package main
import (
"fmt"
"os"
"example.com/day09/inventory"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run(args []string) error {
s := inventory.NewStore()
// parse args, call s methods...
_ = s
return nil
}Example B — Domain package
// inventory/store.go
package inventory
import "fmt"
type Store struct {
items map[string]Item
}
type Item struct {
SKU string
Qty int
}
func NewStore() *Store {
return &Store{items: make(map[string]Item)}
}
func (s *Store) Add(sku string, qty int) error {
if sku == "" {
return fmt.Errorf("empty sku")
}
it := s.items[sku]
it.SKU = sku
it.Qty += qty
s.items[sku] = it
return nil
}Example C — Internal parser
// internal/parse/args.go
package parse
import "fmt"
func SKUQty(args []string) (sku string, qty int, err error) {
if len(args) != 2 {
return "", 0, fmt.Errorf("want sku qty")
}
// parse qty...
return args[0], 1, nil
}// cmd/inv/main.go
import "example.com/day09/internal/parse"Example D — Export control
package inventory
// Pool is an implementation detail.
type pool struct{ /* ... */ }
// Store is public API.
type Store struct {
p *pool
}Keep unexported what you may want to redesign.
Example E — Documenting go.mod
module example.com/day09
go 1.26No external requires yet. Day 15 covers modules in depth.
Labs
Suggested workspace: ~/lab/90daysofx/go/day09
Lab 1 — Multi-package module
mkdir -p ~/lab/90daysofx/go/day09/{cmd/inv,inventory,internal/parse}
cd ~/lab/90daysofx/go/day09
go mod init example.com/day09Requirements:
inventorypackage with store/domain types (nopackage main).
internal/parsefor argument or line parsing.
cmd/invthin main that wires them.
- At least one exported and one unexported identifier used correctly.
go build -o inv ./cmd/invsucceeds.
go test ./...runs (add one trivial test ininventory).
Lab 2 — Prove internal/ enforcement
Create a second module path attempt or a sibling package outside the allowed tree and show the compiler error when importing internal/parse illegally. Document the error text in notes.
(Within one module, create example.com/day09/evil that tries to import another module’s internal—or simply explain the rule with a quote from go help packages / experiment with a nested module if you want full proof.)
Simpler proof: read and restate the rule with your tree diagram in LAYOUT.md.
Lab 3 — go list tour
go list ./...
go list -f '{{.ImportPath}} {{.Name}}' ./...
go doc example.com/day09/inventoryPaste outputs into notes.
Common gotchas
| Gotcha | Fix |
|---|---|
package name ≠ directory |
Keep them aligned |
| Import cycle | Invert dependency; extract third package |
Putting logic only in main |
Extract packages for tests/reuse |
| Exporting everything | Unexport by default; export deliberately |
| Import path typos | Must start with module path |
internal myth “only for stdlib” |
Works in your modules too |
Mega utils package |
Prefer domain-named packages |
Checkpoint
- Can draw module path → import path → directory
- Multi-package build works
internal/purpose explained
- Exported vs unexported demo
- Thin
main+ domain package
go test ./...green on at least one test
Commit
git add .
git commit -m "day09: multi-package layout + internal"Tomorrow
Day 10 — Stage I gate: integrate Days 1–9 into a stdlib-only mini CLI (structs, slices, maps, packages, errors as values) with README and deliberate bad-input handling.