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.

Want structured learning?

Take the full Golang course →