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 verschachtelbarRequestPayloadData → 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.