Monorepo Git Patterns with go work
One Git repository can host multiple Go modules coordinated by go.work during development.
Git history stays unified while each module keeps its own go.mod, tags, and consumer graph.
Summary
Monorepos suit platform teams shipping shared libraries and services together.
Use go.work locally to replace cross-module replace directives in committed go.mod files.
Tag and CI each module explicitly so proxies and consumers resolve the right versions.
Recipe
Quick-reference recipe card - copy-paste ready.
# Repo layout
# services/api/go.mod
# libs/auth/go.mod
cd repo-root
go work init ./services/api ./libs/auth
go work use -r . # optional: discover modules
# Develop across modules
cd services/api
go test ./...
# Validate publish view (no workspace)
GOWORK=off go test ./...When to reach for this:
- Atomic API changes across a library and its consumer service in one PR.
- Replacing machine-specific
replacepaths on shared branches. - Standardizing multi-module CI and tagging in one Git repo.
- Onboarding developers who should not edit each other's
go.modreplace lines.
Working Example
acme-platform/
go.work
go.work.sum
libs/telemetry/go.mod # github.com/acme/telemetry
services/billing/go.mod # github.com/acme/billing
// go.work
go 1.26
use (
./libs/telemetry
./services/billing
)# Change telemetry API and fix billing in one branch
git switch -c feat/telemetry-batch
# edit libs/telemetry/...
# edit services/billing/...
go test ./...
cd services/billing && GOWORK=off go test ./...
git add libs/telemetry services/billing go.work go.work.sum
git commit -m "telemetry: add batch export; billing: adopt batch API"
git push -u origin feat/telemetry-batchTag only the library module when releasing for external consumers:
git switch main && git pull --ff-only
cd libs/telemetry
git tag -a v0.4.0 -m "telemetry v0.4.0"
git push origin v0.4.0What this demonstrates:
go.workwires local replaces implicitly during dev.GOWORK=offsimulates what module consumers experience.- Git tag names version the telemetry module release independently.
Deep Dive
How It Works
- Each module has its own
go.modat a subdirectory root. go.workat repo root lists module directories inusedirectives.- The Go command prefers workspace modules over proxy versions for those paths.
- Git tracks one history; merges can touch multiple
go.modfiles in one PR. - Release tags may target specific subdirectory commits with documented convention.
go.work.sum records checksums for workspace resolution, similar to go.sum.
Commit go.work when the whole team shares the workspace layout.
Keep personal experiments in untracked go.work or GOWORK env override.
Git Patterns
| Pattern | Purpose |
|---|---|
| Path-scoped PR labels | Show which modules a PR affects |
| Per-module CODEOWNERS | Route review to owning team |
Tag prefix telemetry/v0.4.0 | Disambiguate multi-module tags (document in README) |
CI matrix per go.mod | Faster feedback on large repos |
GOWORK=off release job | Verify publishable module graph |
go.work vs replace
| Mechanism | Committed to shared main? | Consumer impact |
|---|---|---|
go.work | Optional team-wide dev aid | None (not published) |
replace in go.mod | Avoid for local paths | Breaks go get for external users |
| Versioned require | Yes | Normal consumer path |
Monorepo CI Sketch
strategy:
matrix:
module: [libs/telemetry, services/billing]
steps:
- run: cd ${{ matrix.module }} && GOWORK=off go test ./...Root workspace job can still run go test ./... from go.work for integration confidence.
Gotchas
- Committed
replace ../libspaths - Break external clones. Fix:go.workfor local dev; require semver versions on main. - Single tag for unrelated modules - Consumers cannot pin one module without pulling others. Fix: independent tags or documented prefixes.
- Assuming
internal/spans modules - Compiler enforces per module root. Fix: shared code in published packages or single module until split is real. - Forgetting
GOWORK=offin release CI - Ships workspace-only graph surprises. Fix: mandatory off mode before tag. - Huge cross-module PRs - Review paralysis. Fix: stack PRs or strict module ownership with smaller slices.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Single module | One release line, simple graph | Independent semver per subsystem |
| Multi-repo | Hard team boundaries | Frequent atomic cross-cutting changes |
| Bazel / custom build | Non-Go polyglot orchestration | Standard go command workflows suffice |
| Git submodules | Legacy separation | Go module ergonomics preferred |
FAQs
Should go.work be committed?
Yes when all developers use the same module set daily.
No when it is a personal superset of modules; use local untracked file instead.
How do consumers import monorepo modules?
By module path in each go.mod, not repo root.
go get github.com/acme/telemetry@v0.4.0 fetches only that module subtree.
Can one PR bump two modules?
Yes.
Run tests with workspace on and GOWORK=off per affected module before merge.
How handle divergent major versions?
Separate module paths (/v2) and tags per major.
Workspace can include both lines during migration.
Does go.work replace go.mod require?
No.
It overrides resolution locally; go.mod still lists intended semver requires for consumers.
How split a monorepo module out?
Extract history with filter-repo or copy subtree, publish new module path, retract old paths if needed.
Plan import path migration in changelog.
What about vendor directories per module?
Possible in each module root.
Refresh vendor in the PR that bumps that module's deps only.
How branch naming work?
Same trunk patterns as single-module repos.
Add module name in branch (feat/telemetry-batch) for clarity.
Can workspaces include replace directives?
go.work supports replace blocks for local overrides.
Prefer use paths over fragile absolute replaces.
How test only changed modules in CI?
Use path filters and git diff --name-only to select matrix entries.
Still run periodic full workspace integration on main.
Do git submodules mix with go.work?
Rarely worth it.
Go modules already version dependencies; submodules add VCS friction.
How document tagging in README?
State which module each tag versions, default branch, and GOWORK=off check commands for maintainers.
Related
- go work: Workspace Mode for Multi-Module Repos - Workspace mechanics in depth
- Git for Go Teams - Git plus module release overview
- GitHub Actions for Go Repos - Matrix CI for multiple modules
- Signed Tags & Go Module Releases - Per-module tagging
- Rebasing, Cherry-Pick & Worktrees - Parallel checkouts in large repos
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).