The modular monolith: structure without the network
A modular monolith is a single deployable application divided into modules with explicit boundaries: each module owns its data, exposes a small public interface, and doesn’t reach into other modules’ internals. You get most of the design benefits of microservices – clear ownership, independent reasoning, replaceable parts – while keeping one codebase, one deploy and real database transactions.
It’s the architecture I recommend for most SaaS teams, and the step I take first when a team asks about microservices vs monolith. The hard part isn’t the idea; it’s enforcing the boundaries so they survive the next hundred pull requests. That’s what most of this guide is about.
What makes a monolith modular
Folders aren’t modules. A codebase with app/billing and app/accounts directories where billing code queries the accounts tables directly is a monolith with folders. A module needs four properties:
- A public interface. A small set of functions, service objects or commands other modules may call. Everything else is private.
- Owned data. The module’s tables are read and written only by the module. Other modules ask through the interface or receive events. No cross-module joins.
- Declared dependencies. Each module states which other modules it depends on, and the graph has no cycles.
- Enforcement in CI. A violation fails the build. Without this, boundaries erode within months, because the shortcut is always one import away.
Finding the right module boundaries
Boundaries should follow business capabilities, not technical layers. “Billing”, “Accounts and identity”, “Projects”, “Notifications” and “Reporting” are modules. “Models”, “Services” and “Controllers” are layers that every module has.
A useful test: if a product change in one area routinely requires edits in another, the boundary is in the wrong place or the interface is too thin. Look at your git history for files that always change together; they belong in the same module. Domain-driven design calls these bounded contexts, and the vocabulary helps, but you don’t need the full method to get the benefit.
Start with fewer, larger modules. Five to ten for a typical SaaS product is plenty. Splitting a module later is cheap; merging two that have grown separate data models is not.
Enforcing boundaries in Rails
- Packwerk (from Shopify, built while modularizing their Rails monolith) lets you group code into packages, each with a package.yml declaring its dependencies, and checks constant references against them. Run packwerk check in CI.
- Namespaces and Zeitwerk: put each module under its own namespace (Billing::, Accounts::) so ownership is visible in every reference.
- Rails engines give stronger isolation – separate routes, migrations and models – at the cost of more ceremony. Worth it for modules with a clear edge, like an admin area or a public API.
- Data ownership: prefix or schema-separate each module’s tables, and avoid Active Record associations that cross modules. A belongs_to from Projects into Billing is a boundary violation dressed as convenience.
Enforcing boundaries in Django
- Django apps are the natural unit. Each module is an app with its own models and migrations.
- import-linter enforces architecture rules on Python imports: independence contracts (these apps must not import each other), layers contracts (higher layers may import lower, not the reverse) and forbidden contracts. Run lint-imports in CI.
- A public api.py or services.py per app as the only import target for other apps. Make it the convention that importing from another app’s models module is a violation.
- Cross-app foreign keys are where Django boundaries usually leak. Prefer storing an ID and resolving it through the owning app’s interface for anything outside the module’s core.
- Be careful with signals. Django signals look like loose coupling, but they run synchronously in the same transaction and make control flow hard to follow. Explicit domain events or a simple in-process event bus is clearer.
Enforcing boundaries in Laravel
- Module structure: a Modules or src/Domain directory with one namespace per module, each with its own service provider, routes and migrations. Packages like nwidart/laravel-modules scaffold this if you want a convention.
- Deptrac defines layers or modules by namespace and the allowed dependencies between them, and fails the build on violations. It works well alongside PHPStan.
- Eloquent relationships across modules are the main leak, as with Active Record. Keep them inside a module.
- Laravel events and queued listeners are a good fit for cross-module communication, as long as listeners are idempotent.
In other ecosystems, Spring Modulith and ArchUnit do the same job for Java, and the pattern carries over. The tool matters less than having one in CI.
How modules talk to each other
Two mechanisms, and you’ll use both:
Synchronous calls through the public interface, for queries and commands that need an answer now – “does this account have an active subscription?” These are plain function calls, fast and transactional.
In-process domain events, for reactions that don’t need to happen in the caller’s flow – “subscription canceled” triggering a notification and a reporting update. Publish them after the transaction commits, or write them to an outbox table in the same transaction, so a rollback never sends an event for something that didn’t happen. This is also the seam that makes later extraction painless; see event-driven architecture.
Migrating an existing monolith to a modular one
- Map the current dependencies. Run the tool in report-only mode to see the existing violations. There will be hundreds. That’s fine.
- Draw the target modules and move code into them without changing behavior. Mechanical moves first.
- Record existing violations as a baseline (Packwerk and Deptrac both support this) so CI blocks new violations without failing on old ones.
- Burn down violations by hotspot, starting with the modules the roadmap touches most. Each fix is a small, reviewable PR.
- Split data last. Once code only reaches a module’s tables through its interface, moving those tables to their own schema is straightforward.
This is the most effective technical debt reduction work I know for a codebase that’s grown tangled, because it makes every later change cheaper.
The path to services, if you ever need it
A well-enforced module is most of the way to being a service. It already has a public interface, owns its data and communicates through events. Extracting it means putting a network transport behind the interface, moving its tables to a separate database, and running the outbox events through a real broker. You can do that for one module – the one with a genuinely different scaling or reliability need – and leave the rest alone.
Most teams never need to. If you’re planning a modularization and want someone who works hands-on in Rails, Django and PHP codebases to work through it with your team, that’s the kind of hands-on software architecture consulting I do.
Questions
What is a modular monolith?
A single deployable application split into modules with enforced boundaries. Each module owns its data, exposes a small public interface and communicates with other modules through that interface or through in-process events. It gives clear ownership and structure without the operational cost of microservices.
Is a modular monolith better than microservices?
For most teams under a few dozen engineers, yes. It keeps one deploy, local development and real database transactions while still giving teams clear boundaries. Microservices make sense when specific components need independent deployment, scaling or isolation, and a modular monolith makes extracting those components much easier.
How do you enforce module boundaries?
Use a dependency-checking tool in CI: Packwerk for Rails, import-linter for Python and Django, Deptrac for PHP and Laravel, ArchUnit or Spring Modulith for Java. Record existing violations as a baseline so new ones fail the build, then burn the baseline down over time.
Can modules share a database in a modular monolith?
They can share a database server, but each module should own its tables exclusively. Other modules access that data through the owning module’s interface, never with direct queries or cross-module joins. Separate schemas per module make ownership visible and later extraction simpler.
Want boundaries that survive the next hundred PRs?
In a two-week sprint I’ll map your modules with the team, set up boundary checks in CI, and fix the first hotspot. Then you decide what comes next.
GMT+2 · Full EU overlap · US East mornings