Day 9 — Packages & layout

Updated

July 30, 2026

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.

Note

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

  1. All files in a directory (not subdirs) are one package.
  2. The package clause is usually the directory’s base name.
  3. package main is special: builds an executable.
  4. You cannot have package foo and package bar in 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 inventory

The 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

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.26

No 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/day09

Requirements:

  1. inventory package with store/domain types (no package main).
  2. internal/parse for argument or line parsing.
  3. cmd/inv thin main that wires them.
  4. At least one exported and one unexported identifier used correctly.
  5. go build -o inv ./cmd/inv succeeds.
  6. go test ./... runs (add one trivial test in inventory).

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

Paste 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.