Architektur
Die Konstruktionsprinzipien hinter allen Jardis-Packages: Hexagonale Architektur, Closure-Orchestrator-Pattern und die Dependency-Richtung.
Überblick
Jardis ist kein monolithisches Web-Framework: kein Framework, das die gesamte Anwendung besitzt, keinen globalen Service-Container vorschreibt, keinen Lifecycle erzwingt. Stattdessen liefert Jardis spezialisierte Packages, die sich zu DDD-Anwendungen zusammensetzen lassen. Jedes Package folgt denselben Architekturprinzipien, die hier beschrieben sind.
Für die HTTP-Anbindung gibt es eine eigene, bewusst schlanke Delivery-Schicht: App (jardiscore/app) stellt einen Router hinter einem eigenen RouterInterface, eine PSR-15-Middleware-Pipeline und einen kanonischen DomainResponse→PSR-7-Envelope-Mapper bereit. Entscheidend bleibt die Entkopplung: Keine generierte Domain importiert diese Schicht. Sie ist optional und jederzeit gegen Symfony, Laravel oder ein eigenes Delivery austauschbar.
Konstruktionsprinzipien
Separation of Concerns
Verschiedene Verantwortlichkeiten → verschiedene Klassen. Cross-Cutting Concerns (Logging, Caching, Auth) werden über Decorator-Closures gelöst, nicht über Vererbung.
Composition over Inheritance
Interfaces und Constructor Injection statt abstrakter Klassen. Keine Traits. abstract nur bei Exception-Hierarchien. static nur bei Value Object Named Constructors (Email::from()).
Data-Behavior Separation
Datenstrukturen (DTOs, Entities, Value Objects) halten Daten. Services operieren auf Daten. Persistence läuft extern über Repositories. In der Package-Struktur spiegelt sich das: Data/ für Daten, Handler/ für Verhalten.
Explizite Abhängigkeiten
Alle Dependencies werden über den Constructor injiziert. Infrastructure steht hinter Interfaces. Kein impliziter Zugriff auf globalen Zustand.
Hexagonale Architektur
Alle Jardis-Packages sind in vier Kategorien organisiert. Die zentrale Regel: Dependencies zeigen immer nach innen. Äußere Kategorien kennen innere, nie umgekehrt.
┌─────────────────────────────────────────┐
│ Tools (JardisTools\*) │ Development-Zeit
├─────────────────────────────────────────┤
│ Adapter (JardisAdapter\*) │ Infrastructure
├─────────────────────────────────────────┤
│ Support (JardisSupport\*) │ Application / Cross-Cutting
├─────────────────────────────────────────┤
│ Core (JardisCore\*) │ Domain
└─────────────────────────────────────────┘
↑ Dependencies zeigen nach innenDie vier Kategorien
Core (JardisCore\*) — Der Domain Layer. Enthält Kernel und App: den immutablen DomainKernel-Koffer für generierte Domains und die HTTP-Delivery-Schicht (Router, PSR-15-Pipeline, Envelope-Mapper) darum herum.
Support (JardisSupport\*) — Application Layer und Cross-Cutting Concerns. Packages wie DbQuery, Repository, Validation, Data, Workflow. Außerdem der Contract-Namespace mit allen Interfaces.
Adapter (JardisAdapter\*) — Infrastructure Layer. Konkrete Implementierungen: Cache, Database Connection, Logger, EventDispatcher, HTTP, Mailer, Messaging, Filesystem.
Tools (JardisTools\*) — Development-Zeit-Werkzeuge. DbSchema für Schema-Analyse, Builder für Code-Generierung. Werden zur Laufzeit nicht benötigt.
Dependency-Richtung
JardisTools → JardisAdapter → JardisSupport → JardisCore
| | |
JardisSupport\Contract <──<──────────<──<- Core importiert niemals Adapter-Code
- Adapter implementieren Interfaces aus
JardisSupport\Contract - Support-Packages sind unabhängig voneinander
- Tools dürfen alles importieren: sie laufen nur zur Entwicklungszeit
Interfaces statt Implementierungen
Alle Jardis-Packages programmieren gegen Interfaces aus jardissupport/contracts. Das entkoppelt Core von der konkreten Infrastruktur:
// Core kennt nur das Interface
use JardisSupport\Contract\Cache\CacheInterface;
// Adapter liefert die Implementierung
use JardisAdapter\Cache\Cache;
// Der Kernel-Packer verdrahtet beides
$kernel = new DomainKernel(cache: new Cache([...]));Tausche den Cache aus (Redis → Memcached): Core und Support bleiben unverändert.
Domain- und BoundedContext-Kapselung
Die Hexagonale Architektur endet nicht bei den Packages: sie setzt sich im generierten DDD-Code fort. Jede Domain ist vollständig gekapselt. Zugriff ist ausschließlich über die API möglich.
PSR-Standards
Jardis implementiert und nutzt durchgängig PSR-Standards:
| Standard | Jardis-Package | Beschreibung |
|---|---|---|
| PSR-3 | Logger | Logging-Interface |
| PSR-7 | HTTP | HTTP-Messages |
| PSR-11 | Factory | Container-Interface |
| PSR-12 | alle | Coding-Standard |
| PSR-14 | EventDispatcher | Event-System |
| PSR-16 | Cache | Simple Cache |
| PSR-17 | HTTP | HTTP-Factories |
| PSR-18 | HTTP | HTTP-Client |
Versionierung — ClassVersion
Jardis löst eines der schwierigsten Probleme in langlebigen Software-Systemen: Wie evolvert man ein System mit hunderten Klassen, ohne dass jede Änderung ein koordiniertes Big-Bang-Release erfordert?
Die Antwort ist ClassVersion, eine Versionierungsstrategie auf Architektur-Ebene, die in den Kernel eingebaut ist.
Das Prinzip
Die Version wird nicht pro Klasse festgelegt, sondern pro Aufrufkontext. Ein generierter {Domain}Context erhält eine Version, und jeder handle()-Aufruf innerhalb dieses Kontexts löst Klassen automatisch in der passenden Version auf, mit Fallback-Chain auf ältere Versionen oder die Base-Class:
handle('Payment', 'v2') → App\Domain\v2\Payment (v2 existiert)
handle('Invoice', 'v2') → App\Domain\v1\Invoice (kein v2 → Fallback v1)
handle('Customer', 'v2') → App\Domain\Customer (kein v2/v1 → Base-Class)Der aufrufende Code kennt keine Versionen. Er arbeitet mit logischen Klassennamen. Die Version ist eine Eigenschaft des Kontexts, nicht der einzelnen Klasse.
Warum das wichtig ist
| Traditionell | Mit ClassVersion |
|---|---|
| Breaking Change → alle Clients gleichzeitig migrieren | Alte und neue Version koexistieren zur Laufzeit |
| Feature-Flags und if/else-Ketten | Architektur übernimmt die Routing-Entscheidung |
| Klasse umbenennen + hunderte Imports anpassen | Klassenname bleibt gleich, Namespace-Injection erledigt den Rest |
| Big-Bang-Release nach Wochen | Klasse für Klasse migrieren, Sprint für Sprint deployen |
Runtime-Koexistenz
Verschiedene API-Versionen, verschiedene Mandanten oder verschiedene Migrationsphasen können gleichzeitig im selben Prozess laufen:
// v1-Endpoint für Legacy-Clients
return (new PlaceOrderContext($this->kernel(), $data, version: 'v1'))();
// v2-Endpoint für neue Clients
return (new PlaceOrderContext($this->kernel(), $data, version: 'v2'))();Derselbe BoundedContext, aber intern werden Validator, Repository, Hydrator jeweils in der passenden Version aufgelöst.
Builder-Integration
In Jardis-Projekten erzeugt der Builder Code als Base-Klassen. Custom-Code lebt in v2/-Unterverzeichnissen und wird bei Regeneration nicht überschrieben:
src/Domain/Order/
├── HydrateOrder.php ← vom Builder erzeugt (regenerierbar)
├── v2/
│ └── HydrateOrder.php ← Custom-Override (geschützt)
└── ValidateOrder.php ← vom Builder erzeugt (kein Override nötig)ClassVersion löst automatisch v2\HydrateOrder auf, weil es existiert. ValidateOrder ohne v2-Override wird als Base-Class verwendet. Generierter und manueller Code koexistieren, ohne Konflikt.
Closure-Orchestrator-Pattern
Das durchgängige Strukturprinzip aller Jardis-Packages. Keine abstrakten Klassen, keine Traits, keine Service-Locator, stattdessen Closures und Composition.
Closure = Atomare Einheit
Eine Closure ist eine Klasse mit einem einzigen public Einstiegspunkt: __invoke(). Der Klassenname beschreibt, was sie tut. Input → Processing → Output. Keine weiteren public Methods.
final class BuildKey
{
public function __invoke(string $prefix, string $path): string
{
return $this->normalize($prefix) . '/' . ltrim($path, '/');
}
private function normalize(string $prefix): string
{
return rtrim($prefix, '/');
}
}Regeln:
- Kein
run(),execute(),handle(): nur__invoke() - Keine weiteren public Methods (SRP-Lackmustest)
- Klassenname = Verb + Objekt (
BuildKey,ValidatePath,SendMessage) - Maximal ~150 Zeilen: darüber hinaus Sub-Closures extrahieren
Orchestrator = Komposition
Ein Orchestrator konsumiert Closures und bildet einen fachlichen Aspekt ab. Er hat keine eigene Logik: nur Verkettung. Output von Closure A wird Input von Closure B, wie Unix-Pipes.
Closures werden im Constructor via First-Class Callable Syntax gebunden:
final class Filesystem
{
private readonly Closure $buildPath;
private readonly Closure $validatePath;
private readonly Closure $readFile;
public function __construct(string $root)
{
$this->buildPath = (new BuildFullPath($root))->__invoke(...);
$this->validatePath = (new ValidatePath())->__invoke(...);
$this->readFile = (new ReadFile())->__invoke(...);
}
public function read(string $path): string
{
$fullPath = ($this->buildPath)($path);
$validated = ($this->validatePath)($fullPath);
return ($this->readFile)($validated);
}
}Auch wenn ein Interface mehrere public Methods vorschreibt (z.B. hash(), verify(), needsRehash()), delegiert der Orchestrator jede Method an eine eigene Closure. Der Orchestrator hat trotzdem keine eigene Logik.
Closures als Decorators
Closures können andere Closures wrappen, das Decorator-Prinzip:
final class Retry
{
private readonly Closure $transport;
public function __construct(
Closure $transport,
private readonly int $maxRetries,
private readonly int $delayMs,
) {
$this->transport = $transport;
}
public function __invoke(Request $request): Response
{
$lastException = null;
for ($i = 0; $i <= $this->maxRetries; $i++) {
try {
return ($this->transport)($request);
} catch (TransportException $e) {
$lastException = $e;
usleep($this->delayMs * 1000 * (2 ** $i));
}
}
throw $lastException;
}
}
// Im Orchestrator:
$transport = (new CurlTransport())->__invoke(...);
$retry = (new Retry($transport, maxRetries: 3, delayMs: 100))->__invoke(...);Retry wrapped CurlTransport, wie AuthorizePermission CheckPermission wrappen könnte. Die Closure-Signatur bleibt gleich. Der Aufrufer merkt nichts.
Reales Beispiel: HTTP-Client
So sieht das Pattern in der Praxis aus, der HTTP-Client als Orchestrator mit Handler-Pipeline:
HttpClient ← Orchestrator (PSR-18 API)
├── Handler/
│ ├── Transport/
│ │ ├── CurlTransport ← Closure: cURL-Request ausführen
│ │ └── Retry ← Decorator: Retry wraps Transport
│ ├── Pipeline/
│ │ ├── ApplyBaseUrl ← Closure: Base-URL voranstellen
│ │ ├── ApplyDefaultHeaders ← Closure: Default-Header setzen
│ │ └── ApplyBearerToken ← Closure: Auth-Header hinzufügen
│ └── Response/
│ └── ClassifyException ← Closure: HTTP-Fehler → PSR-18 Exceptions
└── Config/
└── ClientConfig ← Value Object: KonfigurationDer Orchestrator HttpClient hat keine eigene Logik: er ruft Pipeline-Closures auf, dann den Transport (ggf. mit Retry-Decorator), dann die Exception-Klassifikation.
Verzeichnisstruktur
Alle Packages folgen derselben Verzeichnisstruktur:
src/
├── Orchestrator1.php ← Orchestrator im Root
├── Orchestrator2.php ← Orchestrator im Root
├── Handler/ ← ALLE Closures zentral
│ ├── Feature1/
│ │ ├── DoSomething.php ← Closure
│ │ └── DoSomethingElse.php ← Closure
│ └── Feature2/
│ └── ProcessData.php ← Closure
├── Data/ ← ALLE Value Objects, Enums, Builder
│ ├── MyValueObject.php
│ └── MyEnum.php
└── Exception/ ← Exceptions
└── MyException.phpRegeln:
- Orchestratoren liegen direkt in
src/— sie sind die Einstiegspunkte des Packages - Handler (Closures) liegen unter
src/Handler/mit Kategorie-Subdirectories - Data-Klassen (VOs, Enums) liegen unter
src/Data/ - Exceptions liegen unter
src/Exception/ - Test-Fakes (z.B.
InMemoryTokenStore) liegen untertests/Support/, nicht insrc/
Technische Basis
PHP 8.3+ | declare(strict_types=1) | PHPStan Level 8 | PSR-4 / PSR-12 | SOLID | Code Coverage ≥ 80%Alle Packages teilen diese technische Basis. PHPStan Level 8 stellt sicher, dass Typen vollständig geprüft werden. declare(strict_types=1) ist in jeder Datei gesetzt. Code Coverage wird per CI erzwungen.