Skip to content

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 innen

Die 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:

php
// 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.

Domain: Ecommerce
BoundedContext: Sales
command()
createOrder() · addItem() · removeItem()
query()
getOrder() · listOrders()
event()
OrderCreated · ItemAdded · ItemRemoved
BoundedContext: Inventory
command()
reserveStock() · releaseStock()
query()
getStock() · listWarehouses()
event()
StockReserved · StockReleased

PSR-Standards

Jardis implementiert und nutzt durchgängig PSR-Standards:

StandardJardis-PackageBeschreibung
PSR-3LoggerLogging-Interface
PSR-7HTTPHTTP-Messages
PSR-11FactoryContainer-Interface
PSR-12alleCoding-Standard
PSR-14EventDispatcherEvent-System
PSR-16CacheSimple Cache
PSR-17HTTPHTTP-Factories
PSR-18HTTPHTTP-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

TraditionellMit ClassVersion
Breaking Change → alle Clients gleichzeitig migrierenAlte und neue Version koexistieren zur Laufzeit
Feature-Flags und if/else-KettenArchitektur übernimmt die Routing-Entscheidung
Klasse umbenennen + hunderte Imports anpassenKlassenname bleibt gleich, Namespace-Injection erledigt den Rest
Big-Bang-Release nach WochenKlasse 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:

php
// 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.

php
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:

php
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:

php
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: Konfiguration

Der 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.php

Regeln:

  • 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 unter tests/Support/, nicht in src/

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.