Monorepo vs Multi-Repo for Go Projects

Choose monorepo for shared code and atomic updates, or multi-repo for independent deployment and access control in Go projects.

The repo war: one house or many?

You have two microservices. Both need to parse a weird date format from a legacy API. You write the parser in Service A. Service B needs it too. You face a choice: copy the code, create a third repository just for the parser, or dump everything into a single repository. This is the monorepo versus multi-repo debate. Go doesn't force a hand. The language treats a repository as just a folder on disk. The real unit of work is the Go module.

Go modules are the unit, not the repository

Think of a monorepo as a single office building. Every team has a desk in the same building. If you need to change a rule, you update the policy document in the lobby, and everyone sees it instantly. A multi-repo setup is like a network of independent shops. Each shop has its own inventory system. To share a product, you have to ship a box from one shop to another. The box has a version number. If you fix a defect, you ship a new box. The shops update when they are ready.

Go modules define the atomic unit of dependency management. A module is defined by a go.mod file. The module path is a URL, like github.com/org/project. The repository is just the mechanism that delivers the module to other machines. You can have one module per repository. You can have multiple modules in one repository. You can even have multiple modules in one directory, though that causes pain. The compiler only cares about module paths. It does not care about Git remotes.

Convention aside: public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. In a monorepo, this visibility rule helps you expose only what you intend. If a function is lowercase, other packages in the same module can still call it, but external modules cannot. This gives you fine-grained control over your API surface without needing separate repositories.

Minimal monorepo: one module, many packages

The simplest monorepo has a single go.mod at the root. You organize code into directories based on purpose. Common conventions use cmd for executables and pkg or internal for libraries.

Here's the simplest monorepo layout: one module at the root, packages in subdirectories.

// cmd/server/main.go
package main

import (
	"fmt"
	// Import the shared package using the module path.
	// Go resolves this to the local directory because it is part of the same module.
	"github.com/org/project/pkg/shared"
)

// main starts the server and calls the shared greeting function.
func main() {
	// Use the shared function to demonstrate cross-package calls.
	fmt.Println(shared.Greet("World"))
}
// pkg/shared/greet.go
package shared

// Greet returns a formatted greeting string.
// The function name is capitalized, so it is exported from the package.
func Greet(name string) string {
	// Return the greeting.
	return "Hello, " + name + "!"
}

When you run go build ./cmd/server, the tool reads go.mod. It sees the module path. It looks for imports. If an import matches a subdirectory within the same module, it uses the local code. No network request. No version check. This is fast. You change shared.go, rebuild server, and the change is there immediately. This is the "atomic update" benefit. You can refactor the shared code and update all consumers in a single commit.

Goroutines are cheap. Channels are not magic. In a monorepo, you can share concurrency patterns easily. If you write a worker pool in pkg/shared, every service can use it. You don't need to version the pool separately. You just import it.

Realistic structure: internal boundaries

As the monorepo grows, you need to protect code that should not be imported by outsiders. Go provides the internal directory for this. Code inside internal cannot be imported by modules outside the current module.

Here's how you structure shared code that must remain private to your project.

// internal/auth/token.go
package auth

// Validate checks if a token is valid.
// This function is exported from the package, but the package is inside internal.
// External modules cannot import this package.
func Validate(token string) bool {
	// Check the token signature.
	return len(token) > 0
}

If you try to import internal/auth from a different module, the compiler rejects the program with use of internal package not allowed. This is a compile-time guarantee. You don't need to hope that other teams respect your boundaries. The tool enforces them.

In a monorepo with a single module, internal protects against external imports, but everything inside the module can still import internal. If you have multiple modules in a monorepo, internal boundaries become module boundaries. A module cannot import internal from another module, even if they share the same repository.

Convention aside: the receiver name is usually one or two letters matching the type. Write (t *Token) Validate(), not (this *Token) Validate(). This keeps method signatures short and readable. In a monorepo, consistency across packages makes the codebase easier to scan.

Multi-module monorepos and workspaces

Some teams want multiple modules in one repository. This allows independent versioning for different parts of the codebase. For example, a shared library might need a stable version, while a service can move fast.

Historically, this required replace directives in every go.mod to point to local versions during development. This created maintenance overhead. Go 1.18 introduced workspaces to solve this.

Here's how Go workspaces simplify multi-module monorepos.

// go.work
go 1.22

use (
	./service-a
	./service-b
	./shared-lib
)

You create a go.work file at the root. You list the modules. When you run go build, the tool uses the workspace. You don't need replace directives in go.mod. This keeps go.mod clean for production. The workspace is local to your machine. You don't commit go.work to version control.

Workspaces also help with testing. You can run go test ./... and the tool tests all modules in the workspace. This gives you a unified view of your codebase health.

Context is plumbing. Run it through every long-lived call site. In a monorepo, if you have a shared HTTP client, make sure it accepts context.Context as the first parameter. If you forget, you create a leak that propagates to every service using the client. The receiver name is usually one or two letters matching the type: (c *Client) Do(ctx context.Context, ...). Not (this *Client).

Pitfalls and compiler traps

Monorepos shift complexity from dependency management to build orchestration. You change one file, and you need to know which services are affected. If you update a shared interface, every consumer must update its implementation. The compiler catches missing methods, but you might have runtime logic errors.

The compiler rejects the build with go.mod requires a replace directive if you import a module that exists locally but isn't replaced or part of a workspace. This error appears when you try to build a module that depends on a sibling module without proper configuration.

Multi-repos have their own pain. You update a dependency, but the downstream service hasn't updated its go.mod yet. You get a runtime bug because the old version has a defect. The compiler won't save you here. The error happens in production.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't try to hide errors in a monorepo. The verbosity scales. If you wrap errors, use fmt.Errorf("context: %w", err) to preserve the chain. This helps you debug issues across service boundaries.

Don't pass a *string. Strings are already cheap to pass by value. In a monorepo, unnecessary pointers add cognitive load. Pass strings by value unless you have a specific reason to mutate them.

The worst monorepo bug is the one that breaks three services and you don't know which commit caused it. Use path-based triggering in your CI pipeline. Tools like go list can help find dependencies. If a file changes, rebuild only the modules that import it.

Decision matrix

Use a monorepo when you have shared code that changes frequently and you want atomic updates across all consumers. Use a monorepo when your team is small and you want a single source of truth for tooling and CI pipelines. Use a monorepo when you want to refactor code across services without coordinating releases. Use a multi-repo setup when services have independent release cycles and different ownership teams. Use a multi-repo setup when you need strict access control, where some teams should not see the source code of other services. Use a multi-repo setup when a service is a standalone library intended for public consumption by the wider Go community. Use a hybrid approach when you have a core platform in a monorepo and satellite services in separate repositories.

Go modules are the unit. The repo is just the box.

Where to go next