Refactor: resolve validation contradiction, add transactional event bridge, remove duplicate TransactionManager block, fix section numbering 1-13
This commit is contained in:
+252
-165
@@ -5,10 +5,10 @@
|
||||
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.
|
||||
- **Kein Interface**: UseCases benötigen kein separates `UseCaseInterface`. Der Vertrag wird ausschließlich durch die Native PHP Typisierung der Signatur und den Symfony Dependency Injection Container gestellt.
|
||||
- **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).
|
||||
- **Rückgabewert**: Ein UseCase gibt entweder ein Response-DTO zurück oder `void` (bei reinen Schreiboperationen). Die PHP-Signatur ist native typisiert, sodass zur Laufzeit keine `instanceof`-Prüfungen nötig sind.
|
||||
- **Fehlerbehandlung**: Business-Fehler werden über spezifische **Domain-Exceptions** signalisiert.
|
||||
|
||||
### DTOs (Data Transfer Objects)
|
||||
@@ -25,55 +25,142 @@ Das UseCase Pattern bildet das Herzstück der Business-Logik. Zusammen mit dem R
|
||||
- `src/Logic/Sales/Order/Dto/CreateOrderRequest.php`
|
||||
- `src/Logic/Sales/Order/Dto/CreateOrderResponse.php`
|
||||
|
||||
### Code Beispiel
|
||||
### Input-Validierungsstrategie (Zweistufig)
|
||||
DTOs im Logic-Layer sind **reine Datenträger** und enthalten keinerlei Framework-Abhängigkeiten. Der Einbau von Symfony Validator Constraints würde das Logic-Layer an ein konkretes Webframework koppeln.
|
||||
|
||||
#### Interface
|
||||
Daher gilt: **Syntaktische Validierung findet ausschließlich im UI-Layer statt**, bevor das DTO erstellt wird. **Semantische / Business-Validierung** erfolgt in der Logic Layer (UseCase und Models).
|
||||
|
||||
#### UI Layer — Syntaktische Vorabprüfung
|
||||
- **Verantwortung**: Controller / Command parsen und prüfen die Rohdaten (meist aus `$_POST`, Request-Bodies oder Konsolenargumenten).
|
||||
- **Prüfung**: Der Controller prüft Datentypen, Pflichtfelder, String-Längen, E-Mail-Format. Bei einer Abweichung wird eine `ValidationException` geworfen.
|
||||
- **DTO-Konstruktion**: Erst wenn alle syntaktischen Checks bestanden sind, baut der UI-Layer das typisierte Request-DTO für den UseCase.
|
||||
|
||||
#### Logic Layer — Business-Validierung
|
||||
- **Verantwortung**: Der UseCase und die Business Models prüfen fachliche Regeln (z. B. „Reicht der Bestand?", „Ist der Statusübergang erlaubt?").
|
||||
- **Prüfung**: Erfolgt innerhalb von `execute()` und in Model-Konstruktoren bzw. -Methoden. Bei Verstoß werden spezifische **Domain-Exceptions** geworfen.
|
||||
- **Ziel**: Die Business-Regeln bleiben framework-unabhängig und sind auch für nicht-HTTP-Eintrittspunkte (Message Handler, CLI, interne Service-Calls) konsistent gültig.
|
||||
|
||||
#### Beispiel: Controller mit manueller Validierung
|
||||
```php
|
||||
namespace App\Logic\Common;
|
||||
namespace App\UI\Controller;
|
||||
|
||||
/**
|
||||
* @template TRequest
|
||||
* @template TResponse
|
||||
*/
|
||||
interface UseCaseInterface
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
|
||||
use App\Logic\Sales\Order\UseCase\CreateOrderUseCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class OrdersController
|
||||
{
|
||||
/**
|
||||
* @param TRequest $request
|
||||
* @return TResponse|void
|
||||
*/
|
||||
public function execute(mixed $request): mixed;
|
||||
public function __construct(private CreateOrderUseCase $createOrder) {}
|
||||
|
||||
public function create(Request $request): string
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
// Validierung im UI-Layer, bevor das DTO zustande kommt.
|
||||
if (!isset($data['customerId']) || !is_string($data['customerId'])) {
|
||||
throw new BadRequestHttpException('customerId is required and must be a string.');
|
||||
}
|
||||
|
||||
if (!isset($data['items']) || !is_array($data['items']) || empty($data['items'])) {
|
||||
throw new BadRequestHttpException('At least one item is required.');
|
||||
}
|
||||
|
||||
$createOrderRequest = new CreateOrderRequest(
|
||||
customerId: $data['customerId'],
|
||||
items: $data['items'],
|
||||
);
|
||||
|
||||
$response = $this->createOrder->execute($createOrderRequest);
|
||||
|
||||
return "Order created with ID: {$response->orderId}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event-Publishing (UseCase Strategie)
|
||||
Wenn ein UseCase einen signifikanten Statuswechsel bewirkt, ist dieser auch für das Publizieren entsprechender **Domänen-Events** zuständig.
|
||||
|
||||
- **Verantwortung**: Der UseCase dispatcht das Event am Ende von `execute()`, nach erfolgreicher Geschäftsfertigung.
|
||||
- **Entkopplung**: Das Logic-Layer spricht ausschließlich gegen ein Interface (z.B. `EventDispatcherInterface`). Das konkrete Framework (Symfony, RabbitMQ, AWS SNS) bleibt im Data/UI-Layer.
|
||||
- **Granularität**: Es werden spezifische Domänenereignisse auf UseCase-Ebene verwandt (z.B. `OrderCreatedEvent`), nicht generische Model-Ereignisse („Entity Changed").
|
||||
|
||||
#### Implementierung
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\UseCase;
|
||||
|
||||
use App\Logic\Common\UseCaseInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderResponse;
|
||||
use App\Logic\Sales\Order\Event\OrderCreatedEvent;
|
||||
use App\Logic\Sales\Order\Exception\InsufficientStockException;
|
||||
|
||||
readonly class CreateOrderUseCase implements UseCaseInterface
|
||||
readonly class CreateOrderUseCase
|
||||
{
|
||||
public function execute(mixed $request): CreateOrderResponse
|
||||
{
|
||||
if (!$request instanceof CreateOrderRequest) {
|
||||
throw new \InvalidArgumentException('Invalid request type');
|
||||
}
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
) {}
|
||||
|
||||
public function execute(CreateOrderRequest $request): CreateOrderResponse
|
||||
{
|
||||
// Business Logik hier...
|
||||
if ($this->stockTooLow()) {
|
||||
throw new InsufficientStockException();
|
||||
}
|
||||
|
||||
return new CreateOrderResponse(orderId: '123');
|
||||
$orderId = '123';
|
||||
|
||||
// Publish domain event nach erfolgreichem Abschluss.
|
||||
// Die EventDispatcherInterface-Implementierung garantiert automatisch
|
||||
// ein Post-Commit-Dispatching, sodass bei einem DB-Rollback kein
|
||||
// "Geister-Event" in den Message Bus gelangt.
|
||||
$this->eventDispatcher->dispatch(new OrderCreatedEvent(orderId: $orderId));
|
||||
|
||||
return new CreateOrderResponse(orderId: $orderId);
|
||||
}
|
||||
|
||||
private function stockTooLow(): bool { return false; }
|
||||
}
|
||||
```
|
||||
|
||||
#### Transaktionale Event-Garantie (Bridge-Konvention)
|
||||
Um „Geister-Events" bei Datenbank-Rollbacks zu vermeiden, darf kein Async- oder Broadcast-Event vor erfolgreichem Commit in den Message Bus gelangen. Die Logic Layer ruft einfach `dispatch()` auf und bleibt frei von Transaktions-Boilerplate. Die Infrastruktur-Bridge kapselt das Framework-Spezifische und stellt die transaktionale Konsistenz sicher:
|
||||
|
||||
```php
|
||||
namespace App\Data\Infrastructure\Messenger;
|
||||
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentTransactionStamp;
|
||||
|
||||
readonly class SymfonyEventDispatcherBridge implements EventDispatcherInterface
|
||||
{
|
||||
public function __construct(private MessageBusInterface $bus) {}
|
||||
|
||||
public function dispatch(object $event): void
|
||||
{
|
||||
$this->bus->dispatch($event, [new DispatchAfterCurrentTransactionStamp()]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Vorteile
|
||||
- **Logic bleibt clean**: Der UseCase ist frei von Transaktions-Boilerplate und ORM-Wissen.
|
||||
- **Konsistenz**: Nur com-mittete Daten erzeugen Events.
|
||||
- **Testbarkeit**: Das Interface wird im Unit-Test gemockt; die Bridge-Implementierung ist separat in Integrationstests prüfbar.
|
||||
|
||||
#### Domain Event
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\Event;
|
||||
|
||||
readonly class OrderCreatedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $orderId,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
#### DTOs
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\Dto;
|
||||
@@ -109,7 +196,7 @@ Das Read Pattern beschreibt den Weg einer Datenabfrage von der UI bis zur Datenq
|
||||
|
||||
#### 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.
|
||||
- **Verantwortung**: Ausschließlich Read- und Write-Operationen für das fachliche Business-Model sowie die Cache-Koordination. Es finden keine geschäftlichen Berechnungen oder Logikverzweigungen im Manager statt. Die eigentliche Geschäftslogik liegt in den UseCases oder spezialisierten Services.
|
||||
- **Schnittstelle**: Besitzt ein eigenes Interface in der Logic Layer zur Gewährleistung von Testbarkeit und Austauschbarkeit.
|
||||
- **Zustand**: Strikt zustandslos (stateless).
|
||||
|
||||
@@ -129,21 +216,16 @@ Das Read Pattern beschreibt den Weg einer Datenabfrage von der UI bis zur Datenq
|
||||
```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\OrderManagerInterface;
|
||||
|
||||
readonly class GetOrdersQuery implements UseCaseInterface
|
||||
readonly class GetOrdersQuery
|
||||
{
|
||||
public function __construct(private OrderManagerInterface $orderManager) {}
|
||||
|
||||
public function execute(mixed $request): GetOrdersResponse
|
||||
public function execute(GetOrdersRequest $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);
|
||||
@@ -242,7 +324,51 @@ Das Write Pattern definiert den Weg von einer Zustandsänderung hin zur Persiste
|
||||
- **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.
|
||||
|
||||
#### Transaktionssteuerung ("All or Nothing")
|
||||
Um Schreibvorgänge atomar zu gestalten, wird ein `TransactionManager` eingesetzt, der im Data-Layer implementiert ist (z.B. mittels Doctrine) und gegen ein Interface in der Logic Layer spricht.
|
||||
- **Strategie**: Der UseCase übergibt die gesamte Schreiblogik als Callback (`callable`) an den TransactionManager. Die Logik bleibt frei von Boilerplate wie manuellem `beginTransaction()`, `commit()` oder `rollback()`.
|
||||
- **Mechanik**: Der Manager führt die DB-Transaktion aus. Gibt der Callback einen Wert zurück, wird gecommittet. Tritt eine Exception auf, erfolgt ein automatisches Rollback und die Exception wird hochgeworfen.
|
||||
|
||||
```php
|
||||
// --- Interface (Logic Layer) ---
|
||||
namespace App\Logic\Common;
|
||||
|
||||
interface TransactionManagerInterface
|
||||
{
|
||||
public function execute(callable $work): mixed;
|
||||
}
|
||||
|
||||
// --- Implementierung (Data Layer) ---
|
||||
namespace App\Data\Infrastructure\Doctrine;
|
||||
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
readonly class EntityManagerTransactionManager implements TransactionManagerInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function execute(callable $work): mixed
|
||||
{
|
||||
$this->em->beginTransaction();
|
||||
try {
|
||||
$result = $work();
|
||||
$this->em->commit();
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
$this->em->rollback();
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($this->em->getConnection()->isTransactionActive()) {
|
||||
$this->em->rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Code Beispiel (Vollständig integriert)
|
||||
- **Granularität**: Fokus auf eine einzelne Entität pro Processor.
|
||||
|
||||
### Code Beispiel
|
||||
@@ -280,53 +406,65 @@ readonly class OrderProcessor implements OrderProcessorInterface
|
||||
}
|
||||
```
|
||||
|
||||
#### UseCase und Write-Manager Integration
|
||||
#### UseCase mit Transaktionssteuerung
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\UseCase;
|
||||
|
||||
use App\Logic\Common\UseCaseInterface;
|
||||
use App\Logic\Common\EventDispatcherInterface;
|
||||
use App\Logic\Common\TransactionManagerInterface;
|
||||
use App\Logic\Sales\Event\OrderCreatedEvent;
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderResponse;
|
||||
use App\Logic\Sales\Order\Exception\InsufficientStockException;
|
||||
use App\Logic\Sales\Order\Manager\OrderManagerInterface;
|
||||
use App\Logic\Sales\Stock\Manager\StockManagerInterface;
|
||||
|
||||
readonly class CreateOrderUseCase implements UseCaseInterface
|
||||
readonly class CreateOrderUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private TransactionManagerInterface $transactionManager,
|
||||
private OrderManagerInterface $orderManager,
|
||||
// TransactionManager ggf. für Multi-Processor-Szenarien
|
||||
private StockManagerInterface $stockManager,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
) {}
|
||||
|
||||
public function execute(mixed $request): CreateOrderResponse
|
||||
public function execute(CreateOrderRequest $request): CreateOrderResponse
|
||||
{
|
||||
if (!$request instanceof CreateOrderRequest) {
|
||||
throw new \InvalidArgumentException('Invalid request type');
|
||||
}
|
||||
return $this->transactionManager->execute(function () use ($request): CreateOrderResponse {
|
||||
// 1. Vorbedingungen prüfen -> Fehlschlag löst automatisches Rollback aus.
|
||||
if (!$this->stockManager->isSufficient($request->items)) {
|
||||
throw new InsufficientStockException();
|
||||
}
|
||||
|
||||
// Business Logik / Validierung ...
|
||||
// 2. Business Model anlegen und eigene State-Logik ausführen.
|
||||
$order = new Order(/* ... */);
|
||||
$order->confirm();
|
||||
|
||||
// Delegiere an den Manager: Der persistiert UND invalidiert den Read-Cache
|
||||
$order = $this->orderManager->createOrder($request);
|
||||
// 3. Persistieren (Manager -> Processor).
|
||||
$createdOrder = $this->orderManager->create($order);
|
||||
|
||||
return new CreateOrderResponse(orderId: $order->id);
|
||||
// 4. Domänen-Event dispatchen. Die Bridge-Implementierung des
|
||||
// EventDispatcherInterface garantiert ein Post-Commit-Dispatching,
|
||||
// sodass das Event nur den Bus erreicht, wenn die Transaktion
|
||||
// erfolgreich abgeschlossen wurde (siehe Sektion 1).
|
||||
$this->eventDispatcher->dispatch(new OrderCreatedEvent(orderId: $createdOrder->id));
|
||||
|
||||
return new CreateOrderResponse(orderId: $createdOrder->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.
|
||||
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 PHP-basierte Validierung 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 |
|
||||
| **UI (Controller)** | Syntaktische Prüfung mit reinem PHP | 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 |
|
||||
|
||||
@@ -334,42 +472,39 @@ Validierung findet so früh wie möglich statt. Jede Layer prüft, was sie prüf
|
||||
|
||||
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 */ ;
|
||||
$email = $request->request->get('customerEmail');
|
||||
$name = $request->request->get('customerName');
|
||||
$items = $request->request->all('items') ?? [];
|
||||
|
||||
// Syntaktische Validierung (frühes Fail)
|
||||
$violations = $validator->validate($dto);
|
||||
if (count($violations) > 0) {
|
||||
return new JsonResponse(['errors' => (string)$violations], 422);
|
||||
// Syntaktische Validierung mit reinem PHP (frühes Fail)
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return new JsonResponse(['error' => 'Invalid email format'], 422);
|
||||
}
|
||||
|
||||
if (empty($name) || strlen($name) < 2) {
|
||||
return new JsonResponse(['error' => 'Customer name is required'], 422);
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return new JsonResponse(['error' => 'Order must contain items'], 422);
|
||||
}
|
||||
|
||||
$dto = new CreateOrderRequest(
|
||||
customerEmail: $email,
|
||||
customerName: $name,
|
||||
itemIds: $items,
|
||||
);
|
||||
|
||||
// UseCase übernimmt Business-Validierung
|
||||
$responseDto = $useCase->execute($dto);
|
||||
return new JsonResponse(['orderId' => $responseDto->orderId], 201);
|
||||
@@ -402,94 +537,14 @@ class Order extends BaseModel
|
||||
- **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;
|
||||
}
|
||||
```
|
||||
|
||||
#### Anwendung im UseCase (`src/Logic`)
|
||||
Der UseCase nutzt den TransactionManager, um sicherzustellen, dass entweder alle oder keine Änderungen persistiert werden (All-or-Nothing). Die Orchestrierung zwischen Managern erfolgt innerhalb der Transaktions-Closure.
|
||||
|
||||
```php
|
||||
readonly class CreateOrderUseCase implements UseCaseInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TransactionManagerInterface $transactionManager,
|
||||
private OrderManager $orderManager,
|
||||
private StockManager $stockManager
|
||||
) {}
|
||||
|
||||
public function execute(mixed $request): CreateOrderResponse
|
||||
{
|
||||
return $this->transactionManager->transactional(function() use ($request) {
|
||||
// 1. Produktbestand reduzieren (Manager orchestriert Processor + Cache)
|
||||
$this->stockManager->reduceStockForOrder($request->items);
|
||||
|
||||
// 2. Bestellung anlegen (Manager orchestriert Processor + Cache)
|
||||
$order = $this->orderManager->createOrder($request);
|
||||
|
||||
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 Manager können konsistent in einer Transaktion orchestriert werden.
|
||||
- **Testbarkeit**: Der TransactionManager kann in Unit Tests einfach durch einen Mock ersetzt werden, der den Closure direkt ausführt.
|
||||
|
||||
Die Manager-Konzepte werden in Sektion 3 (Write Pattern) detailliert beschrieben. Die UseCases arbeiten auf Managern, die ihrerseits Processors aufrufen.
|
||||
|
||||
## 6. Entity vs. Business Model (`src/Data` $\rightarrow$ `src/Logic`)
|
||||
## 5. 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.
|
||||
2. **Vermeidung von Side-Effects**: ORMs wie Doctrine nutzen Lazy-Loading Proxies. Wenn diese Objekte in die 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 enthalten Rich Domain Logic.
|
||||
|
||||
### Definitionen
|
||||
|
||||
@@ -504,6 +559,7 @@ Um die Geschäftslogik vollständig vom Framework und dem ORM zu entkoppeln, wir
|
||||
- **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).
|
||||
- **Zustandsübergänge (State Transitions)**: Das Model kapselt die eigenen Statusänderungen. Anstatt `setStatus()` zu nutzen, bietet das Model explizite Methoden an (`confirm()`, `cancel()`), welche die Erlaubtheit des Übergangs prüfen und eine fachliche Exception werfen, falls er gegen die Geschäftsregeln verstößt.
|
||||
- **Regel**: Kennt keine Details über die Persistenz.
|
||||
|
||||
### Übergabe & Mapping
|
||||
@@ -513,7 +569,7 @@ Der Austausch erfolgt ausschließlich über ein Mapping in der Data Layer:
|
||||
|
||||
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`)
|
||||
## 6. 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.
|
||||
|
||||
@@ -527,7 +583,39 @@ 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
|
||||
### DTO-zu-Model-Mapping (Logic Layer)
|
||||
Um die UseCases schlank zu halten, findet die Transformation von Eingabe-DTOs (`Request`) in Business Models nicht durch Inline-Logik im UseCase statt. Stattdessen kommen dedizierte **ModelFactories** oder **Logic-Maps** zum Einsatz.
|
||||
|
||||
- **Ort**: `src/Logic/{Module}/{Feature}/Mapping/`
|
||||
- **Verantwortung**: Abbildung der Struktur von einem DTO auf ein Business Model (einschließlich Child-Models).
|
||||
- **Dependency Rule**: Diese Mappers liegen zwingend in der Logic Layer und kennen keine Entitys oder Provider.
|
||||
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\Mapping;
|
||||
|
||||
use App\Logic\Sales\Order\Dto\CreateOrderRequest;
|
||||
use App\Logic\Sales\Order\Model\Order;
|
||||
use App\Logic\Sales\Order\Model\OrderItem;
|
||||
use App\Logic\Sales\Order\Model\OrderStatus;
|
||||
|
||||
readonly class OrderModelFactory
|
||||
{
|
||||
public function createFromRequest(CreateOrderRequest $request): Order
|
||||
{
|
||||
return new Order(
|
||||
customerId: $request->customerId,
|
||||
// Delegierung der Child-Objekte
|
||||
items: array_map(
|
||||
fn($data) => new OrderItem(productId: $data['productId'], quantity: $data['quantity']),
|
||||
$request->items
|
||||
),
|
||||
status: OrderStatus::Created,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Code Beispiel (Data Layer Mapping)
|
||||
|
||||
#### Mapper Implementierung
|
||||
```php
|
||||
@@ -565,7 +653,7 @@ readonly class OrderMapper
|
||||
- **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`)
|
||||
## 7. 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.
|
||||
|
||||
@@ -617,7 +705,7 @@ readonly class ShippingProvider implements ShippingProviderInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 9. UI Controller Pattern (`src/UI`)
|
||||
## 8. 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.
|
||||
|
||||
@@ -676,7 +764,7 @@ readonly class OrderController extends AbstractController
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Exception Hierarchie & Error Handling (`src/Logic` $\rightarrow$ `src/UI`)
|
||||
## 9. 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.
|
||||
|
||||
@@ -742,8 +830,7 @@ Je nach Reichweite und Zeitkritikalität kommen verschiedene Mechanismen zum Ein
|
||||
- **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.
|
||||
Um "Geister-Events" durch Datenbank-Rollbacks zu vermeiden, ist das Dispatching von Async- und Broadcast-Events zwingend an einen erfolgreichen DB-Commit gebunden. Die konkrete Umsetzung erfolgt über die **Bridge-Konvention** aus Sektion 1: Der UseCase ruft `dispatch()` innerhalb der Transaktions-Closure auf; die `SymfonyEventDispatcherBridge` im Data Layer setzt automatisch den `DispatchAfterCurrentTransactionStamp`, sodass nur com-mittete Daten Events erzeugen. Ein Fehlschlag in der DB verhindert das Erreichen des Message Bus.
|
||||
|
||||
### Zusammenfassung Payload-Strategie
|
||||
| Event Typ | Timing | Payload | Transport |
|
||||
@@ -768,7 +855,7 @@ Die UI Layer fängt die `ConcurrencyException` im zentralen Exception-Subscriber
|
||||
- **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`)
|
||||
## 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user