845 lines
37 KiB
Markdown
845 lines
37 KiB
Markdown
# Architektur Patterns
|
||
|
||
## 1. UseCase Pattern (`src/Logic`)
|
||
|
||
Das UseCase Pattern bildet das Herzstück der Business-Logik. Zusammen mit dem Read Pattern (Sektion 2) folgt die Architektur einem **Light-CQRS Ansatz** (Command Query Responsibility Segregation), bei dem Schreiboperationen (Commands/UseCases) strikt von Leseoperationen (Queries) getrennt werden, um Komplexität zu reduzieren und die Performance zu optimieren. 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 `UseCaseInterface` implementieren.
|
||
- **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 `Request` für Eingabe, `Response` fü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.php`
|
||
- `src/Logic/Sales/Order/Dto/CreateOrderRequest.php`
|
||
- `src/Logic/Sales/Order/Dto/CreateOrderResponse.php`
|
||
|
||
### Code Beispiel
|
||
|
||
#### Interface
|
||
```php
|
||
namespace App\Logic\Common;
|
||
|
||
/**
|
||
* @template TRequest
|
||
* @template TResponse
|
||
*/
|
||
interface UseCaseInterface
|
||
{
|
||
/**
|
||
* @param TRequest $request
|
||
* @return TResponse|void
|
||
*/
|
||
public function execute(mixed $request): mixed;
|
||
}
|
||
```
|
||
|
||
#### Implementierung
|
||
```php
|
||
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
|
||
```php
|
||
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. Hier erfolgt primär die Cache-Logik ("Cache-Aside") und die Koordination von Providern.
|
||
- **Verantwortung**: Entscheidung, ob Daten aus dem Cache oder frisch vom Provider geladen werden müssen.
|
||
- **Schnittstelle**: Besitzt ein eigenes Interface in der Logic Layer zur Gewährleistung von Testbarkeit und Austauschbarkeit.
|
||
- **Zustand**: Strikt zustandslos (stateless).
|
||
|
||
#### Provider (Data Layer)
|
||
- **Zweck**: Führt die technische Abfrage gegen die Infrastruktur aus (DB, API).
|
||
- **Schnittstelle**: Die Provider-Interfaces liegen zwingend in der **Logic Layer**, um Dependency Inversion zu gewährleisten.
|
||
- **Rückgabewert**: Liefert ausschließlich **Kern-Business-Models** zurück.
|
||
- Einfache Listen $\rightarrow$ `array` (mit PHPDoc `@return Model[]`).
|
||
- Paginierten Listen $\rightarrow$ Ein Wrapper-Objekt (z. B. `PaginatedCollection`), das Daten und Metadaten enthält.
|
||
- **Fehlerbehandlung**: Wirft im Fehlerfall direkt **Domain-Exceptions**.
|
||
- **Parameter**: Nutzt für einfache Lookups primitive Typen (`string`, `int`), für komplexe Abfragen DTOs.
|
||
- **Granularität**: Ein Provider pro Business-Model.
|
||
|
||
### 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 (Read + Cache)
|
||
```php
|
||
namespace App\Logic\Sales\Order\Manager;
|
||
|
||
use App\Data\Sales\Order\Provider\OrderProvider;
|
||
|
||
readonly class OrderManager
|
||
{
|
||
public function __construct(
|
||
private OrderProvider $orderProvider,
|
||
// private CacheInterface $cache, <-- Symfony/PSR-6 Cache (injected via DI)
|
||
) {}
|
||
|
||
/**
|
||
* Liefert Orders mit Cache-Aside Pattern.
|
||
*/
|
||
public function findOrders(array $filters, int $page): array
|
||
{
|
||
// cacheKey aus filters + page generieren ...
|
||
// if ($cache->has($key)) return $cache->get($key);
|
||
|
||
$orders = $this->orderProvider->fetchOrders($filters, $page);
|
||
// $cache->set($key, $orders);
|
||
return $orders;
|
||
}
|
||
|
||
/**
|
||
* Invalidiert den Cache für die Order-Domain.
|
||
*/
|
||
public function invalidateCache(): void
|
||
{
|
||
// Tags/Keys löschen, die durch den Write betroffen sind ...
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Provider Interface (Logic Layer)
|
||
```php
|
||
namespace App\Logic\Sales\Order;
|
||
|
||
interface OrderProviderInterface
|
||
{
|
||
public function fetchOrders(array $filters, int $page): array;
|
||
}
|
||
```
|
||
|
||
#### Provider Implementierung (Data Layer)
|
||
```php
|
||
namespace App\Data\Sales\Order\Provider;
|
||
|
||
use App\Logic\Sales\Order\OrderProviderInterface;
|
||
|
||
readonly class OrderProvider implements OrderProviderInterface
|
||
{
|
||
public function fetchOrders(array $filters, int $page): array
|
||
{
|
||
// DB Abfrage und Mapping zu Business-Models
|
||
return [];
|
||
}
|
||
}
|
||
```
|
||
```
|
||
|
||
## 3. Write Pattern (`src/Logic` $\rightarrow$ `src/Data`)
|
||
|
||
Das Write Pattern definiert den Weg von einer Zustandsänderung hin zur Persistenz. Es stellt sicher, dass Schreiboperationen atomar erfolgen und die Integrität der Business-Models gewahrt bleibt.
|
||
|
||
### Der Datenfluss
|
||
`UI Layer` $\rightarrow$ **UseCase** $\rightarrow$ **Manager** $\rightarrow$ **Processor**
|
||
|
||
#### Manager (Logic Layer)
|
||
- **Zweck**: Zentrale Steuereinheit pro Domain, die sowohl Lese- als auch Schreiboperationen koordiniert.
|
||
- **Verantwortung**: Führt im Write-Kontext die Persistierung via Processor durch und invalidiert daraufhin zwingend alle relevanten eigenen Read-Caches (Cache-Sidecar). Dies garantiert Konsistenz zwischen gecachten Daten und der primären Datenquelle.
|
||
|
||
#### Processor (Data Layer)
|
||
- **Zweck**: Übernimmt die physische Persistierung eines Business-Models.
|
||
- **Schnittstelle**: Das `ProcessorInterface` liegt zwingend in der **Logic Layer** (Dependency Inversion).
|
||
- **Input**: Ein Business-Model.
|
||
- **Rückgabewert**: Das aktualisierte/neu erstellte Business-Model (z.B. zur Rückgabe von generierten IDs).
|
||
- **Fehlerbehandlung**: Wirft im Fehlerfall direkt **Domain-Exceptions**.
|
||
- **Strategie**: Verfolgt den "All or Nothing" Ansatz; die übergeordnete Transaktion wird vom UseCase gesteuert.
|
||
- **Granularität**: Fokus auf eine einzelne Entität pro Processor.
|
||
|
||
### Code Beispiel
|
||
|
||
#### Processor Interface & Implementierung
|
||
```php
|
||
namespace App\Logic\Sales\Order;
|
||
|
||
use App\Logic\Sales\Order\Model\Order;
|
||
|
||
interface OrderProcessorInterface
|
||
{
|
||
public function save(Order $order): Order;
|
||
public function delete(string $id): void;
|
||
}
|
||
|
||
// --- Data Layer ---
|
||
namespace App\Data\Sales\Order\Processor;
|
||
|
||
use App\Logic\Sales\Order\OrderProcessorInterface;
|
||
use App\Logic\Sales\Order\Model\Order;
|
||
|
||
readonly class OrderProcessor implements OrderProcessorInterface
|
||
{
|
||
public function save(Order $order): Order
|
||
{
|
||
// Persistenz-Logik (DB/API) ...
|
||
return $order; // Hier ggf. mit generierter ID zurückgeben
|
||
}
|
||
|
||
public function delete(string $id): void
|
||
{
|
||
// Lösch-Logik ...
|
||
}
|
||
}
|
||
```
|
||
|
||
#### UseCase und Write-Manager Integration
|
||
```php
|
||
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\Manager\OrderManager;
|
||
|
||
readonly class CreateOrderUseCase implements UseCaseInterface
|
||
{
|
||
public function __construct(
|
||
private OrderManager $orderManager,
|
||
// TransactionManager ggf. für Multi-Processor-Szenarien
|
||
) {}
|
||
|
||
public function execute(mixed $request): CreateOrderResponse
|
||
{
|
||
if (!$request instanceof CreateOrderRequest) {
|
||
throw new \InvalidArgumentException('Invalid request type');
|
||
}
|
||
|
||
// Business Logik / Validierung ...
|
||
|
||
// Delegiere an den Manager: Der persistiert UND invalidiert den Read-Cache
|
||
$order = $this->orderManager->createOrder($request);
|
||
|
||
return new CreateOrderResponse(orderId: $order->id);
|
||
}
|
||
}
|
||
```
|
||
|
||
Die Manager-Methode `createOrder` orchestriert intern:
|
||
1. Mapping Request-DTO $\rightarrow$ Business Model
|
||
2. Aufruf des Processors innerhalb einer Transaktion
|
||
3. Cache-Invalidierung via `invalidateCache()`, sodass der nächste Read frische Daten liefert.
|
||
|
||
## 4. Validierungsstrategie (Mehrschichtig)
|
||
|
||
Validierung findet so früh wie möglich statt. Jede Layer prüft, was sie prüfen kann — von syntaktischen Format-Checks im Frontend über DTO-Attribute im UI-Layer bis hin zu komplexen Business-Regeln in der Logic Layer. Ein schlecht formatierter Input schlägt bereits im Controller fehl, wohingegen eine komplexe fachliche Regel erst in der UseCase- oder Model-Schicht geprüft wird.
|
||
|
||
### Verantwortung pro Layer
|
||
|
||
| Layer | Was wird geprüft? | Beispiel |
|
||
| :--- | :--- | :--- |
|
||
| **Frontend** | Sofortiges Format-Feedback | E-Mail-Format, Positive Nummern |
|
||
| **UI (Controller + DTO)** | Syntaktische Prüfung via Symfony Validator | Pflichtfelder, Datentypen, Längen |
|
||
| **Logic (UseCase / Model)** | Business-Regeln, Cross-Entity Constraints, Zustandsgültigkeit | Host auflösbar? Guthaben reicht? Statusübergang erlaubt? |
|
||
| **Data (Processor)** | Externe Constraints, DB-Integrität | Unique-Konflikt, API Rate Limits |
|
||
|
||
### Syntaktische Validierung im Controller
|
||
|
||
Die UI Layer führt syntaktische Checks mit reinem PHP durch – ohne Framework-Validator oder Annotations auf den DTOs. Request-DTOs in der Logic Layer bleiben unverändert und frei von jeglichen Framework-Abhängigkeiten.
|
||
|
||
```php
|
||
namespace App\Logic\Sales\Order\Dto;
|
||
|
||
readonly class CreateOrderRequest
|
||
{
|
||
public function __construct(
|
||
public string $customerEmail,
|
||
public string $customerName,
|
||
public array $itemIds,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
### Controller-Integration
|
||
|
||
```php
|
||
namespace App\UI\Http\Sales\Order;
|
||
|
||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||
|
||
readonly class OrderController extends AbstractController
|
||
{
|
||
public function create(
|
||
Request $request,
|
||
ValidatorInterface $validator,
|
||
CreateOrderUseCase $useCase
|
||
): JsonResponse {
|
||
// DTO aus Request bauen ...
|
||
$dto = /* mapping */ ;
|
||
|
||
// Syntaktische Validierung (frühes Fail)
|
||
$violations = $validator->validate($dto);
|
||
if (count($violations) > 0) {
|
||
return new JsonResponse(['errors' => (string)$violations], 422);
|
||
}
|
||
|
||
// UseCase übernimmt Business-Validierung
|
||
$responseDto = $useCase->execute($dto);
|
||
return new JsonResponse(['orderId' => $responseDto->orderId], 201);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Business Models ohne Symfony-Attributes
|
||
|
||
Business Models in `src/Logic` sind frei von Framework-Abhängigkeiten. Ihre Invarianten werden im Konstruktor mit reinem PHP durchgesetzt:
|
||
|
||
```php
|
||
class Order extends BaseModel
|
||
{
|
||
public function __construct(public readonly string $email, public readonly float $totalAmount)
|
||
{
|
||
if ($this->totalAmount < 0) {
|
||
throw new BusinessRuleViolationException('Order amount must be positive');
|
||
}
|
||
}
|
||
|
||
public function canShip(): bool
|
||
{
|
||
return $this->status === OrderStatus::PAID && $this->isAddressComplete();
|
||
}
|
||
}
|
||
```
|
||
|
||
### Vorteile
|
||
- **Frühes Erkennen**: Formatfehler werden im Controller erkannt, sodass keine UseCase- oder Data-Layer-Logik ausgelastet wird.
|
||
- **Framework-Unabhängigkeit**: Die Logic Layer bleibt frei von Symfony-Attributen und ist unabhängig testbar.
|
||
- **Schichtenweise Abdeckung**: Jede Schicht validiert ihren Bereich — das Frontend schützt den Controller, der Controller schützt den UseCase, der UseCase schützt die Persistenz.
|
||
|
||
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.
|
||
|
||
```php
|
||
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).
|
||
|
||
```php
|
||
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.
|
||
|
||
```php
|
||
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.
|
||
|
||
## 6. 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?
|
||
1. **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.
|
||
2. **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.
|
||
3. **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).
|
||
- **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` $\rightarrow$ `Business Model`
|
||
- **Processor (Write)**: `Business Model` $\rightarrow$ `Persistence Entity`
|
||
|
||
Das Mapping stellt sicher, dass die Logic Layer nur mit stabilen, validen Objekten arbeitet und nicht mit instabilen ORM-Proxies.
|
||
|
||
## 7. 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:
|
||
1. `toModel(Entity $entity): Model`: Konvertierung von der DB zur Logik (genutzt in Providern).
|
||
2. `toEntity(Model $model, ?Entity $entity = null): Entity`: Konvertierung von der Logik zur DB. Das optionale `$entity` Objekt ermöglicht Updates bestehender Datensätze ohne Neuerstellung.
|
||
|
||
### Code Beispiel
|
||
|
||
#### Mapper Implementierung
|
||
```php
|
||
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.
|
||
|
||
## 8. 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 ungefiltert in die Logic Layer gelangen. Der Provider/Processor muss folgende Übersetzung vornehmen:
|
||
- **HTTP 404** $\rightarrow$ `ResourceNotFoundException` (Domain)
|
||
- **HTTP 401/403** $\rightarrow$ `ExternalSystemAccessException` (Domain)
|
||
- **HTTP 500 / Timeout** $\rightarrow$ `ExternalSystemUnavailableException` (Domain)
|
||
|
||
### Code Beispiel (Konzept)
|
||
```php
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 9. 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 Schritte aus:
|
||
1. **Syntaktische Validierung**: Prüfung, ob die notwendigen Parameter vorhanden sind und das richtige Format haben.
|
||
2. **Authentifizierung & Grob-Autorisierung**: Sicherstellung, dass der User eingeloggt ist und über die erforderliche Rolle verfügt (z.B. via Symfony Security).
|
||
3. **DTO-Transformation**: Umwandlung des HTTP-Requests in ein spezifisches Request-DTO für den UseCase oder die BusinessQuery.
|
||
4. **Delegation**: Aufruf eines `UseCase` (für Schreiboperationen) oder einer `BusinessQuery` (für Leseoperationen).
|
||
5. **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 $\rightarrow$ Ergebnis ist oft `void` oder ein Bestätigungs-DTO.
|
||
- **Queries/Reads**: Der Controller ruft eine BusinessQuery auf $\rightarrow$ Ergebnis 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 `BusinessQuery` oder einen `PermissionService` in der **Logic Layer** auf, um basierend auf fachlichen Regeln zu entscheiden (z.B. "Besitzt der User dieses Objekt?").
|
||
|
||
### Code Beispiel (Konzept)
|
||
```php
|
||
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());
|
||
}
|
||
}
|
||
```
|
||
|
||
## 10. 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:
|
||
|
||
```php
|
||
// 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.
|
||
|
||
## 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) |
|
||
|
||
## 11. 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.
|
||
|
||
1. **Versionierung**: Jede persistierte Einheit im Data Layer besitzt eine Versionsnummer (INT). Diese wird in das entsprechende Business Model der Logic Layer übernommen.
|
||
2. **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`).
|
||
3. **Konflikterkennung**: Schlägt das Update fehl (0 Zeilen aktualisiert), wirft der Processor eine fachliche `ConcurrencyException` (erbt von `BusinessRuleViolationException`).
|
||
|
||
### 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.
|
||
|
||
## 12. 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.
|
||
|
||
### 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. `S3Storage` vs. `LocalStorage`).
|
||
- **Externe Bibliotheken**: Wenn Abhängigkeiten innerhalb von Third-Party-Paketen manuell konfiguriert werden müssen.
|
||
|
||
### Konfigurations-Beispiel (`services.yaml`)
|
||
```yaml
|
||
services:
|
||
_defaults:
|
||
autowire: true
|
||
autoconfigure: true
|
||
|
||
# Nur nötig, wenn mehr als eine Implementierung existiert
|
||
App\Logic\Common\StorageInterface: '@App\Data\Infrastructure\S3Storage'
|
||
```
|
||
|
||
### 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
|
||
) {}
|
||
}
|
||
```
|
||
|
||
## 13. Testing Strategy & Structure
|
||
|
||
Die Testsuite ist so aufgebaut, dass sie die Schichtenmodell-Architektur widerspiegelt. Dies erleichtert die Wartung und stellt sicher, dass jede Komponente auf der richtigen Abstraktionsebene geprüft wird.
|
||
|
||
### Verzeichnisstruktur & Mapping
|
||
Die Struktur unter `tests/` folgt strikt der Symmetrie zu `src/`:
|
||
|
||
| Test Typ | Pfad in `tests/` | Spiegelt $\rightarrow$ | Werkzeug / Ansatz | Fokus |
|
||
| :--- | :--- | :--- | :--- | :--- |
|
||
| **Unit** | `tests/Unit/Logic/` | `src/Logic/` | PHPUnit + Mocks | Pure Business Logic & Edge Cases. |
|
||
| **Integration** | `tests/Integration/Data/` | `src/Data/` | Real DB (Test-Env) | Repositories, Mapper, Infrastruktur. |
|
||
| **Functional UI**| `tests/Functional/UI/` | `src/UI/` | Symfony `WebTestCase` | Einzelspezifische Endpunkte / Requests. |
|
||
| **Functional E2E**| `tests/Functional/Scenarios/`| (Szenario-basiert) | Symfony `WebTestCase` | Komplexe User Flows (Multi-Step). |
|
||
|
||
### Detaillierte Guidelines
|
||
|
||
#### 1. Unit Tests (`tests/Unit/Logic`)
|
||
Da die Logic Layer "Pure PHP" ist, müssen diese Tests extrem schnell sein.
|
||
- **Kein Framework**: Es wird kein Symfony Kernel gebootet.
|
||
- **Mocking**: Interfaces der Data Layer (`Provider`, `Processor`, `TransactionManager`) werden gemockt.
|
||
- **Business Models**: Werden *nicht* gemockt, sondern echt verwendet (da sie zustandslos/POPOs sind).
|
||
|
||
#### 2. Integration Tests (`tests/Integration/Data`)
|
||
Hier wird die Brücke zur Infrastruktur geprüft.
|
||
- **Datenbank**: Nutzung einer dedizierten Test-DB. Jeder Test sollte in einer Transaktion laufen, die am Ende gerolled wird (oder via Database-Reset).
|
||
- **Mapper-Tests**: Explizite Prüfung: `Entity` $\rightarrow$ `toModel()` $\rightarrow$ `Business Model`.
|
||
|
||
#### 3. Functional Tests (`tests/Functional`)
|
||
Diese nutzen den Symfony `WebTestCase`, um das System als "Black Box" zu testen.
|
||
- **UI Mirroring**: In `tests/Functional/UI` wird pro Controller ein entsprechender Test-Case angelegt, der die HTTP-Antworten (Status-Codes, JSON-Struktur) validiert.
|
||
- **Scenario-Tests**: In `tests/Functional/Scenarios` werden reale Business Flows abgebildet (z.B. `OrderProcessTest`), die mehrere API-Calls hintereinander ausführen und den finalen Zustand in der Datenbank prüfen.
|
||
|
||
#### 4. Mocking Guidelines
|
||
Um "fragile Tests" zu vermeiden, gilt:
|
||
- **Mock a Interface, not a Class**: Mocke immer das Interface (z.B. `OrderProviderInterface`), niemals die konkrete Implementierung (`OrderProvider`).
|
||
- **No Mocks for Models/DTOs**: Value Objects und Business Models werden immer echt instanziiert.
|
||
- **Avoid Mocking 3rd Party Libs**: Wenn externe Libs getestet werden müssen, schreibe einen eigenen Wrapper/Interface in die Logic Layer und mocke diesen.
|