Why the Microservices-First Mindset Can Hurt You
Somewhere around 2015, the software industry collectively decided that microservices were the only modern way to build applications. Conferences buzzed with talks about API gateways, service meshes, and container orchestration. Teams of three developers suddenly had Kubernetes clusters with 40 services, a message broker, and a distributed tracing stack they barely understood.
The result? According to a 2023 survey by InfoQ, roughly 60% of teams that adopted microservices reported an increase in overall system complexity without a proportional gain in productivity. Many ended up spending more time debugging network issues, managing deployments, and dealing with data consistency than actually shipping features.
The truth is, microservices solve a very specific set of problems — independent deployability for large, autonomous teams and targeted scaling of isolated workloads. If those are not your top constraints, there is a better path: the modular monolith.
What Exactly Is a Modular Monolith?
A modular monolith is a single deployable application whose internal structure is divided into well-defined, loosely coupled modules. Each module owns its domain logic, its data, and exposes an explicit internal API to the rest of the application.
Think of it this way:
- Traditional monolith: One codebase, no boundaries, everything calls everything. Over time it becomes a tangled mess.
- Microservices: Many codebases, each independently deployed, communicating over the network. Massive operational overhead.
- Modular monolith: One codebase, one deployment, but strict internal boundaries that mimic the discipline of microservices without the distributed-system tax.
Shopify, one of the largest e-commerce platforms on the planet, famously chose this pattern. Their monolith serves over 80,000 requests per second at peak, and they have publicly shared how modular boundaries inside a single Rails application let them scale both the codebase and their engineering teams.
Core Principles
- Explicit module boundaries — No module reaches into another module’s internals. Communication happens through well-defined interfaces.
- Independent data ownership — Each module owns its database schema (or at least its tables). No cross-module joins.
- Encapsulated domain logic — Business rules live inside the module that owns the concept. No “God services” that know everything.
- In-process communication — Modules call each other via method calls or an in-process event bus, not HTTP or gRPC.
- Single deployment unit — You build and ship one artifact. One CI/CD pipeline. One rollback procedure.
Modular Monolith vs. Microservices: A Realistic Comparison
Let’s compare these architectures across the dimensions that matter most to real-world teams.
| Dimension | Traditional Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment complexity | Low | Low | High |
| Module boundaries | Weak or none | Strong (enforced) | Strong (network) |
| Data consistency | Easy (single DB) | Manageable (schema separation) | Hard (eventual consistency) |
| Operational overhead | Low | Low | Very high |
| Team scalability | Poor beyond ~10 devs | Good up to ~30–50 devs | Excellent for large orgs |
| Latency between modules | Nanoseconds | Nanoseconds | Milliseconds (network) |
| Independent scaling | Not possible | Limited | Full flexibility |
| Transition to microservices | Painful | Straightforward | N/A |
| Best for team size | 1–5 | 2–30 | 20+ with DevOps maturity |
The takeaway: for the vast majority of web projects — e-commerce stores, SaaS platforms, content management systems, internal tools — a modular monolith offers the best balance of simplicity and maintainability.
At Lueur Externe, where we have been architecting web solutions since 2003, this is a pattern we have seen validate itself time and again. Whether we are building a Prestashop-based e-commerce site or a custom WordPress platform, clean module boundaries inside a single codebase consistently deliver faster time-to-market and lower total cost of ownership for our clients.
How to Structure a Modular Monolith: A Practical Guide
Let’s get hands-on. We’ll use a hypothetical e-commerce application to illustrate the architecture, since this is a domain we know deeply from our Prestashop and custom development work.
Step 1: Identify Your Modules by Business Domain
Start with Domain-Driven Design (DDD) concepts. Identify your bounded contexts — distinct areas of your business that have their own language, rules, and data.
For an e-commerce platform, typical modules might be:
- Catalog — Products, categories, attributes, search indexing
- Inventory — Stock levels, warehouse locations, restocking rules
- Orders — Order creation, state machine, fulfillment tracking
- Payments — Payment processing, refunds, invoicing
- Customers — Accounts, addresses, preferences, authentication
- Shipping — Carrier integration, rate calculation, label generation
- Notifications — Email, SMS, push notifications
Each of these becomes a top-level module (or package/namespace) in your project.
Step 2: Enforce Module Boundaries in Code
Boundaries are only useful if they are enforced. Here is an example folder structure for a PHP (Symfony-based) or Node.js application:
src/
├── Modules/
│ ├── Catalog/
│ │ ├── Application/ # Use cases (services)
│ │ ├── Domain/ # Entities, value objects, interfaces
│ │ ├── Infrastructure/ # Repositories, external APIs
│ │ └── CatalogModule.php # Public API for this module
│ ├── Orders/
│ │ ├── Application/
│ │ ├── Domain/
│ │ ├── Infrastructure/
│ │ └── OrdersModule.php
│ ├── Payments/
│ │ ├── Application/
│ │ ├── Domain/
│ │ ├── Infrastructure/
│ │ └── PaymentsModule.php
│ └── Shared/
│ ├── EventBus/ # In-process event dispatcher
│ ├── ValueObjects/ # Money, Email, etc.
│ └── Kernel/ # App bootstrapping
├── config/
├── public/
└── tests/
├── Catalog/
├── Orders/
└── Payments/
The critical rule: the only way to interact with a module from the outside is through its public API — the CatalogModule.php, OrdersModule.php, etc. files. Everything else in the module is internal.
Step 3: Define Clear Internal APIs
Each module exposes a facade or a set of application services that represent its public contract. Here is a simplified example in PHP:
// src/Modules/Catalog/CatalogModule.php
namespace App\Modules\Catalog;
use App\Modules\Catalog\Application\GetProductById;
use App\Modules\Catalog\Application\SearchProducts;
use App\Modules\Catalog\Application\DTO\ProductDTO;
final class CatalogModule
{
public function __construct(
private GetProductById $getProductById,
private SearchProducts $searchProducts,
) {}
public function getProduct(string $productId): ProductDTO
{
return $this->getProductById->execute($productId);
}
public function search(string $query, int $page = 1): SearchResultDTO
{
return $this->searchProducts->execute($query, $page);
}
}
Notice:
- The return type is a DTO (Data Transfer Object), not a domain entity. This prevents leaking internal domain models to other modules.
- The Orders module would depend on
CatalogModule, never onCatalog\Domain\Productdirectly. - If you later extract Catalog into its own microservice, you only need to replace the
CatalogModuleimplementation with an HTTP client adapter. The rest of the application doesn’t change.
Step 4: Separate Data at the Schema Level
One of the most important rules: no cross-module database joins.
You do not need separate database servers. Simply use schema prefixing or separate schemas within the same database:
-- Catalog module tables
CREATE TABLE catalog_products (
id UUID PRIMARY KEY,
name VARCHAR(255),
price_cents INTEGER,
created_at TIMESTAMP
);
-- Orders module tables
CREATE TABLE orders_orders (
id UUID PRIMARY KEY,
customer_id UUID,
status VARCHAR(50),
total_cents INTEGER,
created_at TIMESTAMP
);
CREATE TABLE orders_line_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders_orders(id),
product_id UUID, -- References catalog, but NO foreign key constraint
quantity INTEGER,
unit_price_cents INTEGER
);
The orders_line_items.product_id stores a reference to a catalog product, but there is no foreign key constraint across module boundaries. If the Orders module needs product details, it calls CatalogModule->getProduct(). This discipline pays enormous dividends when the application grows.
Step 5: Use an Event Bus for Cross-Module Side Effects
When an order is placed, the Inventory module needs to decrement stock, the Notifications module needs to send a confirmation email, and the Payments module needs to capture the charge. Rather than having the Orders module call all of these directly, use an in-process event bus:
// In the Orders module, after order creation:
$this->eventBus->dispatch(new OrderPlacedEvent(
orderId: $order->id(),
customerId: $order->customerId(),
lineItems: $order->lineItemsAsArray(),
totalCents: $order->totalCents(),
));
// In the Inventory module, a listener:
class DecrementStockOnOrderPlaced
{
public function __invoke(OrderPlacedEvent $event): void
{
foreach ($event->lineItems as $item) {
$this->inventoryService->decrementStock(
$item['productId'],
$item['quantity']
);
}
}
}
The beauty of this approach is that the Orders module has zero knowledge of what happens after it dispatches the event. Adding a new reaction to an order (e.g., a loyalty points module) requires zero changes to the Orders code.
And if you ever move to microservices? You swap the in-process event bus for RabbitMQ, Kafka, or Amazon SNS/SQS. The event structure and listeners stay almost identical.
Enforcing Boundaries: Tools and Techniques
Discipline alone is not enough. Developers under deadline pressure will take shortcuts if the architecture doesn’t enforce itself. Here are concrete tools:
- PHP/Symfony: Use Deptrac to define allowed dependencies between layers and modules. Run it in CI.
- Java/Spring: Use ArchUnit to write architecture tests that fail if a module accesses another module’s internals.
- TypeScript/Node.js: Use ESLint
import/no-restricted-pathsrules or Nx workspace boundaries. - Any language: Write a simple CI script that greps for forbidden imports. Low-tech but effective.
At Lueur Externe, when we onboard development teams to this architecture, we always set up boundary enforcement in the CI pipeline on day one. It is far easier to maintain boundaries from the start than to retrofit them later.
Real-World Results: What Teams Actually Experience
Let’s talk numbers. Here are typical improvements we have observed when teams migrate from a tangled monolith to a modular monolith:
- Onboarding time for new developers: reduced by 40–60%. New team members can focus on one module without understanding the entire codebase.
- Deployment frequency: remains high (multiple times per day) because there is still only one artifact to deploy.
- Bug locality: over 80% of bugs are contained within a single module, making root-cause analysis faster.
- Test execution speed: module-level test suites run in seconds, not minutes. Full integration tests still run, but developers get fast feedback loops.
- Future extraction cost: extracting a module into a microservice takes weeks instead of months because boundaries, APIs, and data separation already exist.
Contrast this with the microservices approach, where a recent study by Humanitec found that the average team spends 30% of its time on infrastructure and platform concerns rather than feature development.
When You Should Not Use a Modular Monolith
Fairness demands we discuss the limitations:
- You need independent scaling per module. If your search functionality requires 20x the compute of your checkout flow, microservices (or at least a hybrid) make sense.
- You have 100+ developers across many autonomous teams. At this scale, organizational independence may outweigh architectural simplicity.
- You have strict fault-isolation requirements. In a monolith, a memory leak in one module can take down the entire application. Microservices provide process-level isolation.
- You are working with multiple technology stacks. If your ML pipeline is Python and your API is Go, they cannot live in the same monolith.
For the other 90% of projects? The modular monolith is the pragmatic choice.
The Migration Path: Monolith → Modular Monolith → Microservices (If Needed)
One of the greatest advantages of this architecture is that it positions you perfectly for a future transition — if that transition ever becomes necessary.
Here is the typical evolution:
- Phase 1 — Start modular. Build your application with clear module boundaries from day one. Ship fast, iterate quickly, keep operational costs low.
- Phase 2 — Identify bottlenecks. After months or years of production traffic, data will tell you which modules need independent scaling or isolation.
- Phase 3 — Extract surgically. Because your module already has a defined API and its own data, extracting it into a standalone service is a controlled, low-risk operation.
This is the opposite of the “big bang rewrite” that has destroyed countless projects. It is evolutionary architecture at its best.
Conclusion: Choose Boring Architecture That Works
The modular monolith is not glamorous. It will not get you speaking slots at conferences or impress people at cocktail parties. But it will do something far more valuable: it will let your team ship features quickly, maintain the codebase confidently, and sleep well at night knowing that a single deployment pipeline has your back.
The best architecture is the one that solves your actual problems with the least accidental complexity. For most web applications — whether you are building an e-commerce store on Prestashop, a content platform on WordPress, or a custom SaaS product — the modular monolith is that architecture.
At Lueur Externe, we have been designing and building production web applications for over 20 years. As certified Prestashop experts, AWS Solutions Architects, and SEO specialists, we bring both deep technical architecture knowledge and real business understanding to every project. If you are planning a new application or struggling with a monolith that has grown out of control, let’s talk. We will help you design an architecture that scales with your ambitions — without unnecessary complexity.