Every new project eventually hits the same argument. One developer wants microservices "so it scales". Another says a single Laravel or Next.js app is all you need. Someone else has read about the modular monolith and wants to try that. The debate often turns into opinions and conference talks, when the real answer depends on your team, your domain, and how much operational work you can actually handle.
This guide compares the three options in practical terms, shows what each looks like in code, and gives you a simple way to decide.
The Three Options in Plain Terms
Monolith
A monolith is one application, deployed as one unit, usually talking to one database. A typical Laravel app with controllers, models, jobs and Blade or Inertia views is a monolith. So is a Next.js app with API routes and a single PostgreSQL database.
The problem is not the word "monolith". The problem is the unstructured monolith, where any file can call any other file, every model knows about every table, and a change to invoices breaks the inventory screen.
Modular Monolith
A modular monolith is still one deployable application, but the code is split into modules with clear boundaries: Sales, Inventory, Billing, HR. Each module owns its data and exposes a small public interface. Other modules use that interface instead of reaching into its internals or its tables.
You get most of the organisational benefits of services (clear ownership, smaller areas to reason about) without network calls between them.
Microservices
Microservices split the system into separately deployed services, each with its own database, communicating over HTTP, gRPC, or a message broker. Each service can be scaled, deployed and even written in a different language independently.
That independence is real, but so is the cost: distributed transactions, network failures, versioned contracts between services, separate CI/CD pipelines, centralised logging and tracing, and a team that knows how to run all of it.

Side-by-Side Comparison
ConcernMonolithModular MonolithMicroservices DeploymentOne unitOne unitMany independent units CommunicationFunction callsFunction calls through module interfacesNetwork calls and events DataShared databaseOne database, tables owned per moduleDatabase per service TransactionsSimpleSimpleSagas, outbox, eventual consistency Operational effortLowLowHigh Boundary disciplineOften weakEnforced by convention and toolingEnforced by the network Independent scalingNoLimitedYes
What a Modular Monolith Looks Like in Practice
In a Laravel project, a common layout is one folder per module, each with its own models, services, routes and migrations:
app/
Modules/
Sales/
Models/Order.php
Services/OrderService.php
Contracts/SalesApi.php // the only thing other modules use
Events/OrderPlaced.php
Inventory/
Models/StockItem.php
Services/StockService.php
Contracts/InventoryApi.php
Listeners/ReserveStockForOrder.php
Billing/
...The key rule: Sales never queries the stock_items table directly. It calls the Inventory module's public contract, or it raises an event that Inventory listens to.
// Modules/Inventory/Contracts/InventoryApi.php
interface InventoryApi
{
public function availableQuantity(string $sku): int;
public function reserve(string $sku, int $qty, string $orderId): void;
}
// Modules/Sales/Services/OrderService.php
class OrderService
{
public function __construct(private InventoryApi $inventory) {}
public function place(array $lines): Order
{
return DB::transaction(function () use ($lines) {
$order = Order::create([...]);
foreach ($lines as $line) {
$this->inventory->reserve($line['sku'], $line['qty'], $order->id);
}
event(new OrderPlaced($order->id));
return $order;
});
}
}Notice what you still get for free: a single database transaction. If reserving stock fails, the order is rolled back. In microservices, the same flow needs a saga or an outbox pattern, compensating actions, and careful handling of partial failure.
In a Next.js or Node codebase the same idea applies. Put each domain in its own folder, export a small public API from an index.ts, and use a lint rule (for example ESLint's no-restricted-imports) to stop other modules importing internal files.
When a Simple Monolith Is the Right Choice
You are building an MVP or validating a product idea.
The team is small, roughly one or two squads working in one codebase.
The domain is still changing, so you don't yet know where the real boundaries are.
You want the fastest path from idea to production with the least infrastructure.
Even here, a little structure goes a long way. Grouping code by feature instead of by technical layer makes a later move to modules much easier.
When a Modular Monolith Is the Right Choice
The application is growing into several distinct areas, such as an ERP with sales, purchasing, inventory, accounting and HR.
Multiple developers or teams work on different areas and keep stepping on each other.
You need strong consistency across areas (an invoice and a stock movement must both happen or neither does).
You want the option of extracting a service later without committing to it now.
For most business applications, including ERP-style systems, this is the sweet spot. It gives you clear ownership and maintainable boundaries while keeping deployment, debugging and transactions simple.
When Microservices Are Worth the Cost
Several teams need to deploy independently, and coordinating releases in one codebase has become a real bottleneck.
One part of the system has very different scaling or reliability needs, for example a high-volume search or notification service.
A component needs a different technology stack for a good reason, such as a Python service for machine learning.
You already have the platform maturity: automated deployments, centralised logs, distributed tracing, health checks and on-call practices.
If most of these aren't true, microservices usually slow a team down. A common failure mode is the "distributed monolith": services that must all be deployed together because they share a database or call each other synchronously for every request. You pay the cost of microservices and keep the coupling of a monolith.
A Simple Decision Checklist
Ask these questions in order:
Do we know the domain boundaries yet? If not, start with a monolith and organise by feature.
Are different areas of the code owned by different people and changing for different reasons? If yes, move to a modular monolith.
Is one module blocked by the release cycle or scaling needs of the rest? If yes, consider extracting that one module into a service.
Can we operate distributed systems well today? If not, fix that first, or keep the module inside the monolith.
Migrating Without a Big Rewrite
You rarely need to choose once and forever. A healthy path for many teams looks like this:
Group existing code by business area instead of by controllers, models and services.
Define a public interface for each module and route cross-module calls through it.
Stop modules from joining across each other's tables; replace those joins with interface calls or read models.
Replace direct side effects with domain events where it reduces coupling.
Only when a module has a clear reason to be independent, extract it, keeping the same interface so callers barely change.
This is often called the strangler approach: the new structure grows around the old one until the old one can be removed, with production running the whole time.
Conclusion
Microservices are a deployment and team-scaling strategy, not a code-quality strategy. Clean boundaries come from modular design, and you can have that inside a single application. For most teams building web apps and ERP-style systems with Laravel, Next.js and PostgreSQL, a well-structured modular monolith is the best default. Start simple, enforce boundaries early, and extract services only when a specific module gives you a concrete reason to.
