Managing Monorepos at Scale: Lessons from Meta, AWS, and a 15-Repo Migration
Early in my career, I thought polyrepos were the way. Microservices, each in their own repo, independently deployable. It felt clean and modern. Then I joined Meta and worked in their massive monorepo, and it changed how I think about code organization. Anything I needed to change, I could change it right there. Everything was discoverable. One commit could do everything I needed it to.
Then I moved to AWS, back to polyrepo land, and the contrast was painful. Now I’m building my own products with monorepos, and I migrated SID Technologies from 15 separate repositories to one in about a week.
Here’s what I learned from each approach, and how the migration actually went, with real metrics.
Meta’s Monorepo
At Meta, virtually everything lived in one Mercurial repository: Facebook, Instagram, WhatsApp web, internal tools, mobile apps, backend services. Thousands of engineers committing thousands of times per day.
Cross-cutting changes were routine, not projects. One engineer could touch iOS, Android, backend, and web in a single diff. Refactoring was safe because your IDE could find all usages across the entire codebase. There was no version management for internal code, and CI caught integration issues before merge. The costs were real too: onboarding meant downloading gigabytes of code, search was slow without custom tooling, build times required distributed caching (Buck/Bazel), and merge conflicts were frequent in hot files.
Then there was the dead code problem. Our team had this one legacy project, super old, not maintained anymore. We’d get the occasional error on it and have to try to fix it. I eventually figured out we could deprecate it, and I created a commit deleting tens of thousands of lines of code. But there was this one function in one folder, some sort of hex translation function, and because this was ancient code in the main monorepo, it had over 200 references across dozens of different teams’ folders. The obvious move was to delete everything around it and not touch that function.
That’s the monorepo tradeoff in one story: you can find everything, change everything, delete everything, but you also carry the responsibility of not breaking what other teams depend on. Dead code is a codebase problem, not really a monorepo one. In a polyrepo company, you get entire repos that drift away and nobody even knows they’re still running. At least in a monorepo you can see it.
AWS’s Polyrepo
At AWS, nearly every service lives in its own repository. Thousands of repositories, each with its own CI/CD, deployment pipeline, and ownership. Ownership boundaries are clear: one team, one repo. Deployment cadences are independent, teams pick their own stack, and security isolation between services is strong. Small repositories are fast to clone and search.
The pain shows up in discoverability. Half the time I’m using internal code search to find out how someone else did something and copy their code. I know people have rewritten CDK components all over the place because there’s no standardization. The same infrastructure pattern gets implemented dozens of times by dozens of teams. Cross-cutting changes require coordination across dozens of repos. Shared libraries mean version management hell. Duplicate code accumulates across services. Integration testing requires complex test environments.
Neither approach is wrong. Meta optimizes for velocity and coordination. AWS optimizes for autonomy and isolation. I’ve come to think polyrepo is mostly an organizational choice, not an engineering one: it gives you the same thing as setting CODEOWNERS and clear team boundaries in a monorepo, except now you’re adding dependency injection and version management on top of it.
The Migration: 15 Repos to 1
At SID, we started with the “best practice” of one repo per service. By the time we had 15 services, the overhead was crushing us.
I got tired of building cross-dependencies and injecting versions and libraries into repos. I really dislike GitHub submodules, and the constant issue of being pinned on a version because someone didn’t update was eating my time. The trigger was adding organization-level permissions: it required coordinated changes across 9 repositories. Two weeks of work. One week coding. One week dependency management.
Our User model was defined in 8 different repositories. When we added a field to the users table, we had to update 8 different type definitions, hope they stayed in sync, and debug runtime errors when they didn’t. Security fixes were worse. I found a security bug in authentication middleware, its own repo: fix and release [email protected], open PRs in 14 consuming repos, wait for 14 code reviews, merge and deploy 14 services, hope no one is still on the old version. For a security fix that should have been 10 minutes.
I just called it. Time to migrate.
Saturday morning: structure planning (3 hours)
sid-monorepo/
├── services/ # Each old repo becomes a directory
│ ├── authentication/
│ ├── billing/
│ └── ... # 15 services
├── pkg/ # Shared Go packages (consolidated)
├── packages/ # Shared TypeScript (consolidated)
├── db/ # Database models (single source of truth)
└── tools/ # Deployment scripts, CI tools
Key decisions: services stay independently deployable, shared code moves to pkg/ and packages/ with no more versioning, database models get a single source of truth in db/.
Saturday afternoon: the migration (8 hours)
For each service, I cloned the old repo and moved contents into the monorepo structure:
git clone [email protected]:sid/authentication-service.git temp
mkdir -p services/authentication
mv temp/* services/authentication/
git add services/authentication
git commit -m "Migrate authentication service"
Did this for all 15 services. Took about 4 hours with breaks.
Then came the hard part: consolidating shared code. I had 8 copies of the User model (slightly different), 5 copies of auth middleware (different versions), 3 copies of the Stripe client (different features). For each, I compared all versions, picked the most complete one, added missing features from the others, and moved it to db/models/ or pkg/.
// Before: 8 different definitions across 8 repos
// After: One definition
// db/models/user.go
type User struct {
ID uuid.UUID
Email string
OrganizationID uuid.UUID // This field was missing in 3 services
CreatedAt time.Time
}
Sunday morning: CI/CD (6 hours)
Before: 15 separate GitHub Actions workflows, 3,200 lines of CI config total.
After: one workflow with change detection:
name: CI
on: [push, pull_request]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.filter.outputs.services }}
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
authentication:
- 'services/authentication/**'
- 'pkg/authentication/**'
- 'db/**'
billing:
- 'services/billing/**'
- 'pkg/stripe/**'
- 'db/**'
test:
needs: detect-changes
if: needs.detect-changes.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Test ${{ matrix.service }}
run: |
cd services/${{ matrix.service }}
go test ./...
Change services/billing? Test only billing. Change db/models? Test everything.
Sunday afternoon: deployment, and the origin of Pilum
This is where I hit a new problem. I had services that were Cloud Run containers, Cloud Run Jobs, Cloud Functions, and a frontend, all in the same repo. I didn’t have a way to deploy all of these different service types with one tool, and I didn’t want 5 different infrastructure tools.
At Plaid (also a monorepo), they had a ton of services and had built internal tooling around deployment that made it easy. I knew I wanted my services segmented and isolated, not a monolith, but I needed one deployment tool that understood different service types. So I started building Pilum, an open-source multi-cloud deployment orchestrator. That’s its origin story, born from this exact migration.
Sunday evening: the bugs (3 hours)
Deployed to staging. Found a circular dependency: Service A imported Service B, Service B imported Service A. Hidden by versioning in polyrepo, visible immediately in monorepo. I’d also missed some import paths during the migration, and had to reconcile conflicting database migrations from different services.
Fixed each, re-deployed, verified. The cutover itself was roughly 24 hours over a weekend; the full migration took about a week. I could have preserved git history if I’d been more careful about it. I skipped it for speed, and I’d probably do that differently next time.
We tracked metrics for 3 months before and 3 months after:
| Polyrepo | Monorepo | |
|---|---|---|
| Deploy all services | 45m | 8m |
| PRs for a cross-cutting change | 8 to 15 PRs | 1 PR |
| Feature spanning 3 services | 10 to 14 days | 2 to 3 days |
| Lines of CI/CD config | 3200 lines | 400 lines |
| Dependency management | 4 to 6 h/week | 0 h/week |
| Bugs from version skew | 2 to 3 a month | 0 a month |
Ranges compare at the top end. Deploys went from serial to parallel. Developer onboarding went from 2 days to 4 hours.
The Real Polyrepo Pain Points
I don’t think polyrepo fails as an approach. It introduces specific friction that most startups don’t need to pay for.
Discoverability is the big one. 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. At AWS, I spend real time just finding how someone else solved a problem. In Meta’s monorepo, I could find it in seconds.
Then there’s duplication. Without a shared codebase, teams reinvent the same solutions independently. I’ve seen the same CDK patterns rewritten across dozens of teams at AWS. At SID, we had 8 copies of the User model before the migration. In a monorepo, there’s one definition and everyone imports it.
Version pinning causes its own mess. When service-A depends on [email protected] and service-B depends on [email protected], and service-C needs both, you have a diamond dependency problem. In a monorepo, this can’t happen. Everyone is always on the same version.
Polyrepo gives you roughly the same thing as CODEOWNERS and clear team boundaries in a monorepo. The isolation is real, but it’s organizational isolation, not engineering isolation, and you pay for it with dependency injection, version management, and coordination overhead. For a 5-person startup, that cost is close to pure waste. For Amazon, with genuinely independent business units like AWS, Retail, Prime Video, and Alexa, the isolation maps to real organizational boundaries and the cost is justified.
When Each Approach Wins
Monorepo tends to win for teams of 1-50 engineers who need to coordinate constantly, where services share data models, auth, and infrastructure, and where you want to iterate and refactor quickly with one set of tooling and standards. Polyrepo makes more sense for genuinely independent business units with different customers and roadmaps, services with different security or compliance requirements (a HIPAA service next to a marketing site, say), open source components that need their own release cadences, or organizations with 200+ engineers in truly autonomous teams.
Google, Meta, Microsoft, Stripe, and Uber run monorepos. Amazon, Netflix, and Spotify run polyrepos. Both models work. The difference is organizational structure, not engineering capability.
A Few Objections
“Monorepos don’t scale” doesn’t hold up well. Google has 25,000+ engineers in a monorepo with billions of lines of code. They needed custom tooling (Bazel, CitC, Critique), but the approach scales, and most startups will never hit those limits anyway.
“A bug in shared code takes down everything” gets the blast radius backwards. In a monorepo, CI catches it before merge. In polyrepo, you catch it weeks later when services finally upgrade to the new version. Also, most production incidents aren’t from application code bugs anyway. They’re from configuration changes, database issues, and external dependency outages. The blast radius argument applies to maybe 10% of real incidents.
“Microservices need multiple repos” confuses two different things. Microservices are an architectural pattern. Repository structure is orthogonal. SID has 19 services in one repo. They deploy independently, scale independently, and have clear ownership. The monorepo is a development choice, not a deployment choice.
The Evolution Path
At 0-10 engineers, a monolith or 2-3 services in a monorepo is fine. Don’t overthink it. At 10-50 engineers, natural service boundaries emerge and you split along team lines; the monorepo keeps coordination costs low. At 50-200 engineers, domain-driven design starts to matter, services map to business domains, and you want strong CODEOWNERS conventions. Past 200 engineers you might consider polyrepo for genuinely independent business units, but remember Google has 25,000+ engineers in a monorepo, so don’t assume you’ve hit scale limits before you have.
The mistake I see most is treating the 200+ architecture as the starting point. Premature microservices are premature optimization.
If you’re considering a migration like this: start with 3-5 related services instead of all of them. Decide ahead of time where shared packages, database models, and tools live, and write it down in CONTRIBUTING.md. Put real time into CI change detection, since your pipeline needs to figure out which services changed and test only those plus their dependents. Use CODEOWNERS even on a small team, because explicit ownership beats the “everyone and no one owns this” default. And measure before and after: deploy time, PRs for cross-cutting changes, dependency management hours. Without numbers you can’t tell if the migration was worth it.
The Monorepo Structure That Works
Here’s what SID looks like today, 19 services, growing:
├── services/ # Independent microservices (19 total)
│ ├── 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
│ └── ...
├── 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 (30+)
│ ├── authentication/ # Auth utilities
│ ├── middleware/ # HTTP middleware
│ ├── stripe/ # Billing integration
│ └── ...
└── db/ # Database schemas, migrations
One commit can touch the API, the web app, and a backend service: single PR, single code review.
Meta showed me what was possible. AWS reminded me what the alternative felt like. The initial SID migration took about a week. After that we layered on a mountain of improvements, because we had the room to build the platform we wanted to launch many products from. The launch took longer, and so did later migrations, but standing up each new service was quick for the scope we had. If you want a gut check before you start one yourself, go count how many PRs last month touched multiple repos or needed coordinated releases. If it’s more than a handful, you’re already paying the coordination tax.
Further reading: