跳转到内容

Der erste vertikale Slice

此内容尚不支持你的语言。

Nach den bisherigen Infrastruktur-Schritten ist die Turnier-App technisch startklar.

Die Anwendung läuft. Das Deployment steht. Die CI prüft statische Quality Gates. Keycloak ist integriert. Das API Gateway validiert JWTs. Transloco, Tailwind und PrimeNG bilden die Grundlage für UI und Texte.

Damit ist der Punkt erreicht, an dem das Projekt endlich fachlich werden darf.

Und genau hier lauert die nächste Falle.

Denn der erste fachliche Schritt entscheidet oft darüber, wie sich ein Projekt weiterentwickelt. Wenn man jetzt einfach “Spielerverwaltung bauen” sagt, entsteht schnell Code. Vielleicht sogar funktionierender Code. Aber nicht zwingend eine tragfähige Struktur.

Deshalb geht es in diesem Schritt nicht nur um CRUD.

Es geht um den ersten vertikalen Slice.

Die fachliche Idee der App ist überschaubar: Ein Verein möchte Tischtennisturniere vorbereiten und durchführen. Dafür braucht es Spieler, Turniere, Gruppen, Spielpläne, Ergebnisse und Historie.

Man könnte also direkt mit irgendeinem sichtbaren Feature starten.

Zum Beispiel:

Spieler anlegen
Turnier erstellen
Gruppen per Drag & Drop einteilen
Spielplan generieren

Der sichtbare Reiz liegt wahrscheinlich bei der Gruppeneinteilung. Drag & Drop, Gruppen, Ranking, Turniertag — das fühlt sich nach Produkt an.

Trotzdem ist das nicht der beste erste Schritt.

Die Spieler-Verwaltung ist kleiner. Langweiliger. Weniger beeindruckend.

Aber genau deshalb eignet sie sich gut als erster fachlicher Durchstich.

Sie ist fachlich einfach genug, um nicht sofort in Turnierregeln zu versinken. Gleichzeitig berührt sie alle relevanten Schichten:

Frontend
API Gateway
Backend-Service
Datenbank
Auth-Kontext
Tenant-/Vereinskontext
UI
i18n
Validierung

Damit ist sie ideal, um die Architektur einmal vollständig zu beweisen.

Der erste Slice soll nicht nur beweisen, dass ein Formular gespeichert werden kann.

Er soll zeigen, ob die gewählte Struktur trägt.

Für die Spieler-Verwaltung bedeutet das:

User öffnet die Spieler-Seite.
User sieht Spieler im aktuellen Vereinskontext.
User legt einen Spieler an.
Der Spieler wird serverseitig dem Verein zugeordnet.
Der Spieler erhält Audit-Informationen.
Die API ist geschützt.
Die Datenbank speichert den Datensatz.
Die UI aktualisiert sich.

Das ist kein großes Feature. Aber es ist ein kompletter Weg durch das System.

Genau deshalb ist es ein guter erster fachlicher Schritt.

Eine wichtige Entscheidung betrifft die Frontend-Struktur.

Die naheliegende technische Ordnung wäre:

libs/tournament/model
libs/tournament/domain
libs/tournament/presentation

Das sieht auf den ersten Blick sauber aus. Modelle liegen bei Modellen. Domain-Code liegt bei Domain-Code. Presentation-Code liegt bei Presentation-Code.

Das Problem: Diese Struktur ist horizontal.

Mit wachsender Fachlichkeit würde dort schnell alles landen:

libs/tournament/model
├─ player.model.ts
├─ tournament.model.ts
├─ schedule.model.ts
├─ match.model.ts
└─ standings.model.ts
libs/tournament/domain
├─ players.store.ts
├─ tournaments.store.ts
├─ schedule.store.ts
└─ standings.store.ts
libs/tournament/presentation
├─ players-page.component.ts
├─ tournament-setup-page.component.ts
├─ schedule-page.component.ts
└─ standings-page.component.ts

Formal wäre das sortiert. Fachlich wäre es aber bereits vermischt.

Deshalb wird die Fachlichkeit vertikal geschnitten.

Für die Spieler-Verwaltung entsteht eine eigene Struktur:

libs/tournament/players/
├─ model/
├─ domain/
└─ presentation/

Spätere Fachlichkeiten bekommen ihre eigenen Bereiche:

libs/tournament/schedule/
├─ model/
├─ domain/
└─ presentation/
libs/tournament/tournaments/
├─ model/
├─ domain/
└─ presentation/

Der wichtige Punkt ist:

Players bleibt bei Players. Schedule bleibt bei Schedule. Tournament bleibt bei Tournament.

Das klingt banal. Ist es aber nicht.

Gerade in Frontend-Projekten entsteht der Big Ball of Mud oft nicht dadurch, dass niemand Struktur wollte. Er entsteht dadurch, dass die Struktur zu technisch gedacht wurde.

Die Entscheidung für eigene Feature-Libs ist bewusst.

Sie soll mehrere Dinge leisten:

  • klare Boundaries
  • besserer Fokus beim Entwickeln
  • kleinere Review-Flächen
  • bessere Führung für Coding-Agenten
  • weniger versehentliche Kopplung
  • einfachere spätere Extraktion
  • weniger Druck, alles in shared zu verschieben

Ein späterer Auftrag kann dadurch klar begrenzt werden:

Arbeite nur in libs/tournament/players/**.
Keine Änderungen an schedule.
Keine Änderungen an tournaments.

Das ist für menschliche Entwickler hilfreich.

Für Coding-Agenten ist es fast noch wichtiger.

Ein Agent orientiert sich stark an vorhandenen Mustern. Wenn die erste Fachlichkeit sauber vertikal liegt, ist die Wahrscheinlichkeit höher, dass spätere Fachlichkeiten ebenfalls so entstehen.

Wenn die erste Fachlichkeit dagegen in globalen Ordnern verteilt ist, wird genau diese Verteilung zum neuen Muster.

Eine weitere Regel wird direkt mit dem ersten Slice festgelegt:

Fachliche Features kommunizieren im Frontend nicht über fremde Stores.

Konkret:

Die Spieler-Verwaltung darf Spieler laden, anzeigen, anlegen und löschen.

Aber sie wird nicht zur globalen Datenquelle für Schedule, Turnier-Setup oder andere spätere Features.

Wenn Schedule später Spieler benötigt, dann greift Schedule nicht einfach auf den PlayersStore zu.

Stattdessen holt sich Schedule seine Daten über einen eigenen Backend-Endpunkt.

Warum?

Weil verschiedene Fachlichkeiten oft unterschiedliche Sichten auf dieselben Daten brauchen.

Die Spieler-Verwaltung braucht vielleicht:

Vorname
Nachname
initiales Ranking
Geburtsdatum
Status

Das Turnier-Setup braucht vielleicht:

Spieler-ID
Anzeigename
Ranking
Verfügbarkeit
bereits zugeordnet

Schedule braucht vielleicht:

Spieler-ID
Anzeigename
Ranking-Snapshot für dieses Turnier

Das sind nicht zwingend dieselben Modelle.

Wenn man zu früh ein globales PlayerModel baut, hängt später alles an allem.

Deshalb gilt für dieses Projekt:

Wiederverwendung ist nicht automatisch besser als eine klare Grenze.

Kontrollierte Doppelung ist erlaubt.

Auch das API Gateway wird fachlich geschnitten.

Nicht so:

controllers/
services/
dto/

Sondern so:

apps/apis/tournament-api/src/app/
├─ players/
│ ├─ dto/
│ ├─ players.controller.ts
│ ├─ players.service.ts
│ └─ players.module.ts
├─ tournaments/
├─ schedule/
└─ auth/

Der players.service.ts im API Gateway ist dabei kein Domain-Service.

Er ist ein Gateway-Service.

Seine Aufgabe ist:

  • HTTP Requests annehmen
  • DTOs validieren
  • den authentifizierten User auswerten
  • den aktuellen Vereins-/Tenant-Kontext bestimmen oder weiterreichen
  • Commands und Queries an den Backend-Service weitergeben
  • Responses für das Frontend mappen

Die eigentliche Fachlichkeit liegt nicht im API Gateway.

Aber die API-Struktur bleibt trotzdem fachlich nachvollziehbar.

Ein Spieler-Endpoint liegt im Spieler-Bereich. Ein späterer Schedule-Endpoint liegt im Schedule-Bereich. Ein Turnier-Endpoint liegt im Turnier-Bereich.

Das verhindert nicht jede Kopplung. Aber es macht Kopplung sichtbarer.

Der Backend-Service bleibt ein einzelner Service.

Es gibt also keinen eigenen players-service, keinen eigenen schedule-service und keinen eigenen tournaments-service.

Für den MVP wäre das unnötig.

Trotzdem wird auch der Service intern vertikal geschnitten:

apps/services/tournament-service/src/app/
├─ players/
│ ├─ dto/
│ ├─ player.entity.ts
│ ├─ players.repository.ts
│ ├─ players.service.ts
│ └─ players.module.ts
├─ tournaments/
├─ schedule/
└─ shared/

Das ist der Kern der bisherigen Architekturentscheidung:

Ein Deployment-Monolith, aber kein fachlicher Mischmasch.

Die Fachmodule teilen sich am Ende eine Datenbank. Aber sie sollen nicht beliebig ihre Controller, DTOs, Repositories und Use Cases vermischen.

Die gemeinsame Datenbank ist eine Betriebsentscheidung.

Sie ist kein Freifahrtschein für einen globalen Repository-Ordner.

Ein wichtiger Punkt in dieser Architekturfindung ist der bewusste Umgang mit Doppelung.

Viele Teams versuchen sehr früh, gemeinsame Modelle, DTOs oder Services zu extrahieren.

Das wirkt ordentlich.

Ist aber oft gefährlich.

Denn am Anfang ist noch nicht klar, welche Ähnlichkeiten stabil sind und welche nur zufällig gleich aussehen.

Ein PlayerVm in der Spieler-Verwaltung ist nicht automatisch dasselbe wie ein PlayerSelectionItem im Turnier-Setup.

Ein PlayerDto für CRUD ist nicht automatisch dasselbe wie ein ScheduledPlayerSnapshot für einen Spielplan.

Daher gilt:

Lieber kleine, kontrollierte Doppelung als falsche Wiederverwendung.

Gemeinsame Modelle wandern erst dann nach shared, wenn die Wiederverwendung fachlich stabil ist.

Nicht, weil zwei Properties zufällig gleich heißen.

Schon beim ersten fachlichen Feature stellt sich die Frage nach Datenräumen.

Aktuell geht es um ein internes Vereinsturnier. Aber die Modellierung sollte nicht so eng sein, dass spätere Erweiterungen unnötig schwer werden.

Was passiert, wenn später mehrere Vereine die App nutzen?

Was passiert, wenn ein Admin einen Verein anlegt und Trainer dazu einlädt?

Was passiert, wenn später Turniere über mehrere Vereine hinweg stattfinden?

Das soll jetzt nicht gebaut werden.

Aber es soll auch nicht verbaut werden.

Die saubere fachliche Sicht ist:

Der Verein ist der Tenant.

Technisch könnte man also von tenantId sprechen. Fachlich ist clubId vermutlich der bessere Begriff.

Ein späteres Zielbild könnte so aussehen:

Club
├─ Admins
├─ Trainers
├─ Players
└─ Tournaments

Mit Mitgliedschaften:

ClubMembership
├─ clubId
├─ userId
├─ role
└─ status

Für den MVP wird das noch nicht implementiert.

Aber die Player-Daten sollen bereits einem Vereinskontext zugeordnet werden.

Durch die Keycloak-Integration gibt es bereits eine stabile technische User-ID:

userId = token.sub

Diese User-ID kommt aus dem Access Token und wird vom API Gateway validiert.

Für die Datenmodellierung bedeutet das:

createdByUserId
updatedByUserId

Diese Felder sind Audit-Informationen.

Sie beantworten:

Wer hat den Datensatz angelegt oder zuletzt verändert?

Der fachliche Datenraum ist davon getrennt.

Dieser wird durch den Verein bestimmt:

clubId

Damit ergibt sich eine wichtige Regel:

sub identifiziert den Benutzer. clubId identifiziert den fachlichen Datenraum. createdByUserId ist Audit, nicht automatisch die Tenant-Grenze.

Für den ersten Durchstich kann clubId noch aus einer Konfiguration kommen, zum Beispiel aus einer Default-Club-ID.

Das Frontend setzt diese ID nicht selbst.

Der Server bestimmt den Kontext.

Eine naheliegende einfache Absicherung wäre:

where: {
clubId: currentClubId,
createdByUserId: currentUser.userId
}

Das ist sehr restriktiv.

Aber für eine Vereins-App ist es fachlich wahrscheinlich falsch.

Wenn ein Admin Spieler anlegt, sollen Trainer diese Spieler später vermutlich auch sehen und für Turniere verwenden können. Die Spieler gehören nicht dem einzelnen Benutzer. Sie gehören dem Verein.

Deshalb ist die bessere Richtung:

where: {
clubId: currentClubId;
}

Die Prüfung, ob ein Nutzer diesen clubId-Kontext überhaupt verwenden darf, erfolgt später über Memberships und Rollen.

Für den MVP kann diese Membership-Logik noch fehlen. Aber die Modellierung sollte bereits in die richtige Richtung zeigen.

Der erste fachliche Slice ist die Spieler-Verwaltung.

Bewusst klein:

Liste
Anlegen
Löschen beziehungsweise Deaktivieren

Keine Detailseite.

Keine komplexe Bearbeitung.

Keine Turnierzuordnung.

Kein Schedule.

Kein Drag & Drop.

Die UI kann zunächst aus einer Übersichtsseite bestehen:

  • Tabelle mit Spielern
  • Button “Spieler anlegen”
  • Modal/Dialog für Create
  • Aktion pro Zeile zum Löschen oder Deaktivieren

Wichtig ist nicht, dass dieses Feature spektakulär ist.

Wichtig ist, dass es den ersten fachlichen Durchstich sauber beweist.

Bei Spielern sollte man vorsichtig mit echtem Löschen sein.

Für ein MVP ist ein Delete-Button in der UI verständlich. Fachlich kann dahinter aber ein Soft Delete beziehungsweise eine Deaktivierung stehen.

Also:

UI: Löschen
Backend: Deaktivieren

Das hat Vorteile:

  • versehentlich gelöschte Spieler sind nicht sofort aus der Historie verschwunden
  • spätere Turniere können auf alte Spieler referenzieren
  • Audit bleibt möglich
  • das Modell ist robuster für Historie

Für den ersten Durchstich reicht eine einfache Entscheidung:

Kein Hard Delete, wenn Historie später relevant werden kann.

Auch hier gilt: nicht überbauen, aber nicht verbauen.

Der erste fachliche Slice bleibt eng.

Nicht enthalten:

Turnierliste
Turnier-Setup
Gruppeneinteilung
Drag & Drop
Spielplan-Generator
Ergebniserfassung
Eltern-Live-Ansicht
Vereinsverwaltung
Einladungen
Rollenmatrix
Membership-Verwaltung

Das mag hart wirken. Aber genau diese Begrenzung schützt das Projekt.

Ein großer fachlicher Prompt würde sehr schnell wieder mehrere Grenzen gleichzeitig berühren. Dann wäre schwer zu erkennen, ob ein Problem aus der UI, der API, dem Service, der Datenbank, der Auth oder der Modellierung kommt.

Der erste Slice soll klein bleiben, damit sein Review möglich bleibt.

Was dieser Schritt für spätere Features vorbereitet

Abschnitt betitelt „Was dieser Schritt für spätere Features vorbereitet“

Wenn der Players-Slice sauber sitzt, ist viel gewonnen.

Dann gibt es ein Muster für:

  • vertikale FE-Libs
  • API Gateway Fachordner
  • Service Fachmodule
  • geschützte Endpoints
  • Auth-Kontext im Request
  • clubId als Datenraum
  • Audit über createdByUserId
  • i18n in der UI
  • PrimeNG-Komponenten
  • Tailwind-Layout
  • kleine fachliche Prompts

Spätere Features können sich daran orientieren.

Zum Beispiel Turnier-Setup:

libs/tournament/tournaments/model
libs/tournament/tournaments/domain
libs/tournament/tournaments/presentation

Oder Schedule:

libs/tournament/schedule/model
libs/tournament/schedule/domain
libs/tournament/schedule/presentation

Aber sie greifen nicht einfach in Players hinein.

Sie bekommen eigene Backend-Abfragen und eigene ViewModels.

Das ist der Schutz vor dem Big Ball of Mud.

Der erste fachliche Durchstich ist bewusst unspektakulär.

Spieler anzeigen. Spieler anlegen. Spieler löschen oder deaktivieren.

Aber architektonisch ist dieser Schritt wichtig.

Er entscheidet, ob die Anwendung fachlich vertikal wächst oder ob sie direkt wieder in technische Sammelordner zerfasert.

Die zentrale Entscheidung lautet:

Nicht erst alles global sammeln und später hoffen, dass man es wieder sortiert bekommt. Sondern von Anfang an fachlich schneiden.

Für dieses Projekt bedeutet das:

Frontend vertikal nach Fachlichkeit
API Gateway vertikal nach Fachlichkeit
Backend-Service vertikal nach Fachlichkeit
gemeinsame Datenbank
bewusste Doppelung
kleines Shared
clubId als Datenraum
sub als Benutzeridentität

Das ist kein großer Enterprise-Blueprint.

Es ist eine kleine, pragmatische Strukturentscheidung, damit aus dem MVP kein Big Ball of Mud wird.

Und genau deshalb beginnt die Fachlichkeit mit Players.