Go modules are how Go manages dependencies, but they can get tricky in large codebases.

Here’s a look at how they work in practice, using a simple multi-module project.

Let’s say we have a main application app that depends on a shared library lib.

myproject/
├── app/
│   ├── main.go
│   └── go.mod
├── lib/
│   ├── helper.go
│   └── go.mod
└── go.work

The app/go.mod might look like this:

module myproject/app

go 1.21

require myproject/lib v0.0.0-20231027100000-abcdef123456 // Example require

And lib/go.mod:

module myproject/lib

go 1.21

The go.work file orchestrates this:

go 1.21

use (
	./app
	./lib
)

This go.work file tells the Go toolchain to consider ./app and ./lib as modules within the same workspace. When you run go build or go test from the root of myproject/, Go will use the local versions of app and lib defined in the workspace, rather than fetching them from a remote repository. This is crucial for development when you’re actively changing both the library and the application that uses it.

The primary problem Go modules solve is dependency versioning. Before modules, GOPATH was king, and managing different versions of the same library across projects was a nightmare. Modules brought explicit versioning and reproducible builds. A workspace, defined by go.work, extends this by allowing multiple local modules to be treated as a single unit for development. This means if you change lib/helper.go, running go build in app/ will automatically pick up that change without needing to publish a new version of lib.

Here’s how you’d typically interact with this setup:

  1. Initialize modules:

    cd myproject/app
    go mod init myproject/app
    cd ../lib
    go mod init myproject/lib
    cd ../.. # back to myproject/
    go work init
    

    go mod init creates the go.mod file for each module. go work init creates the go.work file.

  2. Add dependency: If app needed another external module, say rsc.io/quote:

    cd myproject/app
    go get rsc.io/quote
    

    This would update app/go.mod to include rsc.io/quote and its version. Because we’re in a workspace, go get in app won’t try to resolve myproject/lib externally; it will use the local version from the workspace.

  3. Build and test: From the root myproject/ directory:

    go build ./app
    go test ./lib
    go test ./app
    

    These commands will build and test using the modules defined in the workspace.

  4. Vendoring (optional but good practice): For more reproducible builds, especially in CI/CD or production, you can vendor dependencies.

    cd myproject/app
    go mod vendor
    

    This creates a vendor/ directory within app/ containing the source code of all its dependencies. You then tell Go to use the vendor directory:

    go build -mod=vendor ./...
    

    The go.work file itself doesn’t directly use vendor/; vendoring is a per-module concept. However, when you’re in a workspace, go mod vendor within a specific module (like app/) will only vendor the dependencies of that module, not the other modules in the workspace.

The most surprising thing about go.work is that it fundamentally alters how go get, go build, and go test resolve module paths. When a go.work file is present, Go prioritizes the local modules listed in use directives. It effectively creates a temporary, local "proxy" for those modules, overriding any remote versions that might otherwise be resolved. This means you can have a require directive in app/go.mod pointing to myproject/lib with a specific version, but when go build ./app is run from the root myproject/ (where go.work is), Go will use the lib from the use directive in go.work instead of the version specified in app/go.mod. This is a powerful mechanism for local development and testing of interconnected modules.

If you have a go.work file, running go get on a module within that workspace (e.g., go get myproject/lib while myproject/ is the current directory and myproject/lib is listed in use) will actually update the go.mod file of the dependent module (e.g., app/go.mod) to point to the newly fetched version of myproject/lib from its remote source, unless you have explicitly replaced it with a replace directive in the dependent module’s go.mod. This behavior can be a bit counter-intuitive: go get typically updates the module it’s run in, but in a workspace context, it can also update other modules’ dependencies if they are not otherwise pinned or replaced.

The next step after mastering workspaces is understanding replace directives for more complex local development scenarios, especially when you want to test a specific commit of a dependency without affecting other parts of your workspace.

Want structured learning?

Take the full Golang course →