Blog
Symfony und React: Verantwortlichkeiten und Abhängigkeiten von Anfang an klären
Verantwortlichkeiten in Symfony und React klären, Muster gezielt einsetzen und Grenzen durch Tests schützen, ohne unnötige Architekturschichten aufzubauen.
Welche Änderung sollte lokal bleiben?
Als Beispiel dient eine fiktive Anwendung für Reparaturmeldungen in einem Kulturzentrum. Ein Mitarbeiter meldet einen beschädigten Einrichtungsgegenstand, ein Techniker dokumentiert die Reparatur, und eine zuständige Person schließt den Vorgang ab. Bestimmte Kategorien verlangen eine Prüfung durch eine zweite Person. Anschließend kann ein externer Dienst den Hinweis auf der digitalen Raumtafel aktualisieren.
Für die erste Version braucht es weder Microservices noch einen ganzen Musterkatalog. Geklärt sein muss hingegen: Wer entscheidet über den Abschluss, wer speichert ihn, und wer kennt die API des Tafeldienstes? Muss ein späterer Anbieterwechsel den Controller, die Abschlussregel und React verändern, reicht die Abhängigkeit bereits zu weit.
Wir betrachten diese eine Operation durchgehend. Ihr Vertrag ist fiktiv und beschreibt keine GiSoft-Installation. Die Beispiele setzen PHP ab 8.2 voraus; den Framework-Kontext bilden Symfony 7.4 und React 19. Ausgelassene projektspezifische Typen werden jeweils benannt.
Verantwortlichkeiten brauchen einen festen Platz
React gestaltet die Interaktion: Vorgang anzeigen, Abschluss anfordern, Ergebnis erklären. Symfony authentifiziert den Aufrufer, prüft Berechtigungen und setzt den Zustandswechsel durch. Die Anwendungsoperation koordiniert diese Entscheidungen mit der Speicherung. Ein Adapter kennt die Darstellung einer Mitteilung beim externen Anbieter.
Die Übersicht unterscheidet Aufrufe von Implementierungsbeziehungen. Sie zeigt keine vorgeschriebene Reihenfolge von Framework-Listenern:
React repairs -- JSON --> RepairController --> CloseRepair
CloseRepair verwendet ClosurePolicy und RepairTickets
DoctrineRepairTickets implementiert RepairTickets
Benachrichtigungs-Handler verwendet RoomBulletin
RemoteRoomBulletin implementiert RoomBulletinDie Anwendung definiert die benötigten Fähigkeiten. Die Infrastruktur implementiert sie; der Symfony-Container wählt die Implementierung aus. Zur Laufzeit erreichen die Aufrufe weiterhin Doctrine und den Anbieter. Deren APIs müssen deshalb aber keine Abhängigkeiten der Fachregel sein. Vollständige Framework-Unabhängigkeit verursacht ebenfalls Aufwand. Sie lohnt sich dort, wo sie Änderungen oder Tests erleichtert.
Vom HTTP-Controller zur Anwendungsoperation
Ein früher Controller, der einen Vorgang lädt und dessen Beschreibung ändert, ist gut nachvollziehbar. Schwierig wird es, wenn er außerdem die Prüfpflicht bestimmt, den Vorgang abschließt, das Anbieter-SDK aufruft und die Entity serialisiert. Ein späterer CLI-Import muss dann entweder die Regel duplizieren oder HTTP-geprägten Code verwenden.
Eine kleine Korrektur besteht darin, CloseRepair herauszulösen. Routing, Einlesen und Validieren der Eingabe sowie die Abbildung auf eine HTTP-Antwort bleiben im Controller. Die Identität stammt aus dem authentifizierten Kontext, keinesfalls aus einer ungeprüften Benutzer-ID im JSON. Die Operation erhält Vorgangs-ID und validierte Revision. Ein Controller darf dabei durchaus mehrere sinnvolle Zeilen haben.
Der Ausschnitt definiert Projektverträge, keine Symfony- oder Doctrine-APIs. RepairTicket, Actor, ClosurePermission und die Exceptions sind ausgelassene Projekttypen. ClosurePermission prüft die Abschlussberechtigung und den Zugriff auf den Standort dieses Vorgangs; requireAwaitingClosure() weist einen unzulässigen aktuellen Zustand zurück.
interface RepairTickets
{
public function get(string $id): RepairTicket;
public function closeIfCurrent(
string $id, int $revision, string $actorId,
): bool;
}
final readonly class CloseRepair
{
public function __construct(
private RepairTickets $tickets,
private ClosurePolicy $policy,
private ClosurePermission $permission,
) {}
public function __invoke(Actor $actor, string $id, int $revision): void
{
$ticket = $this->tickets->get($id);
$this->permission->requireClosure($actor, $ticket);
$ticket->requireAwaitingClosure();
if (($reason = $this->policy->rejection($ticket->closureFacts())) !== null) {
throw new ClosureRejected($reason);
}
if (!$this->tickets->closeIfCurrent($id, $revision, $actor->id)) {
throw new StaleRepair();
}
}
}get() muss einen fehlenden Vorgang ausdrücklich melden. Der Vertrag von closeIfCurrent() ist anspruchsvoll: erwartete Revision und zulässigen Zustand atomar vergleichen, danach Abschluss, neue Revision und handelnde Person im Änderungsprotokoll speichern. Jede Änderung an entscheidungsrelevanten Daten muss an dieser Versionierung teilnehmen. Sonst kann die Prüfung vor dem Schreiben veralten. Der Adapter kann optimistisches Locking oder einen gleichwertigen bedingten Schreibzugriff verwenden; das Interface allein gewährleistet dieses Verhalten nicht.
Die Umsetzung braucht außerdem klare Transaktionsgrenzen und eine sichere HTTP-Abbildung erwartbarer Fehler. Die Datenbankimplementierung bleibt hier bewusst offen. Berechtigungsdienst und Policy sind konkrete Klassen: Eine injizierte Abhängigkeit benötigt nicht allein deshalb ein eigenes Interface.
Eine Abschlussregel für HTTP und CLI
In dieser Anwendung ist ein Arbeitsbericht erforderlich. Für eine prüfpflichtige Kategorie muss zusätzlich eine andere Person als der ausführende Techniker die Reparatur geprüft haben. Diese Policy entscheidet ohne HTTP, Doctrine oder Uhr:
final readonly class ClosureFacts
{
public function __construct(
public string $workNote,
public bool $needsReview,
public string $performedBy,
public ?string $reviewedBy,
) {}
}
final class ClosurePolicy
{
public function rejection(ClosureFacts $facts): ?string
{
if (trim($facts->workNote) === '') {
return 'work_note_missing';
}
if ($facts->needsReview && (
trim($facts->reviewedBy ?? '') === ''
|| $facts->reviewedBy === $facts->performedBy
)) {
return 'independent_review_required';
}
return null;
}
}Die Fakten stammen aus vertrauenswürdigen gespeicherten Datensätzen und den Kategorieregeln. Der Browser kann eine Prüfung nicht selbst für erledigt erklären. Nicht leere Mitarbeiter-IDs und gültige Prüfvermerke sind Invarianten des Modells. Eine Änderung der geprüften Arbeit macht die Prüfung ungültig und erhöht die Revision. Die Berechtigung zum Abschluss wird davon unabhängig geprüft.
Eine eigene Policy lohnt sich hier, weil auch ein CLI-Ablauf die Entscheidung braucht und mehrere Fälle abzudecken sind. Eine Entity-Methode oder eine kleine Methode im Anwendungsdienst wäre ebenfalls vertretbar. Für jedes if eine Specification anzulegen, würde die Regel eher auf zusätzliche Dateien verteilen.
Mit Pest 3 lässt sich diese reine PHP-Entscheidung ohne Symfony-Kernel testen. Die obigen Klassen müssen im Testprojekt per Autoloading verfügbar sein; ein Fixture-Builder oder versteckter Helper wird nicht vorausgesetzt.
it('verlangt einen Arbeitsbericht und die erforderliche unabhängige Prüfung', function (
string $note, bool $review, ?string $reviewer, ?string $expected,
): void {
$facts = new ClosureFacts($note, $review, 'worker-7', $reviewer);
expect((new ClosurePolicy())->rejection($facts))->toBe($expected);
})->with([
['', false, null, 'work_note_missing'],
['hinge adjusted', true, null, 'independent_review_required'],
['hinge adjusted', true, 'worker-7', 'independent_review_required'],
['hinge adjusted', true, 'worker-9', null],
['hinge adjusted', false, null, null],
]);Die erlaubten Fälle sind ebenso wichtig wie die Ablehnungen. Datenbanksperren oder die HTTP-Berechtigungsprüfung belegt dieser Test nicht.
Speicherung und öffentliche Daten gezielt trennen
Ein Repository kann Operationen wie Laden und Abschließen ausdrücken. Das Interface ist hier nützlich, weil die Anwendung eine definierte Persistenzfähigkeit samt Nebenläufigkeitsvertrag benötigt. Nicht jede Entity und nicht jeder Lookup braucht deshalb ein Repository-Interface. Wer EntityManager überall injiziert oder einen unfertigen QueryBuilder weiterreicht, überträgt Persistenzentscheidungen an immer mehr Aufrufer.
Für die Reparaturliste eines Raums kann ein Query Object eine komplexe, wiederverwendete Abfrage kapseln. Ein Read Repository bündelt zusammengehörige Lesezugriffe. Eine Projection beziehungsweise ein Read Model liefert begrenzte Felder wie Vorgangs-ID, Raumbezeichnung und Status. Dafür müssen nicht alle Notizen, Anhänge und Mitarbeiterdaten geladen werden. Ein einfacher Lookup braucht diese drei Abstraktionen nicht gleichzeitig. Grenzen für Abfragezahl und Antwortgröße sind dort sinnvoll, wo sie ein konkretes Regressionsrisiko absichern.
Die Modelle erfüllen unterschiedliche Aufgaben. Eine Persistenz-Entity beschreibt gespeicherten Zustand und kann auch Fachverhalten enthalten. Ein Request-DTO steht für nicht vertrauenswürdige Eingaben. Ein Response-DTO oder ein explizites Array definiert die öffentliche Ausgabe. Ein Read Model dient einer Abfrage, ein React View Model einer Ansicht. Ein Value Object lohnt sich bei einem Begriff wie einer Raumkennung mit relevanten Invarianten. Dafür braucht ein Feature nicht sieben Kopien derselben Felder.
Doctrine-Entities dürfen innerhalb der Anwendung verwendet werden, wenn das eine bewusste Abwägung ist. Ihr vollständiger serialisierter Objektgraph sollte jedoch nicht allein deshalb zur API werden, weil der Serializer ihn durchlaufen kann. Häufig genügt eine kurze Mapping-Funktion; eine eigene Mapper-Klasse muss ihren zusätzlichen Aufwand rechtfertigen.
Die Begriffe des Anbieters an der Grenze übersetzen
Grenz- und Integrationsmuster lösen unterschiedlich große Probleme. Ein Port, meist ein projekteigenes Interface, beschreibt eine benötigte Fähigkeit. Ein Adapter übersetzt sie in Anbieteraufrufe und überführt deren Ergebnis. ClosureNotice ist hier ein ausgelassenes Projekt-DTO mit genau den Daten für die Mitteilung:
interface RoomBulletin
{
public function recordClosure(
ClosureNotice $notice,
string $operationId,
): void;
}Eine Facade hilft, wenn eine Mitteilung mehrere technische Anbieteroperationen erfordert. Benennt sie lediglich eine einzelne Methode um, bringt sie wenig. Ein Anti-Corruption Layer (ACL) lohnt sich bei widersprüchlichen Fachmodellen: „closed“ kann beim Anbieter eine zurückgezogene Mitteilung bezeichnen, bei uns aber eine abgeschlossene Reparatur. Für eine kleine Feldumwandlung ist eine ganze Schicht selten nötig.
DTOs und View Models begrenzen die Daten an Transport- und Darstellungsgrenzen. Eine Anbieterantwort mit identischen Feldern in ein eigenes DTO zu kopieren, isoliert deren anbieterspezifische Bedeutung noch nicht. Bei einer kleinen, bereits lokal begrenzten Integration kann eine Adapterfunktion genügen. operationId formuliert einen Bedarf an Deduplizierung; der Parameter macht einen beliebigen Anbieter nicht idempotent.
React arbeitet mit einem Vertrag des Features
Die Beispiel-API nimmt POST /api/repairs/{id}/close samt Revision entgegen. Ihr dokumentierter Vertrag sieht 204 bei Erfolg, 409 bei einem Revisions- oder Zustandskonflikt und 422 bei einer verletzten Abschlussregel vor. Das sind Projektentscheidungen. Fehlende Authentifizierung und verweigerter Zugriff haben eigene vereinbarte Antworten, für diese JSON-API üblicherweise 401 und 403, sowie Tests für anonyme, abgewiesene und berechtigte Aufrufer.
Die Funktion closeRepair gehört zum Feature und übernimmt die HTTP-Details: das vorgesehene Session- oder Bearer-Credential, dazu passenden CSRF-Schutz, Antwortvalidierung und die Abbildung sicherer öffentlicher Fehlercodes. Sie liefert das kleine Ergebnis unten. PHP-Exception-Namen und ungeprüfte Servertexte gehören nicht in die Oberfläche. TypeScript-Deklarationen validieren externes JSON nicht zur Laufzeit.
Die Komponente verwaltet Warten, Ablehnung und Erfolg. Der Ausschnitt zeigt ihre Datei; der Netzwerkadapter ist ausgelassen. Der Funktionstyp ist unser eigener Vertrag und kein Framework-Helper.
import { useState } from 'react';
export type CloseResult =
| { kind: 'closed' }
| { kind: 'blocked'; message: string };
export type CloseRepair = (id: string, revision: number) => Promise<CloseResult>;
type Props = { id: string; revision: number; closeRepair: CloseRepair };
export function CloseRepairButton({ id, revision, closeRepair }: Props) {
const [phase, setPhase] = useState<'idle' | 'pending' | 'closed'>('idle');
const [notice, setNotice] = useState('');
async function submit() {
if (phase !== 'idle') return;
setPhase('pending');
setNotice('');
try {
const result = await closeRepair(id, revision);
setPhase(result.kind === 'closed' ? 'closed' : 'idle');
if (result.kind === 'blocked') setNotice(result.message);
} catch {
setPhase('idle');
setNotice('Abschluss nicht bestätigt. Vor einem erneuten Versuch den Vorgang prüfen.');
}
}
return (
<>
<button type="button" disabled={phase !== 'idle'} onClick={submit}>
{phase === 'closed' ? 'Vorgang abgeschlossen' : 'Vorgang abschließen'}
</button>
{notice && <p role="alert">{notice}</p>}
</>
);
}In Next.js wird die Komponente innerhalb eines Client-Teilbaums importiert. Ein übergeordnetes Modul mit 'use client' kann den Callback aus dem Browseradapter des Features übergeben. Ein gewöhnlicher Callback darf nicht von einer Server Component über diese Grenze gereicht werden. Serverseitige Anfragen benötigen ebenfalls eine ausdrückliche Regelung zur Credential-Weitergabe; sie übernehmen Browser-Credentials nicht automatisch.
Ein deaktivierter Button verbessert die Bedienung, erzwingt aber weder Backend-Berechtigungen noch Nebenläufigkeitsschutz oder Idempotenz. React kann einen leeren Arbeitsbericht sofort beanstanden; Symfony muss ihn trotzdem zurückweisen. Bei unklarem Netzwerkergebnis sollte der Vorgang vor einem erneuten Versuch geprüft oder neu geladen werden. Automatische Wiederholungen schreibender Operationen brauchen einen eigenen Sicherheitsvertrag.
Kleine Module mit sichtbaren Abhängigkeiten
Eine mögliche Struktur sieht so aus. Sie beschreibt das fiktive Feature und ist kein Vorschlag zur Umorganisation dieses Repositorys:
src/Repairs/
Application/ CloseRepair, RepairTickets
Domain/ ClosurePolicy, RepairTicket
Infrastructure/ DoctrineRepairTickets, RemoteRoomBulletin
UI/ RepairController
features/repairs/
api/ closeRepair
model/ CloseResult
ui/ CloseRepairButton
shared/ui/ ButtonReact muss die Backend-Schichten nicht nachbauen. Das Endpoint-Mapping bleibt beim Feature. Eine gemeinsame HTTP-Grundfunktion lohnt sich, wenn mehrere Features dasselbe Transportverhalten benötigen. Ein globaler ApiService für sämtliche Endpoints wird leicht selbst zu einem übergroßen Modul.
Das Raummodul kann einen kleinen Anwendungsvertrag für den Standortzugriff anbieten. Das Reparaturmodul sollte weder dessen privates Repository importieren noch dessen Entity eigenmächtig ändern. Ein direkter Aufruf zwischen Anwendungsdiensten ist durchaus angemessen; ein Event ist keine Pflicht. Zirkuläre Abhängigkeiten deuten häufig auf ungeklärte Zuständigkeiten hin, nicht auf ein fehlendes Interface.
Konstruktorparameter zeigen Abhängigkeiten. Zugriffe auf den Service-Container verbergen sie. Auch Shared, Common, Utils und Helpers brauchen klare Zuständigkeiten: Dort gehören bewusst ausgewählte, stabile modulübergreifende Konzepte hin. Zwei ähnliche Funktionen können günstiger sein als eine vorzeitige gemeinsame Abstraktion, deren Bedeutung noch nicht feststeht.
Entscheidungs- und Erzeugungsmuster passend dosieren
Policy, Strategy, Specification und Zustandsübergänge beantworten verschiedene Fragen. Unsere Policy entscheidet über den Abschluss. Eine Strategy könnte zwischen tatsächlich austauschbaren Verfahren zur Technikerzuweisung wählen. Gibt es nur einen Algorithmus, genügt ein normaler Dienst. Eine Specification benennt eine wiederverwendbare, kombinierbare Bedingung; eine lokale Prüfung braucht diesen Mechanismus selten.
Einfache Zustandsübergänge können explizit in der Entity oder Operation bleiben. Ein Zustandsautomat hilft, wenn zahlreiche Zustände, Übergänge und Bedingungen ein gemeinsames Modell brauchen. Drei offensichtliche Übergänge rechtfertigen ihn für sich genommen noch nicht. Diese Entscheidungen bleiben beim Backend, auch wenn React eine hilfreiche Vorschau anbietet.
Named Constructor, Factory und Builder betreffen die Erzeugung. RepairTicket::reported(...) kann Anfangszustand und Invarianten deutlich machen. Eine Factory hilft, wenn Referenzdaten oder eine relevante Auswahl zwischen Implementierungen nötig sind. Ein gewöhnliches DTO braucht weder Factory noch Erzeugungsdienst. Ein Builder ist vor allem für Tests mit optionalen Prüfungen und Notizen nützlich, solange er den entscheidenden Zustand sichtbar macht, statt ihn hinter großzügigen Defaults zu verstecken.
Jede Abstraktion verlangt ein weiteres Konzept, Konfiguration und Navigation zwischen Dateien. Dieser Aufwand lohnt sich, wenn er eine Entscheidung oder Erzeugungsregel verständlicher macht.
Abläufe koordinieren, ohne ein Command-Framework aufzubauen
Ein Anwendungsdienst beziehungsweise Use Case enthält den bereits gezeigten Ablauf: laden, Zugriff prüfen, Regel auswerten, speichern. Er muss weder Request noch React oder einen Anbieter-Antworttyp kennen. Eine einfache CRUD-Operation darf wesentlich kleiner bleiben.
Ein Command kann die Absicht ausdrücken, einen Vorgang abzuschließen. Eine Query kann offene Reparaturen eines Raums anfordern. Lese- und Schreiboperationen sollten dort getrennt sein, wo das Verständnis verbessert wird; nicht jeder Methodenaufruf braucht ein Objekt und einen Bus. Command/Query Separation verlangt weder vollständiges CQRS noch getrennt gespeicherte Lesemodelle oder asynchrone Aktualisierungen.
CQRS wird interessant, wenn sich Lese- und Schreibanforderungen so stark unterscheiden, dass getrennte Modelle und gegebenenfalls deren Synchronisierung den Aufwand wert sind. Ein Message Handler verarbeitet Arbeit, die bewusst über Messaging ausgeführt wird. Er ist keine Pflichtschicht um jeden Anwendungsdienst.
Technische Erweiterungen dürfen Fachentscheidungen nicht verstecken
Ein Decorator kann den Tafeldienst messen oder einen Lesezugriff unter demselben Vertrag cachen. Middleware kann ein einheitliches Verhalten auf einen Transport- oder Nachrichtenpfad anwenden. Beides hilft bei wiederkehrendem Logging, Metriken und Tracing; rund um einen einzelnen einfachen Aufruf kann es die Nachvollziehbarkeit verschlechtern.
Ein Event Subscriber beziehungsweise Listener eignet sich für technische Reaktionen mit geklärter Reihenfolge. Wird die Berechtigungsprüfung oder Abschlussentscheidung in einem Doctrine-Listener versteckt, ist der Ablauf schwerer zu verstehen. Kritische synchrone Entscheidungen sollten sichtbar bleiben. Eine Pipeline ordnet tatsächlich aufeinanderfolgende Verarbeitungsschritte; eine kurze Methode braucht kein eigenes Stufenregister.
Caching und Retry verändern ebenfalls Verhalten. Cache-Schlüssel und Invalidierung müssen Berechtigungen und Aktualitätsanforderungen beachten. Ein Retry-Wrapper muss wissen, welche Fehler und Operationen Wiederholungen zulassen. Die Bezeichnung „Querschnittsbelang“ nimmt ihm diese Verantwortung nicht ab.
Vor der Queue festlegen, was „abgeschlossen“ bedeutet
Der Vorgang ist abgeschlossen, sobald die berechtigte Zustandsänderung festgeschrieben ist. Die Raumtafel zu aktualisieren ist in diesem Beispiel nachgelagerte Arbeit. Symfony Messenger kann sie später ausführen; die Berechtigungsprüfung in einen verzögerten Handler zu verschieben, würde hingegen den Vertrag ändern. Eine Queue verlagert Arbeit und bringt Zustellungsfragen mit sich. Sie verbessert nicht automatisch den Entwurf.
Darf eine Mitteilung nicht verloren gehen, kann eine Outbox die Zustellabsicht in derselben Datenbanktransaktion wie den Abschluss speichern. Ein Worker übernimmt die spätere Zustellung. Mehrfache Zustellung bleibt möglich. Ein Idempotenzschlüssel identifiziert dieselbe logische Mitteilung über mehrere Versuche hinweg. Doppelte Effekte zu verhindern erfordert atomare lokale Koordination oder passende Anbieterunterstützung, nicht nur ein Feld operationId.
Eine Retry Policy begrenzt Wiederholungen bei vorübergehenden Fehlern; dauerhafte Berechtigungsfehler sollten nicht wiederholt werden. Ein Circuit Breaker kann Aufrufe eines dauerhaft fehlschlagenden Anbieters vorübergehend aussetzen. Ein Fallback muss ein akzeptiertes Ergebnis sein, etwa „Mitteilung ausstehend“, statt erfolgreiche Zustellung vorzutäuschen. Diese Mechanismen kosten Speicher und Betriebsaufwand. Die Zustellanforderungen entscheiden über ihren Einsatz, nicht eine Projektvorlage.
Ein Muster hat einen Preis
Diese kompakte Auswahlhilfe unterstützt die Prüfung einer vorgeschlagenen Abstraktion. Die einfachere Möglichkeit bleibt richtig, wenn sie dieselbe Verantwortung schützt.
Problem Kandidat Einfachere Lösung
Anbietermodell Port + Adapter / ACL Lokales Mapping
Gemeinsame Regel Policy / Specification Einzelne Methode
Mehrere Algorithmen Strategy Ein Dienst
Komplexe Liste Query + Projektion Einfacher Lookup
Wiederholte Messung Decorator / Middleware Direkte Messung
Erzeugungsregeln Factory KonstruktorDie Übersicht ist keine Einkaufsliste. Für den ersten Abschlussvorgang können eine konkrete Policy, ein Anwendungsdienst, ein Persistenzvertrag und eine API-Funktion des Features reichen. Der Anbieteradapter kommt mit der Integration hinzu. Interfaces für jede Klasse, Factories für jedes DTO, Domain Events für jede Änderung oder Messenger/CQRS für ein synchrones Formular müssen nicht auf Vorrat entstehen.
Die tatsächlich gefährdete Grenze testen
Statische Prüfungen liefern früh Rückmeldung: PHPStan und TypeScript erkennen Typfehler und falsche Null-Annahmen. Importregeln oder Architekturtests können vereinbarte Abhängigkeitsrichtungen schützen, etwa Anbieter-SDKs aus dem Anwendungscode oder serverseitige Imports aus Client-Modulen heraushalten. Dafür braucht es explizite Regeln und geeignete Werkzeuge. Diese leiten die Architektur nicht selbst ab und beweisen kein Laufzeitverhalten.
Der Policy-Test prüft Entscheidungen mit wenig Aufwand. Ein Integrationstest sollte DoctrineRepairTickets gegen eine isolierte Testdatenbank ausführen: echte Filterung, Revisionskonflikte und erforderliche Rollbacks. Ein gemockter QueryBuilder belegt diese Eigenschaften nicht. Adaptertests verwenden kontrollierte Anbieterantworten für Mapping und Fehlerbehandlung, nicht den Live-Dienst in der normalen CI.
Ein funktionaler Symfony-API-Test deckt Routing, Decodierung, Berechtigungen, Validierung, das öffentliche Ergebnis und ausgewählte Seiteneffekte ab. Neben Ablehnungen gehört ein berechtigter, erfolgreicher Abschluss dazu. Der GiSoft-Artikel „Symfony-APIs mit Pest testen“ behandelt die HTTP-Testfälle ausführlich.
Bei React zählt beobachtbares Verhalten. Dieses Beispiel mit Vitest und React Testing Library setzt eine DOM-Umgebung sowie @testing-library/jest-dom/vitest im Test-Setup voraus. Es verwendet die obige Komponente und ein bewusst noch nicht erfülltes Promise, ohne Timer oder Netzwerkanfrage:
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CloseRepairButton, type CloseResult } from './CloseRepairButton';
it('sperrt den Abschluss während der laufenden Anfrage', async () => {
const user = userEvent.setup();
let finish!: (result: CloseResult) => void;
const response = new Promise<CloseResult>((resolve) => { finish = resolve; });
const closeRepair = vi.fn().mockReturnValue(response);
render(<CloseRepairButton id="repair-42" revision={8} closeRepair={closeRepair} />);
const button = screen.getByRole('button', { name: 'Vorgang abschließen' });
await user.click(button);
expect(button).toBeDisabled();
expect(closeRepair).toHaveBeenCalledWith('repair-42', 8);
await act(async () => { finish({ kind: 'closed' }); });
expect(screen.getByRole('button', { name: 'Vorgang abgeschlossen' })).toBeDisabled();
});Ergänze einen Ablehnungsfall, der die Eingaben erhält und eine Korrektur ermöglicht. Ein Vertrags- oder Adaptertest prüft Statusabbildung, JSON-Struktur, nullable Werte, Datumsangaben und sichere Fehlercodes gegen den tatsächlichen Backend-Vertrag. Ein TypeScript-Cast ist kein solcher Test.
Ein Browserablauf kann Meldung, Prüfung und Abschluss über die gesamte Anwendung hinweg abdecken. Die Grenzfälle der Policy bleiben in Unit-Tests. Große Snapshots, gemeinsame Fixtures, echte Uhrzeit und versteckte Helper erhöhen Wartungskosten, ohne zwangsläufig ein weiteres Risiko zu prüfen. Verwende deterministische Daten und isolierten Zustand. Schnelle Prüfungen laufen früh, danach passende Integrations-, API- und ausgewählte Browsertests; die konkrete CI-Reihenfolge hängt vom Projekt ab.
Grenze / Risiko Entwurfswerkzeug Verlässlicher Test
Abschlussentscheidung Policy Unit
Revision und Daten Persistenzvertrag DB-Integration
Anbieterübersetzung Port + Adapter Adaptervertrag
HTTP-Berechtigung Sicherheitsgrenze Funktionaler API-Test
Warten / Fehler im UI Feature-Komponente Komponententest
Gesamter Ablauf Mehrere Grenzen Ausgewählter BrowsertestMit einem vollständigen Feature beginnen
Setze Meldung und Abschluss über die echten HTTP- und Persistenzgrenzen um, bevor ein umfangreicher Verzeichnisbaum entsteht. Halte fest, wo die Regel liegt, was die API verspricht, was eine konkurrierende Änderung bedeutet und ob eine fehlgeschlagene Mitteilung das Ergebnis verändert. Prüfe diese Entscheidungen erneut, sobald ein zweiter Anwendungsfall einen tatsächlichen Wiederverwendungsbedarf schafft.
Details zu Doctrine-Optimierung, Firewall-Diagnose, erneuter Nachrichtenzustellung und API-Testkatalogen bleiben in den jeweiligen Artikeln. Hier genügt eine konkrete Entwurfsfrage: Bleibt der Wechsel des Tafeldienstes im Adapter, und hat eine Änderung der Abschlussregel einen klaren Ort mit einem gezielten Test? Zusätzliche Struktur lohnt sich, wenn sie eine solche Änderung sicherer macht.
Technische Referenzen
Versionsabhängige Details wurden anhand dieser Dokumentation geprüft; Reparaturszenario und Beispiele wurden für diesen Artikel entwickelt.
- Doctrine ORM 3.6, Transaktionen und Nebenläufigkeit: https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- React, Grenzen von Client-Modulen: https://react.dev/reference/rsc/use-client
- Symfony 7.4, Nachrichtenzustellung und Wiederholungen: https://symfony.com/doc/7.4/messenger.html
