Structure before the code turns into a big ball of mud
A modular monolith splits a Symfony application into clearly bounded modules, each shipped as its own bundle with its own data layer and defined interface. That keeps a growing project maintainable without immediately taking on the operational complexity of microservices. This article shows the concrete directory layout, the communication between modules and how Deptrac enforces architectural boundaries automatically.
Table of Contents
- 1. Why a modular monolith is often the better choice
- 2. Core principles: modules, boundaries and bounded contexts
- 3. Directory layout: one bundle per module
- 4. Building a module as its own Symfony bundle
- 5. Communication between modules without direct coupling
- 6. Database boundaries: separate Doctrine configuration per module
- 7. Enforcing architecture boundaries automatically with Deptrac
- 8. Extracting a microservice from the modular monolith
- 9. Modular monolith in comparison
- 10. Summary
- 11. FAQ
1. Why a modular monolith is often the better choice
A modular monolith is a single deployable Symfony application whose code is internally organized into clearly separated modules, each with its own responsibility and its own data layer. On one side stands the classic monolith, where controllers, services and repositories reach across the whole project. On the other side stands a microservice architecture, where each module is its own network service. The modular monolith sits deliberately in between, offering the clarity of microservices without distributed transactions, network latency and a separate deployment pipeline for every module.
Many teams reach for microservices too early, even though the actual problem is not the deployment unit but missing module boundaries in the code. A modular monolith solves exactly this problem first. A team that cannot cleanly define which module owns which table will not manage that in a distributed architecture either, only with extra network complexity added on top. Symfony already ships the right tool for isolating modules technically through its bundle system, without forcing a single repository to fracture into dozens of separate Git repositories.
2. Core principles: modules, boundaries and bounded contexts
The most important rule of a modular monolith is that every module corresponds to a business bounded context, not a technical layer. Instead of folders like Controller, Service and Repository spanning the whole application, each module, say Catalog, Order or Billing, gets its own complete vertical slice with its own controllers, services and repositories. This vertical cut is the decisive difference from a purely technical layered architecture, which looks organized but scatters every business change across many folders.
A second principle concerns the direction of communication between modules in a modular monolith. Modules may only talk to each other through public interfaces, never through the internal classes of another module. The Order module must not inject the ProductRepository class of the Catalog module directly, only a dedicated facade service or a public event. This rule feels like bureaucracy at first, but it prevents exactly the creeping coupling that slowly turns a well planned monolith back into an untangleable big ball of mud.
3. Directory layout: one bundle per module
In Symfony, every business module of a modular monolith is implemented as its own bundle, typically under src/Modules/{ModuleName} instead of the classic src/Controller and src/Entity. Each module contains its own Domain, Application and Infrastructure layer, plus a Resources level with routing and Doctrine mapping that applies exclusively to that module. It is important that all classes of a module live under their own namespace, for example App\Modules\Catalog, so that tools like Deptrac can later detect the boundaries from the namespace alone.
This structure makes immediately visible how large a module is and how many dependencies it has, something that is barely possible with a purely technical layered structure. A new team member on a modular monolith can open only the folder of the relevant module without having to understand the rest of the application first. This local understandability alone is one of the biggest practical advantages over a grown monolith without module boundaries.
# Directory layout of a modular monolith with one bundle per module
src/
Modules/
Catalog/
Domain/
Entity/Product.php
Repository/ProductRepositoryInterface.php
Application/
Command/CreateProductCommand.php
Query/GetProductByIdQuery.php
Infrastructure/
Doctrine/DoctrineProductRepository.php
Http/ProductController.php
Resources/
config/routes.yaml
config/doctrine/Product.orm.xml
CatalogBundle.php
Order/
Domain/
Entity/Order.php
Application/
Command/PlaceOrderCommand.php
Infrastructure/
Doctrine/DoctrineOrderRepository.php
OrderBundle.php
Billing/
Domain/
Application/
Infrastructure/
BillingBundle.php
4. Building a module as its own Symfony bundle
For a module in a modular monolith to be technically isolated, it needs its own bundle class that ships its own configuration. The bundle class implements BundleInterface and registers the routing as well as the dependency injection container extension of the module, so services within the module load automatically without the central services.yaml having to know about every module individually. In config/bundles.php the module is registered like any other bundle.
The central advantage of this approach is that a module in a modular monolith knows exactly which of its own services it exports and which stay private. Services that are only needed internally within the module are marked public: false and cannot be injected from outside at all, even if another module tried. This container level encapsulation is one of the strongest technical guarantees Symfony already ships for module boundaries, with no additional tooling required.
<?php
declare(strict_types=1);
namespace App\Modules\Catalog;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\Config\FileLocator;
// Each module is its own Symfony Bundle with its own container config
final class CatalogBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
}
public function loadExtension(
array $config,
ContainerBuilder $container,
): void {
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__ . '/Resources/config'),
);
$loader->load('services.yaml');
}
public function getPath(): string
{
return __DIR__;
}
}
5. Communication between modules without direct coupling
The hardest question in every modular monolith is how one module gets information from another module without knowing its internal classes. The common solution is a public facade service per module that exposes a narrow set of business methods, for example CatalogFacade::findProductPrice(int $productId): Money. The Order module injects only this facade, never the internal ProductRepository class, which means the Catalog module can change its internal implementation at any time without breaking other modules.
For changes that only need to inform other modules instead of returning an answer, an internal domain event is the more suitable pattern in a modular monolith. When an order is completed in the Order module, the module dispatches an OrderCompletedEvent through the Symfony Messenger event bus, and the Billing module reacts with its own handler, without Order ever knowing that Billing exists. This decoupling through events is the same mechanism that later makes the move to real microservices much easier, because the business boundary already exists in the code.
6. Database boundaries: separate Doctrine configuration per module
An often overlooked aspect of a modular monolith is the database layer. If all modules share the same database connection and can freely access foreign tables, the module boundary is only an illusion in the application code. Symfony allows defining its own Doctrine mapping per bundle under Resources/config/doctrine, so each module only knows its own entities. In addition, each module can get its own entity manager with its own connection name in doctrine.yaml, making cross module joins technically impossible at the database level.
This strict separation feels uncomfortable at first, because reports or aggregations that used to run as a single SQL join across multiple tables now have to be assembled in application code instead. That friction is exactly the point of a modular monolith. A team that feels this friction early finds the real module boundaries before a move to microservices forces it, which is a far more expensive correction to make later.
7. Enforcing architecture boundaries automatically with Deptrac
Module boundaries that are only kept through discipline in code review erode within a few months in practice. A resilient modular monolith needs an automated check, and Deptrac has established itself as the standard tool for this in the PHP world. Deptrac defines layers based on namespace patterns and lets teams explicitly declare which layer may access which other layer. A violation, for example when Order tries to import a class from Catalog\Infrastructure directly, is reported as an error in the CI pipeline before it ever reaches the main branch.
In practice, deptrac analyse runs as its own step in the pipeline, alongside PHPStan and the tests. The big advantage over pure code review discipline is that Deptrac knows no exceptions due to time pressure. A developer who accidentally takes a shortcut through a foreign module in a modular monolith gets that feedback instantly and automatically, long before the code runs in production and the coupling cements itself in further commits.
# deptrac.yaml — enforce module boundaries in a modular monolith
deptrac:
paths:
- ./src/Modules
layers:
- name: CatalogDomain
collectors:
- type: className
regex: App\\Modules\\Catalog\\Domain\\.*
- name: CatalogFacade
collectors:
- type: className
regex: App\\Modules\\Catalog\\CatalogFacade
- name: OrderModule
collectors:
- type: className
regex: App\\Modules\\Order\\.*
ruleset:
OrderModule:
- CatalogFacade
CatalogFacade:
- CatalogDomain
CatalogDomain: []
8. Extracting a microservice from the modular monolith
The real strategic value of a modular monolith becomes visible once a single module grows so large that it needs its own scaling, its own deployment or its own team. Because the module boundary already exists through facade services, dedicated Doctrine mapping and domain events, such a module can be extracted into a standalone Symfony service with manageable effort. The facade methods become HTTP endpoints, and the internal events get distributed through a real message broker such as RabbitMQ instead of the local Messenger bus.
Without this groundwork in the modular monolith, an extraction is almost always painful in practice, because distributed transactions suddenly have to be solved for cases that were never visible as a problem inside the monolith. Teams that modularize cleanly first and only extract when there is real demand avoid the typical early mistakes of a premature microservice migration, which often ends up as a distributed monolith with network latency instead of real independence.
9. Modular monolith in comparison
The choice between a classic monolith, a modular monolith and microservices depends heavily on team size, domain complexity and operational maturity. The following table compares the three approaches across the most important criteria.
| Criterion | Classic Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment units | One | One | Many, one per service |
| Module boundaries | Not enforced | Enforced by bundles and Deptrac | Enforced by the network boundary |
| Operational complexity | Low | Low | High, separate infrastructure per service |
| Independent scaling | Not possible | Only to a limited extent | Fully possible |
| Refactoring effort | Rises quickly | Low thanks to clear boundaries | High with wrong service boundaries |
In practice, the modular monolith is the more pragmatic starting point for most mid sized teams, because it forces the architectural discipline of microservices without immediately incurring their operational costs. Only once concrete scaling or team boundaries argue against it does extracting individual modules into real services pay off.
Mironsoft
Symfony architecture, modular systems and enterprise backends
Growing Symfony application without clear module boundaries?
We analyze existing Symfony projects, draw business module boundaries along bounded contexts and set up Deptrac rules so a modular monolith stays maintainable instead of falling back into a big ball of mud.
Architecture audit
Analyze existing coupling and propose module boundaries along the domain
Bundle migration
Gradual conversion of existing modules into standalone Symfony bundles
Deptrac setup
Define architecture rules and enforce them automatically in the CI pipeline
10. Summary
A modular monolith built with Symfony bundles combines the business clarity of microservices with the operational simplicity of a single deployable application. Every module lives as its own bundle with its own domain, its own infrastructure and its own Doctrine mapping, communicates with other modules exclusively through facade services and domain events, and is protected against accidental coupling by Deptrac running automatically. This structure keeps a growing project maintainable in the long run without forcing the cost of distributed systems too early.
The biggest benefit shows up once a team later decides to extract a single module as a standalone service. Because the module boundary in the modular monolith already exists through bundles, facades and events, this extraction is a technical restructuring rather than a business rediscovery of the domain boundaries. Teams that follow this structure consistently from the start save themselves the most expensive of all architectural corrections later on.
Modular Monolith with Symfony Bundles — The Key Takeaways
Module boundaries
Every module is its own bundle with its own namespace, its own domain and its own infrastructure layer.
Communication
Facade services for requests, domain events over Messenger for notifications between modules.
Database
Separate Doctrine mapping per module, no direct cross module joins at the table level.
Enforcement
Deptrac in the CI pipeline automatically prevents modules from crossing their boundaries.