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.
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.
