What is a Monorepo?
A monorepo is a single Git repository that contains multiple distinct services or modules. The alternative is a polyrepo: one repo per service.
This repo is forge-monorepo. It contains:
forge-monorepo/
├── services/
│ ├── api/ ← HTTP API service
│ ├── worker/ ← Background job processor
│ └── notifier/ ← Notification service
├── pkg/
│ ├── shared/ ← Shared types + domain logic
│ ├── events/ ← Event definitions
│ └── proto/ ← Protobuf definitions
└── gen/
└── go/ ← Generated proto stubs (own go.mod)
The core idea: code needed by multiple services lives in pkg/, and all services import it directly — no private module proxy, no vendoring ceremony.
Tool 1: Go Workspaces (go work) — The Foundation
Go workspaces are the monorepo primitive built into the Go toolchain since 1.18. They replace replace directives in go.mod for local multi-module development.
How it's declared
go.work at the repo root:
go 1.22
use (
./gen/go
./pkg/shared
./pkg/events
./services/api
./services/worker
./services/notifier
)
This tells the Go toolchain: treat every listed directory as a module, and resolve imports between them on disk. That's it — one file defines the entire workspace.
What this enables
When you run any go command inside the workspace, the toolchain:
- Reads every
go.modin every listed directory - Resolves inter-module imports to local disk paths automatically
- Lets every module keep its own
go.modand version independently
Internal imports
Each module has a normal go.mod with a full module path:
// pkg/shared/go.mod
module github.com/yourorg/forge/pkg/shared
go 1.22
Any other workspace module imports it by that exact path:
// services/api/main.go
import (
"github.com/yourorg/forge/pkg/shared"
"github.com/yourorg/forge/pkg/events"
)
No special syntax. The workspace resolves github.com/yourorg/forge/pkg/shared to ./pkg/shared on disk automatically.
Critical: do NOT commit go.work
go.work is a local development convenience. It is not a source of truth for module dependencies. Add both files to .gitignore:
# Go workspace (local dev only)
go.work
go.work.sum
# Task cache
.task/
CI validates each module independently with GOWORK=off — more on that in Tool 2.
Why go work over replace directives?
replace directives in go.mod are messy: they leak into the module graph, they break when you go get, and you have to touch every module's go.mod to wire things up. go work is additive — the workspace file lives outside the modules and doesn't pollute their dependency declarations.
Tool 2: Task (Taskfile.yml) — Task Orchestration and Caching
Go workspaces handle module resolution. Task handles build orchestration.
The problem Task solves
If you need to build 6 modules in the right order, you have to figure out the sequence yourself. And if nothing in pkg/shared changed, you shouldn't rebuild everything that depends on it.
Task solves both: explicit task dependency ordering + file-based output caching.
Taskfile.yml — the pipeline
version: '3'
includes:
gen: ./gen/go/Taskfile.yml
shared: ./pkg/shared/Taskfile.yml
events: ./pkg/events/Taskfile.yml
api: ./services/api/Taskfile.yml
worker: ./services/worker/Taskfile.yml
notifier: ./services/notifier/Taskfile.yml
tasks:
build:all:
desc: Build all modules in dependency order
deps:
- gen:build
cmds:
- task: shared:build
- task: events:build
- task: api:build
async: true
- task: worker:build
async: true
- task: notifier:build
async: true
test:all:
desc: Test all modules
cmds:
- task: shared:test
- task: events:test
- task: api:test
- task: worker:test
- task: notifier:test
lint:all:
desc: Run golangci-lint across all modules
cmds:
- go work edit -json | jq -r '.Use[].DiskPath' | xargs -P4 -I{} sh -c 'cd {} && golangci-lint run ./...'
tidy:all:
desc: Run go mod tidy in every module
cmds:
- go work edit -json | jq -r '.Use[].DiskPath' | xargs -I{} sh -c 'cd {} && go mod tidy'
A per-module Taskfile (e.g., pkg/shared/Taskfile.yml) looks like:
version: '3'
tasks:
build:
sources:
- '**/*.go'
generates:
- '.task/shared.built'
cmds:
- go build ./...
- touch .task/shared.built
test:
sources:
- '**/*.go'
cmds:
- go test ./...
Key concepts:
-
deps:— tasks listed here run in parallel before the current task starts -
cmds:— commands run sequentially in order -
sources/generates— Task hashes these paths. Cache hit → task skipped entirely -
async: true— run the task concurrently with other commands in the samecmds:block
The build graph
gen/go (buf generate)
↓
pkg/shared (go build)
↓
pkg/events (go build)
↓
services/api services/worker services/notifier
(go build) (go build) (go build)
Affected-only in CI
Task has no --affected flag. Implement it with a git diff script:
#!/usr/bin/env bash
# scripts/affected.sh
# Outputs a newline-separated list of changed module paths
BASE=${1:-origin/main}
CHANGED_FILES=$(git diff --name-only "$BASE"...HEAD)
declare -A AFFECTED
while IFS= read -r file; do
# Match each workspace module by its directory prefix
go work edit -json | jq -r '.Use[].DiskPath' | while read -r mod; do
mod_clean="${mod#./}"
if [[ "$file" == "$mod_clean"/* ]]; then
echo "$mod_clean"
fi
done
done <<< "$CHANGED_FILES" | sort -u
In CI:
- name: Run affected tests
run: |
AFFECTED=$(bash scripts/affected.sh origin/main)
for mod in $AFFECTED; do
(cd "$mod" && GOWORK=off go test ./...)
done
Tool 3: Shared Modules — The Internal Module Pattern
Each pkg/ module is a normal Go module with its own go.mod. It doesn't need to be published anywhere.
pkg/shared/
├── go.mod ← module github.com/yourorg/forge/pkg/shared
├── types.go
├── domain.go
└── errors.go
Because pkg/shared is listed in go.work, any service imports it by its declared module path:
package main
import (
"github.com/yourorg/forge/pkg/shared"
"github.com/yourorg/forge/pkg/events"
)
func main() {
user := shared.NewUser("alice")
ev := events.NewUserCreated(user)
_ = ev
}
The key insight: the workspace resolves that import to disk at development time. In CI, with GOWORK=off, each module must declare real require entries in its own go.mod. This forces you to be explicit about dependencies while keeping the local dev loop frictionless.
Proto and generated code
pkg/proto/ holds .proto files. gen/go/ holds the generated stubs as its own module:
// gen/go/go.mod
module github.com/yourorg/forge/gen/go
go 1.22
require google.golang.org/protobuf v1.34.1
Services import generated types the same way — no magic:
import forgepb "github.com/yourorg/forge/gen/go"
Tool 4: Root .golangci.yml — Shared Config
Centralize lint rules instead of duplicating them across every module.
.golangci.yml at repo root:
version: "2"
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofumpt
- goimports
- revive
- gosec
- exhaustive
- wrapcheck
linters-settings:
gofumpt:
extra-rules: true
goimports:
local-prefixes: github.com/yourorg/forge
revive:
rules:
- name: exported
severity: warning
- name: error-return
severity: error
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
golangci-lint v2 walks upward from the directory where it's invoked to find the nearest config file. Running golangci-lint run ./... inside services/api/ picks up the root .golangci.yml automatically — no per-module config needed unless you want to override.
Why this matters: change one rule and it propagates to every service. No drift, no eslint.config.js archaeology.
Tool 5: golangci-lint + gofumpt — Unified Linting and Formatting
One .golangci.yml at the root. Each module runs golangci-lint run ./... but rules are defined once. One source of truth, many consumers.
golangci-lint v2
The version: "2" key at the top of .golangci.yml is required for the v2 config schema. The v1 schema is silently different — don't mix them.
Run across all modules in parallel:
go work edit -json | jq -r '.Use[].DiskPath' | \
xargs -P4 -I{} sh -c 'cd {} && golangci-lint run ./...'
gofumpt
gofumpt is a stricter superset of gofmt. It enforces additional rules: blank lines between imports and first declaration, grouping of related declarations, trailing newlines. Configure it under formatters: in the v2 schema (or as a linter via gofumpt: true in linters-settings as shown above).
Install:
go install mvdan.cc/gofumpt@latest
Run standalone (useful in hooks):
gofumpt -l -w .
Why not just gofmt?
gofmt is the baseline. gofumpt is what you actually want in a team — it eliminates the formatting debates gofmt leaves open. Add it to the linter pipeline once and you never argue about blank lines again.
Tool 6: lefthook — Git Hooks
lefthook is a fast, language-agnostic Git hooks manager written in Go. It ships as a single binary, runs hooks in parallel by default, and integrates natively with staged files.
Install
go install github.com/evilmartians/lefthook@latest
# or: brew install lefthook
Initialize in the repo:
lefthook install
lefthook.yml
pre-commit:
parallel: true
commands:
go-lint:
glob: "*.go"
run: golangci-lint run {staged_files}
stage_fixed: true
go-fmt:
glob: "*.go"
run: gofumpt -l -w {staged_files}
stage_fixed: true
proto-lint:
glob: "*.proto"
run: buf lint {staged_files}
mod-tidy-check:
run: |
go work edit -json | jq -r '.Use[].DiskPath' | while read -r mod; do
(cd "$mod" && go mod tidy && git diff --exit-code go.mod go.sum) || exit 1
done
commit-msg:
commands:
conventional:
run: |
msg=$(cat {1})
if ! echo "$msg" | grep -qE '^(feat|fix|chore|docs|refactor|test|ci)(\(.+\))?: .+'; then
echo "Commit message must follow Conventional Commits format"
exit 1
fi
Key concepts:
-
{staged_files}— lefthook's template variable; expands to the list of files currently staged for this commit -
stage_fixed: true— ifgofumptrewrites a file, lefthook re-stages it automatically. You don't have togit addagain. -
parallel: true— go-lint and go-fmt run concurrently; the hook only blocks as long as the slowest command
The key insight: it doesn't lint the whole repo on every commit — only the files currently staged. golangci-lint run {staged_files} is dramatically faster than golangci-lint run ./... across every module. This keeps commits fast while still enforcing quality.
The Full Picture
Developer commits
↓
lefthook pre-commit
↓ golangci-lint on staged .go files (fast, per-file)
↓ buf lint on staged .proto files
↓ go mod tidy check
↓ (fails → abort; passes → continue)
↓
go work resolves internal imports to local disk paths
↓
task build:all
↓ reads Taskfile.yml includes
↓ builds modules in order: gen/go → pkg/shared → pkg/events → services/* (parallel)
↓ caches outputs in .task/
↓
CI: GOWORK=off — each module tested independently
↓ git diff → affected modules → test only those + dependents
↓ golangci-lint matrix across modules
↓ .golangci.yml config inherited from repo root
Summary: Why This Stack
| Problem | Tool | Mechanism |
|---|---|---|
| Share code without publishing | Go Workspaces |
go.work + disk path resolution |
| Run tasks in dependency order | Task |
deps: + cmds: ordering |
| Don't rebuild unchanged modules | Task cache |
sources/generates hashing |
| CI: only changed modules | git diff script |
GOWORK=off per-module testing |
| Consistent lint config | Root .golangci.yml
|
upward config file walk |
| One linter + formatter | golangci-lint + gofumpt | single .golangci.yml
|
| Quality before commits | lefthook |
{staged_files} pre-commit hook |
Quick Start
# 1. Clone and initialize workspace
git clone https://github.com/yourorg/forge-monorepo
cd forge-monorepo
go work sync # sync all module dependencies
# 2. Install tools
go install github.com/go-task/task/v3/cmd/task@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install mvdan.cc/gofumpt@latest
go install github.com/evilmartians/lefthook@latest
brew install bufbuild/buf/buf
# 3. Install git hooks
lefthook install
# 4. Build everything
task build:all
# 5. Test everything
task test:all
What to Watch Out For
go.work in CI. Set GOWORK=off in your CI environment variables. Each module's go.mod must have correct require entries — the workspace won't paper over missing dependencies in CI.
Module path discipline. Your go.mod module paths must be globally valid (i.e., github.com/yourorg/forge/...), even if you never publish. The workspace resolves them locally, but the paths need to be stable and consistent across all modules.
golangci-lint v1 vs v2. The config schema changed significantly. If you're migrating from v1, the version: "2" key is mandatory and several linter names moved. Run golangci-lint migrate to auto-upgrade your config.
Task and go generate. Add a generate task that runs buf generate before any go build task that depends on generated types. Generated code in gen/go/ is a real module with its own go.mod — it needs to be built first, just like any other dependency.