Go Monorepo Magic: Organize, Build, and Ship Multi-Service Apps

go dev.to

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)
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reads every go.mod in every listed directory
  2. Resolves inter-module imports to local disk paths automatically
  3. Lets every module keep its own go.mod and 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
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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 same cmds: 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Services import generated types the same way — no magic:

import forgepb "github.com/yourorg/forge/gen/go"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ./...'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run standalone (useful in hooks):

gofumpt -l -w .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Initialize in the repo:

lefthook install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key concepts:

  • {staged_files} — lefthook's template variable; expands to the list of files currently staged for this commit
  • stage_fixed: true — if gofumpt rewrites a file, lefthook re-stages it automatically. You don't have to git add again.
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials