Struktur, bevor der Code zum Big Ball of Mud wird
Ein modularer Monolith teilt eine Symfony Anwendung in klar abgegrenzte Module auf, jedes als eigenes Bundle mit eigener Datenschicht und definierter Schnittstelle. So bleibt ein wachsendes Projekt wartbar, ohne sofort die operative Komplexitaet von Microservices zu uebernehmen. Dieser Artikel zeigt die konkrete Verzeichnisstruktur, die Kommunikation zwischen Modulen und wie Deptrac Architekturgrenzen automatisch durchsetzt.
Inhaltsverzeichnis
- 1. Warum ein modularer Monolith die bessere Wahl ist
- 2. Grundprinzipien: Module, Grenzen und Bounded Contexts
- 3. Verzeichnisstruktur: ein Bundle pro Modul
- 4. Ein Modul als eigenes Symfony Bundle bauen
- 5. Kommunikation zwischen Modulen ohne direkte Kopplung
- 6. Datenbankgrenzen: eigene Doctrine Konfiguration pro Modul
- 7. Architekturgrenzen automatisch pruefen mit Deptrac
- 8. Vom modularen Monolith zum Microservice extrahieren
- 9. Modularer Monolith im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein modularer Monolith die bessere Wahl ist
Ein modularer Monolith ist eine einzige deploybare Symfony Anwendung, deren Code intern in klar getrennte Module gegliedert ist, jedes mit eigener Verantwortung und eigener Datenschicht. Der Gegenentwurf ist einerseits der klassische Monolith, in dem Controller, Services und Repositories quer durch das Projekt aufeinander zugreifen, andererseits die Microservice Architektur, in der jedes Modul ein eigener Netzwerkdienst ist. Der modulare Monolith liegt bewusst dazwischen: Die Trennschaerfe von Microservices, aber ohne verteilte Transaktionen, Netzwerklatenz und eigene Deployment Pipelines pro Modul.
Viele Teams greifen zu frueh zu Microservices, obwohl das eigentliche Problem nicht die Deployment Einheit ist, sondern fehlende Modulgrenzen im Code. Ein modularer Monolith loest genau dieses Problem zuerst: Wer nicht sauber trennen kann, welches Modul welche Tabelle besitzt, wird das in einer verteilten Architektur erst recht nicht schaffen, nur mit zusaetzlicher Netzwerkkomplexitaet obendrauf. Symfony bringt mit dem Bundle System bereits das passende Werkzeug mit, um Module technisch zu isolieren, ohne dass ein einziges Repository in dutzende Git Repositories zerfallen muss.
2. Grundprinzipien: Module, Grenzen und Bounded Contexts
Der wichtigste Grundsatz eines modularen Monolith lautet: Jedes Modul entspricht einem fachlichen Bounded Context, nicht einer technischen Schicht. Statt Ordnern wie Controller, Service und Repository quer durch die gesamte Anwendung, bekommt jedes Modul, etwa Catalog, Order oder Billing, seinen eigenen vollstaendigen vertikalen Ausschnitt mit eigenen Controllern, Services und Repositories. Diese vertikale Schneidung ist der entscheidende Unterschied zu einer rein technischen Schichtenarchitektur, die zwar organisiert aussieht, aber fachliche Aenderungen ueber viele Ordner verstreut.
Ein zweites Prinzip betrifft die Kommunikationsrichtung zwischen Modulen im modularen Monolith. Module duerfen nur ueber oeffentliche Schnittstellen miteinander sprechen, niemals ueber interne Klassen eines anderen Moduls. Das Order Modul darf die ProductRepository Klasse des Catalog Moduls also nicht direkt injizieren, sondern nur einen dafuer vorgesehenen Facade Service oder ein oeffentliches Event. Diese Regel wirkt zunaechst wie Bürokratie, verhindert aber genau die schleichende Kopplung, die aus einem sauber geplanten Monolithen langsam wieder einen unentwirrbaren Big Ball of Mud macht.
3. Verzeichnisstruktur: ein Bundle pro Modul
In Symfony wird jedes fachliche Modul eines modularen Monolith als eigenes Bundle umgesetzt, meist unter src/Modules/{ModulName} statt im klassischen src/Controller und src/Entity. Jedes Modul enthaelt seine eigene Domain, Application und Infrastructure Schicht, dazu eine Resources Ebene mit Routing und Doctrine Mapping, die ausschliesslich fuer dieses Modul gilt. Wichtig ist, dass alle Klassen eines Moduls unter einem eigenen Namespace liegen, zum Beispiel App\Modules\Catalog, damit spaeter Werkzeuge wie Deptrac die Grenzen anhand des Namespace erkennen koennen.
Diese Struktur macht sofort sichtbar, wie gross ein Modul ist und wie viele Abhaengigkeiten es hat, was bei einer rein technischen Schichtenstruktur kaum moeglich ist. Ein neues Teammitglied kann in einem modularen Monolith gezielt nur den Ordner des relevanten Moduls oeffnen, ohne den Rest der Anwendung verstehen zu muessen. Genau diese lokale Verstaendlichkeit ist einer der groessten praktischen Vorteile gegenueber einem gewachsenen Monolithen ohne Modulgrenzen.
# 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. Ein Modul als eigenes Symfony Bundle bauen
Damit ein Modul in einem modularen Monolith technisch isoliert ist, braucht es eine eigene Bundle Klasse, die ihre Konfiguration selbst mitbringt. Die Bundle Klasse implementiert BundleInterface und registriert das Routing sowie das Dependency Injection Container Extension des Moduls, sodass Services innerhalb des Moduls automatisch geladen werden, ohne dass die zentrale services.yaml jedes Modul einzeln kennen muss. In der config/bundles.php wird das Modul wie jedes andere Bundle registriert.
Der zentrale Vorteil dieses Ansatzes ist, dass ein Modul in einem modularen Monolith exakt weiss, welche eigenen Services es exportiert und welche privat bleiben. Services, die nur intern innerhalb des Moduls gebraucht werden, werden als public: false markiert und koennen von aussen gar nicht injiziert werden, selbst wenn ein anderes Modul es versuchen wuerde. Diese Kapselung auf Container Ebene ist eine der staerksten technischen Garantien, die Symfony fuer Modulgrenzen bereits eingebaut mitbringt, ganz ohne zusaetzliche Tools.
<?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. Kommunikation zwischen Modulen ohne direkte Kopplung
Die schwierigste Frage in jedem modularen Monolith ist, wie ein Modul Informationen von einem anderen Modul bekommt, ohne dessen interne Klassen zu kennen. Die uebliche Loesung ist ein oeffentlicher Facade Service pro Modul, der eine schmale Schnittstelle mit fachlichen Methoden anbietet, etwa CatalogFacade::findProductPrice(int $productId): Money. Das Order Modul injiziert nur diese Facade, niemals die interne ProductRepository Klasse, wodurch das Catalog Modul seine interne Implementierung jederzeit aendern kann, ohne andere Module zu brechen.
Fuer Aenderungen, die andere Module nur informieren sollen statt eine Antwort zu brauchen, ist ein internes Domain Event das passendere Muster im modularen Monolith. Wenn eine Bestellung im Order Modul abgeschlossen wird, dispatcht das Modul ein OrderCompletedEvent ueber den Symfony Messenger Event Bus, und das Billing Modul reagiert mit einem eigenen Handler, ohne dass Order ueberhaupt weiss, dass Billing existiert. Diese Entkopplung ueber Events ist derselbe Mechanismus, der spaeter den Uebergang zu echten Microservices erheblich erleichtert, weil die fachliche Grenze bereits im Code existiert.
6. Datenbankgrenzen: eigene Doctrine Konfiguration pro Modul
Ein oft uebersehener Aspekt eines modularen Monolith ist die Datenbankschicht. Wenn alle Module dieselbe Datenbankverbindung nutzen und beliebig auf fremde Tabellen zugreifen koennen, ist die Modulgrenze nur eine Illusion im Anwendungscode. Symfony erlaubt, pro Bundle ein eigenes Doctrine Mapping unter Resources/config/doctrine zu definieren, sodass jedes Modul nur seine eigenen Entities kennt. Zusaetzlich kann jedes Modul in doctrine.yaml einen eigenen Entity Manager mit eigenem connection Namen bekommen, was Cross Module Joins auf Datenbankebene technisch unmoeglich macht.
Diese strikte Trennung fuehlt sich anfangs unbequem an, weil man Reports oder Aggregationen, die frueher mit einem einzigen SQL Join ueber mehrere Tabellen gelaufen sind, jetzt im Anwendungscode zusammenfuehren muss. Genau das ist aber der Punkt eines modularen Monolith: Wer diese Reibung frueh spuert, findet die echten Modulgrenzen, bevor ein Wechsel zu Microservices sie erzwingt und dann deutlich teurer korrigiert werden muss.
7. Architekturgrenzen automatisch pruefen mit Deptrac
Modulgrenzen, die nur durch Disziplin im Code Review eingehalten werden, erodieren erfahrungsgemaess innerhalb weniger Monate. Fuer einen belastbaren modularen Monolith braucht es eine automatisierte Pruefung, und dafuer hat sich Deptrac als Standardwerkzeug in der PHP Welt etabliert. Deptrac definiert Schichten anhand von Namespace Mustern und erlaubt, explizit zu deklarieren, welche Schicht auf welche andere zugreifen darf. Ein Verstoss, etwa wenn Order versucht, direkt eine Klasse aus Catalog\Infrastructure zu importieren, wird als Fehler in der CI Pipeline gemeldet, bevor er in den Hauptzweig gelangt.
In der Praxis laeuft deptrac analyse als eigener Schritt in der Pipeline, parallel zu PHPStan und den Tests. Der grosse Vorteil gegenueber reiner Code Review Disziplin: Deptrac kennt keine Ausnahmen aus Zeitdruck. Ein Entwickler, der im modularen Monolith versehentlich eine Abkuerzung ueber ein fremdes Modul nimmt, bekommt das sofort automatisiert zurueckgemeldet, lange bevor der Code in Produktion laeuft und die Kopplung sich in weiteren Commits festsetzt.
# 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. Vom modularen Monolith zum Microservice extrahieren
Der eigentliche strategische Wert eines modularen Monolith zeigt sich, sobald ein einzelnes Modul so stark waechst, dass es eigene Skalierung, ein eigenes Deployment oder ein eigenes Team braucht. Weil die Modulgrenze bereits durch Facade Services, eigenes Doctrine Mapping und Domain Events existiert, laesst sich ein solches Modul mit ueberschaubarem Aufwand in einen eigenstaendigen Symfony Service extrahieren. Die Facade Methoden werden dabei zu HTTP Endpunkten, die internen Events werden ueber einen echten Message Broker wie RabbitMQ statt ueber den lokalen Messenger Bus verteilt.
Ohne diese Vorarbeit im modularen Monolith ist eine Extraktion in der Praxis fast immer schmerzhaft, weil ploetzlich verteilte Transaktionen fuer Faelle geloest werden muessen, die im Monolithen nie als Problem sichtbar waren. Teams, die zuerst sauber modularisieren und erst bei echtem Bedarf extrahieren, sparen sich die typischen Anfangsfehler einer verfruehten Microservice Migration, bei der am Ende ein verteilter Monolith mit Netzwerklatenz entsteht statt echter Unabhaengigkeit.
9. Modularer Monolith im Vergleich
Die Wahl zwischen einem klassischen Monolithen, einem modularen Monolith und Microservices haengt stark von Teamgroesse, Domaenenkomplexitaet und operativer Reife ab. Die folgende Tabelle stellt die drei Ansaetze entlang der wichtigsten Kriterien gegenueber.
| Kriterium | Klassischer Monolith | Modularer Monolith | Microservices |
|---|---|---|---|
| Deployment Einheiten | Eine | Eine | Viele, pro Service |
| Modulgrenzen | Nicht durchgesetzt | Durch Bundles und Deptrac erzwungen | Durch Netzwerkgrenze erzwungen |
| Operative Komplexitaet | Niedrig | Niedrig | Hoch, eigene Infrastruktur pro Service |
| Unabhaengige Skalierung | Nicht moeglich | Nur begrenzt | Voll moeglich |
| Refactoring Aufwand | Steigt schnell an | Gering, dank klarer Grenzen | Hoch bei falscher Servicegrenze |
In der Praxis ist der modulare Monolith fuer die meisten mittelgrossen Teams der pragmatischere Startpunkt, weil er die architektonische Disziplin von Microservices erzwingt, ohne deren operative Kosten sofort zu verursachen. Erst wenn konkrete Skalierungs- oder Teamgruenzen dagegen sprechen, lohnt sich die Extraktion einzelner Module in echte Services.
Mironsoft
Symfony Architektur, modulare Systeme und Enterprise Backends
Wachsende Symfony Anwendung ohne klare Modulgrenzen?
Wir analysieren bestehende Symfony Projekte, ziehen fachliche Modulgrenzen entlang von Bounded Contexts und richten Deptrac Regeln ein, damit ein modularer Monolith wartbar bleibt, statt in einen Big Ball of Mud zurueckzufallen.
Architektur Audit
Bestehende Kopplung analysieren und Modulgrenzen entlang der Domaene vorschlagen
Bundle Migration
Schrittweise Umbau bestehender Module in eigenstaendige Symfony Bundles
Deptrac Setup
Architekturregeln definieren und automatisiert in der CI Pipeline durchsetzen
10. Zusammenfassung
Ein modularer Monolith mit Symfony Bundles verbindet die fachliche Klarheit von Microservices mit der operativen Einfachheit einer einzigen deploybaren Anwendung. Jedes Modul lebt als eigenes Bundle mit eigener Domain, eigener Infrastruktur und eigenem Doctrine Mapping, kommuniziert mit anderen Modulen ausschliesslich ueber Facade Services und Domain Events, und wird durch Deptrac automatisiert gegen versehentliche Kopplung abgesichert. Diese Struktur macht ein wachsendes Projekt langfristig wartbar, ohne die Kosten verteilter Systeme fruehzeitig zu erzwingen.
Der groesste Nutzen zeigt sich, sobald ein Team spaeter entscheidet, ein einzelnes Modul als eigenstaendigen Service auszulagern. Weil die Modulgrenze im modularen Monolith bereits durch Bundles, Facades und Events existiert, ist diese Extraktion ein technischer Umbau statt einer fachlichen Neuentdeckung der Domaenengrenzen. Wer diese Struktur von Anfang an konsequent durchzieht, spart sich spaeter die teuerste aller Architekturkorrekturen.
Modularer Monolith mit Symfony Bundles — Das Wichtigste auf einen Blick
Modulgrenzen
Jedes Modul ist ein eigenes Bundle mit eigenem Namespace, eigener Domain und eigener Infrastruktur Schicht.
Kommunikation
Facade Services fuer Anfragen, Domain Events ueber Messenger fuer Benachrichtigungen zwischen Modulen.
Datenbank
Eigenes Doctrine Mapping pro Modul, keine direkten Cross Module Joins auf Tabellenebene.
Durchsetzung
Deptrac in der CI Pipeline verhindert automatisch, dass Module ihre Grenzen ueberschreiten.