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:
-
Initialize modules:
cd myproject/app go mod init myproject/app cd ../lib go mod init myproject/lib cd ../.. # back to myproject/ go work initgo mod initcreates thego.modfile for each module.go work initcreates thego.workfile. -
Add dependency: If
appneeded another external module, sayrsc.io/quote:cd myproject/app go get rsc.io/quoteThis would update
app/go.modto includersc.io/quoteand its version. Because we’re in a workspace,go getinappwon’t try to resolvemyproject/libexternally; it will use the local version from the workspace. -
Build and test: From the root
myproject/directory:go build ./app go test ./lib go test ./appThese commands will build and test using the modules defined in the workspace.
-
Vendoring (optional but good practice): For more reproducible builds, especially in CI/CD or production, you can vendor dependencies.
cd myproject/app go mod vendorThis creates a
vendor/directory withinapp/containing the source code of all its dependencies. You then tell Go to use the vendor directory:go build -mod=vendor ./...The
go.workfile itself doesn’t directly usevendor/; vendoring is a per-module concept. However, when you’re in a workspace,go mod vendorwithin a specific module (likeapp/) 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.