02 · Modules, packages and go.work¶
Objectives: Understand the difference between a Go package and a Go module, learn how
go.modpins a module's identity and dependencies, and see howgo.workstitches nine independent modules into a single development workspace. Estimated time: 20 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- Package = "one chapter in a book — it has its own title and content, but belongs to a larger volume." A package is a folder of
.gofiles that all share the samepackagedeclaration; it is the smallest unit you can import. - Module = "the sealed box the chapter ships in, with a label showing the version." A module is a directory tree rooted by
go.mod, and it is the unit Go versions, publishes, and downloads; onego.modcan contain many packages. - Workspace (
go.work) = "a sticky note on your desk that says 'use my local draft, not the printed copy.'" It tells the Go toolchain to resolve module imports from the local source tree instead of fetching published tags — purely a local developer convenience, nothing published. - Replace directive = "a forwarding address inside a single package slip." Inside one
go.mod, areplaceline redirects an import path to a local directory, so example programs compile against local source without needing a workspace. GOWORK=off= "pulling out the sticky note so a colleague sees exactly what a real customer sees." It disables workspace mode for one command, making the build behave exactly as a downstream user's build would — only thereplacedirectives in that module's owngo.modapply.MODULESvariable = "the single guest list for the party — change it once and every room gets the updated headcount." The Makefile'sMODULESvariable is the canonical registry of all nine modules; every looping target (test, lint, fuzz) reads it automatically.
Why it matters: SafeHeaders-Go is nine separate modules that must be developed together. Without a workspace, every cross-module change would require publishing a new version tag before the next module could depend on it — a workflow that collapses under rapid iteration.
See it — how go.work wires nine local modules into one workspace.
One module up close — jsmn-go/go.mod¶
Every Go module starts with a two-line minimum (in the code block below, lines starting with // are comments — notes for human readers that the computer ignores):
In plain terms: this whole block is the entire content of a file named go.mod — it just declares the module's official name and the minimum version of the Go language it needs.
moduledeclares the canonical import path. "Importing" is how one piece of code says "I want to use the code that lives in that other folder" — Go then goes and fetches or locates it. Any file anywhere on earth can writeimport "github.com/alikatgh/safeheaders-go/jsmn-go"and Go's toolchain (the collection of programs that turns Go source text into a runnable program, checks it for errors, and manages its dependencies) knows exactly where to fetch it from.go 1.23is the minimum language version this module requires. You can writego 1.23features freely; the toolchain enforces the floor.- There is no
requireblock (arequireblock is a list, insidego.mod, of other modules this one depends on) becausejsmn-gohas no third-party dependencies — a deliberate design choice for a security-hardening library.
All nine modules in this repo follow the same pattern:
github.com/alikatgh/safeheaders-go/<name>, replacing <name> with the
subdirectory (e.g. dr-wav-go, miniz-go).
The workspace — go.work¶
When you want to work on several modules simultaneously without publishing intermediate tags, Go 1.18 introduced workspace mode:
// go.work (repo root)
go 1.24.0
toolchain go1.24.7
use (
./cgltf-go
./cjson-go
./dr-wav-go
./jsmn-go
./linenoise-go
./miniz-go
./stb-image-go
./stb-truetype-go
./tinyxml2-go
)
In plain terms: this file tells Go's tools "here are nine folders on my own computer, each one a separate module — while I'm working locally, treat all nine as if they were already published, so I can test changes across them together before publishing anything."
Each use line tells the toolchain: "this directory contains a real go.mod;
treat it as the authoritative source for that module path."
The effect is invisible but powerful: if cjson-go ever imported jsmn-go,
the workspace would resolve that import straight to ./jsmn-go/ on disk
instead of downloading a published version. Right now none of the nine modules
depend on each other (each is a standalone port), but the workspace still
matters for cross-cutting tooling — go work sync, govulncheck, and IDE
navigation all understand it.
The toolchain line
toolchain go1.24.7 pins the exact Go toolchain binary used to build the
workspace. This is distinct from the minimum language version (go 1.24.0).
Think of it as a .nvmrc for Go — it ensures everyone on the team compiles
with the same binary.
Why examples use GOWORK=off and replace directives¶
The examples/ subdirectory contains standalone demonstration programs. Each
one is its own module with its own go.mod:
// examples/json-parser/go.mod
module github.com/alikatgh/safeheaders-go/examples/json-parser
go 1.23
require github.com/alikatgh/safeheaders-go/jsmn-go v0.5.0
replace github.com/alikatgh/safeheaders-go/jsmn-go => ../../jsmn-go
In plain terms: this example's own go.mod first says "I officially depend on version v0.5.0 of jsmn-go," then immediately adds a second line that overrides that and says "actually, for now, get it from this folder on my own disk instead."
Two things are happening here:
require ... v0.5.0— the example declares an explicit published version. This is how a real downstream user would depend on the library.replace ... => ../../jsmn-go— during local development, Go is told to use the local directory instead of fetchingv0.5.0from the internet.
Notice that examples/ is not listed in go.work. That's intentional.
Example modules are treated as if they are external consumers. Keeping them out
of the workspace means the workspace resolver doesn't override their replace
directives in unexpected ways.
When the Makefile builds examples it also sets GOWORK=off. (A Makefile is a plain-text file named Makefile that lists short, named recipes — here called "targets" — of shell commands; typing make examples on the command line runs the recipe named examples below.)
# Makefile (examples target, abridged)
examples:
@for ex in examples/json-parser examples/jsmn-demo \
examples/production-usage examples/linenoise-repl; do \
(cd $$ex && GOWORK=off go build ./...) || exit 1; \
(cd $$ex && GOWORK=off go vet ./...) || exit 1; \
done
@(cd examples/json-parser && GOWORK=off go run .) || exit 1
@(cd examples/jsmn-demo && GOWORK=off go run .) || exit 1
In plain terms: this recipe loops over four example folders, and inside each one runs go build (turn the source code into a runnable program — this is "compiling") and go vet (a check that scans the code for likely mistakes without running it) with the workspace switched off, then actually runs two of them with go run.
GOWORK=off prevents the parent workspace from leaking into the example's
build. The replace directive in the example's own go.mod still applies, so
the local source is used — but now exclusively through the replace mechanism,
exactly as an end-user would experience it after cloning only that subdirectory.
Forgetting GOWORK=off on examples
If you run go build ./... from the repo root without GOWORK=off, the
workspace use blocks take effect and the replace directives inside
example go.mod files may be silently ignored or shadowed. Always use
GOWORK=off (or cd into the example directory and build from there) when
testing examples as a standalone consumer.
The MODULES variable — single source of truth in the Makefile¶
Managing nine modules means every make target — tests, linting, fuzzing,
dependency updates — must iterate over all of them. Rather than hardcoding the
list nine times, the Makefile declares it once:
# Makefile
# Single source of truth for the list of Go modules. Keep alphabetized.
MODULES := cgltf-go cjson-go dr-wav-go jsmn-go linenoise-go miniz-go \
stb-image-go stb-truetype-go tinyxml2-go
In plain terms: this line creates a named list (a "variable") called MODULES holding the names of all nine module folders, so other recipes can say "loop over MODULES" instead of spelling out all nine names again.
Every looping target then expands that variable:
test:
@for dir in $(MODULES); do \
echo "Testing $$dir..."; \
(cd $$dir && go test -v ./...) || exit 1; \
done
In plain terms: this recipe walks through every name in MODULES one at a time and, for each one, runs that module's automated tests (a test is a small piece of code written specifically to check that another piece of code behaves correctly) — stopping immediately if any module's tests fail.
The comment "Keep alphabetized" is load-bearing documentation: it signals that
this list is the canonical registry — if you add a new module, you update
MODULES, add a use line to go.work, and everything else (tests, lint,
security scans, fuzz) picks it up automatically.
Adding a tenth module
The checklist for adding a new module to this repo is exactly three steps:
- Create the directory with its own
go.mod(correct module path, minimum Go version). - Add a
use ./new-moduleline togo.work. - Add
new-module(alphabetically) toMODULESin the Makefile.
No other file needs changing for the module to participate in make test,
make lint, make fuzz, and all other CI targets.
Workspace vs. replace directives — when to use each¶
(In the table below, "CI" stands for Continuous Integration — an automated system that runs builds and tests every time someone submits new code, without a human triggering it by hand.)
| Scenario | Tool |
|---|---|
| Multiple modules in one repo, developed together | go.work |
| One module that needs a local fork of a dependency | replace in go.mod |
| Simulating a downstream consumer in CI | GOWORK=off + replace |
| Pinning a specific Go toolchain for the whole repo | toolchain line in go.work |
Try it
From the repo root, verify the workspace is valid and list all module paths:
Expected outcome: go work sync exits silently (no errors).
go list -m all prints nine lines, one per module, e.g.:
github.com/alikatgh/safeheaders-go/cgltf-go
github.com/alikatgh/safeheaders-go/cjson-go
...
github.com/alikatgh/safeheaders-go/tinyxml2-go
Then confirm GOWORK=off isolates an example:
This should compile cleanly using the replace directive inside
examples/json-parser/go.mod — no workspace involvement.
Key takeaways¶
- A package is a folder; a module is a versioned unit with a
go.modthat declares its import path and minimum Go version. go.workwires multiple local modules together for development without requiring published version tags — add auseline per module.- Example programs are intentionally outside the workspace so they behave
like real downstream consumers;
GOWORK=offenforces that isolation in CI. - The
MODULESvariable in the Makefile is the single authoritative list — one edit there propagates to tests, linting, fuzzing, and security scans. - When you add a new module, the three-step checklist is:
go.mod,go.workuseline,MODULESentry.