Skip to content
Codeloom
Go

Go Workspace Mode for Multi-Module Development

Use go.work files to develop multiple Go modules together without replace directives, simplifying local multi-module workflows.

·6 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • What Go workspace mode solves and when to use it
  • Creating and managing go.work files
  • Developing multiple modules together without replace directives
  • Integrating workspaces with CI and version control

Prerequisites

None — this post is self-contained.

Before Go 1.18, developing two modules together locally required replace directives in go.mod. You would point one module’s dependency at a local path, do your work, then remember to remove the directive before committing. Workspace mode eliminates this friction. A go.work file sits above your modules and tells the Go toolchain to use local versions automatically.

The Problem With Replace Directives

Suppose you have a shared library and a service that imports it:

projects/
  shared-lib/
    go.mod    (module github.com/yourorg/shared-lib)
  api-service/
    go.mod    (module github.com/yourorg/api-service)

To develop them together, you would add a replace directive in api-service/go.mod:

replace github.com/yourorg/shared-lib => ../shared-lib

This works but creates problems. The directive must be removed before pushing. CI builds fail if the local path does not exist. Multiple developers must all have the same directory layout. It does not scale to three or more modules.

Creating a Workspace

Initialize a workspace from a parent directory:

cd projects
go work init ./shared-lib ./api-service

This creates a go.work file:

go 1.22

use (
    ./shared-lib
    ./api-service
)

Now any go command run inside this directory tree uses local versions of modules listed in use. No replace directives needed.

How It Works

When the Go toolchain detects a go.work file in the current directory or any parent directory, it enters workspace mode. In this mode:

  1. All modules listed in use are resolved locally.
  2. If api-service imports github.com/yourorg/shared-lib, the toolchain uses the local copy from ./shared-lib instead of downloading it.
  3. Each module’s own go.mod remains unchanged and valid for independent builds.

The workspace is a development-time overlay. It does not affect how the modules behave when built outside the workspace.

Adding and Removing Modules

Add a new module to an existing workspace:

go work use ./new-module

Remove a module:

go work edit -dropuse ./old-module

You can also list modules from subdirectories:

go work use ./services/auth ./services/billing ./pkg/common

Workspace Structure for a Monorepo

A typical monorepo layout with workspaces:

monorepo/
  go.work
  pkg/
    database/
      go.mod
      db.go
    auth/
      go.mod
      auth.go
  services/
    api/
      go.mod
      main.go
    worker/
      go.mod
      main.go

The go.work file:

go 1.22

use (
    ./pkg/database
    ./pkg/auth
    ./services/api
    ./services/worker
)

Changes to pkg/database are immediately visible when building services/api, with no publishing step and no replace directives.

A Practical Example

Create a shared types package and a service that uses it:

// pkg/database/go.mod
module github.com/yourorg/monorepo/pkg/database

go 1.22
// pkg/database/db.go
package database

import (
    "context"
    "fmt"
)

type User struct {
    ID    string
    Email string
    Name  string
}

type Store interface {
    GetUser(ctx context.Context, id string) (*User, error)
}

type MemoryStore struct {
    users map[string]*User
}

func NewMemoryStore() *MemoryStore {
    return &MemoryStore{users: make(map[string]*User)}
}

func (s *MemoryStore) GetUser(ctx context.Context, id string) (*User, error) {
    u, ok := s.users[id]
    if !ok {
        return nil, fmt.Errorf("user %s not found", id)
    }
    return u, nil
}

The API service imports the database package:

// services/api/go.mod
module github.com/yourorg/monorepo/services/api

go 1.22

require github.com/yourorg/monorepo/pkg/database v0.0.0
// services/api/main.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/yourorg/monorepo/pkg/database"
)

func main() {
    store := database.NewMemoryStore()
    user, err := store.GetUser(context.Background(), "1")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("User: %s\n", user.Name)
}

With the go.work file in the monorepo root, go build ./services/api/... resolves the database import locally. You can edit both packages in the same editor session and see changes immediately.

Running Commands in Workspace Mode

Most go commands work in workspace mode:

go build ./...          # Builds all modules in the workspace
go test ./...           # Tests all modules
go vet ./...            # Vets all modules
go run ./services/api   # Runs a specific module

To synchronize dependencies across workspace modules:

go work sync

This updates each module’s go.mod to use consistent dependency versions, which prevents version conflicts between workspace modules.

Disabling Workspace Mode

Sometimes you need to build a single module as if the workspace does not exist, for example to verify it works independently:

GOWORK=off go build ./...

Setting GOWORK=off disables workspace mode for that command. This is useful in CI to verify each module builds on its own.

Should You Commit go.work?

This depends on your team structure:

Commit it when the workspace represents the canonical development setup and everyone on the team develops all modules together. This is common in monorepos where the workspace boundary matches the repository boundary.

Do not commit it when different developers work on different subsets of modules, or when the workspace includes modules from other repositories. In this case, add go.work and go.work.sum to .gitignore and let each developer create their own workspace.

A middle-ground approach is to commit a go.work file for CI that includes all modules, ensuring the entire workspace builds and tests together.

Workspace Mode in CI

For CI pipelines in a monorepo, the workspace ensures all modules are tested with compatible local versions:

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go work sync
      - run: go test ./...
      - run: go vet ./...

For independent module CI, disable workspace mode:

  test-api:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: services/api
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: GOWORK=off go test ./...

Limitations

Workspace mode does not replace proper module versioning for published libraries. When you release pkg/database as a standalone module, consumers outside the workspace still resolve it through the module proxy. The workspace only affects local development.

The go.work file does not support replace directives. If you need replaces, they stay in individual go.mod files. However, workspace mode usually eliminates the need for development-time replaces.

Wrap Up

Go workspace mode removes the friction of multi-module development. A single go.work file tells the toolchain which modules live locally, replacing fragile replace directives with a clean, purpose-built mechanism. Use it in monorepos to develop shared packages alongside their consumers, run workspace-wide tests and builds, and maintain independent go.mod files that remain valid for standalone use.