Modular Monoliths: Clean Domains, Simple Operations
Let’s be honest: the microservices dream of 2020 has largely become the “distributed mud” nightmare. You wanted decoupling and velocity. Instead, you got 50+ services, a shocking cloud expenses, and a team paralyzed by the cognitive load of tracing a single request across six different network hops.
The industry is undergoing a massive “vibe shift.” We are realizing that network boundaries are poor substitutes for logical boundaries. If your team lacks the platform engineering maturity of Netflix or Uber, adopting microservices-by-default is not an architectural decision – it is an unforced error.
The Solution: The Modular Monolith with Enforced Boundaries. This is the pragmatic leader’s choice. It hits the sweet spot: the strict domain isolation and logical separation of microservices, but deployed as a single unit to eliminate network latency and operational complexity. It prioritizes Change Safety – refactoring a module without cascading failures – over theoretical purity.
Anatomy of a Modern Modular Monolith
The core philosophy here is Logical Architecture > Physical Separation.
In this model, a “Module” is not just a folder; it is a Bounded Context. We treat each module as if it were a separate library or service, but they live in the same repository and run in the same process.
The Structure: 3-Layered Isolation
We utilize a strict 3-layered architecture within each module (Domain, Application, Infrastructure). This structure fits perfectly with Symfony and modern PHP standards.
| Layer | Responsibility | Dependencies Allowed |
| Domain | Business logic, Entities, Value Objects, Purest layer. | Zero (Exceptions: Pure utilities like ramsey/uuid, webmozart/assert). |
| Application | Use Cases, Command Handlers, DTOs, Orchestrates the Domain. | Domain, Vendor libraries (e.g., Logger interface). |
| Infrastructure | Implementation details, DB repositories, COntrollers, Public APIs. | Application, Domain, Vendor (Symfony, Doctrine), Other Modules’ Infra. |
Crucial Constraint: Only the Infrastructure layer is permitted to communicate with the outside world (HTTP requests) or other internal modules. The Domain and Application layers must remain unaware that other modules exist.
Implementing Boundaries with PHP
Modern PHP provides exceptional tools to enforce these contracts at the language level. We leverage PHP 8.4’s Property Hooks and strict typing to reduce boilerplate and harden our domain entities
The Domain Layer: Pure & Expressive
Here is how we define a Product entity in the Catalog module. Notice the lack of ORM annotations. The domain cares about business rules, not database tables. Annotations or Attributes couple your business logic to a specific database vendor.
The Solution: XML or PHP Mapping. By using XML or separate PHP mapping files located in the Infrastructure layer, your Domain entity remains a “Plain Old PHP Object” (POPO).
// src/Catalog/Domain/Entity/Product.php
namespace App\Catalog\Domain\Entity;
use App\Catalog\Domain\ValueObject\ProductId;
use App\Catalog\Domain\ValueObject\Money;
/**
* Pure Domain Entity. No Doctrine attributes here.
* This class is 100% portable and framework-agnostic.
*/
class Product
{
public function __construct(
public private(set) ProductId $id,
public private(set) string $name,
public private(set) Money $price,
private bool $active = true
) {}
// PHP 8.4 Property Hook for derived state
public bool $isPurchasable {
get => $this->active && $this->price->amount > 0;
}
public function updatePrice(Money $newPrice): void
{
$this->price = $newPrice;
}
}Architectural Note: In the Infrastructure layer, you would then define a product.orm.xml file. This maps the App\Catalog\Domain\Entity\Product class to your database table, keeping the Domain clean.
The Application Layer: The Orchestrator
This layer handles the “What”, not the “How”. It accepts raw data via DTOs and loads entities via interfaces.
// src/Catalog/Application/UseCase/UpdateProductPrice.php
namespace App\Catalog\Application\UseCase;
use App\Catalog\Domain\Repository\ProductRepositoryInterface;
use App\Catalog\Domain\ValueObject\Money;
use App\Catalog\Domain\ValueObject\ProductId;
use App\Catalog\Domain\Exception\ProductNotFoundException;
final readonly class UpdateProductPrice
{
public function __construct(
private ProductRepositoryInterface $repository
) {}
public function execute(string $id, int $amount, string $currency): void
{
$product = $this->repository->find(ProductId::fromString($id));
if (!$product) {
throw new ProductNotFoundException("Product not found.");
}
$product->updatePrice(new Money($amount, $currency));
$this->repository->save($product);
}
}The Infrastructure Layer: The Wiring
Infrastructure deals with the database (Doctrine) and Symfony components.
// src/Catalog/Infrastructure/Persistence/DoctrineProductRepository.php
namespace App\Catalog\Infrastructure\Persistence;
use App\Catalog\Domain\Entity\Product;
use App\Catalog\Domain\Repository\ProductRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineProductRepository implements ProductRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function save(Product $product): void
{
$this->entityManager->persist($product);
$this->entityManager->flush();
}
}Cross-Module Communication: The “Infra-to-Infra” Handshake
The Rule: Only the Infrastructure layer of Module A can talk to the Infrastructure layer of Module B.
Step 1: The Consumer’s Need (Cart Module)
The Cart module defines a contract for what it needs from the outside.
// src/Cart/Application/Port/ProductPriceProviderInterface.php
namespace App\Cart\Application\Port;
interface ProductPriceProviderInterface {
public function getPrice(string $productId): ?int;
}Step 2: The Producer’s Public API (Catalog Module)
The Catalog module exposes a Public API Facade in its Infrastructure.
// src/Catalog/Infrastructure/PublicApi/CatalogFacade.php
namespace App\Catalog\Infrastructure\PublicApi;
use App\Catalog\Application\UseCase\GetProductPrice;
use App\Catalog\Infrastructure\PublicApi\CatalogFacadeInterface;
final readonly class CatalogFacade implements CatalogFacadeInterface
{
public function __construct(
private GetProductPrice $useCase
) {}
public function getProductPrice(string $id): ?int
{
// We return a primitive or a simple DTO, strictly avoiding
// returning a Domain Entity (like Product) to prevent leakage.
return $this->useCase->execute($id);
}
}Step 3: The Bridge (Cart Infrastructure)
Finally, the Cart Infrastructure layer implements the provider interface by wiring it to the Catalog Facade. This works like an adapter.
// src/Cart/Infrastructure/Adapter/CatalogModuleAdapter.php
namespace App\Cart\Infrastructure\Adapter;
use App\Cart\Application\Port\ProductPriceProviderInterface;
use App\Catalog\Infrastructure\PublicApi\CatalogFacadeInterface;
final readonly class CatalogModuleAdapter implements ProductPriceProviderInterface
{
public function __construct(
// We inject the OTHER module's Public API here
private CatalogFacadeInterface $catalogFacade
) {}
public function getPrice(string $productId): ?int
{
// We delegate to the Catalog module via its public infrastructure facade
return $this->catalogFacade->getProductPrice($productId);
}
}The Result:
- Cart Application is pure. It depends only on its own interface (
ProductPriceProviderInterface). - Catalog Domain is pure. It is accessed only via its own Application layer.
- The Coupling is isolated entirely within
Cart/Infrastructure. If theCatalogAPI changes, you only fix this one Adapter class.
The Sheriff in Town: Enforcing Rules with Deptrac
A Modular Monolith without enforcement is just a “Spaghetti Monolith” waiting to happen. You cannot rely on developer discipline alone. You need CI/CD failure states.
We use Deptrac to define and police our architectural layers. This tool ensures that a Junior Dev cannot accidentally inject the CartRepository into the Catalog Domain.
The Deptrac Configuration
This YAML configuration strictly maps our “Infra talks to Infra” rule.
# deptrac.yaml
parameters:
paths:
- ./src
layers:
- name: CatalogDomain
collectors:
- type: directory
value: src/Catalog/Domain
- name: CatalogApp
collectors:
- type: directory
value: src/Catalog/Application
- name: CatalogInfra
collectors:
- type: directory
value: src/Catalog/Infrastructure
# Other Modules
- name: CartInfra
collectors:
- type: directory
value: src/Cart/Infrastructure
ruleset:
CatalogDomain: [] # Zero dependencies
CatalogApp:
- CatalogDomain
CatalogInfra:
- CatalogDomain
- CatalogApp
# Cross-Module Communication allowed ONLY at Infra layer
- CartInfraWhy This Matters: If someone tries to use CatalogDomain classes inside CartDomain, the build fails. This effectively creates “firewalls” between your modules inside a single repo.
Troubleshooting & Edge Cases
When moving to this architecture, you will encounter friction. Here is how to handle the most common issues:
The “God Entity” Problem
Symptom: You have a User entity that every module needs. Solution: Do not share the entity. Use Polysemy. The Identity module has a User (username, password). The Sales module has a Customer (shipping address, tax ID). These map to the same user_id, but they are different classes in different domains. Do not create a single 5,000-line User class.
Circular Dependencies
Symptom: Module A needs Module B, and Module B needs Module A. Solution: You have likely identified a missing third concept. Extract the shared logic into a SharedKernel or use Domain Events to decouple them asynchronously.
Leaking Vendor Dependencies
Symptom: Doctrine Attributes inside Domain Entities. Solution: While convenient, this couples your domain to your ORM. For strict isolation, use XML mapping or external PHP configuration for Doctrine, keeping your Entity classes pure PHP.
The “Boilerplate” Explosion
Symptom: You will notice a significantly higher number of files, classes, and interfaces. While this can be discouraging, it is a intensional trade-off. This verbosity is what enables the Single Responsibility Principle (SRP). By isolating Use Cases, Adapters, and Ports into separate files, you ensure that a change in your persistence layer doesn’t leak into your business logic, keeping your system decoupled and maintainable as it scales.
Summary: The Competitive Advantage
The Modular Monolith is not a step backward; it is a step toward maturity. By leveraging Symfony, PHP, and Deptrac, you gain:
- Observability: Tracing is trivial because it’s a single process.
- Agility: Refactoring cross-module contracts is a “Find & Replace,” not a multi-repo coordination meeting.
- Performance: Zero network overhead between modules.