What’s a Monorepo?#

A monorepo (monolithic repository) is a single git repository containing multiple related projects. Instead of one repo per service or library, everything lives together:

my-company/
├── backend/          # Go service
├── frontend/         # React app
├── mobile/           # React Native app
├── shared/
│   ├── proto/        # Protobuf schemas
│   ├── types/        # Shared TypeScript types
│   └── utils/        # Shared utilities
├── infra/            # Terraform, Kubernetes configs
└── tools/            # Internal tooling

One git clone, and you have the entire codebase. One PR can touch backend + frontend + shared types + infra in a single commit.

The alternative — a polyrepo (multiple separate repositories) — has each component in its own repo. Backend in github.com/company/backend, frontend in github.com/company/frontend, etc.

Both are widely used by major companies. Google, Meta, and Microsoft use monorepos. Netflix, Amazon, and many startups use polyrepos. There’s no universally correct answer.

The Case For Monorepos#

Atomic Cross-Cutting Changes#

This is the biggest advantage. When you rename a field in your Protobuf schema, you need to update the Go backend, the TypeScript frontend, and the docs — simultaneously, in the same commit.

In a polyrepo: three PRs, three reviews, three merges, three CI runs, a coordination dance that almost always results in a period where the repos are out of sync.

In a monorepo: one PR, one review, one merge. The change is atomic. There’s no state where the backend is on the new schema but the frontend is on the old one.

Easier Code Sharing#

Shared libraries don’t need versioning. Instead of publishing @company/shared-types@1.2.0 to npm and updating the version in every repo that uses it, you just import from the monorepo path directly.

1
2
3
4
5
// Polyrepo: published npm package with version management
import { Trade } from '@company/shared-types';  // npm package, must be versioned

// Monorepo: direct import from relative path
import { Trade } from '../../shared/types/trade';  // just works

No version pinning, no “which version of shared-types is this service on?”, no breaking changes hidden in a minor version bump.

Unified CI/CD#

One CI configuration handles the entire codebase. A change to the backend only triggers backend tests. A change to the shared Protobuf triggers backend + frontend tests. The CI system knows what changed and tests exactly what it needs to.

Easier Onboarding#

A new team member clones one repo and has everything. No “which repo do I need to clone to work on feature X?”, no hunting through org-level documentation to find which service lives where.

The Case Against Monorepos (Polyrepo Advantages)#

Scale and Performance#

git clone of a monorepo with years of history and thousands of files is slow. At extreme scale (Google’s is estimated at 2+ billion lines of code), standard git breaks down entirely. Google built their own version control system (Piper) to handle it.

For most projects (under a few hundred engineers, under a few million LOC), this isn’t a real problem. But it’s worth knowing.

Independent Deployment Velocity#

In a polyrepo, teams deploy independently. The backend team can release 20 times a day without coordinating with the frontend team. Each service has its own release cycle, its own version history, its own rollback strategy.

In a monorepo, releases are often coordinated (especially if shared libraries change). Some teams find this adds friction.

Access Control#

In a polyrepo, you can give a contractor access to only the frontend repository. In a monorepo, coarser — GitHub’s CODEOWNERS can restrict who can approve changes to specific paths, but everyone with read access sees everything.

Less Tooling Overhead#

A polyrepo with go build and npm install just works. A monorepo needs a build tool that understands cross-language dependencies (Bazel, Nx, Turborepo, Pants). That tooling has a learning curve and maintenance cost.

Monorepo Tooling Comparison#

The key challenge in a monorepo: building only what changed, in the right order.

Bazel (Google)#

The most powerful, most complex. Originally designed for Google’s monorepo. Supports Go, C++, Java, Python, TypeScript, Rust, and more. Complete dependency graph across all languages.

  • Pros: hermetic builds (identical everywhere), remote caching, any language, battle-tested at scale
  • Cons: steep learning curve, BUILD file maintenance, complex configuration
  • Best for: multi-language monorepos (Go + C++ + TypeScript + Protobuf), large teams, CI performance matters
1
2
3
4
5
6
# Bazel BUILD file
go_binary(
    name = "backend",
    srcs = glob(["*.go"]),
    deps = ["//shared/proto:proto_go", "@com_github_redis_go_redis_v9//:redis"],
)

Turborepo (Vercel)#

Focused on JavaScript/TypeScript monorepos. Simple configuration, good caching, integrates with npm/pnpm/yarn workspaces.

  • Pros: very easy to set up, great for JS/TS, good remote cache with Vercel
  • Cons: JS/TS only, less powerful than Bazel for complex dependency graphs
  • Best for: frontend-heavy monorepos (Next.js apps, component libraries, shared TS packages)
1
2
3
4
5
6
7
8
// turbo.json
{
  "pipeline": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
    "test":  { "dependsOn": ["build"] },
    "lint":  {}
  }
}

Nx (Nrwl)#

Similar to Turborepo but more opinionated. Includes generators for creating new packages, migrations, and a visual dependency graph.

  • Pros: great DX, generators reduce boilerplate, good caching
  • Cons: more opinionated than Turborepo, primarily JS/TS (Go/Rust plugins exist but secondary)
  • Best for: large JS/TS monorepos where generator productivity matters

Pants (Twitter/Foursquare)#

Python, Go, Java, Scala, Kotlin. Less common than Bazel but simpler.

  • Pros: simpler than Bazel, good Python support, active community
  • Cons: smaller ecosystem, less documentation
  • Best for: Python-heavy backends with some Go/Java

No Tool (Just Scripts)#

For small monorepos with a few related services, a root Makefile or Taskfile.yml that orchestrates individual builds is enough:

1
2
3
4
5
6
7
build-all: build-backend build-frontend

build-backend:
	cd backend && make build

build-frontend:
	cd frontend && npm run build

Simple, no dependencies, works fine until the repo grows to 5+ services with complex interdependencies.

Migrating to a Monorepo#

Moving existing polyrepos into a monorepo without losing git history:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Add the existing repo as a remote
git remote add backend-repo https://github.com/company/backend.git
git fetch backend-repo

# Merge with allow-unrelated-histories and move to subdirectory
git merge backend-repo/main --allow-unrelated-histories
git mv * backend/  # move everything into a subdirectory

# Repeat for other repos
git remote add frontend-repo https://github.com/company/frontend.git
git fetch frontend-repo
git merge frontend-repo/main --allow-unrelated-histories
git mv *.tsx *.ts frontend/

The git subtree split and git filter-branch (or the newer git filter-repo) tools can do this more cleanly, rewriting history so commits from backend-repo appear as if they always lived in backend/.

Making the Decision#

A heuristic that works well:

Consider a monorepo if:

  • You have frequent cross-service changes (shared types, APIs, schemas)
  • Teams are small and coordinate closely
  • You have strong CI/CD tooling (or want an excuse to invest in it)
  • Multiple services in the same language that share utilities

Consider polyrepos if:

  • Teams are large and work independently
  • Services deploy on completely different schedules
  • Strong access control requirements
  • Teams use very different tech stacks with no shared code

The hybrid approach — a middle ground that works for many teams: one monorepo for tightly-coupled services (backend + frontend that share Protobuf schemas), separate repos for independent services or entirely different products.

For a project like a market data platform with a Go backend, C++ chart, and React frontend that all depend on the same Protobuf schema — monorepo with Bazel is the right call. Schema changes, data structure changes, and API changes happen atomically across all three with one PR.