Why I Prefer Monorepo After Microservices Hell
Learn: Why I Prefer Monorepo After Microservices Hell
Welcome to TopperBlog! π
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
π― What I Write About:
β’ AI/ML Engineering & LLMs
β’ Web3 & Blockchain Development
β’ System Design & Architecture
β’ Interview Preparation (FAANG)
β’ Freelancing & Remote Work
β’ Modern Tech Stacks (Next.js, React, Rust, TypeScript)
β’ Performance Optimization & Best Practices
πΌ Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
π 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
π Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
Why I Prefer Monorepo After Microservices Hell
The 3 AM Wake-Up Call That Changed Everything
My phone screamed at 3:17 AM. Production was down. Again.
I fumbled through my laptop, VPN'd in, and stared at the dashboard. Twelve microservices. Eleven were green. One was red. But which one broke the checkout flow? The payment service looked fine. The inventory service was responding. The user service... wait, was it using the old authentication contract?
I spent 45 minutes just figuring out which service was the problem. Then another hour deploying fixes across three repositories because they all needed version bumps. By the time I crawled back to bed, I had a realization: we'd built a distributed monolith with extra steps.
This is my story of going from microservices chaos to monorepo clarityβand why I'm never going back.
The Seductive Promise of Microservices
Two years earlier, our team was riding high. We'd just finished reading all the right blog posts. Netflix does microservices. Amazon does microservices. Surely, our 8-person startup needed them too, right?
The pitch was intoxicating:
- Independent deployments (ship faster!)
- Technology diversity (use the best tool for each job!)
- Team autonomy (no more merge conflicts!)
- Scalability (we'll be the next unicorn!)
We split our modest Rails monolith into services: user-service, product-service, order-service, payment-service, notification-service, analytics-service. Each got its own repo, its own CI/CD pipeline, its own database.
We felt like architects. We were building the future.
Welcome to Hell
Month 3: "Why is the staging environment broken again?"
Someone updated the user service API. The order service wasn't updated. Staging had the new user service but the old order service. Production had the old user service but the new order service. Nobody knew what "working" even meant anymore.
Month 6: "We need to add a feature that touches three services."
What used to be a single PR now required:
- Update
user-service(PR #1) - Wait for CI (12 minutes)
- Deploy to staging
- Update
order-service(PR #2) - Wait for CI (15 minutes)
- Deploy to staging
- Update
notification-service(PR #3) - Wait for CI (10 minutes)
- Deploy to staging
- Test the integration
- Find a bug
- Go back to step 1
A two-day feature became a two-week ordeal.
Month 9: "Can someone explain our deployment process?"
We had a 47-step runbook. New developers took three weeks to understand the system. We spent more time managing infrastructure than building features.
The problems compounded:
- Version hell: Which version of
shared-typesdoes each service need? - Testing nightmares: Integration tests required spinning up 12 services
- Debugging black holes: Tracing a request across services meant grep-ing through 12 different log streams
- Deployment anxiety: Every deploy was a potential cascade failure
- Cognitive overload: Developers needed 8 terminal windows open just to run the app locally
The Monorepo Awakening
I was complaining to a friend at Google. She laughed. "We have 2 billion lines of code in one repo. Works fine."
"But... but... that's Google," I protested.
"Try it for a month," she said. "Worst case, you revert."
We didn't revert.
The Migration
We didn't do a big-bang rewrite. We moved incrementally:
Week 1: Consolidate Repositories
# Created new monorepo structure
monorepo/
βββ apps/
β βββ api/
β βββ web/
β βββ worker/
βββ packages/
β βββ database/
β βββ auth/
β βββ shared-types/
β βββ ui-components/
βββ tools/
β βββ scripts/
βββ package.json
We used Turborepo (though Nx and Lerna are great too). The key was maintaining our service boundaries as packages, not repositories.
Week 2: Unified Dependencies
{
"name": "monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"devDependencies": {
"turbo": "^1.10.0",
"typescript": "^5.0.0",
"jest": "^29.0.0"
}
}
One package.json to rule them all. No more version mismatches. No more "works on my machine" because of different dependency versions.
Week 3: Shared Tooling
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false
}
}
}
One command to rule them all:
# Build everything (with intelligent caching)
turbo run build
# Test everything that changed
turbo run test --filter=...[HEAD^]
# Run the entire app locally
turbo run dev
Week 4: Atomic Changes
This was the game-changer. Here's a real example:
// packages/shared-types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
// NEW: Adding a required field
phoneNumber: string;
}
In the microservices world: This change would require updating 4 repositories, coordinating 4 PRs, and praying nothing breaks in between.
In the monorepo world: One PR, one commit:
// packages/database/src/user-repository.ts
export class UserRepository {
async create(data: CreateUserInput): Promise<User> {
return this.db.user.create({
data: {
email: data.email,
name: data.name,
phoneNumber: data.phoneNumber, // TypeScript forces us to add this
},
});
}
}
// apps/api/src/routes/users.ts
app.post('/users', async (req, res) => {
const user = await userRepo.create({
email: req.body.email,
name: req.body.name,
phoneNumber: req.body.phoneNumber, // Compiler error until we add this
});
res.json(user);
});
// apps/web/src/components/UserForm.tsx
export function UserForm() {
return (
<form>
<input name="email" />
<input name="name" />
<input name="phoneNumber" /> {/* TypeScript won't compile without this */}
</form>
);
}
The TypeScript compiler became our integration test. If it compiles, the change is consistent across the entire system.
The Results
After six months with the monorepo:
Development velocity: 3x faster feature delivery
- Cross-cutting changes: 2 weeks β 2 days
- New developer onboarding: 3 weeks β 3 days
- Local development setup: 2 hours β 10 minutes
Operational simplicity:
- Deployment runbook: 47 steps β 3 steps
- CI/CD pipelines: 12 β 1
- Mean time to recovery: 45 minutes β 8 minutes
Code quality:
- Shared code reuse: 15% β 60%
- Test coverage: 45% β 78%
- Production incidents: 12/month β 2/month
Developer happiness: Immeasurable. No more 3 AM debugging sessions trying to figure out which service version is deployed where.
The Monorepo Mindset
Here's what I learned: Monorepo isn't about putting all your code in one place. It's about atomic changes and shared context.
Key Principles
1. Boundaries Still Matter
packages/
βββ user-domain/ # Business logic
βββ order-domain/ # Business logic
βββ payment-domain/ # Business logic
βββ shared-kernel/ # Truly shared code
We kept our domain boundaries. We just stopped pretending that repository boundaries enforced them. Code reviews and architecture guidelines do that job better.
2. Dependency Graph is King
# Visualize dependencies
turbo run build --graph
# Only build what changed
turbo run build --filter=...@user-domain
Turborepo's caching meant we only rebuilt what changed. A change to the payment domain didn't trigger rebuilds of the user domain.
3. Tooling Makes or Breaks You
Invest in:
- Fast CI: We use Turborepo's remote caching. CI runs take 3 minutes, not 30.
- Code ownership: GitHub's CODEOWNERS file. Teams still own their domains.
- Selective testing: Only run tests for affected code.
# .github/workflows/ci.yml
- name: Test
run: turbo run test --filter=...[HEAD^] --concurrency=4
When Monorepo Isn't the Answer
I'm not a zealot. Monorepos aren't always right:
Don't use a monorepo if:
- You have truly independent products with separate teams and release cycles
- You're building open-source libraries (separate repos make sense)
- Your teams are distributed across companies (different security boundaries)
- You have massive scale (Google-level) without Google-level tooling
Do use a monorepo if:
- You have one product with multiple services/apps
- Teams need to coordinate changes frequently
- You value consistency over autonomy
- You're tired of version hell
The Real Lesson
Microservices aren't bad. We were solving the wrong problem.
We thought our problem was: "Our monolith is too coupled."
Our real problem was: "We don't have good module boundaries or testing practices."
Microservices didn't fix our architecture. They just made our bad architecture distributed.
The monorepo forced us to confront our real issues:
- Unclear domain boundaries
- Tight coupling between business logic
- Poor testing practices
- Lack of architectural discipline
Once we fixed those in a monorepo, we didn't need microservices anymore.
Practical Takeaways
If you're in microservices hell:
Audit your service boundaries. Are they truly independent? Or do they change together?
Count the cost. How much time do you spend on infrastructure vs. features?
Try a monorepo experiment. Pick two services that always change together. Merge them. See if life gets better.
Invest in tooling. Turborepo, Nx, or Bazel. Don't try to build a monorepo with basic tools.
Keep your services. You can have a monorepo AND microservices at runtime. Deploy them separately if you need to. But develop them together.
The Code That Changed My Mind
Here's the moment I knew we'd made the right choice:
# Before (microservices): Add a feature touching 3 services
git clone user-service && cd user-service
# make changes, commit, PR, wait for CI, deploy
cd ..
git clone order-service && cd order-service
# make changes, commit, PR, wait for CI, deploy
cd ..
git clone notification-service && cd notification-service
# make changes, commit, PR, wait for CI, deploy
# Total time: 2 weeks
# After (monorepo): Same feature
git checkout -b feature/new-checkout-flow
# make changes across all three domains
git commit -m "Add new checkout flow"
git push
# CI runs all tests, everything passes
# Deploy
# Total time: 2 days
That's a 7x improvement. Not from working harder. From removing self-imposed obstacles.
Conclusion
I don't hate microservices. I hate unnecessary complexity.
For most teams, a well-structured monorepo with clear boundaries beats a poorly-structured microservices architecture every time. You get the development velocity of a monolith with the architectural clarity of services.
My phone still rings at 3 AM sometimes. But now when it does, I can find the problem in 5 minutes, fix it in one PR, and deploy with confidence.
That's worth more than any architectural purity.
The best architecture isn't the one that looks good on a whiteboard. It's the one that lets you ship features, sleep at night, and actually enjoy your job.
For me, that's a monorepo. Your mileage may vary. But if you're drowning in microservices complexity, maybe it's time to question whether you're solving the right problem.
Now if you'll excuse me, I'm going to enjoy my first full night's sleep in two years.