Skip to content
Architecture

The Modular Monolith: Boundaries Without the Network Bill

Hard module boundaries, zero network calls: contracts and facades, tables owned by one module, in-process events, and the architecture tests that make the rules real — with Laravel examples.

5 min read Updated Sep 4, 2026
The Modular Monolith: Boundaries Without the Network Bill

There's a moment in the monolith-vs-microservices argument when both sides are right: the monolith crowd is right that networks make everything harder, and the microservices crowd is right that boundaries make everything better. The modular monolith is the architecture that takes both points seriously — hard boundaries, zero networks — and it's what I actually build when someone gives me a green field and a deadline.

Modular monolith: modules with public contracts and enforced boundaries inside one deployable

The core deal

One deployable. Inside it, modules that treat each other like external services: each owns its models, its tables, its business logic, and exposes exactly one public surface. The discipline of microservices, the ergonomics of a monolith — refactoring across the codebase still works, transactions still exist, deployment is still one artifact, but the Billing team can rewrite their internals without Catalog noticing.

Here's the shape in a Laravel app (the .NET version is the same idea with projects instead of namespaces — one solution, one deployable, internal visibility doing the enforcement):

app/Modules/
├── Billing/
│   ├── Contracts/
│   │   ├── BillingFacade.php      # THE public API of the module
│   │   └── Events/InvoicePaid.php # public events others may listen to
│   ├── Models/                    # internal — nobody else touches these
│   ├── Services/
│   ├── Http/                      # module's own controllers & routes
│   └── Database/Migrations/
├── Catalog/
└── Identity/

The three rules that make it real

Rule 1: Cross-module calls go through the contract

// ✅ Catalog asking Billing something, the sanctioned way
final class PublishProductAction
{
    public function __construct(private BillingFacade $billing) {}

    public function execute(Product $product): void
    {
        if (! $this->billing->merchantHasActiveSubscription($product->merchant_id)) {
            throw new SubscriptionRequired();
        }
        // ...
    }
}

// ❌ The path to mud: Catalog importing Billing internals
use App\Modules\Billing\Models\Invoice;   // architecture test should kill this

And the enforcement — because a rule without CI is a suggestion:

// Pest architecture tests: cheap, brutal, effective
arch('modules only touch each other\'s contracts')
    ->expect('App\Modules\Catalog')
    ->not->toUse(['App\Modules\Billing\Models', 'App\Modules\Billing\Services']);

arch('contracts stay dependency-free')
    ->expect('App\Modules\Billing\Contracts')
    ->toOnlyUse(['App\Modules\Billing\Contracts', 'Illuminate\Contracts']);

Rule 2: Tables belong to one module

Billing's tables are Billing's. Catalog never joins against invoices — if it needs billing data, it asks the facade or listens to an event. Yes, this occasionally means an extra query where a JOIN would've been cute. That tiny tax is what keeps the future extraction option open: a module that shares no tables can become a service in a sprint; a module tangled into cross-module JOINs, never. (Pragmatic exception: read-only reporting can live in its own module that's explicitly allowed to query everything. Name the exception; don't let it happen by accident.)

Rule 3: "This happened" travels as events

// Billing announces; it doesn't orchestrate other modules
final class InvoicePaid
{
    public function __construct(
        public readonly string $invoiceId,
        public readonly string $merchantId,
        public readonly int $amountMinor,
    ) {}
}

// Notifications module reacts — Billing has no idea it exists
final class SendPaymentReceiptListener
{
    public function handle(InvoicePaid $event): void { /* ... */ }
}

In-process events today; if a module ever graduates to a service, the same events go over a broker and the shape of the system barely changes. That's the entire migration story of event-driven architecture, rehearsed for free.

Where teams stumble

  • Module boundaries drawn by layer, not domain. Modules/Repositories is not a module. Modules are business capabilities — Billing, Catalog, Identity — each vertically complete with its own controllers, services and models. Horizontal layers are how you get one giant module wearing a trench coat.
  • The "Shared" module that eats the world. A tiny Shared kernel (value objects like Money, base exceptions) is fine. The moment business logic lands in Shared "because two modules need it", stop — that logic has an owner, find it, and let the other module go through the contract.
  • Facades that are just bags of getters. If BillingFacade exposes getInvoice(), getInvoiceLines(), getInvoiceTax(), callers are reassembling Billing's internals outside Billing. Expose intentionschargeMerchant(), merchantHasActiveSubscription() — not data plumbing.
  • Skipping enforcement. Every team believes they'll respect boundaries voluntarily. Every codebase I've audited says otherwise. The arch tests take twenty minutes to write; write them the same day you create the second module.

Why this is my default recommendation

Because it's the only architecture that keeps every door open. Stays small forever? You've lost nothing — it's just a tidy monolith. Grows into real microservice territory? The seams are already cut, proven by years of production, and extraction is a mechanical exercise instead of an archaeology project. It converts the biggest, scariest architecture decision of a young product from a bet into a deferral — and deferring decisions until you have information is most of what good architecture is.

Want the boundaries drawn into your existing codebase — or a greenfield started this way? That's a project brief I'd enjoy reading.

Keep reading

Related articles

Architecture 4 min read

Clean Architecture Meets a Real Deadline

The one load-bearing idea under all the concentric circles, the interface-with-one-implementation tax, and the three-folder dose that keeps domains pure without nine-file one-line changes.