Architektur & Patterns
Die common-api folgt vier wiederkehrenden Mustern. Wer diese kennt, kann Controller in zwei Minuten lesen — und neue Endpunkte in fünf schreiben.
Die vier Schichten
| Schicht | Zweck | Beispiel |
|---|---|---|
| Controller | nur Routing, Validation triggern, Response bauen | Grenke\RequestController::create |
| FormRequest | Eingabe validieren | CreateGrenkeRequestRequest |
| DTO | typisierter Container, fromArray() ↔ toArray() | RequestPayloadData |
| Action | EINE atomare Operation gegen ein externes System | CreateGrenkeRequestAction::execute() |
| Service | orchestriert mehrere Actions / hält Helfer-Logik | RequestFlowService, RequestStateService |
| ApiService | dünner HTTP-Client mit Auth & Error-Handling | Grenke\ApiService, WeclappApiService |
1. Action Pattern
Eine Action ist eine Klasse mit einer Methode execute(), die genau eine Operation ausführt. Dependencies werden im Constructor injiziert.
namespace App\Actions\Grenke;
use App\DataTransferObjects\Grenke\RequestPayloadData;
use App\DataTransferObjects\Grenke\RequestResponseData;
use App\Services\Grenke\ApiService;
class CreateGrenkeRequestAction
{
public function __construct(
protected ApiService $grenkeApiService
) {}
public function execute(RequestPayloadData $payload): RequestResponseData
{
$response = $this->grenkeApiService->postJson('requests', $payload->toArray());
return RequestResponseData::fromArray($response);
}
}
Vorteile
- ✅ One class, one job — leicht zu testen, leicht zu mocken
- ✅ Wiederverwendbar in mehreren Controllern und im
RequestFlowService - ✅ DI macht Test-Setup trivial (
$this->mock(CreateGrenkeRequestAction::class))
Konvention — Action-Namen sind Verb + Domain + Substantiv:
CreateGrenkeRequestAction
PatchGrenkeLesseeAction
WaitForGrenkeReadyToSignAction
GetGrenkeContractDocumentAction
2. DTO Pattern
Externe APIs liefern oft PascalCase (Grenke: FinancingId, Lessee), interner Code nutzt camelCase. DTOs sind die Übersetzungs-Schicht und garantieren Typ-Sicherheit.
class RequestPayloadData
{
public function __construct(
public readonly string $financingId,
public readonly string $productType,
public readonly float $financingAmount,
public readonly LesseeData $lessee,
// …
) {}
public static function fromArray(array $data): self
{
return new self(
financingId: (string) ($data['FinancingId'] ?? ''),
productType: (string) ($data['ProductType'] ?? ''),
financingAmount: (float) ($data['FinancingAmount'] ?? 0),
lessee: LesseeData::fromArray($data['Lessee'] ?? []),
);
}
public function toArray(): array
{
return [
'FinancingId' => $this->financingId,
'ProductType' => $this->productType,
'FinancingAmount' => $this->financingAmount,
'Lessee' => $this->lessee->toArray(),
];
}
}
Regeln
- DTOs sind immutable (
readonly) - DTOs sind verschachtelbar —
RequestPayloadData → LesseeData → AddressData - Jede DTO implementiert sowohl
fromArray()als auchtoArray()— egal ob sie nur als Input oder nur als Output benutzt wird (außer es ist sicher, dass sie nie in beide Richtungen läuft)
3. Service Pattern
Services kommen in zwei Geschmäckern:
a) *ApiService — HTTP-Wrapper
Dünner Wrapper um Laravel Http / Guzzle. Stellt nur die HTTP-Verben + Auth + Error-Logging bereit.
$response = $this->grenkeApiService->getJson('requests', ['pageSize' => 100]);
$response = $this->grenkeApiService->postJson('requests', $payload->toArray());
Siehe Grenke ApiService, Weclapp ApiService, MFR ApiService.
b) Orchestrator-Services
Verkettet mehrere Actions zu einem Geschäfts-Workflow. Beispiel: Grenke\RequestFlowService führt Create → Patch → WaitForReady → ESignature in einem Aufruf aus.
$result = $this->grenkeRequestFlowService->execute(
requestPayload: $requestPayload,
eSignaturePayload: $eSignaturePayload,
cancelESignature: false,
);
Siehe Grenke Request Flow.
4. State Machine (Enum)
Statt magischer Strings für API-Statuswerte → typisiertes Enum mit Verhalten.
namespace App\Enums\Grenke;
enum RequestState: string
{
case ReadyToSign = 'ReadyToSign';
case MissingInfo = 'MissingInfo';
case ContractPrinted = 'ContractPrinted';
case Cancelled = 'Cancelled';
case Declined = 'Declined';
case RequestToGrenke = 'RequestToGrenke';
case RunningContract = 'RunningContract';
public function isTerminal(): bool { /* Cancelled, Declined, RunningContract, ContractPrinted */ }
public function abortsProcess(): bool { /* Cancelled, Declined */ }
public function triggersMailFlow(): bool { /* MissingInfo */ }
}
So wird Logik nicht im Controller verteilt:
if ($request->state?->abortsProcess()) {
throw new RuntimeException("Grenke-Prozess abgebrochen.");
}
Siehe Grenke Enums und State-Machine im Request-Flow.
Wie schreibe ich einen neuen Endpunkt?
Beispiel: neuer GET /api/v1/grenke/{financingId}/audit-Endpunkt.
- Route in
routes/grenke.phpergänzenRoute::get('/{financingId}/audit', [RequestController::class, 'audit']); - Action anlegen —
app/Actions/Grenke/GetGrenkeAuditAction.phppublic function execute(string $financingId): array
{
return $this->grenkeApiService->getJson("requests/{$financingId}/audit");
} - (optional) DTO für Response, falls strukturiert
- Controller-Methode im
RequestControllerpublic function audit(string $financingId): JsonResponse
{
try {
$result = $this->getGrenkeAuditAction->execute($financingId);
return response()->json(['success' => true, 'data' => $result]);
} catch (Throwable $e) {
return $this->errorResponse($e);
}
} - Test in
tests/Feature/Grenke/AuditTest.php(Pest)
Das war's. Kein Boilerplate, keine versteckte Magie.