Managing Monorepos at Scale: Why Startups Should Embrace the Monorepo
Monorepos get a bad reputation. Engineers fresh out of microservices dogma will tell you they don’t scale, that they’re a relic of the past, that “real” engineering teams split everything into separate repositories. I’ve worked at both ends of the spectrum—Meta’s massive monorepo and AWS’s sea of isolated repos—and I can tell you definitively: for startups optimizing for velocity, monorepos win. And it’s not even close.
The Industry’s Dirty Secret
Here’s something that doesn’t get talked about enough: the companies shipping the fastest and most reliable software in the world use monorepos.
Google stores virtually all of their code—billions of lines—in a single repository. Over 25,000 engineers commit to it daily. They built custom tooling (Piper, CitC, Blaze/Bazel) specifically to make this scale.
Meta runs one of the largest monorepos in existence. When I was there, the Mercurial repository contained the code for Facebook, Instagram, WhatsApp web, internal tools, and countless services. Thousands of engineers committing thousands of times per day.
Microsoft migrated Windows to a single Git repository—the largest Git repo in the world at the time. They built VFS for Git specifically to handle it.
Twitter, Uber, Airbnb, Stripe—the pattern repeats. Companies that need to move fast and ship reliable software converge on monorepos.
Meanwhile, at AWS, I’ve watched teams spend weeks on what should be a simple cross-cutting change. Want to update a shared library across 50 services? That’s 50 PRs, 50 code review cycles, 50 deployment pipelines, and 50 different teams to coordinate with. The overhead is staggering.
The Migration: From 15 Repos to One
Before I make the theoretical case, let me tell you what actually happened at SID.
We started the “right” way. Each service got its own repository. Authentication service. Billing service. Calendar service. Notifications. Search. By the time we had 15 services, we had 15 repositories, each with its own CI pipeline, its own deployment scripts, its own version of shared types.
It was hell.
The database model problem: Our User type was defined in 8 different repositories. When we added a field to the users table, we had to update 8 repos. Inevitably, someone would forget one, and we’d get runtime errors in production because Service X expected a field that Service Y wasn’t sending.
The cross-repo dependency nightmare: Our authentication middleware lived in its own repo, versioned with semver. When we fixed a security bug, we had to:
- Fix and release
auth-middleware@2.3.1 - Open PRs in all 14 consuming repos
- Wait for each team (me, wearing 14 different hats) to review and merge
- Deploy all 14 services
For a security fix. That should have been a 10-minute patch.
The backwards compatibility trap: Because services updated dependencies at different times, every change to shared code had to be backwards compatible. We couldn’t remove a deprecated field from an API response because some service might still be reading it. We couldn’t rename a function because some service might be on the old version. Our shared libraries accumulated cruft because deletion was too risky.
The breaking point came when a simple feature—adding organization-level permissions—required coordinated changes across 9 repositories. The feature took two weeks. One week of actual coding. One week of dependency management, version bumping, and deployment coordination.
I spent a weekend consolidating everything into a single repository.
The results were immediate:
- Database models defined once, in
db/, imported everywhere - Shared middleware updated in one PR, affecting all services atomically
- No more version management for internal code
- No more backwards compatibility for code only we consume
- One CI pipeline to maintain instead of 15
- Feature branches that span the entire stack
The organization-level permissions feature that took two weeks in polyrepo? The next feature of similar scope took two days. Not because I got faster at coding—because I stopped fighting the repository structure.
Everything became a migration, not a negotiation. In polyrepo, updating a shared library means convincing every consumer to upgrade, maintaining backwards compatibility indefinitely, or accepting version fragmentation. In monorepo, you just change it. All services update atomically. If something breaks, CI tells you before merge. There’s no “stubborn team” refusing to upgrade—there’s only one version, and it’s the current one.
Why Polyrepo Fails Startups
The polyrepo model—where each service, library, or component lives in its own repository—sounds clean on paper. Separation of concerns. Independent deployability. Microservices nirvana.
In practice, it introduces friction at every turn:
Dependency Hell
When service-A depends on shared-lib, and shared-lib lives in a different repo, you’re managing versions. That’s not inherently bad, but consider what happens when you find a bug in shared-lib:
- Fix the bug in
shared-librepo - Cut a new version
- Open PRs in every repo that consumes
shared-lib - Wait for code review in each repo
- Merge and deploy each repo
- Hope you didn’t miss any consumers
Now multiply this by every shared component in your system. At a startup, you might have 20-30 services after a year. That’s a lot of PRs for a one-line fix.
Diamond Dependency Problems
It gets worse. What if service-A depends on shared-lib@1.0 and service-B depends on shared-lib@2.0? Now service-C, which needs both A and B, has to deal with version conflicts. In a monorepo, this can’t happen—everyone is always on the same version.
Inconsistent Tooling
Every repo accumulates its own CI/CD pipeline, its own linting rules, its own testing patterns. Teams make different choices. One repo uses Jest, another uses Vitest. One has 90% coverage requirements, another has none. The cognitive load of context-switching between repos is real.
Cross-Cutting Changes Are Painful
Want to:
- Upgrade your TypeScript version?
- Switch from Lodash to native methods?
- Add structured logging across all services?
- Update authentication middleware?
In polyrepo land, each of these is a multi-week project. In a monorepo, it’s often a single PR.
The Discovery Problem
Where does the code live? In a monorepo, you grep and find it. In polyrepo, you’re searching across dozens of repositories, hoping the naming conventions are consistent, hoping the README is up to date, hoping someone documented which repo owns what.
The Monorepo Structure That Works
At SID Technologies, we run a monorepo that follows a pattern I call “Monorepo, Polyservice.” Everything lives in one repository, but services maintain independent deployability and bounded contexts. Here’s the structure:
├── services/ # Independent microservices
│ ├── authentication/ # User auth, OAuth, tokens
│ ├── billing/ # Stripe integration, subscriptions
│ ├── calendar/ # Calendar management
│ ├── kanban/ # Task boards
│ ├── notifications/ # Push, email, SMS
│ ├── organization/ # Team and org management
│ ├── permissions/ # RBAC, access control
│ ├── search_engine/ # Full-text search
│ └── ... # 19 services total
│
├── packages/ # Shared TypeScript packages
│ ├── api/ # Generated API clients
│ ├── configs/ # Shared ESLint, TS configs
│ ├── ui/ # Component library
│ └── utils/ # Common utilities
│
├── apps/ # Client applications
│ ├── web/ # Next.js web app
│ ├── desktop/ # Electron app
│ └── mobile/ # React Native
│
├── pkg/ # Shared Go packages
│ ├── authentication/# Auth utilities
│ ├── middleware/ # HTTP middleware
│ ├── stripe/ # Billing integration
│ ├── workos/ # SSO/SAML
│ └── ... # 30+ shared packages
│
└── db/ # Database schemas, migrations
This structure gives us:
Atomic changes: A feature that touches the API, the web app, and a backend service is a single PR with a single code review.
Shared code without versioning: When we update pkg/authentication, every service gets the change immediately. No version bumps, no dependency updates.
Consistent tooling: One Makefile, one golangci.yaml, one set of pre-commit hooks. Every service follows the same patterns because it’s enforced at the repo level.
Easy refactoring: Renaming a function? Your IDE can find and replace across the entire codebase. Moving code between services? It’s just moving files.
Conway’s Law: Microservices Are Organizational, Not Technical
Here’s the key insight that gets lost in the microservices debate: the right architecture for your system is a function of your organization, not your technology choices.
Conway’s Law states: “Any organization that designs a system will produce a design whose structure is a copy of the organization’s communication structure.”
A 5-person startup doesn’t need 50 microservices. They need to ship fast. When everyone sits in the same room (or Slack channel), the communication overhead of service boundaries is pure waste.
But a 5,000-person company with 50 teams across 10 time zones? Now those service boundaries map to organizational boundaries. Team A owns Service A. They can deploy independently, operate independently, make technology choices independently. The service boundary becomes a coordination boundary.
The Evolution Path
What I’ve seen work in practice:
0-10 engineers: Monolith in a monorepo. Maybe 2-3 services. Don’t overthink it.
10-50 engineers: Natural service boundaries emerge. Split along team lines. Authentication is probably its own service. The monorepo keeps coordination costs low.
50-200 engineers: Domain-driven design matters now. Services map to business domains. You might have dedicated platform teams. Still a monorepo, but with strong ownership conventions.
200+ engineers: You might consider polyrepo for genuinely independent business units. But Google has 25,000+ engineers in a monorepo, so don’t assume you’ve hit scale limits.
The mistake is treating the 200+ architecture as the starting point. Premature optimization is the root of all evil, and premature microservices are premature optimization.
The Tooling Gap (And Why I Built Pilum)
The main argument against monorepos is tooling. “Git doesn’t scale.” “CI takes too long.” “Deployments are complicated.”
These are tooling problems, not architectural problems. And they’re solved problems:
Git scaling: Use shallow clones, sparse checkout, or VFS for Git if your repo gets large. In practice, most startups won’t hit these limits for years.
CI performance: Use incremental builds. Bazel, Nx, and Turborepo all do this well. Only rebuild and retest what changed.
Deployments: This is where Pilum comes in. When you have 20 services in a monorepo, you need a way to deploy them. Not a shell script per service. Not clicking through cloud consoles. A single, declarative tool that knows your structure.
Pilum treats deployments like a build system treats compilation. Declare what you want deployed (service configs), define how to deploy it (recipes), and let the orchestrator handle parallelization, retries, and cross-provider differences.
A typical release at SID:
pilum deploy --tag=v1.2.0 --services=authentication,billing,web
This:
- Validates all service configurations against their recipes
- Builds binaries in parallel
- Pushes Docker images in parallel
- Deploys to Cloud Run in parallel
- Rolls back automatically if health checks fail
One command. All services. No copy-pasting shell scripts.
Trunk-Based Development: The Monorepo Workflow
The monorepo enables a workflow that would be painful in polyrepo: trunk-based development.
We commit directly to main with these rules:
-
All tests must pass before merge. CI is the gatekeeper.
-
Services are independently deployable. A change to
services/billingdoesn’t require deployingservices/authentication. -
Breaking changes require API versioning. Internal APIs are versioned just like external ones.
-
Feature flags over long-lived branches. Ship code dark, enable it when ready.
The key insight is isolation: services share code but deploy independently. A commit that touches pkg/middleware might affect 10 services, but we only deploy the services that were actually modified. The shared code change doesn’t force a deploy—the service changes do.
This works because of CI structure:
# Pseudo-structure
on: [push]
jobs:
detect-changes:
outputs:
services: ${{ steps.filter.outputs.services }}
build:
needs: detect-changes
strategy:
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services) }}
Changed services/billing? Build and test billing. Changed pkg/middleware? Run tests for every service that imports it. Changed apps/web? Build and deploy the web app.
Common Objections (And Rebuttals)
“But Google built Bazel because monorepos don’t scale!”
Yes, and Google has successfully scaled a monorepo to billions of lines of code and decades of history. The fact that they needed custom tooling doesn’t mean the approach is wrong—it means the default tools have limits at extreme scale. Most startups will never hit those limits. And if you do, congratulations on your success.
”Microservices give teams autonomy!”
The autonomy argument is about organizational structure, not repository structure. You can have strong service ownership in a monorepo. At SID, the billing team owns services/billing. They set their own release cadence. They choose their database schemas. The monorepo doesn’t prevent autonomy—it prevents isolation.
”A bug in shared code takes down everything!”
A bug in shared code takes down everything in polyrepo too—you just find out slower because services are pinned to old versions. In a monorepo, CI catches it before merge. In polyrepo, you catch it weeks later when someone finally upgrades.
”Code reviews are harder with a giant repo!”
Code reviews are scoped to the files changed, not the repo size. A PR touching 3 files is the same whether the repo has 100 files or 100,000 files. CODEOWNERS ensures the right people review the right code.
”What about security/permissions?”
Git doesn’t have great directory-level permissions, that’s true. But GitHub’s CODEOWNERS and branch protection rules handle most cases. For highly sensitive code (secrets management, payment processing), you might extract to a separate repo. That’s fine—it’s a specific, justified exception, not the default.
”The blast radius is too large!”
This is the most sophisticated-sounding objection, and it deserves a serious response. The argument goes: in a monorepo, a bad commit can break everything. In polyrepo, failures are isolated. The “blast radius” of a mistake is smaller.
Let’s examine this carefully.
First, what actually causes production incidents?
In my experience, the distribution looks something like:
- 40% - Configuration changes (feature flags, environment variables, infrastructure)
- 30% - Database issues (bad migrations, missing indexes, connection pool exhaustion)
- 20% - External dependencies (third-party API outages, certificate expiration)
- 10% - Application code bugs that passed CI
That last category—bugs in application code—is the only one where blast radius even applies. And it’s the smallest slice.
Second, monorepos don’t mean monolithic deployments.
The blast radius argument conflates two unrelated things: where code lives and how code deploys. At SID, we have 19 services in one repo. A bug in services/billing doesn’t affect services/authentication because they deploy independently. The monorepo is a development choice, not a deployment choice.
A bad commit to services/billing goes through:
- CI runs billing-specific tests
- PR review by billing code owners
- Deployment to billing service only
- Health checks on billing service only
- Rollback of billing service only (if needed)
The blast radius is identical to polyrepo. The code living in the same Git repository doesn’t change any of this.
Third, what’s the blast radius of shared code?
Here’s where it gets interesting. Yes, a bug in pkg/middleware could affect multiple services. But consider the alternatives:
Monorepo approach: Change pkg/middleware, CI runs tests for all affected services, you see failures before merge. Fix them atomically in the same PR. Deploy updated services.
Polyrepo approach: Change shared-middleware library, publish new version, each service updates at different times over weeks/months. Some services never update. When they finally do, they discover the bug—weeks after it was introduced, with no context on what changed or why.
The monorepo has a larger immediate blast radius but a smaller total blast radius over time. You find problems faster, fix them atomically, and never have services running on different versions of shared code with different bugs.
Fourth, polyrepo blast radius is hidden, not eliminated.
The dirty secret of polyrepo architecture: the blast radius isn’t smaller, it’s just delayed and distributed. That “isolated” service still depends on:
- Shared infrastructure (the same Kubernetes cluster, the same load balancer)
- Shared databases (or at least shared database servers)
- Shared external dependencies (AWS, Stripe, Auth0)
- Shared libraries (eventually)
When AWS us-east-1 goes down, your polyrepo architecture doesn’t help. When your shared authentication service fails, every downstream service fails regardless of which repo it lives in.
The polyrepo model trades visible, immediate, testable blast radius for hidden, delayed, untestable blast radius. That’s not an improvement—it’s just harder to reason about.
Finally, blast radius is a function of testing, not repository structure.
If you’re worried about blast radius, invest in:
- Comprehensive CI that tests affected services before merge
- Canary deployments that catch issues before full rollout
- Feature flags that let you disable code without deploying
- Automated rollback when health checks fail
- Good monitoring that surfaces problems quickly
These practices work in monorepo or polyrepo. The repository structure is orthogonal to blast radius management.
The engineers who argue loudly about blast radius usually aren’t investing in the practices that actually reduce it. They’re using polyrepo as a substitute for good deployment practices, and it’s a poor substitute.
The Meta vs. AWS Experience
Let me contrast my experiences:
At Meta: Ship a feature that needs changes across the stack? Single diff (PR), single code review, single land (merge). The same engineer could touch iOS, Android, backend, and internal tooling. Cross-cutting changes were routine, not projects.
At AWS: Change request for a shared library? That’s a design review, a code review for the library, a rollout plan, PR to each consuming service, code review for each PR, staged rollout across services, and a postmortem when one of them inevitably breaks because you couldn’t test the integration. A simple version bump becomes a multi-week saga.
The AWS model makes sense for AWS. They have thousands of teams, strict security boundaries, and services that genuinely have different operational requirements. The polyrepo overhead is worth it for their scale and constraints.
But a startup copying AWS’s architecture is like a food truck copying McDonald’s supply chain. The complexity exists to solve problems you don’t have yet.
Making It Work: Practical Advice
If you’re starting a monorepo or converting to one:
1. Invest in CI Early
Your CI pipeline is the gatekeeper. It needs to be fast (minutes, not hours) and comprehensive (catch problems before merge). Use incremental builds from day one.
2. Establish Conventions
Directory structure, naming conventions, service config format—standardize early. Document it in a root CONTRIBUTING.md. Enforce it in CI.
3. Use CODEOWNERS
Even in a small team, explicit ownership prevents the “everyone and no one owns this” problem. Every directory should have an owner.
4. Automate Deployments
You will have more services than you can manually deploy. Build (or adopt) tooling that handles multi-service deployments. This is why Pilum exists.
5. Shared Code is a First-Class Concern
The pkg/ or packages/ directory is load-bearing. Treat it like a product. Review it carefully. Test it thoroughly. It’s the foundation everything else builds on.
6. Don’t Prematurely Split
If you’re wondering whether to extract a service or library into its own repo, the answer is probably “not yet.” Wait until the pain of not splitting is clear and present, not hypothetical.
Conclusion
The monorepo vs. polyrepo debate isn’t really about technology—it’s about coordination costs.
In a monorepo, the cost of change is low but the cost of isolation is high. Everyone sees everything. Changes ripple instantly.
In a polyrepo, the cost of change is high but the cost of isolation is low. Teams can diverge. Changes require explicit coordination.
For startups, low change cost beats low isolation cost. You need to move fast. You need to refactor freely. You need one engineer to ship a feature end-to-end without waiting on five other teams.
The giants of the industry—Google, Meta, Microsoft—have proven monorepos scale. The tooling exists. The patterns are known. The only thing holding you back is the myth that “microservices means multiple repos.”
Embrace the monorepo. Ship faster. Coordinate less. Win.