Microservices are the default architecture in a lot of engineering organizations, even ones that do not need them. The pitch is independent deployment, technology choice, and team autonomy. The reality is service meshes, distributed tracing, eventual consistency bugs, and a 30-engineer platform team just to keep the lights on.
The modular monolith is a middle ground: a single deployable application, but internally organized into modules with explicit boundaries, no shared database tables across modules, and discipline about what can call what. You get most of the architectural clarity microservices promise without the operational tax.
The Core Idea
A monolith is one repository, one deployable, one database. A modular monolith adds boundaries inside that monolith — module boundaries that you treat as if they were service boundaries.
app/
├── Modules/
│ ├── Billing/
│ │ ├── Public/ ← exposed to other modules
│ │ │ ├── BillingFacade.php
│ │ │ └── Events/
│ │ └── Internal/ ← off-limits to other modules
│ │ ├── InvoiceRepository.php
│ │ └── PaymentGateway.php
│ ├── Inventory/
│ └── Notifications/
└── Shared/
└── Domain/ ← common primitives
The rule is simple: code in Inventory may only talk to other modules through their Public namespace. Reaching into another module's Internal namespace is a violation, the same way reaching into another microservice's database would be.
Enforcing Module Boundaries
Discipline alone does not survive contact with deadlines. Encode the rules.
// deptrac.yaml
deptrac:
layers:
- name: Billing
collectors:
- { type: directoryRegex, regex: app/Modules/Billing/.* }
- name: Inventory
collectors:
- { type: directoryRegex, regex: app/Modules/Inventory/.* }
- name: BillingPublic
collectors:
- { type: directoryRegex, regex: app/Modules/Billing/Public/.* }
ruleset:
Inventory:
- BillingPublic ← allowed
Billing:
- InventoryPublic ← allowed
Deptrac (or ArchUnit-style tools in other languages) fails the CI build when one module imports another module's internals. The rules become enforceable, not aspirational.
Communication Between Modules
There are two patterns for module-to-module communication, and they map directly to what you would do across services.
Synchronous calls through a facade. When the caller needs an immediate result.
// In InventoryModule
$invoice = app(BillingFacade::class)->createInvoice(
customerId: $order->customer_id,
amount: $order->total,
);
Asynchronous events for everything else. When the caller does not need to wait.
// In OrdersModule
event(new OrderPlaced(orderId: $order->id, customerId: $order->customer_id));
// In NotificationsModule
class SendOrderConfirmation
{
public function handle(OrderPlaced $event): void { /* ... */ }
}
Same patterns you'd use with microservices, minus the network. When you extract a module to a service later, you replace the in-process facade with an HTTP client and the in-process event dispatcher with a message bus — the calling code rarely changes.
Database Boundaries
The hardest discipline in a modular monolith is the database. The temptation is to write a query that joins across module tables because "it's all the same database." Resist it.
Each module owns its tables. Other modules read through a public API, not the database directly.
// In Inventory module — don't do this
$invoiceCount = DB::table('billing_invoices')
->where('customer_id', $customer->id)
->count();
// Do this instead
$invoiceCount = app(BillingFacade::class)
->countInvoicesFor($customer->id);
This rule is the one that survives extraction to microservices. The day you split the modules into services, every cross-module join is a problem that has to be redesigned. If you have already designed those interactions as facade calls, the lift becomes mechanical.
Migrations Per Module
Keep migration files per module so the database schema mirrors the code structure.
app/Modules/Billing/Database/Migrations/
app/Modules/Inventory/Database/Migrations/
A custom migration discovery class registers each module's path. When you extract Billing to its own service, its migrations come with it.
When to Extract
A modular monolith is not a stepping stone — it is a destination. Most applications never need to extract a module to a service. Extraction is justified when one of these is true:
- A module has different scaling requirements than the rest. Notifications get hit 100x more than inventory.
- A module is owned by a team that needs independent deploy cadence.
- A module has compliance requirements (PCI, HIPAA) that justify isolating its data.
- A module needs a different runtime — Python for ML, Go for high-throughput message processing.
If none of these apply, the modular monolith is the better steady state. One repo, one deploy pipeline, one observability stack — and still clear architectural boundaries.
Common Anti-Patterns
- Cross-module database joins "just this once." The first one becomes a precedent. Reject in code review.
- Shared models. If
Useris used by every module, every module's tests depend on every other module's user changes. Each module should have its own representation of the user. - Circular module dependencies. A → B → A is a structural problem, not a design choice. Refactor.
- Modules that are too small. A module with three classes is a folder. Modules should represent real subdomains with real teams behind them.
Decision Framework
| Situation | Architecture |
|---|---|
| Small team, one product | Plain monolith |
| Growing team, single product | Modular monolith |
| Multiple teams, shared product, no independent scaling | Modular monolith |
| Independent scaling, independent deploys, separate compliance | Microservices |
The default for almost everyone should be modular monolith. You get the architectural benefits of microservices — clear boundaries, ownership, replaceability — without the operational overhead. When a real reason to extract emerges, you extract one module. You do not start with twelve services.
Deciding whether your application needs microservices or just better boundaries? We help teams pick architectures that match their actual team size and traffic, not the conference talk. scopeforged.com