A Go monorepo for multiple services isn’t just about putting code in one place; it’s a deliberate architectural choice that fundamentally changes how you manage dependencies and deploy.
Imagine you have three services: auth-service, user-service, and product-service. In a traditional setup, each would be its own repository, with its own dependencies, build process, and release cycle. In a monorepo, they all live under a single top-level directory.
/monorepo
/services
/auth-service
main.go
go.mod
...
/user-service
main.go
go.mod
...
/product-service
main.go
go.mod
...
/libs
/common
utils.go
go.mod
...
/database
db.go
go.mod
...
Makefile
docker-compose.yml
...
This structure immediately offers a few advantages. Changes that span multiple services, like updating a shared library or refactoring an API contract, become trivial to coordinate. You can see all the affected code in a single commit.
When building, you’ll typically use a tool that understands the monorepo structure. Let’s say you’re using Bazel. A BUILD file in /services/auth-service might look like this:
go_library(
name = "auth-service",
srcs = ["main.go"],
importpath = "github.com/yourorg/monorepo/services/auth-service",
deps = [
"//libs/common:common",
"//libs/database:database",
# ... other internal or external dependencies
],
)
go_binary(
name = "auth-service_bin",
embed = [":auth-service"],
goos = "linux",
goarch = "amd64",
)
Here, go_library defines a Go package, and go_binary creates an executable. Notice how it directly references other parts of the monorepo using labels like //libs/common:common. This is the core of how dependencies are managed internally. Bazel resolves these, ensuring that auth-service gets the correct version of common and database from within the monorepo.
Your go.mod files in each service directory would then only list external dependencies. For example, services/auth-service/go.mod:
module github.com/yourorg/monorepo/services/auth-service
go 1.20
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.3.0
// ... other external dependencies
)
The internal dependencies (libs/common, libs/database) are managed by the build system, not go.mod. This prevents dependency conflicts between services that might otherwise pull in different versions of the same internal library.
To run this locally, you might use a docker-compose.yml:
version: '3.8'
services:
auth:
build:
context: ./services/auth-service
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://user:password@db:5432/authdb
user:
build:
context: ./services/user-service
dockerfile: Dockerfile
ports:
- "8081:8081"
environment:
DATABASE_URL: postgres://user:password@db:5432/userdb
product:
build:
context: ./services/product-service
dockerfile: Dockerfile
ports:
- "8082:8082"
environment:
DATABASE_URL: postgres://user:password@db:5432/productdb
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: authdb # Note: In a real scenario, you'd have separate DBs or a single one managed carefully
ports:
- "5432:5432"
Each service’s Dockerfile would then simply copy the compiled binary and run it. The key here is that the build process (e.g., Bazel) compiles everything before it’s copied into the container.
The one thing most people don’t realize is how the go.mod replace directive can be used to tell the Go toolchain to use local paths for development. If you’re not using a dedicated monorepo build tool, you might have a top-level go.mod with replace directives:
module github.com/yourorg/monorepo
go 1.20
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.3.0
)
replace (
github.com/yourorg/monorepo/libs/common => ./libs/common
github.com/yourorg/monorepo/libs/database => ./libs/database
github.com/yourorg/monorepo/services/auth-service => ./services/auth-service
// ... and so on for other services and libs
)
This setup allows go build, go test, and other Go commands to resolve internal dependencies as if they were external modules, all managed by the Go toolchain itself. This is incredibly powerful for local development and testing without needing a full-blown monorepo build system.
This approach makes managing shared code and cross-service changes much more efficient, but it introduces a strong reliance on your build and dependency management tooling to handle the complexity. The next challenge is often optimizing build times and ensuring granular deployment of individual services.