Beseitigung architektonischer Schulden: Implementierung von SOLID-Prinzipien und Repository-Mustern in modernem PHP & Laravel

Ein Leitfaden für Software-Engineering-Leads zur Pflege von Codebasen, die über fünf Jahre reibungslos skalieren — wo lose Kopplung, strikte Interface-Verträge, Dependency Injection und automatisierte Teststrategien (PHPUnit) den Verfall von Unternehmenssoftware verhindern.
Einführung — Das Fünf-Jahre-Codebasis-Problem
Die meisten Softwaresysteme scheitern nicht, weil sie falsch gebaut wurden. Sie scheitern, weil sie ohne Plan für Veränderung gebaut wurden.
Das Muster ist vorhersehbar:
Jahr 1: Die Codebasis ist sauber. Features werden schnell ausgeliefert. Das Team ist produktiv.
Jahr 2: Geschäftsanforderungen entwickeln sich weiter. Controller wachsen. Models übernehmen Verantwortlichkeiten. Tests werden brüchig.
Jahr 3: Neue Entwickler brauchen Wochen, um das System zu verstehen. Eine Änderung in der Abrechnung bricht das Inventar. Deployments werden riskant.
Jahr 4: Das Team vermeidet Refactoring, weil „es funktioniert”. Technische Schulden akkumulieren.
Jahr 5: Das System ist unwartbar. Die einzige Option ist ein Rewrite — der mehr kostet als der ursprüngliche Build.
Dies sind architektonische Schulden — und sie sind nicht unvermeidlich. Sie sind das Ergebnis spezifischer architektonischer Entscheidungen (oder deren Abwesenheit), die in Jahr 1 getroffen wurden.
Dieser Artikel behandelt die Disziplinen, die sie verhindern:
SOLID-Prinzipien, Repository-Muster, Dependency Injection und automatisierte Tests — pragmatisch angewendet in modernem PHP und Laravel, wo sie tatsächlich relevant sind.
Das Ziel ist nicht theoretische Reinheit. Das Ziel ist eine Codebasis, die nach fünf Jahren geschäftlicher Veränderung wartbar, testbar und entwickelbar bleibt.
Teil 1 — Was architektonische Schulden tatsächlich sind
Technische Schulden sind eine Metapher. Architektonische Schulden sind ein struktureller Zustand.
Die Symptome
| Symptom | Grundursache |
|---|---|
| Brüchige Änderungen | Enge Kopplung — eine Änderung bricht unabhängige Features |
| Nicht testbarer Code | Abhängigkeiten hartcodiert, keine Nahtstellen zum Mocking |
| Duplizierte Logik | Keine Single Source of Truth für Geschäftsregeln |
| Langsames Onboarding | Keine klaren Grenzen zwischen Belangen |
| Angst vor Refactoring | Keine Testabdeckung, um Regressionen zu erkennen |
| Framework-Lock-in | Geschäftslogik mit Framework-Code verflochten |
Die Grundursache: Verletzte Grenzen
Architektonische Schulden akkumulieren, wenn Verantwortlichkeiten nicht getrennt werden:
Typischer Laravel-Controller (Jahr 3):
┌─────────────────────────────────────────────────────────┐
│ class OrderController │
│ { │
│ public function store(Request $request) │
│ { │
│ // Validierung (sollte separat sein) │
│ // Geschäftslogik (sollte in Domain sein) │
│ // Datenbankabfragen (sollte in Repository) │
│ // E-Mail-Versand (sollte in Service sein) │
│ // Zahlungsabwicklung (sollte in Service) │
│ // Logging (sollte querschnittlich sein) │
│ // Response-Formatierung (sollte in Resource) │
│ } │
│ } │
└─────────────────────────────────────────────────────────┘Dieser Controller hat sieben Verantwortlichkeiten. Er verletzt das Single-Responsibility-Prinzip, bevor wir überhaupt SOLID diskutieren.
Teil 2 — SOLID-Prinzipien in Laravel: Wo sie tatsächlich relevant sind
SOLID ist keine Checkliste. Es ist eine Reihe von architektonischen Einschränkungen, die spezifische Fehlermodi verhindern.
S — Single Responsibility Principle
Eine Klasse sollte einen, und nur einen, Grund zur Änderung haben.
Laravel-Fehlermodus: Der „God Controller” oder „God Model”.
Engineering-Lösung: Belange in verschiedene Ebenen trennen:
App/ ├── Http/ │ ├── Controllers/ ← Nur HTTP-Handling │ ├── Requests/ ← Nur Validierung │ └── Resources/ ← Nur Response-Formatierung ├── Actions/ ← Einzelne Geschäftsoperationen ├── Services/ ← Mehrstufige Geschäftslogik ├── Repositories/ ← Nur Datenzugriff └── Models/ ← Nur Eloquent-Entities
Praktisches Beispiel:
// VORHER: Controller macht alles class OrderController { public function store(Request $request) { $validated = $request->validate([...]); $order = Order::create($validated); Mail::to($order->customer)->send(new OrderConfirmation($order)); return new OrderResource($order); } } // NACHHER: Single Responsibility pro Klasse class OrderController { public function store(StoreOrderRequest $request, CreateOrderAction $action) { $order = $action->execute($request->validated()); return new OrderResource($order); } } class CreateOrderAction { public function __construct( private OrderRepository $orders, private OrderConfirmationMailer $mailer ) {} public function execute(array $data): Order { $order = $this->orders->create($data); $this->mailer->send($order); return $order; } }
Engineering-Auswirkung: Jede Klasse hat einen Grund zur Änderung. Geschäftsregeln entwickeln sich weiter, ohne HTTP-Handling zu berühren.
O — Open/Closed Principle
Software-Entitäten sollten offen für Erweiterung, aber geschlossen für Modifikation sein.
Laravel-Fehlermodus: Das Hinzufügen einer neuen Zahlungsmethode erfordert die Modifikation einer Switch-Anweisung im Controller.
Engineering-Lösung: Ein Interface definieren; neue Verhaltensweisen als neue Klassen implementieren.
interface PaymentGateway { public function charge(Order $order): PaymentResult; } class StripeGateway implements PaymentGateway { ... } class PayPalGateway implements PaymentGateway { ... } class CryptoGateway implements PaymentGateway { ... } // Neues Gateway hinzufügen = Klasse hinzufügen, nicht bestehenden Code modifizieren
Engineering-Auswirkung: Neue Features erweitern das System, ohne bestehenden Code zu destabilisieren.
L — Liskov Substitution Principle
Subtypen müssen für ihre Basistypen substituierbar sein.
Laravel-Fehlermodus: Ein CachedUserRepository, das eine Exception wirft, wenn der Cache leer ist — und damit Code bricht, der UserRepository-Verhalten erwartet.
Engineering-Lösung: Interfaces definieren Verträge; Implementierungen erfüllen sie vollständig.
interface UserRepository { public function find(int $id): ?User; // Muss null zurückgeben, niemals werfen } class EloquentUserRepository implements UserRepository { ... } class CachedUserRepository implements UserRepository { ... }
Engineering-Auswirkung: Jede Implementierung kann ausgetauscht werden, ohne den Aufrufer zu ändern — ermöglicht Caching, Mocking und Testing.
I — Interface Segregation Principle
Clients sollten nicht gezwungen sein, von Interfaces abzuhängen, die sie nicht nutzen.
Laravel-Fehlermodus: Ein einzelnes RepositoryInterface mit 30 Methoden, bei dem die meisten Implementierungen nur 5 nutzen.
Engineering-Lösung: Kleine, fokussierte Interfaces.
// VORHER: Fat Interface interface RepositoryInterface { public function all(); public function find($id); public function create(array $data); public function update($id, array $data); public function delete($id); public function paginate($perPage); public function search($query); // ... 20 weitere Methoden } // NACHHER: Getrennte Interfaces interface ReadableRepository { public function find(int $id): ?Model; public function findBy(array $criteria): Collection; } interface WritableRepository { public function create(array $data): Model; public function update(int $id, array $data): Model; public function delete(int $id): bool; }
Engineering-Auswirkung: Implementierungen implementieren nur das, was sie tatsächlich unterstützen.
D — Dependency Inversion Principle
High-Level-Module sollten nicht von Low-Level-Modulen abhängen. Beide sollten von Abstraktionen abhängen.
Laravel-Fehlermodus: Geschäftslogik hängt direkt von DB::table() oder Model::query() ab.
Engineering-Lösung: Von Interfaces abhängen; Implementierungen im Service Container binden.
// VORHER: Direkte Abhängigkeit class OrderService { public function create(array $data) { return Order::create($data); // Eng an Eloquent gekoppelt } } // NACHHER: Umgekehrte Abhängigkeit class OrderService { public function __construct( private OrderRepository $orders // Interface, nicht Eloquent ) {} public function create(array $data) { return $this->orders->create($data); } } // Bindung im Service Provider $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
Engineering-Auswirkung: Geschäftslogik wird framework-agnostisch und in Isolation testbar.
Teil 3 — Das Repository-Muster: Datenzugriff als Vertrag
Das Repository-Muster ist die wirkungsvollste architektonische Entscheidung für langlebige Laravel-Anwendungen.
Was das Repository-Muster löst
| Problem | Repository-Lösung |
|---|---|
| Geschäftslogik hängt von Eloquent ab | Repository abstrahiert Datenzugriff hinter einem Interface |
| Queries über die Codebasis verstreut | Repository zentralisiert Datenzugriff |
| Schwer in Tests zu mocken | Interface kann gemockt werden — keine Datenbank erforderlich |
| Datenbankmigrations-Risiko | Implementierungen austauschen, ohne Geschäftslogik zu berühren |
| Duplizierte Query-Logik | Single Source of Truth für Datenzugriff |
Repository-Struktur
App/
├── Contracts/
│ └── Repositories/
│ └── OrderRepositoryInterface.php
├── Repositories/
│ └── EloquentOrderRepository.php
└── Providers/
└── RepositoryServiceProvider.phpDas Interface (Vertrag)
namespace App\Contracts\Repositories; interface OrderRepositoryInterface { public function find(int $id): ?Order; public function findByCustomer(int $customerId): Collection; public function create(array $data): Order; public function update(int $id, array $data): Order; public function delete(int $id): bool; public function paginate(int $perPage = 15): LengthAwarePaginator; }
Die Implementierung
namespace App\Repositories; use App\Contracts\Repositories\OrderRepositoryInterface; class EloquentOrderRepository implements OrderRepositoryInterface { public function __construct( private Order $model ) {} public function find(int $id): ?Order { return $this->model->find($id); } public function findByCustomer(int $customerId): Collection { return $this->model->where('customer_id', $customerId) ->orderBy('created_at', 'desc') ->get(); } public function create(array $data): Order { return $this->model->create($data); } public function paginate(int $perPage = 15): LengthAwarePaginator { return $this->model->paginate($perPage); } }
Die Bindung
namespace App\Providers; use Illuminate\Support\ServiceProvider; use App\Contracts\Repositories\OrderRepositoryInterface; use App\Repositories\EloquentOrderRepository; class RepositoryServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind( OrderRepositoryInterface::class, EloquentOrderRepository::class ); } }
Wann Repositories verwendet werden sollten (und wann nicht)
| Repository verwenden, wenn | Repository NICHT verwenden, wenn |
|---|---|
| ✅ Geschäftslogik ohne Datenbank testbar sein muss | ❌ Die App ein einfaches CRUD-Interface ist |
| ✅ Mehrere Datenquellen (Eloquent + externe API) | ❌ Eloquent allein ausreichend ist |
| ✅ Komplexe Queries an mehreren Stellen genutzt werden | ❌ Jede Query nur einmal verwendet wird |
| ✅ Langfristige Wartbarkeit wichtig ist | ❌ Prototyp oder Wegwerf-Projekt |
| ✅ Team hat mehrere Entwickler | ❌ Einzelentwickler, kleiner Umfang |
Repositories fügen eine Ebene hinzu. Diese Ebene muss ihre Kosten rechtfertigen. In großen, langlebigen Anwendungen tut sie das.
Teil 4 — Dependency Injection: Der Klebstoff der Clean Architecture
Laravels Service Container ist das am meisten unterschätzte Feature in Produktionsanwendungen. Es ist keine Magie — es ist Dependency-Management.
Constructor Injection
Das Standardmuster für injizierbare Abhängigkeiten:
class CreateOrderAction { public function __construct( private OrderRepositoryInterface $orders, private PaymentGateway $payment, private OrderConfirmationMailer $mailer ) {} public function execute(array $data): Order { $order = $this->orders->create($data); $this->payment->charge($order); $this->mailer->send($order); return $order; } }
Engineering-Auswirkung: Abhängigkeiten sind explizit, testbar und austauschbar.
Service Provider Binding
// app/Providers/AppServiceProvider.php public function register(): void { $this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class); $this->app->bind(PaymentGateway::class, StripeGateway::class); $this->app->singleton(OrderConfirmationMailer::class); }
Kontextuelles Binding
Verschiedene Implementierungen für verschiedene Consumer:
$this->app->when(AdminOrderService::class) ->needs(OrderRepositoryInterface::class) ->give(AdminOrderRepository::class); $this->app->when(CustomerOrderService::class) ->needs(OrderRepositoryInterface::class) ->give(CustomerOrderRepository::class);
Engineering-Auswirkung: Dasselbe Interface löst zu verschiedenen Implementierungen auf, je nach Kontext — ohne Geschäftslogik zu ändern.
Teil 5 — PHPUnit-Teststrategie: Das Sicherheitsnetz
Tests sind keine Dokumentation. Sie sind das Sicherheitsnetz, das Refactoring ermöglicht.
Die Testpyramide in Laravel
┌─────────────┐
│ E2E / │ ← Wenige: vollständiges HTTP + DB + extern
│ Feature │
├─────────────┤
│ Integration │ ← Einige: Repository + DB
├─────────────┤
│ Unit │ ← Viele: Actions, Services, Domain-Logik
└─────────────┘Engineering-Prinzip: Die Mehrheit der Tests sollten Unit-Tests sein — schnell, isoliert und auf Geschäftslogik fokussiert.
Unit-Testing von Actions (keine Datenbank)
class CreateOrderActionTest extends TestCase { public function test_it_creates_order_and_sends_confirmation(): void { // Arrange $order = new Order(['id' => 1]); $orders = $this->createMock(OrderRepositoryInterface::class); $orders->expects($this->once()) ->method('create') ->willReturn($order); $payment = $this->createMock(PaymentGateway::class); $payment->expects($this->once()) ->method('charge') ->with($order); $mailer = $this->createMock(OrderConfirmationMailer::class); $mailer->expects($this->once()) ->method('send') ->with($order); $action = new CreateOrderAction($orders, $payment, $mailer); // Act $result = $action->execute(['customer_id' => 42]); // Assert $this->assertSame($order, $result); } }
Warum das wichtig ist: Keine Datenbank. Kein HTTP. Keine externen Dienste. Der Test läuft in Millisekunden und verifiziert Geschäftslogik in Isolation.
Feature-Testing von Controllern
class OrderControllerTest extends TestCase { use RefreshDatabase; public function test_it_creates_order_via_api(): void { $this->postJson('/api/orders', [ 'customer_id' => 42, 'items' => [['sku' => 'ABC', 'qty' => 2]] ]) ->assertStatus(201) ->assertJsonStructure(['id', 'customer_id', 'total']); $this->assertDatabaseHas('orders', ['customer_id' => 42]); } }
Repository-Testing
class EloquentOrderRepositoryTest extends TestCase { use RefreshDatabase; public function test_it_finds_orders_by_customer(): void { Order::factory()->count(3)->create(['customer_id' => 42]); Order::factory()->create(['customer_id' => 99]); $repo = new EloquentOrderRepository(new Order()); $results = $repo->findByCustomer(42); $this->assertCount(3, $results); } }
Testabdeckungs-Ziele
| Ebene | Zielabdeckung | Begründung |
|---|---|---|
| Domain-Logik / Actions | 90 %+ | Geschäftsregeln müssen verifiziert werden |
| Repositories | 80 %+ | Datenzugriffskorrektheit |
| Controller | 70 %+ | HTTP-Handling + Validierung |
| E2E | Nur kritische Pfade | Teuer, langsam, brüchig |
Engineering-Prinzip: Abdeckung ist ein Signal, kein Ziel. 100 % Abdeckung mit bedeutungslosen Assertions ist schlechter als 80 % Abdeckung mit bedeutungsvollen.
Teil 6 — Die Fünf-Jahre-Architektur
Alle Disziplinen in einer Codebasis kombiniert, die geschäftliche Evolution überlebt:
┌─────────────────────────────────────────────────────────────────────────────┐ │ HTTP-EBENE │ │ Controller (dünn) │ Form Requests (Validierung) │ Resources (Response) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ ANWENDUNGS-EBENE │ │ Actions (einzelne Operationen) │ Services (mehrstufige Workflows) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ DOMAIN-EBENE │ │ Interfaces (Verträge) │ Value Objects │ Domain Events │ ├─────────────────────────────────────────────────────────────────────────────┤ │ INFRASTRUKTUR-EBENE │ │ Repositories (Datenzugriff) │ Externe Dienste │ Mailer │ Payment │ ├─────────────────────────────────────────────────────────────────────────────┤ │ FRAMEWORK-EBENE │ │ Laravel │ Eloquent │ Service Container │ Queue │ Cache │ └─────────────────────────────────────────────────────────────────────────────┘
Architekturregeln
| Regel | Begründung |
|---|---|
| Controller fragen niemals die Datenbank ab | Geschäftslogik gehört in Actions/Services |
| Geschäftslogik hängt niemals direkt von Eloquent ab | Repository-Interface abstrahiert Datenzugriff |
| Jede Abhängigkeit wird injiziert, nicht instanziiert | Testbarkeit und Austauschbarkeit |
| Interfaces definieren Verträge; Implementierungen erfüllen sie | Lose Kopplung |
| Unit-Tests decken Domain-Logik ab; Feature-Tests decken Integration ab | Schnelles Feedback + Korrektheit |
| Neue Features erweitern Interfaces; bestehender Code wird nicht modifiziert | Open/Closed-Prinzip |
Teil 7 — Wann diese Architektur angewendet werden sollte (und wann nicht)
SOLID + Repository + DI + Testing anwenden, wenn:
✅ Die Anwendung voraussichtlich 3+ Jahre lebt
✅ Mehrere Entwickler an der Codebasis arbeiten
✅ Geschäftsregeln komplex und evolvierend sind
✅ Testing eine Anforderung ist (nicht optional)
✅ Langfristige Wartbarkeit wichtiger ist als anfängliche Geschwindigkeit
✅ Die Domain klare Bounded Contexts hat
NICHT anwenden, wenn:
❌ Das Projekt ein Prototyp oder MVP mit ungewisser Zukunft ist
❌ Das Team ein Entwickler ist, der an kleinem Umfang arbeitet
❌ Die Anwendung einfaches CRUD mit minimaler Geschäftslogik ist
❌ Time-to-Market die langfristige Struktur überwiegt
❌ Das Team keine Erfahrung mit diesen Mustern hat (erhöht Risiko)
Architektur ist eine Investition. Sie zahlt sich in Wartbarkeit aus. Sie kostet in anfänglicher Komplexität. Die Frage ist, ob die Codebasis lange genug lebt, um die Erträge einzusammeln.
Fazit — Verfall verhindern ist eine architektonische Entscheidung
Unternehmenssoftware verfällt nicht, weil Entwickler nachlässig sind. Sie verfällt, weil keine architektonischen Grenzen etabliert wurden, um sie zu verhindern.
SOLID-Prinzipien, Repository-Muster, Dependency Injection und automatisierte Tests sind keine akademischen Übungen. Sie sind die spezifischen Disziplinen, die verhindern:
Brüchige Änderungen → lose Kopplung
Nicht testbaren Code → Dependency Injection
Duplizierte Logik → Single Responsibility
Framework-Lock-in → Interface-Verträge
Angst vor Refactoring → Testabdeckung
Die Fünf-Jahre-Codebasis ist keine Fantasie. Sie ist das Ergebnis von Entscheidungen, die in Jahr 1 getroffen — und in den Jahren 2 bis 5 eingehalten werden.
Code wird nicht für den Computer geschrieben. Er wird für den Entwickler geschrieben, der ihn nächstes Jahr ändern wird.
Hinweis des Autors
Dieser Artikel spiegelt architektonische Muster wider, die bei der Entwicklung von CoreBiz ERP — einer modularen Unternehmensplattform für langfristige Wartbarkeit — entwickelt wurden. Für die Zusammenarbeit an Enterprise-PHP/Laravel-Architektur erreichen Sie mich über die Kontaktseite.