Monorepo vs Polyrepo: dependency graphs, build orchestration, CI strategy

puml.online

Monorepo or Polyrepo — the eternal backend architecture debate. This is the real cost comparison of both layouts, and how to use PlantUML to draw service / package dependency graphs, Bazel/Nx/Turborepo build orchestration, and CI incremental build strategy.

Three repo layouts

Polyrepo (one repo per service)

1
2
3
4
git@github.com:org/user-service.git
git@github.com:org/order-service.git
git@github.com:org/payment-service.git
git@github.com:org/shared-lib.git

Monorepo (all services in one repo)

1
2
3
4
5
6
7
8
9
10
11
git@github.com:org/platform.git
├── services/
│ ├── user/
│ ├── order/
│ └── payment/
├── libs/
│ ├── common-utils/
│ ├── auth-client/
│ └── db-helper/
└── tools/
└── ci-scripts/
1
2
3
4
5
git@github.com:org/checkout-platform.git
├── services/
│ ├── cart/
│ ├── checkout/
│ └── payment/

How to choose

Dimension Polyrepo Monorepo
Code visibility poor — cross-repo view is painful good — IDE full-text search
Cross-service changes hard — multiple PRs to coordinate easy — single PR
Build complexity simple — each builds itself complex — dependency orchestration
CI speed simple — build only self complex — incremental build
Team autonomy high — each team’s rhythm low — synchronized rhythm
Permission management fine-grained — per-repo ACL coarse — CODEOWNERS
Git performance good — small repo poor — git blame slow on big repo
Suitable scale <10 services, >200 services 10-100 services

Rule of thumb:

  • <10 services → Polyrepo, simple
  • 10-50 services, tightly coupled → Monorepo (Google/FB/Meta pattern)
  • >100 services → Multi-repo (aggregate by business domain)

Monorepo dependency graph

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@startuml
skinparam componentStyle rectangle
skinparam defaultTextAlignment center

title "Monorepo Service Dependency Graph"

package "Frontend" {
component "Web App" as web
component "Mobile App" as mobile
}

package "Backend Services" {
component "User Service" as user
component "Order Service" as order
component "Payment Service" as payment
component "Inventory Service" as inv
component "Notification Service" as notif
}

package "Shared Libraries" {
component "common-utils" as cu
component "auth-client" as ac
component "db-helper" as db
component "logging" as log
}

package "Platform" {
component "API Gateway" as gw
component "Config Service" as cfg
}

' Frontend deps
web --> gw : HTTPS
web --> ac : TS SDK
mobile --> gw : HTTPS
mobile --> ac : TS SDK

' Backend service deps
gw --> user : HTTP
gw --> order : HTTP
gw --> payment : HTTP

order --> user : "verify user"
order --> inv : "check stock"
order --> payment : "charge"
payment --> user : "get billing addr"
order --> notif : "send email"
user --> notif : "send welcome email"

' Shared lib deps
user --> cu
order --> cu
payment --> cu
inv --> cu
notif --> cu
user --> db
order --> db
payment --> db
inv --> db
user --> ac
gw --> ac
' cfg depended on by all, but skip to avoid clutter
user ..> cfg : config
order ..> cfg : config
payment ..> cfg : config

@enduml

Drawing dependency graphs tips:

  • solid line = strong dep (compile-time / direct import)
  • dotted ..> = weak dep (config / network / indirect)
  • package grouping = by business domain
  • Platform components at the bottom — depended on by many services

Detecting circular dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# check_cycles.py
import re, pathlib, networkx as nx

deps = {}
for service_dir in pathlib.Path("services").iterdir():
name = service_dir.name
deps[name] = set()
puml = (service_dir / "deps.puml").read_text()
for dep in re.findall(r'(\w+) --> (\w+) :', puml):
if dep[0] == name:
deps[name].add(dep[1])

G = nx.DiGraph(deps)
cycles = list(nx.simple_cycles(G))
assert not cycles, f"Circular dependencies detected: {cycles}"

CI runs this — any cycle fails the build.

Build tool: Bazel

Bazel is Google’s internal Blaze open-sourced — uses BUILD files to describe dependencies:

1
2
3
4
5
6
7
8
9
10
11
12
13
# services/user/BUILD
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "user_service",
srcs = glob(["**/*.go"]),
deps = [
"//libs/common-utils:go_default_library",
"//libs/db-helper:go_default_library",
"//libs/auth-client:go_default_library",
],
visibility = ["//visibility:public"],
)

Dependency graph:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@startuml
title "Bazel Dependency Graph (excerpt)"

node "services/user\nBazel target" as user_target {
component "go_library:\nuser_service"
}

node "libs/common-utils\nBazel target" as cu_target {
component "go_library:\ncommon_utils"
}

node "libs/db-helper\nBazel target" as db_target {
component "go_library:\ndb_helper"
}

node "libs/auth-client\nBazel target" as ac_target {
component "go_library:\nauth_client"
}

user_target --> cu_target
user_target --> db_target
user_target --> ac_target

@enduml

Bazel’s incremental build:

1
2
3
4
5
6
7
# change one file, only rebuild affected targets
bazel build //services/user:user_service
# automatically skips unchanged targets

# remote cache (team-shared)
bazel build //services/user:user_service --remote_cache=https://bazel-cache.company.com
# someone else built the same target, you get it from cache

Build tool: Nx (Nx.dev)

Nx is a monorepo build tool, suits JavaScript/TypeScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// nx.json
{
"npmScope": "@platform",
"tasksRunnerOptions": {
"default": {
"runner": "@nrwl/workspace/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"],
"parallel": 3
}
}
},
"implicitDependencies": {
"package.json": {
"scripts": {
"build": "*"
}
}
}
}
1
2
3
4
5
6
7
# incremental build (only build affected)
nx affected:build --base=main --head=HEAD
# automatically skips unchanged services

# remote cache
nx build user-service --skip-nx-cache=false
# CI builds once, local nx reuses cache

Nx dependency graph auto-generated:

1
2
nx dep-graph
# outputs services/user -> libs/auth-client etc.

Build tool: Turborepo

Turborepo is from Vercel, suited for Next.js / Vite projects:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {},
"dev": {
"cache": false
}
}
}
1
2
3
4
5
6
# incremental build
turbo run build --filter=...[origin/main]
# only build packages affected by current branch

# remote cache (free Vercel, self-hosted enterprise)
turbo run build --remote-cache-signature

CI strategy

Polyrepo CI

1
2
3
4
5
6
7
8
9
10
11
# .github/workflows/build.yml
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: npm test

Simple — one repo, one CI.

Monorepo CI (full build)

1
2
3
4
5
6
7
8
9
10
11
name: Build All
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: nx run-many --target=build --all
- run: nx run-many --target=test --all

Slow — 50 services × 5 min build = 4 hours.

Monorepo CI (incremental)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: Build Affected
on:
push:
branches: [main]
pull_request:

jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full git history to compute affected
- uses: actions/setup-node@v4
- run: npm ci
- run: nx affected -t build --base=origin/main --head=HEAD
- run: nx affected -t test --base=origin/main --head=HEAD
- run: nx affected -t lint --base=origin/main --head=HEAD

Key: fetch-depth: 0 — needs git history to compute affected. Nx’s affected algorithm: diff files in base..head, reverse-trace the dependency graph, build all services on the dependency chain.

Example: changed libs/common-utils, 10 services depend on it → all need rebuild → 10 × 5 min = 50 min.

Monorepo CI (incremental + remote cache)

1
2
3
4
- run: nx affected -t build --base=origin/main --head=HEAD
env:
NX_CACHE_DIRECTORY: .nx/cache
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_TOKEN }}

Remote cache: CI’s builds cache to Nx Cloud, local nx build reuses them. Same code change → no rebuild.

Polyrepo → Monorepo migration

Field steps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@startuml
title Polyrepo to Monorepo Migration

start

:choose tool (Nx / Bazel / Turborepo);

:create new monorepo repo;

note right
Keep old repos for a while
(Polyrepo still in production)
end note

while still have unmigrated services
:use git subtree / git filter-repo\nto move single service dir into monorepo;
:update import paths;
:add BUILD / project.json / package.json;
:deploy service (point to monorepo);
:verify production works;
endwhile

:archive old repos;
:update docs / new-hire onboarding;

stop
@enduml

Key: gradual migration, not big-bang. Verify production after each service migration.

Field foot-guns

  • Monorepo without directory structure — all services flat, tens of thousands of files mess. Strictly organize by services/ libs/ tools/.
  • No dependency graph visualization — newcomers don’t know which service depends on which. Nx’s nx dep-graph outputs SVG, put it on the wiki.
  • Shared lib change breaks everything — change one line in libs/common-utils, all services break. All lib changes must go through CI + all services’ tests.
  • CI doesn’t build incrementally — full build runs hours. Must use Nx affected / Turborepo –filter / Bazel remote cache.
  • Permission chaos — all services see all code, audit is hard. Use CODEOWNERS:
    1
    2
    3
    /services/payment/ @payments-team
    /libs/auth-client/ @security-team
    /libs/db-helper/ @platform-team
  • Git performance poorgit log slow on big repo. Use git log -- <path> for single file, or tig to speed up.
  • IDE lag — VSCode first-time indexing slow on monorepo. Exclude unwanted directories: "files.exclude": {"**/node_modules": true, "**/dist": true}.

Decision tree

1
2
3
4
5
6
7
8
9
10
How big is the team?
├─ <5 people → Polyrepo, simple
├─ 5-30 people, tightly coupled → Monorepo
├─ 30-100 people, multiple business lines → Multi-repo
└─ >100 people → multiple monorepos, one per business domain

Build speed?
├─ Total build <10 min → no need for incremental
├─ Total build 10-60 min → Turborepo / Nx
└─ Total build >1 hour → Bazel + remote cache

Minimum viable monorepo: Nx + npm workspaces + nx affected + GitHub Actions running incremental. Big team / perf-critical upgrade to Bazel.

Minimum viable polyrepo + shared libs: separate platform-shared-lib repo, other services reference via git submodule / version tags / private npm registry.

Remember: Monorepo is a tool, not a religion. Google/Facebook use Monorepo because they have dedicated monorepo teams maintaining the build system. Small teams forcing monorepo actually hurt productivity.

  • Title: Monorepo vs Polyrepo: dependency graphs, build orchestration, CI strategy
  • Author: puml.online
  • Created at : 2026-07-30 17:40:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-monorepo-polyrepo-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.