Lektionen, die ich auf die harte Tour gelernt habe
Was mir MVC, Services und Domain-Modelle über saubere Architektur beigebracht haben
Ich arbeite als Fullstack-Webentwickler und habe in den letzten Jahren in sehr unterschiedlichen PHP-Backends gearbeitet, von Symfony bis hin zu gewachsenen Legacy-Systemen ohne klare Struktur. Manche Projekte fühlten sich sauber und stabil an. Andere waren fragil und voller Sonderlogik. Und fast immer lag der Unterschied nicht an der Sprache, sondern an der Architektur.
1. Warum Architektur wichtiger ist als Frameworks
In einem Projekt sollte ein Bestellprozess umgebaut werden. Die Anforderung klang simpel: Nutzer bucht Kurs, gibt Teilnehmer ein, bezahlt. Das System war in PHP mit Symfony gebaut, technisch solide. Trotzdem dauerte jede Änderung ewig. Jeder neue Schritt im Flow bedeutete, in drei Controllern und zwei Services nach versteckten Bedingungen zu suchen. PHP und Symfony waren nicht das Problem. Das Problem war, dass niemand die Regeln des Prozesses an einem Ort niedergeschrieben hatte.
Mit dem Wachstum des Projekts kollabierte die Wartbarkeit. Was als schnelle Lösung im Controller begann, wurde zur Standardlösung. Schnell fertig schlägt langfristig stabil, aber nur, wenn man den Preis nicht sieht. In diesem Projekt sah man ihn: Jede Erweiterung wurde teurer. Die Lektion: Architektur ist keine Spielerei. Sie entscheidet, ob ein Projekt in zwei Jahren noch beherrschbar ist.
2. MVC funktioniert, bis es echte Geschäftslogik gibt
MVC ist gut für CRUD und einfache Apps. Controller nehmen Requests entgegen, Models halten Daten, Views rendern. So weit, so klar.
class OrderController
{
public function listAction() {}
public function createAction() {}
public function editAction() {}
public function deleteAction() {}
}
Solange man nur Entitäten verwaltet, bleibt das übersichtlich. Es kippt, sobald echte Abläufe ins Spiel kommen: Workflows, Statuswechsel, mehrstufige Prozesse. Dann entstehen Controller-Höllen. Ein typisches Muster: Jede Seite des Wizards wird eine Action. Die Fachlogik steckt in Redirects und Session-Checks. Wo startet der Use Case? Welche Regeln gelten in welchem Schritt? Was darf übersprungen werden? Die Antwort liegt verstreut im Code. Navigation ersetzt Fachlogik, und niemand findet sie wieder.
3. Der erste Refactor: Logik aus Controllern ziehen
Die naheliegende Reaktion: Logik in Services auslagern. Controller rufen nur noch den Service auf, der Service macht die Arbeit. Der Code fühlt sich sofort besser an. Klare Trennung, weniger Duplikate, erste Strukturgewinne.
class DealService
{
public function moveDeal(Deal $deal, Pipeline $pipeline): void
{
if (!$deal->canMoveTo($pipeline)) {
throw new DomainException('Deal cannot be moved to this pipeline');
}
$deal->setPipeline($pipeline);
}
}
Das Problem kommt später. Services wachsen. Ein OrderService macht bald Buchung, Stornierung, Umbuchung, Teilnehmerverwaltung, Zahlungslogik. God Services entstehen: große Klassen, die alles können und damit schwer testbar und schwer änderbar sind. Der Refactor war ein Schritt in die richtige Richtung, aber noch nicht das Ziel.
4. Warum Services allein keine saubere Architektur sind
Services organisieren Abläufe, aber sie erzwingen keine Regeln. Wenn jeder $deal->setPipeline($pipeline) aufrufen kann, ohne den Service zu nutzen, sind die Regeln umgehbar. Der Zustand der Entity ist nicht geschützt. Dazu kommen schwer testbare Abhängigkeiten: Ein Service, der zehn andere Services und Repositories injiziert bekommt, wird zum Knotenpunkt. Jeder Test braucht ein Dutzend Mocks. Und wenn jeder neue Use Case einen neuen Service oder neue Methoden im bestehenden Service bekommt, entsteht Service Sprawl, zu viele Services mit unscharfen Grenzen. Services allein lösen das Kernproblem nicht: Wo lebt die fachliche Wahrheit?
5. Domain-Modelle als Herzstück der Anwendung
Der Aha-Moment: Die fachliche Wahrheit gehört in die Entities. Nicht in Kommentare, nicht in Services, sondern in die Objekte selbst. Eine Entity, die ihre Invarianten und Regeln durchsetzt, macht viele Bugs unmöglich. Wenn ein Deal nur über changePhase(Phase $phase) in eine neue Phase wechseln kann und diese Methode alle Regeln prüft, kann niemand mehr einen ungültigen Zustand erzeugen.
class Deal
{
private Phase $phase;
private ?string $deferralReason = null;
private ?DateTime $deferralTime = null;
private DateTime $lastAction;
public function changePhase(Phase $phase): void
{
if (in_array($phase->getType(), [PhaseType::WON, PhaseType::LOST])) {
throw new DomainException('Use closeDeal() for final phases');
}
$this->phase = $phase;
$this->deferralReason = null;
$this->deferralTime = null;
$this->lastAction = new DateTime();
}
}
Das ist kein Active Record und kein DTO. Ein DTO trägt nur Daten. Eine Entity mit Domain-Logik trägt Daten und Regeln. Wenn die Regeln im Objekt leben, verschwinden ganze Klassen von Fehlern, weil ungültige Zustände gar nicht erst entstehen können.
6. Klare Verantwortlichkeiten im Code
Ordnung entsteht, wenn jede Schicht genau weiß, was sie darf und was nicht. Eine Entity hält Zustand und erzwingt Invarianten. Sie kennt keine Datenbank, keine HTTP-Requests, keine anderen Bounded Contexts. Ein DTO ist reiner Datentransport, keine Logik, nur Felder. Ein Service oder Use Case orchestriert: er lädt Entities, ruft Methoden auf ihnen auf, speichert. Er enthält keine Geschäftsregeln, die in die Entity gehören. Ein Repository lädt und speichert Entities. Er ist dumm: keine Fachlogik, nur Persistenz. Wer was darf: Entity = Regeln und Zustand. DTO = Transport. Service/Use Case = Ablauf. Repository = Speicherung. Alles andere bewusst nicht.
7. Use Cases statt technischer Methoden
Technische Methodennamen wie processStep2() oder handleRedirect() sagen nichts über das Produkt. Fachliche Namen wie MoveDealToPipeline oder BuchungAbschliessen schon. Der Code spricht die Sprache des Produkts. Leserlichkeit im Projekt steigt. Wenn PM und Business von Buchung und Stornierung reden, findest du genau diese Begriffe im Code. Prozesse sind plötzlich auffindbar, nicht versteckt in Controller-Actions oder generischen Service-Methoden. Das Command- und Query-Bus-Pattern (CQRS) trennt Lese- und Schreibseite und modelliert Use Cases in Domain-Sprache. Dazu schreibe ich einen separaten Artikel; kurz gesagt: Read und Write getrennt zu halten bringt in komplexen Systemen enorm viel, und Use Cases fügen sich dort nahtlos ein. Nach einem Gespräch mit dem PM suchst du im Code nach dem Use Case und findest ihn.
8. (Ausblick) Command & Query Bus: warum Trennung Gold wert ist
Read und Write sind unterschiedliche Anliegen. Lesen: Daten abrufen, oft aus mehreren Quellen, optimiert für Darstellung. Schreiben: Eine Aktion ausführen, Zustand ändern, Regeln durchsetzen. Wenn beides in denselben Strukturen vermischt wird, wird beides komplizierter. Ein Command Bus nimmt Befehle entgegen (z.B. MoveDealToPipeline), ein Handler führt sie aus. Queries bleiben separat: für Reports, Listen, Dashboards. Komplexe Systeme profitieren davon, weil Lesepfade und Schreibpfade unterschiedlich skaliert und optimiert werden können. Use Cases passen perfekt als Handler zu Commands. Wer tiefer einsteigen will: Ich plane einen eigenen Deep-Dive-Artikel dazu.
9. Typische Anti-Patterns aus echten Projekten
Fat Controller: Alles landet im Controller: Validierung, Logik, Mails, Redirects. Entsteht, weil es schnell geht und MVC suggeriert, der Controller sei der zentrale Einstieg. Er sollte nur Einstieg sein, keine Entscheidungszentrale.
Anemic Domain: Entities sind leere Hüllen mit Gettern und Settern. Die ganze Logik liegt in Services. Entsteht oft aus falscher Furcht vor „Logik in Entities“ oder aus Gewohnheit. Folge: Zustand ist nicht geschützt, Regeln verstreut.
God Services: Ein Service macht alles: Buchung, Stornierung, Export, Benachrichtigungen. Entsteht, wenn man Controller entleert und alles in wenige Services kippt. Schwer testbar, schwer änderbar.
Smart Repositories: Das Repository enthält Fachlogik, Filter, komplexe Abfragen mit Geschäftsregeln. Entsteht, weil es bequem ist, SQL und Regeln zu vermischen. Besser: Repository bleibt dumm, Domain bleibt im Model.
10. Meine wichtigsten Learnings in einem Satz
- Architektur entscheidet, ob du in zwei Jahren noch Herr des Codes bist; Frameworks sind nur Werkzeug.
- MVC reicht für CRUD; sobald echte Prozesse kommen, brauchst du eine Domain-Schicht.
- Controller sind Transport: sie lesen Input, rufen einen Use Case auf, geben Response zurück.
- Services orchestrieren, sie schützen keine Zustände; das ist Aufgabe der Entities.
- Regeln, die wichtig sind, gehören in die Entity, sonst werden sie umgangen oder vergessen.
- Entity = Zustand und Invarianten. DTO = Transport. Service = Ablauf. Repository = Persistenz. Nichts vermischen.
- Use Cases in Domain-Sprache benennen: dann findest du den Code wieder, nachdem du mit dem PM gesprochen hast.
- Read und Write trennen lohnt sich in komplexen Systemen; Command & Query Bus sind dafür ein sauberes Muster.
- Wenn Logik explodiert, fehlt meist eine echte Domain-Schicht, nicht mehr Services.
Fachliche Komplexität gehört in die Domain. Das Framework soll transportieren und verbinden, nicht entscheiden.