0x55aa
← Back to Blog

Microservices vs Monolith: The Truth Nobody Tells You πŸ—οΈπŸ’₯

β€’11 min read

Microservices vs Monolith: The Truth Nobody Tells You πŸ—οΈπŸ’₯

Hot take incoming: I spent 6 months splitting a perfectly functional Laravel monolith into 11 microservices. The result? Our deployment time went from 4 minutes to 47 minutes. Our on-call incidents tripled. Two engineers quit. And our p99 latency got WORSE.

But every tech blog told me microservices were the future. Netflix does it! Amazon does it! Surely a team of 4 engineers for an e-commerce startup should too, right?

Narrator: They should not have.

Welcome to the post I wish existed before I made the most expensive architectural mistake of my career.

The Architecture That Ate My Team 🐊

When I joined as Technical Lead, the codebase was a monolith. 80,000 lines of Laravel. Beautifully structured, fast to deploy, easy to debug. My first thought:

"This is technical debt. We need microservices."

The Dream Architecture I Pitched to My CTO:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User Svc  β”‚    β”‚  Product Svcβ”‚    β”‚  Order Svc   β”‚
β”‚  Port 3001 β”‚    β”‚  Port 3002  β”‚    β”‚  Port 3003   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                 β”‚                  β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Payment Svcβ”‚    β”‚Inventory Svcβ”‚    β”‚  Email Svc   β”‚
β”‚  Port 3004 β”‚    β”‚  Port 3005  β”‚    β”‚  Port 3006   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                 β”‚                  β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Search Svc β”‚    β”‚  Cart Svc   β”‚
β”‚  Port 3007 β”‚    β”‚  Port 3008  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Me: "Clean! Independent! Scalable!"
Reality: "Good luck debugging a checkout failure across 6 services πŸ’€"

Six months later, here's what actually happened.

The Monolith Reality Check πŸ“Š

Let me show you what a feature used to take vs what it took after the split:

Before (Monolith): Add a discount code to checkout

1. Write the code
2. Run tests
3. Deploy
4. Done βœ…

Total time: 2 hours dev, 4 min deploy

After (Microservices): Add a discount code to checkout

1. Decide which service owns discounts (debate: Order Svc? Cart Svc? New Discount Svc?)
2. Update Order Svc API contract
3. Update Cart Svc to call Order Svc
4. Update API Gateway routing
5. Write integration tests for EACH service
6. Deploy Order Svc
7. Wait for Order Svc health checks to pass
8. Deploy Cart Svc
9. Wait for Cart Svc health checks to pass
10. Update API Gateway config
11. Deploy API Gateway
12. Test the entire flow end-to-end
13. Discover Order Svc has a race condition with Inventory Svc
14. Fix it
15. Redeploy everything
16. Call it "done" while silently crying

Total time: 2 days dev, 47 min deploy, 3 Slack arguments

A scalability lesson that cost us: Microservices make simple things complex. They're only worth it when the complexity pays off - and for most teams, it doesn't until you're much, much bigger.

What ACTUALLY Makes a Monolith Bad πŸ€”

Here's the nuance the hype cycle skips: most "monolith problems" are bad code problems.

// Bad monolith: Everything coupled together
class CheckoutController
{
    public function checkout(Request $request)
    {
        // Directly calling payment API, sending emails,
        // updating inventory, logging analytics...
        // All in one 500-line method πŸ’€
        $stripe = new \Stripe\Stripe();
        $stripe->apiKey = config('services.stripe.secret');
        $charge = \Stripe\Charge::create([...]);

        Mail::to($user)->send(new OrderConfirmation($order));

        DB::table('inventory')->decrement('stock', ...);

        // etc., etc., kill me
    }
}
// Good monolith: Modular, with clear boundaries
class CheckoutController
{
    public function __construct(
        private OrderService $orderService,
        private PaymentService $paymentService,
    ) {}

    public function checkout(Request $request)
    {
        $order = $this->orderService->create($request->validated());
        $payment = $this->paymentService->charge($order);

        OrderPlaced::dispatch($order); // Listeners handle email, inventory, etc.

        return response()->json(['order_id' => $order->id]);
    }
}

Same monolith. Completely different maintainability. The architecture isn't the problem - the discipline is.

The Real Trade-offs (Honest Edition) βš–οΈ

Let me give you the actual comparison I wish I had:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     Factor      β”‚     Monolith         β”‚    Microservices     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Deploy speed    β”‚ βœ… Fast (4-10 min)   β”‚ ❌ Slow (30-60 min)  β”‚
β”‚ Local dev setup β”‚ βœ… Easy (1 command)  β”‚ ❌ Hard (docker-      β”‚
β”‚                 β”‚                      β”‚    compose hell πŸ”₯)  β”‚
β”‚ Debugging       β”‚ βœ… Single log file   β”‚ ❌ Distributed traces β”‚
β”‚ Team onboarding β”‚ βœ… 1 repo to clone   β”‚ ❌ 11 repos to clone  β”‚
β”‚ DB transactions β”‚ βœ… Simple ACID        β”‚ ❌ Sagas, 2PC, pain  β”‚
β”‚ Network latency β”‚ βœ… None (in-process) β”‚ ❌ 5-50ms per hop    β”‚
β”‚ Independent     β”‚ ❌ Deploy everything β”‚ βœ… Deploy one service β”‚
β”‚   deployment    β”‚                      β”‚                       β”‚
β”‚ Scale specific  β”‚ ❌ Scale everything  β”‚ βœ… Scale hot services β”‚
β”‚   components    β”‚                      β”‚                       β”‚
β”‚ Tech diversity  β”‚ ❌ Same stack        β”‚ βœ… Right tool for job β”‚
β”‚ Fault isolation β”‚ ❌ Bug affects all   β”‚ βœ… Isolated failures  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The honest answer: Most of those microservice βœ… advantages only matter when:

  • You have different scaling requirements per service
  • You have multiple teams that need to deploy independently
  • Your services genuinely need different tech stacks
  • You're at a scale where one database can't handle the load

Under those conditions? Microservices are gold. Before those conditions? They're a distributed systems tax you can't afford.

When I ACTUALLY Need Microservices 🎯

After years of designing both, here's my actual threshold:

Stay with a monolith if:

  • Team size < 20 engineers
  • < 1M requests/day
  • One team owns the whole product
  • Startup phase (you'll pivot, trust me)
  • Deploy < 5x per day

Consider microservices if:

The Microservices Readiness Checklist:

[ ] You have 10+ engineers on separate teams with different deploy cadences
[ ] One specific component needs 10x more resources than everything else
    (e.g. image processing, video encoding, ML inference)
[ ] Different components need completely different tech
    (Java for batch processing, Node.js for real-time features)
[ ] You've actually hit scaling limits on the monolith
[ ] You have DevOps maturity: Kubernetes, service mesh, distributed tracing
[ ] You can afford the operational complexity

If you checked < 3 boxes: Stay on the monolith!

When designing our e-commerce backend, the scale that actually justified splitting off a service? When our image resizing was consuming 70% of our EC2 CPU during peak hours. That one service got split out. Everything else stayed together.

The "Majestic Monolith" Approach I Wish I'd Used πŸ‘‘

The best architecture I've seen? A well-structured monolith with modular internals - sometimes called "modular monolith" or the "majestic monolith".

The Modular Monolith (Have Your Cake and Eat It Too):

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Laravel Application             β”‚
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  Orders  β”‚  β”‚ Products β”‚  β”‚  Payments β”‚ β”‚
β”‚  β”‚  Module  β”‚  β”‚  Module  β”‚  β”‚  Module   β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚        β”‚              β”‚             β”‚        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚        Shared Domain Events         β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                              β”‚
β”‚  Single deployment. Single database.         β”‚
β”‚  But modules only talk through events!       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
// app/Modules/Orders/Events/OrderPlaced.php
class OrderPlaced
{
    public function __construct(
        public readonly Order $order
    ) {}
}

// app/Modules/Orders/Services/OrderService.php
class OrderService
{
    public function create(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create($data);

            // Communicate via events, not direct calls
            event(new OrderPlaced($order));

            return $order;
        });
    }
}

// app/Modules/Inventory/Listeners/DecrementStock.php
class DecrementStock
{
    public function handle(OrderPlaced $event): void
    {
        foreach ($event->order->items as $item) {
            Product::where('id', $item->product_id)
                   ->decrement('stock', $item->quantity);
        }
    }
}

// app/Modules/Notifications/Listeners/SendOrderReceipt.php
class SendOrderReceipt
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user)->queue(new OrderConfirmation($event->order));
    }
}

What this gives you:

  • βœ… Modules are independent conceptually (easy to split later if needed)
  • βœ… Events decouple modules (Inventory doesn't import from Orders)
  • βœ… Single deploy, single database (simple operations)
  • βœ… Easy testing (no network, just unit/integration tests)
  • βœ… Can split into a real microservice later if you actually need to

As a Technical Lead, I've learned: Design for the team you have now, not the unicorn scale-up you imagine you'll be in 3 years.

The Migration Path (If You Must) πŸ”„

If you're inheriting a big ball of mud monolith AND you need to scale, here's the Strangler Fig pattern:

Phase 1: Don't touch the monolith! Strangle it from the outside.

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
New Traffic ─────►│   API Gateway    β”‚
                  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                       β”‚
              β–Ό                       β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  Image Service β”‚    β”‚  Legacy Monolith β”‚
     β”‚  (new, fast)   β”‚    β”‚  (everything     β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚   else)          β”‚
                           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 1: Extract the ONE thing causing pain (image processing, in our case)
Step 2: Route ONLY that traffic to the new service
Step 3: Everything else stays on monolith
Step 4: Breathe. Monitor. Don't immediately do step 5.

The pattern:

  1. Identify the ONE bottleneck causing real pain
  2. Extract ONLY that service
  3. Route traffic to new service via API Gateway
  4. Keep everything else in monolith
  5. Never do #4 until the previous extraction is battle-tested

What I actually did wrong: I extracted ALL 11 services at once. Six months of pain for marginal benefit. The right move was 1-2 targeted extractions.

Common Mistakes (The Hall of Shame) πŸͺ€

Mistake #1: Microservices per database table

// The "one service per CRUD table" anti-pattern
UserService    β†’ users table
ProductService β†’ products table
OrderService   β†’ orders table

// Now a checkout needs 3 network hops to complete a join
// that used to be ONE SQL query 🀦

Mistake #2: Shared database with microservices

// You thought you'd get the best of both worlds
UserService ──────────┐
ProductService ───────────► Same PostgreSQL DB
OrderService β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

// Now you have ALL the cons of microservices
// AND the tight coupling of a monolith
// Congratulations on achieving the worst of both worlds! πŸ†

Mistake #3: Microservices to fix bad code

Before:
BadController (1000 lines, doing 10 things) ← THE REAL PROBLEM

After (wrong fix):
BadController split across 5 services ← Still bad code, now distributed!

Right fix:
GoodController + proper services/repos/events ← Actually fix the architecture

The Verdict 🏁

After building both from scratch and migrating between them, here's my honest take:

For a startup or small team: Start with a well-structured monolith. Use modules, events, and clear boundaries internally. You'll ship faster, debug faster, and sleep better.

For a scaling product with team pain points: Extract the specific bottleneck causing measurable problems. Not everything - just the one thing.

For a large org with multiple teams: Microservices make sense because the team independence benefit finally outweighs the operational cost.

The Simple Framework:

Pain Level 0-3: Modular monolith βœ…
Pain Level 4-6: Monolith + extract 1-2 hot services βœ…
Pain Level 7-10: Full microservices (you have DevOps maturity, right?) βœ…

Pain Level: "Someone on the internet said microservices are better": ❌

When designing our e-commerce backend, I eventually migrated back toward a modular monolith with 2 extracted services. Deployment time: back to 6 minutes. On-call incidents: down 60%. Team happiness: up immensely.

The best architecture is the one your team can actually ship with confidence - not the one that looks best in a conference talk.

TL;DR ⚑

  • Monolith != bad code. Bad code is bad code.
  • Microservices solve org problems, not code problems.
  • Start with a modular monolith. Extract services when you have a measurable, specific bottleneck.
  • Distributed systems are HARD. Network latency, distributed transactions, observability, deployment complexity - make sure the trade-off is worth it.
  • The Strangler Fig pattern is your best friend if you're migrating.
  • Your team size determines your ideal architecture more than your traffic does.

Don't let hype cycles make your architectural decisions. Let production metrics do that job instead.


Have a microservices horror story (or a monolith win)? Connect with me on LinkedIn - I collect these like trophies.

Want to see how I structure modular Laravel monoliths? Check out my GitHub for real production patterns.

Now go forth and pick the boring, maintainable option! πŸ—οΈβœ¨


P.S. If your startup of 3 engineers is building microservices because "it scales better" - please close the architecture diagram and ship the feature. You can always extract services later. You can't get back 6 months of over-engineering.

P.P.S. The most scalable system is one that's actually running in production and generating revenue. Keep that in mind before you spend a quarter on infrastructure. πŸ’Έ