28 KiB
Architektur Patterns
## 11. Konfiguration & Feature Flags (`src/Logic` $\leftarrow$ `src/Data`)
Um die Logic Layer unabhängig von Symfony's Parameter-System zu halten und gleichzeitig pragmatisch zu bleiben, wird ein hybrider Ansatz verfolgt.
### Hybrid-Strategie für Einstellungen
Je nach Komplexität der benötigten Konfiguration werden zwei Wege genutzt:
#### 1. Direktes Wiring (Einfache Fälle)
Bei einer geringen Anzahl von Parametern (ca. 1-3 Werte) erfolgt die Injektion direkt über den Konstruktor mittels Symfony's Standard-Autowiring/Wiring.
- **Vorteil**: Minimaler Overhead, kein zusätzlicher Boilerplate-Code.
#### 2. Settings-Objekte (Komplexe Fälle / Gruppen)
Sobald eine thematische Gruppe von Einstellungen existiert oder die Anzahl der Parameter steigt, werden diese in einem **Settings-Objekt** gebündelt. Dies ist eine einfache `readonly` Klasse ohne eigene Logik (`POPO`).
- **Zweck**: Vermeidung von "Constructor Bloat" und bessere Gruppierung verwandter Werte.
- **Vorteil**: Einfachere Testbarkeit und saubere Übergabe an Unterdienste.
**Beispiel für ein Settings-Objekt:**
```php
readonly class OrderSettings
{
public function __construct(
public int $maxItemsPerOrder,
public float $freeShippingThreshold,
public bool $isInternationalShippingEnabled
) {}
}
12. Concurrency & Locking (src/Logic \rightarrow src/Data)
Zur Vermeidung von Race Conditions bei gleichzeitigen Zugriffen auf dieselben Daten wird standardmäßig das Optimistic Locking eingesetzt.
Funktionsweise des Optimistic Locking
Anstatt Ressourcen exklusiv zu sperren, vertraut das System darauf, dass Konflikte selten auftreten, und prüft die Versionierung beim Schreiben.
- Versionierung: Jede persistierte Einheit im Data Layer besitzt eine Versionsnummer (INT). Diese wird in das entsprechende Business Model der Logic Layer übernommen.
- Schreibvorgang: Beim Speichern eines Modells über einen Processor muss die ursprüngliche Versionsnummer mitgegeben werden. Der Data Layer führt das Update nur aus, wenn die Version in der Datenbank noch mit der des Modells übereinstimmt (
WHERE id = :id AND version = :old_version). - Konflikterkennung: Schlägt das Update fehl (0 Zeilen aktualisiert), wirft der Processor eine fachliche
ConcurrencyException(erbt vonBusinessRuleViolationException).
UI-Reaktion
Die UI Layer fängt die ConcurrencyException im zentralen Exception-Subscriber ab und übersetzt sie in eine benutzerfreundliche Antwort:
- HTTP Status:
409 Conflict. - Botschaft: Der Benutzer wird informiert, dass der Datensatz in der Zwischenzeit von einem anderen Prozess geändert wurde und ein Refresh/Neu-Laden erforderlich ist.
10. Event Handling & Messaging (src/Logic \rightarrow src/Data)
Das System nutzt eine differenzierte Strategie für Ereignisse, um Entkopplung zu gewährleisten und gleichzeitig die Last auf die APIs gering zu halten.
Typen von Events
Je nach Reichweite und Zeitkritikalität kommen verschiedene Mechanismen zum Einsatz:
1. Internal Events (Synchron)
- Zweck: Sofortige Nebenwirkungen innerhalb desselben Request-Zyklus.
- Interface:
InternalEventDispatcher - Payload: Übergabe von Business-Models ist zulässig, da der Zustand konsistent bleibt.
- Beispiel: Aktualisierung eines lokalen In-Memory Caches nach einer Änderung.
2. Async Events (Lokal Asynchron)
- Zweck: Zeitintensive Aufgaben, die nicht den HTTP-Response verzögern dürfen.
- Interface:
AsyncEventPublisher - Payload: Nutzung von dedizierten Event-DTOs. Es wird das Prinzip des Event-Carried State Transfer angewandt (nur notwendige Daten senden), um unnötige Callbacks zum Quellsystem zu vermeiden.
- Beispiel: Versenden einer Bestätigungsmail nach Bestellung.
3. Broadcast Events (Extern/Service-übergreifend)
- Zweck: Information anderer Microservices über eine Zustandsänderung.
- Interface:
BroadcastEventPublisher - Payload: Kompakte, fachspezifische Event-DTOs (Event-Carried State Transfer).
- Besonderheit: Die Definitionen dieser Broadcast-Events (DTOs/Schemas) werden in einem separaten externen Repository gepflegt. Dies ermöglicht es anderen Services, die Messages versioniert zu importieren, ohne Abhängigkeiten zum Haupt-Repository zu haben.
- Beispiel: Benachrichtigung des Logistik-Systems über eine neue Bestellung.
Transaktions-Sicherheit (Transactional Consistency)
Um "Geister-Events" durch Datenbank-Rollbacks zu vermeiden, gilt folgende Regel:
Async- und Broadcast-Events dürfen erst gefeuert werden, nachdem der TransactionManager den erfolgreichen Commit bestätigt hat. Ein Fehlschlag in der DB bedeutet, dass kein Event versendet wird.
Zusammenfassung Payload-Strategie
| Event Typ | Timing | Payload | Transport |
|---|---|---|---|
| Internal | Synchron | Models / Objekte | In-Process (EventDispatcher) |
| Async | Asynchron | Event-DTOs \rightarrow State Transfer |
Message Bus (Local) |
| Broadcast | Asynchron | Event-DTOs \rightarrow State Transfer |
Message Broker (External) |
Die Verknüpfung zwischen den Schichten folgt dem Prinzip der maximalen Automatisierung bei gleichzeitiger Kontrolle über die Implementierungen.
Strategie: Pragmatisches Autowiring
Es wird das Standard-Autowiring von Symfony genutzt, um Konfigurationsaufwand zu minimieren und die Entwicklungsgeschwindigkeit zu erhöhen.
1. Automatische Auflösung (Standard)
Wenn ein Interface (z.B. in src/Logic) genau eine korrespondierende Implementierung (z.B. in src/Data) in der Anwendung besitzt, erfolgt das Wiring vollständig automatisch durch Symfony. Es ist keine manuelle Konfiguration in der services.yaml erforderlich.
2. Explizite Bindings (Ausnahme)
Manuelle Einträge in der services.yaml sind nur in folgenden Fällen zwingend:
- Mehrfache Implementierungen: Wenn ein Interface mehrere Implementierungen besitzt und explizit gesteuert werden muss, welche Version aktuell aktiv ist (z.B.
S3Storagevs.LocalStorage). - Externe 외부 Bibliotheken: Wenn Abhängigkeiten innerhalb von Third-Party-Paketen manuell konfiguriert werden müssen.
Konfigurations-Beispiel (services.yaml)
services:
_defaults:
autowire: true
autoconfigure: true
# Nur nötig, wenn mehr als eine Implementierung existiert
App\Logic\Common\StorageInterface: '@App\Data\Infrastructure\S3Storage'
Vorteile dieses Ansatzes
- Geringer Overhead: Neue Features/Interfaces funktionieren sofort ohne Config-Änderungen.
- Transparenz: Abweichungen vom Standard (mehrere Implementierungen) werden in der
services.yamlexplizit sichtbar. - Sauberkeit: Die Logic Layer bleibt durch die Nutzung von Interfaces im Konstruktor vollständig entkoppelt von den technischen Details des Data Layers.
1. UseCase Pattern (src/Logic)
Das UseCase Pattern bildet das Herzstück der Business-Logik. Jeder Anwendungsfall wird als eigenständige Klasse implementiert, um eine klare Trennung der Verantwortlichkeiten und eine hohe Testbarkeit zu gewährleisten.
Struktur & Definition
- Interface: Alle UseCases müssen das
UseCaseInterfaceimplementieren. - Methode: Die primäre Logik liegt in der Methode
execute(). - Eingabe/Ausgabe: Daten werden strikt über DTOs (Data Transfer Objects) ausgetauscht.
- Rückgabewert: Ein UseCase gibt entweder ein Response-DTO zurück oder
void(bei reinen Schreiboperationen). - Fehlerbehandlung: Business-Fehler werden über spezifische Domain-Exceptions signalisiert.
DTOs (Data Transfer Objects)
- Typ: Readonly-Klassen (PHP 8.2+).
- Namenskonvention: Suffix
Requestfür Eingabe,Responsefür Ausgabe. - Typisierung: Maximale native Typisierung; PHPDocs nur bei komplexen Types (z.B. Generics in Arrays).
Namenskonvention & Verzeichnisstruktur
- Klassennamen: Suffix
UseCase. - Pfad:
src/Logic/{Module}/{Feature}/{Typ}/...
Beispiel-Struktur:
src/Logic/Sales/Order/UseCase/CreateOrderUseCase.phpsrc/Logic/Sales/Order/Dto/CreateOrderRequest.phpsrc/Logic/Sales/Order/Dto/CreateOrderResponse.php
Code Beispiel
Interface
namespace App\Logic\Common;
/**
* @template TRequest
* @template TResponse
*/
interface UseCaseInterface
{
/**
* @param TRequest $request
* @return TResponse|void
*/
public function execute(mixed $request): mixed;
}
Implementierung
namespace App\Logic\Sales\Order\UseCase;
use App\Logic\Common\UseCaseInterface;
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
use App\Logic\Sales\Order\Dto\CreateOrderResponse;
use App\Logic\Sales\Order\Exception\InsufficientStockException;
readonly class CreateOrderUseCase implements UseCaseInterface
{
public function execute(mixed $request): CreateOrderResponse
{
if (!$request instanceof CreateOrderRequest) {
throw new \InvalidArgumentException('Invalid request type');
}
// Business Logik hier...
if ($this->stockTooLow()) {
throw new InsufficientStockException();
}
return new CreateOrderResponse(orderId: '123');
}
private function stockTooLow(): bool { return false; }
}
DTOs
namespace App\Logic\Sales\Order\Dto;
readonly class CreateOrderRequest
{
public function __construct(
public string $customerId,
public array $items,
) {}
}
readonly class CreateOrderResponse
{
public function __construct(
public string $orderId,
) {}
}
## 2. Read Pattern (`src/Logic` $\rightarrow$ `src/Data`)
Das Read Pattern beschreibt den Weg einer Datenabfrage von der UI bis zur Datenquelle. Es stellt sicher, dass die UI-Layer niemals direkt auf die Data Layer zugreift und Business-Modelle konsistent zurückgegeben werden.
### Der Datenfluss
`UI Layer` $\rightarrow$ **BusinessQuery** $\rightarrow$ **Manager** $\rightarrow$ **Provider**
#### BusinessQuery (Logic Layer)
- **Zweck**: Dient als fachlicher Einstiegspunkt für Leseoperationen. Sie orchestriert Manager oder andere Queries, um ein Endresultat zu formen.
- **Struktur**: Implementiert das gleiche Muster wie UseCases (`execute()` Methode).
- **Input/Output**: Nutzt Request-DTOs (besonders für Filter und Pagination) und gibt Response-DTOs zurück.
- **Pfad**: `src/Logic/{Module}/{Feature}/Query/...`
#### Manager (Logic Layer)
- **Zweck**: Steuert die Bereitstellung der Daten und koordiniert Schreibvorgänge.
- **Schnittstelle**: Besitzt ein eigenes Interface in der Logic Layer zur Gewährleistung von Testbarkeit und Austauschbarkeit.
- **Verantwortung**:
- Read: Entscheidung zwischen Cache (Cache-Aside) und Provider.
- Write: Triggerung der Cache-Invalidierung nach erfolgreichen Änderungen.
- **Zustand**: Strikt zustandslos (stateless).
...
102: ### Der Datenfluss
103: `UI Layer` $\rightarrow$ **BusinessQuery** $\rightarrow$ **Manager** $\rightarrow$ **Provider**
104:
105: #### BusinessQuery (Logic Layer)
106: - **Zweck**: Dient als fachlicher Einstiegspunkt für Leseoperationen. Sie orchestriert Manager oder andere Queries, um ein Endresultat zu formen.
107: - **Struktur**: Implementiert das gleiche Muster wie UseCases (`execute()` Methode).
108: - **Input/Output**: Nutzt Request-DTOs (besonders für Filter und Pagination) und gibt Response-DTOs zurück.
109: - **Pfad**: `src/Logic/{Module}/{Feature}/Query/...`
110:
111: #### Manager (Logic Layer)
112: - **Zweck**: Steuert die Bereitstellung der Daten. Hier erfolgt primär die Cache-Logik ("Cache-Aside") und die Koordination von Providern.
113: - **Verantwortung**: Entscheidung, ob Daten aus dem Cache oder frisch vom Provider geladen werden müssen.
114:
115: #### Provider (Data Layer)
116: - **Zweck**: Führt die technische Abfrage gegen die Infrastruktur aus (DB, API).
117: - **Schnittstelle**: Die Provider-Interfaces liegen zwingend in der **Logic Layer**, um Dependency Inversion zu gewährleisten.
118: - **Rückgabewert**: Liefert ausschließlich **Kern-Business-Models** zurück.
119: - Einfache Listen $\rightarrow$ `array` (mit PHPDoc `@return Model[]`).
120: - Paginierten Listen $\rightarrow$ Ein Wrapper-Objekt (z. B. `PaginatedCollection`), das Daten und Metadaten enthält.
121: - **Fehlerbehandlung**: Wirft im Fehlerfall direkt **Domain-Exceptions**.
122: - **Parameter**: Nutzt für einfache Lookups primitive Typen (`string`, `int`), für komplexe Abfragen DTOs.
123: - **Granularität**: Ein Provider pro Business-Model.
124:
125: ### Code Beispiel
...
### Code Beispiel
#### BusinessQuery & DTOs
```php
namespace App\Logic\Sales\Order\Query;
use App\Logic\Common\UseCaseInterface;
use App\Logic\Sales\Order\Dto\GetOrdersRequest;
use App\Logic\Sales\Order\Dto\GetOrdersResponse;
use App\Logic\Sales\Order\Manager\OrderManager;
readonly class GetOrdersQuery implements UseCaseInterface
{
public function __construct(private OrderManager $orderManager) {}
public function execute(mixed $request): GetOrdersResponse
{
if (!$request instanceof GetOrdersRequest) {
throw new \InvalidArgumentException('Invalid request type');
}
$orders = $this->orderManager->findOrders($request->filter, $request->page);
return new GetOrdersResponse(orders: $orders);
}
}
Manager & Provider
namespace App\Logic\Sales\Order\Manager;
use App\Data\Sales\Order\Provider\OrderProvider;
readonly class OrderManager
{
public function __construct(private OrderProvider $orderProvider) {}
public function findOrders(array $filters, int $page): array
{
// Cache-Logik hier...
return $this->orderProvider->fetchOrders($filters, $page);
}
}
// --- Data Layer ---
namespace App\Data\Sales\Order\Provider;
readonly class OrderProvider
{
public function fetchOrders(array $filters, int $page): array
{
// DB Abfrage und Mapping zu Business-Models
return [];
}
}
## 3. Write Pattern (`src/Logic` $\rightarrow$ `src/Data`)
... [existing content of Write Pattern] ...
```php
public function delete(string $id): void
{
// Lösch-Logik ...
}
}
4. Transaction Management (src/Logic \rightarrow src/Data)
Da die Logic Layer framework-unabhängig bleibt, darf sie keinen direkten Zugriff auf den Doctrine EntityManager haben. Um dennoch atomare Schreiboperationen über mehrere Processor hinweg zu gewährleisten, wird das Transaction Manager Pattern eingesetzt.
Konzept
Die Steuerung der Transaktion erfolgt über ein Interface in der Logic Layer, während die technische Umsetzung im Data Layer (Infrastructure) liegt.
1. TransactionManagerInterface (src/Logic/Common)
Dieses Interface definiert eine Methode, die einen Closures-Block innerhalb einer Transaktion ausführt.
namespace App\Logic\Common;
interface TransactionManagerInterface
{
/**
* Führt die übergebene Operation atomar aus.
*
* @template T
* @param callable(mixed...): T $operation
* @return T
* @throws \Throwable
*/
public function transactional(callable $operation): mixed;
}
2. Anwendung im UseCase (src/Logic)
Der UseCase nutzt den TransactionManager, um sicherzustellen, dass entweder alle oder keine Änderungen persistiert werden (All-or-Nothing).
readonly class CreateOrderUseCase implements UseCaseInterface
{
public function __construct(
private TransactionManagerInterface $transactionManager,
private OrderProcessorInterface $orderProcessor,
private StockProcessorInterface $stockProcessor
) {}
public function execute(mixed $request): CreateOrderResponse
{
return $this->transactionManager->transactional(function() use ($request) {
// 1. Produktbestand reduzieren
$this->stockProcessor->reduceStock($request->items);
// 2. Bestellung anlegen
$order = $this->orderProcessor->save($request->toModel());
return new CreateOrderResponse(orderId: $order->id);
});
}
}
3. Implementierung im Data Layer (src/Data)
Die implementierende Klasse nutzt die technischen Mittel des Frameworks (z.B. Doctrine), um die Transaktion zu steuern.
namespace App\Data\Common;
use App\Logic\Common\TransactionManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineTransactionManager implements TransactionManagerInterface
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function transactional(callable $operation): mixed
{
return $this->entityManager->wrapInTransaction($operation);
}
}
Vorteile
- Kein Leaking: Die Logic Layer weiß nicht, dass Doctrine verwendet wird.
- Atomarität: Mehrere Processor können konsistent in einer Transaktion kombiniert werden.
- Testbarkeit: Der TransactionManager kann in Unit Tests einfach durch einen Mock ersetzt werden, der den Closure direkt ausführt.
8. Exception Hierarchie & Error Handling (src/Logic \rightarrow src/UI)
Um eine konsistente Fehlerbehandlung über alle Layer hinweg zu gewährleisten, wird eine strukturierte Exception-Hierarchie eingesetzt. Dies erlaubt es der UI-Layer, Exceptions gruppiert und damit automatisiert in HTTP-Statuscodes zu übersetzen, ohne jede einzelne Exception explizit kennen zu müssen.
Die Hierarchie (src/Logic/Common/Exception)
Alle fachlichen Exceptions erben von einer abstrakten Basisklasse DomainException.
1. Root: DomainException (abstract)
Die Wurzel aller fachlichen Fehler der Logic Layer.
2. Kategorien & HTTP-Mapping
Folgende Untergruppen definieren das Verhalten im UI-Layer:
| Exception Gruppe | HTTP Status | Zweck | Beispiel |
|---|---|---|---|
ResourceNotFoundException |
404 Not Found | Ressource existiert nicht. | UserNotFoundException |
BusinessRuleViolationException |
422 Unprocessable Entity | Geschäftsregel verletzt. | InsufficientFundsException |
AccessDeniedException |
403 Forbidden | Fehlende Berechtigung. | PermissionDeniedException |
InfrastructureException |
503 Service Unavailable | Technische Fehler (extern). | ExternalApiTimeoutException |
Umsetzung im UI-Layer (Centralized Handling)
Die Übersetzung erfolgt in einem zentralen Exception-Subscriber oder Listener der UI-Layer. Anstatt try-catch-Blöcke in jedem Controller zu nutzen, fängt dieser Subscriber die Exceptions global ab:
// Konzeptueller Logik-Auszug des Subscribers
if ($exception instanceof ResourceNotFoundException) {
return new JsonResponse(['error' => $exception->getMessage()], 404);
}
if ($exception instanceof BusinessRuleViolationException) {
return new JsonResponse(['error' => $exception->getMessage()], 422);
}
// ... etc.
Vorteile dieses Ansatzes
- Entkopplung: Die Logic Layer definiert nur was passiert ist (z.B. "Order nicht gefunden"). Die UI Layer entscheidet, wie dies dem User präsentiert wird (HTTP 404).
- Wartbarkeit: Neue Exceptions in der Logic Layer müssen lediglich einer bestehenden Kategorie zugeordnet werden und funktionieren sofort im Frontend/API ohne Anpassungen am UI-Code.
- Konsistenz: Alle Endpunkte reagieren bei gleichen Fehlerarten identisch.
7. UI Controller Pattern (src/UI)
Die Controller fungieren als reine Adapter zwischen dem externen Eintrittspunkt (HTTP/CLI) und der Logic Layer. Sie enthalten keine Geschäftslogik.
Verantwortlichkeiten & Workflow
Ein Controller führt strikt folgende the folgenden Schritte aus:
- Syntaktische Validierung: Prüfung, ob die notwendigen Parameter vorhanden sind und das richtige Format haben.
- Authentifizierung & Grob-Autorisierung: Sicherstellung, dass der User eingeloggt ist und über die erforderliche Rolle verfügt (z.B. via Symfony Security).
- DTO-Transformation: Umwandlung des HTTP-Requests in ein spezifisches Request-DTO für den UseCase oder die BusinessQuery.
- Delegation: Aufruf eines
UseCase(für Schreiboperationen) oder einerBusinessQuery(für Leseoperationen). - Response-Mapping: Transformation des Response-DTOs in eine HTTP-Antwort (z.B. JSON via
JsonResponse).
Kommunikation mit der Logic Layer
- Commands/Writes: Der Controller ruft einen UseCase auf
\rightarrowErgebnis ist oftvoidoder ein Bestätigungs-DTO. - Queries/Reads: Der Controller ruft eine BusinessQuery auf
\rightarrowErgebnis ist ein Response-DTO zur Darstellung.
Entkoppelte Autorisierung (Fine-Grained)
Während die grob-körnige Autorisierung (Rollen) im Controller/Framework erfolgt, wird die feinkörnige Logik delegiert:
- Der Controller nutzt Framework-Voter oder Security-Services.
- Diese rufen im Hintergrund eine
BusinessQueryoder einenPermissionServicein der Logic Layer auf, um basierend auf fachlichen Regeln zu entscheiden (z.B. "Besitzt der User dieses Objekt?").
Code Beispiel (Konzept)
namespace App\UI\Http\Sales\Order;
use App\Logic\Sales\Order\UseCase\CreateOrderUseCase;
use App\Logic\Sales\Order\Query\GetOrderQuery;
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
readonly class OrderController extends AbstractController
{
public function create(Request $request, CreateOrderUseCase $useCase): JsonResponse
{
// 1. Syntaktische Validierung & Request-DTO Mapping
$dto = new CreateOrderRequest(
customerId: $request->get('customer_id'),
items: $request->get('items')
);
// 2. Delegation an UseCase
$responseDto = $useCase->execute($dto);
return new JsonResponse(['orderId' => $responseDto->orderId], 201);
}
public function show(string $id, GetOrderQuery $query): JsonResponse
{
// Grob-autorisierung erfolgt via Symfony Attribute/Voter
// Feinkörnige Prüfung wird innerhalb der Query oder über Voter -> Logic Layer gelöst.
$responseDto = $query->execute(new GetOrderRequest($id));
return new JsonResponse($responseDto->toArray());
}
}
4. Entity vs. Business Model (src/Data \rightarrow src/Logic)
Um die Geschäftslogik vollständig vom Framework und dem ORM zu entkoppeln, wird eine strikte Trennung zwischen Persistenz-Objekten (Entities) und Domänen-Objekten (Business Models) eingeführt.
Warum diese Trennung?
- Framework-Unabhängigkeit: Die Logic Layer bleibt ein "Pure PHP" Bereich. Änderungen am ORM oder ein Wechsel der Datenbanktechnologie haben keinen Einfluss auf die Business Rules.
- Vermeidung von Side-Effects: ORMs wie Doctrine nutzen Lazy-Loading Proxies. Wenn diese Objekte in la Logic Layer gelangen, können unerwartete DB-Abfragen (N+1 Problem) an Stellen auftreten, an denen keine Infrastruktur-Logik sein sollte.
- Anämische vs. Reichhaltige Modelle: Persistenz-Entities sind oft "anämisch" (nur Getter/Setter), um die DB-Struktur abzubilden. Business Models hingegen anwenden Rich Domain Logic.
Definitionen
Persistence Entity (src/Data)
- Zweck: Spiegelung des Datenbank-Schemas.
- Eigenschaften: Enthält Framework-Annotationen/Attribute (z.B.
#[ORM\Entity]). - Regel: Darf niemals die Grenze zur Logic Layer überschreiten.
Business Model (src/Logic)
- Zweck: Repräsentation des fachlichen Objekts innerhalb der Geschäftslogik.
- Eigenschaften: POPO (Plain Old PHP Object), readonly wo möglich, enthält Business-Validierung und Logik.
- Logik-Verteilung:
- Intrinsic Logic (im Model): Beantwortet Fragen über den eigenen Zustand, die keine externen Abhängigkeiten benötigen (z.B.
getFullName(),getAge(),isExpired()). - Extrinsic Logic (im UseCase): Steuert Prozesse, Koordination und Logik mit externen Abhängigkeiten (z.B. Prüfung von Beständen via Processor).
- Intrinsic Logic (im Model): Beantwortet Fragen über den eigenen Zustand, die keine externen Abhängigkeiten benötigen (z.B.
- Regel: Kennt keine Details über die Persistenz.
Übergabe & Mapping
Der Austausch erfolgt ausschließlich über ein Mapping in der Data Layer:
- Provider (Read):
Persistence Entity\rightarrowBusiness Model - Processor (Write):
Business Model\rightarrowPersistence Entity
Das Mapping stellt sicher, dass die Logic Layer nur mit stabilen, validen Objekten arbeitet und nicht mit instabilen ORM-Proxies.
5. Mapping Pattern (src/Data)
Um Redundanz zu vermeiden und eine konsistente Transformation zwischen Persistenz- und Domänenebene zu gewährleisten, werden dedizierte Mapper-Klassen eingesetzt.
Verantwortung & Ort
- Ort: Mappers befinden sich in
src/Data/{Module}/{Feature}/Mapper. - Zweck: Sie kapseln die Logik der Konvertierung. Dadurch bleiben Provider und Processor schlank und konzentrieren sich nur auf den Datenzugriff bzw. die Persistenz.
- Status: Mapper sind strikt zustandslos (stateless).
Mapping Richtungen
Ein Mapper implementiert typischerweise zwei Methoden:
toModel(Entity $entity): Model: Konvertierung von der DB zur Logik (genutzt in Providern).toEntity(Model $model, ?Entity $entity = null): Entity: Konvertierung von der Logik zur DB. Das optionale$entityObjekt ermöglicht Updates bestehender Datensätze ohne Neuerstellung.
Code Beispiel
Mapper Implementierung
namespace App\Data\Sales\Order\Mapper;
use App\Data\Sales\Order\Entity\OrderEntity;
use App\Logic\Sales\Order\Model\Order;
readonly class OrderMapper
{
public function toModel(OrderEntity $entity): Order
{
return new Order(
id: $entity->getId(),
customerEmail: $entity->getEmail(),
totalAmount: $entity->getAmount(),
// ... weitere Felder
);
}
public function toEntity(Order $model, ?OrderEntity $entity = null): OrderEntity
{
$entity ??= new OrderEntity();
$entity->setEmail($model->customerEmail);
$entity->setAmount($model->totalAmount);
// ... weitere Felder
return $entity;
}
}
Integration in Provider/Processor
- Provider: Ruft
toModel()auf, bevor das Resultat an den Manager zurückgegeben wird. - Processor: Ruft
toEntity()auf, um das Business Model persistierbar zu machen, und führt anschließend den Save-Vorgang des ORMs aus.
6. Externe Systemintegration (src/Data)
Die Anbindung von Drittsystemen (REST APIs, Soap, Message Queues) wird technologisch identisch zur Datenbank-Persistenz behandelt, um die Logic Layer vor technischen Details der Kommunikation zu schützen.
Architektur & Datenfluss
Der Fluss folgt dem Read- und Write-Pattern:
Logic Layer \rightarrow Provider / Processor \rightarrow External Client \rightarrow API/Drittsystem
1. External Clients (Infrastruktur)
- Zweck: Technische Umsetzung der Kommunikation (z.B. via Symfony HttpClient).
- Verantwortung: Header, Authentication, Request-Body-Formatierung und reine HTTP-Antworten.
- Ort:
src/Data/{Module}/{Feature}/Client.
2. Integration in Provider/Processor
Die Provider/Processor fungieren als Adapter zwischen dem Client und der Domäne:
- Read (Provider): Ruft den Client auf, empfängt die Rohantwort (z.B. JSON) und nutzt einen Mapper, um diese in ein Business Model zu transformieren.
- Write (Processor): Transformiert das Business Model über einen Mapper in das gewünschte API-Format und sendet es via Client an das Drittsystem.
3. Fehlerhandling & Exception Translation
Technische Fehler dürfen nicht nicht ungefiltert in die Logic Layer gelangen. Der Provider/Processor muss folgende Übersetzung vornehmen:
- HTTP 404
\rightarrowResourceNotFoundException(Domain) - HTTP 401/403
\rightarrowExternalSystemAccessException(Domain) - HTTP 500 / Timeout
\rightarrowExternalSystemUnavailableException(Domain)
Code Beispiel (Konzept)
namespace App\Data\Sales\Order\Provider;
use App\Data\Sales\Order\Client\ShippingApiClient;
use App\Data\Sales\Order\Mapper\ShippingApiMapper;
use App\Logic\Sales\Order\Model\ShippingStatus;
use App\Logic\Common\Exception\ExternalSystemUnavailableException;
readonly class ShippingProvider implements ShippingProviderInterface
{
public function __construct(
private ShippingApiClient $client,
private ShippingApiMapper $mapper
) {}
public function getStatus(string $trackingId): ShippingStatus
{
try {
$response = $this->client->fetchStatus($trackingId);
return $this->mapper->toModel($response);
} catch (TransportException $e) {
throw new ExternalSystemUnavailableException('Shipping API is down', 0, $e);
}
}
}