Skip to main content

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​

SchichtZweckBeispiel
Controllernur Routing, Validation triggern, Response bauenGrenke\RequestController::create
FormRequestEingabe validierenCreateGrenkeRequestRequest
DTOtypisierter Container, fromArray() ↔ toArray()RequestPayloadData
ActionEINE atomare Operation gegen ein externes SystemCreateGrenkeRequestAction::execute()
Serviceorchestriert mehrere Actions / hält Helfer-LogikRequestFlowService, RequestStateService
ApiServicedünner HTTP-Client mit Auth & Error-HandlingGrenke\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 auch toArray() — 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.

  1. Route in routes/grenke.php ergänzen
    Route::get('/{financingId}/audit', [RequestController::class, 'audit']);
  2. Action anlegen — app/Actions/Grenke/GetGrenkeAuditAction.php
    public function execute(string $financingId): array
    {
    return $this->grenkeApiService->getJson("requests/{$financingId}/audit");
    }
  3. (optional) DTO für Response, falls strukturiert
  4. Controller-Methode im RequestController
    public 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);
    }
    }
  5. Test in tests/Feature/Grenke/AuditTest.php (Pest)

Das war's. Kein Boilerplate, keine versteckte Magie.