- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I talked to had four Go modules in one repo: two services and two libraries. Each module had its own go.mod and its own release cadence. A developer changed a function signature in one of the libraries and pushed. CI built each module independently and went green. The library shipped a tag. The next morning the service that used the library would not compile, because the service's go.mod still pointed at the previous tag and nobody had bumped it.
The team's reaction was to add a replace line in the service's go.mod pointing at ../libs/auth. That worked on laptops. It broke CI for a different reason: each module's pipeline cloned only its own subtree, so ../libs/auth resolved outside the checkout.
The fix was go.work. It is the piece of the Go module system that exists for exactly this shape: several modules in one repository, developed together, versioned independently. Most teams I have seen end up with a half-broken version of it. Here is what works.
What go.work actually is
A workspace file lives at the root of your repo and lists the modules inside it:
// go.work at /repo/go.work
go 1.24
use (
./services/api
./services/worker
./libs/auth
./libs/billing
)
When the Go toolchain runs anywhere inside this tree, it sees the workspace and resolves imports across modules from the local source instead of from the module proxy. A change you make in libs/auth is visible to services/api immediately, with no replace line and no tag bump.
The go work reference describes the precise resolution rules. In short: when a workspace is active, a use path overrides any version that any other module in the workspace requires of it. Your service builds against your local copy of the library; the version in go.mod becomes a hint for tooling, not a build input.
This is a different mechanism from replace. A replace directive lives in a single go.mod and applies only when that module is the main one being built. A go.work file is a level above the modules and applies whenever the toolchain picks it up.
A real four-module layout
Here is the shape that works for a service-and-library monorepo:
acme/
├── go.work
├── go.work.sum
├── services/
│ ├── api/
│ │ ├── go.mod
│ │ ├── go.sum
│ │ └── cmd/server/main.go
│ └── worker/
│ ├── go.mod
│ ├── go.sum
│ └── cmd/worker/main.go
└── libs/
├── auth/
│ ├── go.mod
│ ├── go.sum
│ └── auth.go
└── billing/
├── go.mod
├── go.sum
└── billing.go
Each go.mod declares the module path the way an external consumer would import it:
// services/api/go.mod
module github.com/acme/services/api
go 1.24
require (
github.com/acme/libs/auth v0.7.2
github.com/acme/libs/billing v0.4.1
)
// libs/auth/go.mod
module github.com/acme/libs/auth
go 1.24
The version numbers in require are the versions the service builds against when there is no workspace. With go.work active, the local source wins. Without it, you are back to module proxy resolution and the tags decide.
The go.work file at the root ties them together:
// go.work
go 1.24
use (
./services/api
./services/worker
./libs/auth
./libs/billing
)
That is the whole setup. Run go build ./... from the root and Go builds across the workspace's modules. The same is not true for go test ./..., which is the surprise the next section unpacks.
The first CI gotcha: go test ./... from root
Here is the trap that catches every team within a week of adopting this layout. From the root:
$ go test ./...
go: warning: "./..." matched no packages
Or worse, a partial result that looks fine but skipped half your tests. The reason is that go test ./... does not traverse module boundaries. Each go.mod is the root of a module tree, and ./... only walks one tree at a time.
The workspace does not change this. go.work makes cross-module imports resolve locally, but it does not merge the modules into one buildable unit. Each module is still its own package graph as far as go test is concerned.
A module-walk script is the fix:
#!/usr/bin/env bash
set -euo pipefail
mods=$(go list -f '{{.Dir}}' -m)
for dir in $mods; do
echo ">> testing $dir"
(cd "$dir" && go test ./...)
done
go list -m enumerates the modules in the workspace. Iterate, change directory, run the tests. This is what every CI pipeline ends up looking like once the team realises why go test ./... from the root is a lie.
A second option is go test ./... ./libs/auth/... ./libs/billing/... ./services/api/... ./services/worker/.... That passes each module's tree explicitly. It works but it does not scale; the script does.
The CI matrix that actually matches the layout
Building each module in CI as if it were a standalone module is the right shape. It catches the case where a library's tests pass with the workspace active (because they pick up a sibling's local source) but would fail for an external consumer that pulls the published tag.
A GitHub Actions matrix for this:
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module:
- services/api
- services/worker
- libs/auth
- libs/billing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Test ${{ matrix.module }}
working-directory: ${{ matrix.module }}
env:
GOWORK: off
run: |
go build ./...
go test ./...
Two things matter in that snippet.
The matrix runs each module in its own job, so a failure in libs/billing does not mask a failure in libs/auth. They report independently.
GOWORK: off is the important line. It tells the Go toolchain to ignore any go.work file in the tree and resolve modules from the proxy, the same way an external consumer would. Without it, the test job for libs/auth would pull libs/billing from the local sibling and you would never find out that the published tag is incompatible. The CI matrix exists to simulate the outside-world view.
A separate workspace-aware job tests the integrated build:
workspace:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Workspace build
run: |
go build ./...
./scripts/test-modules.sh
Now CI gives you both views: each module on its own (publishing surface) and the workspace as a whole (developer experience). Both have to be green for the merge to land.
Commit go.work, or gitignore it?
This is the question every team argues about. Both answers are correct in different contexts.
Commit it when:
- Every contributor is meant to develop all modules in lockstep. The workspace shape is part of the project, not a personal preference.
- Reproducibility matters more than flexibility. New developers clone, run
go build ./..., and get the same resolution everyone else gets. - You want CI's workspace job to use the same
go.workthat developers use, with no extra steps.
Gitignore it when:
- The repo holds many modules and most contributors only work on a few at a time. Each developer composes their own workspace with
go work init && go work use ./services/api ./libs/auth. - You have multi-language modules sharing the repo and Go developers need a workspace that excludes the parts that do not concern them.
- Library maintainers want to test their module in isolation by default and opt in to workspace mode locally.
Commit a minimal go.work that lists every module in the repo, and document GOWORK=off as the way to test a single module in isolation. The committed file gives you reproducibility without locking out the isolation case. New developers get a working build on git clone. Library authors who want to verify their tags can run GOWORK=off go test ./... inside one module.
go.work.sum follows the same decision. If go.work is committed, commit go.work.sum too — it pins the checksums of dependencies the workspace pulls that no individual go.mod requires. If go.work is gitignored, go.work.sum is gitignored as well.
A replace line in go.work is allowed, sparingly
The workspace file supports its own replace directive:
go 1.24
use (
./services/api
./libs/auth
)
replace example.com/badlib => example.com/badlib v1.4.1
This is the right place for a temporary fork pin that should affect every module in the workspace at once. A security patch you want the whole team to pick up while you wait for a proper release is the canonical case. It is the wrong place for a permanent dependency override; that belongs in the consuming module's go.mod.
A workspace replace is invisible outside the workspace. If libs/auth is published and consumed externally, the consumer does not see the workspace's replace line. Same trap as replace in any other module: it applies only to the build the toolchain is running, not to anything that imports the library later.
What to do tomorrow morning
Open the repo, drop a go.work at the root, list the modules, set GOWORK=off on each per-module CI job, and add the module-walk script as a separate workspace-aware job. Once that is in, the next refactor that crosses a module boundary stops being a tag-bump negotiation and goes back to being a normal pull request. The cost of skipping the workspace is the daily tag-bump fights and the replace lines that work for one developer and break CI for the next. A go.work file removes that for the price of an afternoon of layout work.
If this saved you a layout argument
A monorepo of Go modules pays for itself only if the boundaries between the modules are intentional and the tooling matches them. Hexagonal Architecture in Go covers module shape: where a libs/ boundary belongs, and how to grow a workspace without it turning back into a monolith. The Complete Guide to Go Programming covers the toolchain side: modules, workspaces, build cache, vet, and the parts of the compiler that decide whether your refactor is one second or one minute. The pair covers the language and the architecture; the workspace stays useful only when both are intentional.
If you ship Go alongside an AI coding assistant, Hermes IDE is the editor I build for that workflow.

Top comments (0)