Samen Steeve
MY SERVICES.
Back to blog
SoftwareArchitecture
April 10, 20266 min read

Why I Refused to Split This Laravel Monolith — The ADR

In the lifecycle of a high-growth platform, there comes a point where management and engineering teams suggest splitting the “stale monolith” into microservices to solve scaling issues. As an architect, I recently vetoed this transition for a major e-commerce platform. Here is the ADR (Architecture Decision Record) and the engineering reasoning behind this choice.

📄 ADR-024: Modular Monolith vs Microservices
Status: ACCEPTED (Modular Monolith confirmed)
Decider: Tech Lead / Architect
Date: April 2026
Context: The engineering team is facing performance bottlenecks on the inventory API and is requesting a split into 4 independent microservices.

The Myth of the Silver-Bullet Microservice

Microservices solve organizational human problems (e.g. when you have 150 developers across 15 Feature Teams), not pure performance issues. Splitting a codebase too early introduces massive distributed system overhead:

  • Distributed Transactions: Ensuring data consistency between Order and Billing services without the extreme complexity of the Saga pattern (compensation events, asynchronous processing) becomes a nightmare.
  • Network Latency: A simple user action turns into a cascade of internal HTTP (or gRPC) calls, degrading the overall response time.
  • Operational Overhead: Maintaining 4 CI/CD pipelines, a Kubernetes cluster, a service mesh, distributed tracing, and centralized secrets management multiplies operational complexity tenfold.

The Alternative: The Modular Monolith

Rather than breaking up our infrastructure, we restructured the code at the application level to cleanly decouple business domains. In PHP 8+, we enforced strict domain boundaries inside Laravel.

Here is our strict modular directory structure enforced via PHP namespaces:

// Modular folder structure
app/
├── Domain/
│   ├── Order/
│   │   ├── Contracts/
│   │   ├── Models/
│   │   └── Services/
│   └── Billing/
│       ├── Contracts/
│       ├── Services/
│       └── Listeners/
└── Providers/

To allow decoupled domain communication, we leverage Laravel's internal event dispatcher instead of remote network requests. This acts as an efficient In-Memory Event Bus:

namespace App\Domain\Order\Services;

use App\Domain\Order\Events\OrderPlaced;
use Illuminate\Support\Facades\DB;

class CreateOrderService {
    public function execute(array $data) {
        return DB::transaction(function () use ($data) {
            $order = Order::create($data);
            
            // Dispatch in-memory event decoupled from execution
            event(new OrderPlaced($order));
            
            return $order;
        });
    }
}

How We Solved the Performance Issues

Instead of splitting the app, we targeted the actual database and code execution bottlenecks:

  • SQL Optimizations: Deployed composite indexes on lookup tables and optimized raw queries using joins or materialized views.
  • Asynchronous Processing: Utilized Redis-backed queues for heavy background tasks (PDF generation, notifications, third-party API sync).
  • Strategic Caching: Implemented two-tier caching (Redis + local request memory) for inventory tables that experience high read-to-write ratios.

Final Decision and Outcome

The modular monolith allowed us to maintain a single repository, run robust ACID database transactions, and deploy in one simple step. In production, our average API latency dropped below 80ms while maintaining development velocity, proving that a clean application architecture is always better than complex, premature infrastructure distribution.

A critical technical project?

Looking for an experienced engineer to design your architecture, audit your code, or integrate AI?

Start a project