07 - Multi-Module with go work
📋 Jump to TakeawaysYour bookmarks API uses errx for traced errors and slogr for pretty logging. These aren't specific to bookmarks — they're general-purpose tools you'd use in any Go project. So you extract them into their own repository and publish them as a module. Now you have two repos:
github.com/yourname/bookmarks— the APIgithub.com/yourname/gokit— shared libraries (errx,slogr)
This works great until you need to change both at the same time. You're adding a new function to errx and updating the bookmarks API to use it. Without workspaces, this is painful.
The Problem: replace Hacks
The naive approach is a replace directive in go.mod:
// bookmarks/go.mod
module github.com/yourname/bookmarks
require github.com/yourname/gokit v0.2.0
// Temporary — DO NOT COMMIT
replace github.com/yourname/gokit => ../gokitThis points Go to your local copy of gokit instead of the published version. It works, but:
- You have to remember to remove it before committing
- If you forget, CI breaks — it can't find
../gokit - Every developer needs the same directory layout on their machine
- It's per-module — if you have five services using
gokit, that's fivereplacedirectives to manage
The Solution: go work
Go workspaces solve this. From a parent directory that contains both repos:
projects/
├── go.work
├── bookmarks/
│ ├── go.mod ← module: github.com/yourname/bookmarks
│ ├── main.go
│ ├── handler.go
│ └── store.go
└── gokit/
├── go.mod ← module: github.com/yourname/gokit
├── errx/
│ └── errx.go
└── slogr/
└── slogr.goSet it up:
cd projects
go work init
go work use ./bookmarks ./gokitThis creates a go.work file:
go 1.24
use (
./bookmarks
./gokit
)Now when you run go build or go test in bookmarks/, Go uses your local gokit instead of the published version. No replace directives. Both go.mod files stay clean and committable.
Importing Across Modules
Your bookmarks/go.mod still has the normal require:
module github.com/yourname/bookmarks
go 1.24
require github.com/yourname/gokit v0.2.0And your imports look the same as always:
package main
import (
"github.com/yourname/gokit/errx"
"github.com/yourname/gokit/slogr"
)
func main() {
slogr.SetupLog(slogr.LevelDebug, slogr.TypePretty)
// ...
}The workspace silently redirects github.com/yourname/gokit to the local ./gokit directory. Your code doesn't know or care — it uses the same import paths either way.
The Workflow
Here's how you develop across both repos:
- Edit
gokit— add a new function, fix a bug - Edit
bookmarks— use the new function immediately, no publishing needed - Test both —
go test ./...from the workspace root runs tests across all modules - When ready to ship:
- Commit and tag
gokitwith a new version (e.g.,v0.3.0) - Push
gokitto its repo - Update
bookmarks/go.mod:go get github.com/yourname/[email protected] - Remove the workspace (or just don't use it in CI)
- Commit and tag
The workspace is scaffolding for development. go.mod with proper versions is the permanent record.
go.work Is Not Committed
Add go.work and go.work.sum to .gitignore in each repo. The workspace is a local development tool — it shouldn't be in version control because:
- It contains local paths that differ per developer
- CI should test against published versions, not local overrides
- Each developer sets up their own workspace based on what they're working on
In CI, set GOWORK=off to make sure your modules build correctly against their published dependencies:
GOWORK=off go test ./...Running and Building
From the workspace root (where go.work lives):
# Run the API
go run ./bookmarks
# Run tests across all modules
go test ./...
# Build
go build -o bookmarks-api ./bookmarksThe workspace handles module resolution automatically.
When to Use Workspaces
Workspaces are for when you're developing separate modules at the same time:
- A library in one repo and an app that uses it in another
- Multiple microservices that share a common module
- Contributing to an open-source library while testing changes in your own project
You don't need workspaces when all your code is in one repo with one go.mod. Use sub-packages instead — that's simpler and what Go is designed for.
Key Takeaways
go work init+go work usecreates a workspace for developing across separate modules- Workspaces redirect imports to local directories without touching
go.mod - Don't commit
go.work— it's a local development tool - Use
GOWORK=offin CI to test against published versions - When done developing, tag and publish the library, update
go.mod, move on - If your code is in one repo, you don't need workspaces — use sub-packages