Refactor validation strategy: remove Symfony Validator from Logic Layer in favor of pure PHP checks in UI
This commit is contained in:
+160
-18
@@ -151,7 +151,7 @@ readonly class GetOrdersQuery implements UseCaseInterface
|
||||
}
|
||||
```
|
||||
|
||||
#### Manager & Provider
|
||||
#### Manager (Read + Cache)
|
||||
```php
|
||||
namespace App\Logic\Sales\Order\Manager;
|
||||
|
||||
@@ -159,24 +159,46 @@ use App\Data\Sales\Order\Provider\OrderProvider;
|
||||
|
||||
readonly class OrderManager
|
||||
{
|
||||
public function __construct(private OrderProvider $orderProvider) {}
|
||||
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
|
||||
{
|
||||
// Cache-Logik hier...
|
||||
return $this->orderProvider->fetchOrders($filters, $page);
|
||||
// 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 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
// --- Data Layer ---
|
||||
#### Provider (Data Layer)
|
||||
```php
|
||||
namespace App\Data\Sales\Order\Provider;
|
||||
|
||||
readonly class OrderProvider
|
||||
use App\Logic\Sales\Order\Manager\OrderManagerInterface;
|
||||
|
||||
readonly class OrderProvider implements OrderManagerInterface
|
||||
{
|
||||
public function fetchOrders(array $filters, int $page): array
|
||||
{
|
||||
// DB Abfrage und Mapping zu Business-Models
|
||||
return [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -186,12 +208,11 @@ readonly class OrderProvider
|
||||
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** (optional) $\rightarrow$ **Processor**
|
||||
`UI Layer` $\rightarrow$ **UseCase** $\rightarrow$ **Manager** $\rightarrow$ **Processor**
|
||||
|
||||
#### Manager (Logic Layer - optional)
|
||||
- **Zweck**: Koordinationsschicht für Schreiboperationen.
|
||||
- **Verantwortung**: Primär zuständig für die Orchestrierung von Nebenwirkungen, die außerhalb der Kern-Transaktion liegen oder die Datenkonsistenz über verschiedene Ebenen hinweg sicherstellen müssen (z. B. Triggerung der **Cache-Invalidierung** in der Read-Layer nach erfolgreichem Write).
|
||||
- **Schnittstelle**: Besitzt wie im Read Pattern ein eigenes Interface zur Gewährleistung von Testbarkeit.
|
||||
#### 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.
|
||||
@@ -237,7 +258,128 @@ readonly class OrderProcessor implements OrderProcessorInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Transaction Management (`src/Logic` $\rightarrow$ `src/Data`)
|
||||
#### 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.
|
||||
|
||||
@@ -316,7 +458,7 @@ readonly class DoctrineTransactionManager implements TransactionManagerInterface
|
||||
- **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.
|
||||
|
||||
## 5. Entity vs. Business Model (`src/Data` $\rightarrow$ `src/Logic`)
|
||||
## 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.
|
||||
|
||||
@@ -347,7 +489,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.
|
||||
|
||||
## 6. Mapping Pattern (`src/Data`)
|
||||
## 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.
|
||||
|
||||
@@ -399,7 +541,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.
|
||||
|
||||
## 7. Externe Systemintegration (`src/Data`)
|
||||
## 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.
|
||||
|
||||
@@ -451,7 +593,7 @@ readonly class ShippingProvider implements ShippingProviderInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 8. UI Controller Pattern (`src/UI`)
|
||||
## 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.
|
||||
|
||||
@@ -510,7 +652,7 @@ readonly class OrderController extends AbstractController
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Exception Hierarchie & Error Handling (`src/Logic` $\rightarrow$ `src/UI`)
|
||||
## 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user