Yachtlogbuch

arc42 Systemarchitektur — Yachtlogbuch

Stand: August 2026 · Gliederung nach arc42. Dieses Dokument beschreibt Bausteine und Abläufe auf der Ebene ihrer Verantwortlichkeiten, nicht auf Klassenebene — damit es ein Refactoring überlebt.

1. Einführung und Ziele

Das Yachtlogbuch ist eine Webanwendung, mit der Seglerinnen und Segler ein digitales Schiffstagebuch führen. Erfasst werden Reisen, ihre Gliederung in Törns, die einzelnen Segeltage und Tage für andere Aktivitäten sowie Wegpunkte mit Koordinate und Zeit. Aus diesen Daten erzeugt das System auf Abruf ein gesetztes PDF-Logbuch, dasselbe PDF als ZIP mit den Originalbildern, einen schlanken HTML-Export und — für Administratoren — einen Rohdatenexport.

1.1 Aufgabenstellung

Kernfunktion Beschreibung
Logbuchführung Reisen, Törns, Segeltage, Tage für andere Aktivitäten und Wegpunkte anlegen, ändern, löschen
Wegpunkt-Ausprägungen Unterscheidung zwischen GEPLANT, GETRACKT und BEOBACHTET
GPS-Tracking Automatische Aufzeichnung getrackter Wegpunkte auf dem Mobilgerät
Bilder Fotos an Segeltage, Tage für andere Aktivitäten und Wegpunkte hängen; Reise-Logo
Offline-Fähigkeit Erfassen ohne Netz, Nachsenden bei Wiederverbindung
Export PDF-Logbuch, PDF mit Originalbildern als ZIP, minimale Webseite, Excel-Rohdaten (nur Administratoren)
Mehrsprachigkeit Oberfläche und Export in Deutsch, Englisch, Französisch, Italienisch

1.2 Qualitätsziele

Priorität Qualitätsziel Motivation
1 Datenhoheit Was erfasst wurde, muss vollständig wieder herausgehen — als Logbuch und als Rohdaten. Ein Logbuch, das seine Daten einschliesst, ist wertlos.
2 Vertraulichkeit Ein Logbuch enthält Bewegungsprofile. Fremde Reisen sind weder les- noch auffindbar; sie werden nach aussen als nicht existent behandelt.
3 Verfügbarkeit unterwegs Erfassung muss ohne Netzverbindung funktionieren, weil genau dort erfasst wird, wo keines ist.
4 Nachvollziehbarkeit Geplant, getrackt und beobachtet bleiben dauerhaft unterscheidbar, damit Absicht und Wirklichkeit vergleichbar sind.
5 Betreibbarkeit durch eine Person Das System wird von einer einzelnen Person betrieben. Deployment und Rollback müssen in wenigen Minuten erledigt sein.

1.3 Stakeholder

Rolle Erwartungshaltung
Skipper / Anwender Erfasst unterwegs, oft auf dem Mobilgerät, oft ohne Netz. Erwartet, dass nichts verloren geht.
Crew und Familie Liest mit, ohne Konto — über den HTML- oder PDF-Export.
Administrator Betreibt das System, exportiert Rohdaten, verantwortet Sicherung und Deployment.
Entwickler Eine Person. Braucht ein System, das ohne Spezialwissen wieder aufgegriffen werden kann.

2. Randbedingungen

2.1 Technische Randbedingungen

Randbedingung Erläuterung
Java 21, Spring Boot 4.1 Alle drei Backend-Dienste; Maven-Multi-Modul-Projekt mit gemeinsamem Parent-POM
PostgreSQL 17 Einzige relationale Datenbank; Schema ausschliesslich über Flyway-Migrationen
Keycloak 26.1 Einziger Identitätsanbieter; die Dienste sind reine OAuth2-Resource-Server
React 19 / TypeScript / Vite Frontend als Single-Page-Anwendung, ausgeliefert von nginx
S3-kompatibler Objektspeicher Bilder liegen ausserhalb der Datenbank; lokal MinIO
Docker Compose Einzige Betriebsform, lokal wie produktiv
Ein Deployment-Ziel Eine Hetzner-VM, verwaltet über Dokploy

2.2 Organisatorische Randbedingungen

2.3 Konventionen

Bereich Konvention
Fehler DomainException mit stabilem ErrorCode; die Codes sind Teil des API-Vertrags
Löschen Kaskadierende Löschung aufwärts statt JPA-Cascade abwärts, gebündelt im DeletionService
Tests *Test als Unit-Test (Surefire, kein Docker), *IT als Integrationstest (Failsafe, Testcontainers)
Zeitstempel Intern Instant in UTC, an der API OffsetDateTime

3. Kontextabgrenzung

3.1 Fachlicher Kontext

Yachtlogbuch Reisen · Törns · Segeltage · Wegpunkte Skipper erfasst, plant, trackt Administrator betreibt, exportiert Rohdaten Crew & Familie liest PDF / HTML-Export Kartendienst Kachel- und Statikkarten
Abbildung 1: Fachlicher Kontext — wer mit dem System arbeitet und was hinein- und hinausgeht.
Partner Eingehend Ausgehend
Skipper Logbuchdaten, Bilder, GPS-Positionen Darstellung, Karten, Exporte
Administrator Betriebseingriffe Excel-Rohdatenexport, Actuator-Kennzahlen
Crew und Familie PDF-Logbuch, HTML-Export (ohne Konto)
Kartendienst Kartenkacheln, statische Kartenbilder Koordinatenausschnitte

3.2 Technischer Kontext

Schnittstelle Protokoll Beschreibung
Browser ↔ nginx HTTPS Auslieferung von Startseite, Dokumentation und Anwendung; Reverse Proxy für alle API-Aufrufe
Frontend ↔ yachtlogbuch-service REST/JSON über /app/api/v1 Logbuchdaten
Frontend ↔ picture-service REST/multipart über /app/pictures Bild-Upload und -Abruf
Frontend ↔ export-service REST über /app/export PDF-, ZIP-, HTML- und Excel-Export
Frontend ↔ Keycloak OpenID Connect, Authorization Code mit PKCE Anmeldung, Token-Erneuerung
Dienste ↔ Keycloak JWKS über HTTP Signaturprüfung der Zugriffstoken
Dienste ↔ PostgreSQL JDBC Persistenz
picture-service ↔ Objektspeicher S3-API Bilder und Thumbnails

Sämtlicher Browserverkehr läuft über eine einzige Herkunft (https://yachtlogbuch.ch). Damit entfällt CORS im Produktionsbetrieb vollständig — eine bewusste Entscheidung, siehe Kapitel 9.

4. Lösungsstrategie

Qualitätsziel Lösungsansatz
Datenhoheit Ein eigener export-service als dritter Baustein, der die Exporte serverseitig rendert. Der Rohdatenexport gibt zusätzlich alle Wegpunkte heraus — auch die getrackten, die im Logbuch-Export bewusst ausgeblendet bleiben.
Vertraulichkeit Jede Reise trägt einen Besitzer (besitzer_subject aus dem JWT). Fremde Daten liefern 404, nicht 403 — die Existenz fremder Reisen wird nicht preisgegeben.
Verfügbarkeit unterwegs Schreibzugriffe werden im Browser in IndexedDB zwischengespeichert und beim Wiederverbinden in Reihenfolge nachgesendet; Lesedaten liegen als Snapshot-Cache vor.
Nachvollziehbarkeit WegpunktStatus mit den drei Werten GEPLANT, GETRACKT, BEOBACHTET ist Teil des Datenmodells, nicht eine Sichtfilterung.
Betreibbarkeit Fünf Container in einem Compose-Verbund, Deployment über einen Image-Tag. Rollback ist das Zurücksetzen dieses Tags.

Die leitenden Entscheidungen im Überblick — ausführlich in Kapitel 9:

5. Bausteinsicht

5.1 Ebene 1 — Gesamtsystem

nginx (Frontend-Container) Startseite · Doku · SPA · Reverse Proxy picture-service Bilder, Thumbnails yachtlogbuch-service Logbuchdaten export-service PDF · ZIP · HTML · Excel Keycloak Identitäten, Rollen Objektspeicher (S3) Bilder + Thumbnails PostgreSQL Logbuch-Schema PostgreSQL Keycloak-Schema
Abbildung 2: Bausteine und ihre Beziehungen. Gestrichelt umrandet: zugekaufte Infrastruktur.

yachtlogbuch-frontend

Aufgabe. Zwei Dinge in einem Container. Erstens nginx als einziger Einstiegspunkt: Es liefert die öffentliche Startseite (/, vier Sprachfassungen), diese Dokumentation (/docs/) und die Single-Page-Anwendung (/app/) aus und reicht alle API-Aufrufe an die Backend-Dienste weiter. Zweitens die React-Anwendung selbst — Erfassungsmasken, Karten, GPS-Tracking, Offline-Warteschlange.

Schnittstellen. Nach aussen HTTPS. Nach innen HTTP zu allen drei Backend-Diensten. Zu Keycloak per OpenID Connect aus dem Browser heraus. Das Keycloak-Login-Theme entsteht aus demselben Quellbaum (Keycloakify), damit Anmeldemaske und Anwendung dasselbe Erscheinungsbild haben.

yachtlogbuch-service

Aufgabe. Führt die Logbuchdaten und ist alleiniger Eigentümer des Logbuch-Schemas. Setzt die fachlichen Regeln durch: Törns liegen innerhalb ihrer Reise und überlappen einander nicht, Segeltage liegen im Zeitraum ihres Törns, das Löschen des letzten Kindes räumt den Elternteil mit auf. Setzt den Besitzer einer Reise serverseitig aus dem Token und filtert jeden Zugriff darüber.

Schnittstellen. REST/JSON unter /api/v1, spezifiziert in api/yacht-logbuch-api.yaml. JDBC zur Datenbank. HTTP zum picture-service, um beim Löschen einer Entität deren Bilder mitzuentfernen. JWKS-Abruf bei Keycloak zur Tokenprüfung.

picture-service

Aufgabe. Nimmt Bilder entgegen, erzeugt Thumbnails und legt beides im Objektspeicher ab. Hält bewusst keine fachliche Kenntnis — er weiss nichts von Reisen oder Wegpunkten, sondern verwaltet Bilder unter einer eigenen Kennung. Die Zuordnung zu Segeltagen, Tagen für andere Aktivitäten und Wegpunkten liegt beim yachtlogbuch-service.

Schnittstellen. REST unter /pictures (Upload als multipart/form-data, Abruf von Metadaten, Original und Thumbnail, Löschen). S3-API zum Objektspeicher. JWKS-Abruf bei Keycloak.

export-service

Aufgabe. Erzeugt aus verteilten Daten ein Dokument. Er hält selbst keine Daten, sondern aggregiert zur Laufzeit: Logbuchdaten vom yachtlogbuch-service, Bilder vom picture-service, Kartenausschnitte vom Kartendienst. Daraus entstehen drei Ergebnisse — ein gesetztes PDF, ein schlanker HTML-Export und das Excel-ZIP mit Rohdaten.

Schnittstellen. REST unter /export; die PDF- und HTML-Variante unterscheiden sich nur im Accept-Header derselben Ressource. HTTP zu den beiden anderen Diensten und zum Kartendienst. JWKS-Abruf bei Keycloak.

Keycloak

Aufgabe. Einziger Identitätsanbieter, Realm yachtlogbuch. Vergibt die Realm-Rollen logbuch-user und logbuch-admin. Das Anmelde-Theme ist Teil des Deployments und wird als eigenes Image gebaut.

PostgreSQL und Objektspeicher

Zwei getrennte PostgreSQL-Instanzen: eine für das Logbuch-Schema, eine für Keycloak. Die Trennung hält Keycloak-Upgrades von den fachlichen Migrationen fern. Das Logbuch-Schema wird ausschliesslich über Flyway verändert; Hibernate läuft mit ddl-auto: validate und darf nichts anlegen. Bilder liegen im Objektspeicher, nie in der Datenbank.

5.2 Ebene 2 — Innenansicht yachtlogbuch-service

Paket Verantwortung
controller REST-Endpunkte, Eingabevalidierung, einheitliche Fehlerabbildung im GlobalExceptionHandler
domain JPA-Entities und fachliche Aufzählungstypen (WegpunktStatus, Wetterlage, Manoevertyp, Einheiten)
repository Spring-Data-Repositories; Besitzerfilterung als Teil der Abfragen
mapper MapStruct zwischen Entity und generiertem DTO
service DeletionService für die aufwärts kaskadierende Löschung, PictureServiceClient für das Mitlöschen von Bildern
config SecurityConfig (OAuth2-Resource-Server, Rollenabbildung), JpaConfig (Auditing), OpenApiConfig
security AdminAccess — gebündelte Prüfung der Administratorrolle
exception DomainException mit stabilem ErrorCode; die Codenamen sind Teil des API-Vertrags

Die Löschlogik ist bewusst nicht als JPA-Cascade modelliert. Fachlich gilt die Gegenrichtung: Wer den letzten Wegpunkt eines Segeltags entfernt, entfernt damit auch den Segeltag, dessen Törn und gegebenenfalls die Reise. Diese Regel ist an einer Stelle nachlesbar statt über Annotationen verstreut.

5.3 Ebene 2 — Innenansicht export-service

Paket Verantwortung
client YachtlogbuchClient, PictureClient, StaticMapClient — die drei Datenquellen
aggregat ReiseAggregat, SegeltagAggregat, ZeitleistenEintrag — die zusammengeführte Sicht, aus der gerendert wird
service ExportAggregationService (Beschaffung), PdfRenderService, HtmlRenderService, EnumTranslationService
service.excel ExcelExportService, ExcelSheetWriter, SequentialIdMapper für den Rohdatenexport
controller ExportController, GlobalExceptionHandler

Der ZeitleistenEintrag ist der Schlüssel zum Logbuch-Export: Segeltage und Tage für andere Aktivitäten werden zu einer einzigen chronologischen Folge verschmolzen, sodass im Dokument keine unerklärten Lücken entstehen.

Der SequentialIdMapper verdient eine eigene Erwähnung. Intern identifizieren UUIDs die Entitäten — in einer Excel-Tabelle sind die unbrauchbar. Der Mapper vergibt deshalb je Entitätstyp fortlaufende Nummern ab 1 und hält sie über alle Dateien eines Exportlaufs konsistent, sodass Fremdschlüssel zwischen den Blättern lesbar bleiben.

5.4 Ebene 2 — Innenansicht Frontend

Bereich Verantwortung
api/generated Aus der OpenAPI-Datei erzeugter Client; wird nicht von Hand geändert
auth AuthContext (Keycloak-Anbindung, Tokenerneuerung), ProtectedRoute
pages / components Seiten je Entitätstyp und die zugehörigen Formulare, Karten und Modale
offline IndexedDB-Anbindung, Warteschlange für Schreibzugriffe, Snapshot-Cache, Online-Status
i18n Vier Sprachen, geladen über i18next
login / kc.gen Keycloakify-Anmeldetheme aus demselben Quellbaum
site/ Startseite und Dokumentation — bewusst ausserhalb der Anwendung, siehe Kapitel 9

Das Paket offline ist der anspruchsvollste Teil des Frontends. Der entscheidende Kunstgriff: Die Kennung einer neuen Entität wird bereits im Browser als UUID vergeben, nicht erst vom Server. Damit kann ein Wegpunkt auf einen Segeltag verweisen, der noch in der Warteschlange liegt — es gibt keine vorläufigen Kennungen, die später ersetzt werden müssten. Die Warteschlange selbst liegt je Benutzer in IndexedDB, wird in Erfassungsreihenfolge abgearbeitet und zählt Fehlversuche je Eintrag mit.

6. Laufzeitsicht

6.1 Anmeldung

Die Anwendung ist ein öffentlicher OAuth2-Client und nutzt den Authorization-Code-Flow mit PKCE. Ein Client-Geheimnis gibt es nicht — es liesse sich in einer Browseranwendung ohnehin nicht verbergen.

  1. Der Browser ruft /app/ auf. Die Anwendung startet und fragt Keycloak über eine unsichtbare Prüfseite, ob bereits eine Sitzung besteht.
  2. Besteht keine, leitet sie zu Keycloak weiter. Dort erscheint die Anmeldemaske im Yachtlogbuch-Erscheinungsbild — dasselbe Theme, aus demselben Quellbaum gebaut.
  3. Nach erfolgreicher Anmeldung kehrt der Browser mit einem Autorisierungscode zurück, den die Anwendung gegen ein Zugriffs- und ein Erneuerungstoken tauscht.
  4. Jeder API-Aufruf trägt das Zugriffstoken im Authorization-Header. Die Dienste prüfen dessen Signatur gegen den öffentlichen Schlüssel von Keycloak und lesen die Realm-Rollen aus dem Claim realm_access.roles; daraus entstehen Spring-Security-Berechtigungen mit dem Präfix ROLE_.
  5. Vor Ablauf erneuert die Anwendung das Token im Hintergrund.

Die Dienste halten dabei keinerlei Sitzungszustand. Sie sind vollständig zustandslos konfiguriert; alles, was für eine Autorisierungsentscheidung nötig ist, steht im Token.

6.2 Wegpunkt mit Bild erfassen

Dieser Ablauf berührt alle drei Backend-Dienste und zeigt zugleich, warum das Bild vor dem Wegpunkt gespeichert wird.

  1. Der Anwender füllt das Wegpunkt-Formular aus und wählt ein Foto.
  2. Das Frontend lädt zuerst das Bild zum picture-service (POST /pictures). Der Dienst legt Original und erzeugtes Thumbnail im Objektspeicher ab und antwortet mit einer Bildkennung.
  3. Erst danach sendet das Frontend den Wegpunkt an den yachtlogbuch-service — mit der Bildkennung als Verweis.
  4. Der yachtlogbuch-service prüft die fachlichen Regeln (Koordinatenbereich, Zugehörigkeit zum Segeltag, Besitzverhältnis) und speichert.

Die Reihenfolge ist Absicht: Ein Bild ohne Wegpunkt ist ein verwaister Blob, der beim nächsten Aufräumen verschwindet. Ein Wegpunkt, der auf ein nicht existierendes Bild zeigt, wäre dagegen ein dauerhafter Datenfehler.

Im Offline-Fall verschiebt sich der Ablauf: Die Anwendung vergibt die Kennung des Wegpunkts selbst, legt den Schreibzugriff in der IndexedDB-Warteschlange ab und zeigt ihn sofort in der Oberfläche an. Als offline gilt dabei nicht nur ein gemeldeter Netzverlust, sondern auch eine Anfrage, die ohne Antwort bleibt — siehe Konzept 8.2. Kommt die Verbindung zurück, arbeitet der Warteschlangen-Versand die Einträge in Erfassungsreihenfolge ab. Weil die Kennungen bereits feststehen, bleiben Verweise zwischen nachgesendeten Objekten gültig.

Beim GPS-Tracking entsteht derselbe Wegpunkt automatisch, mit Status GETRACKT und ohne Anwenderinteraktion; die Positionen liefert die Geolocation-Schnittstelle des Geräts. Jeder Punkt durchläuft denselben Weg sofort nach seiner Aufzeichnung — online direkt zum Dienst, offline in die Warteschlange. Er wird also nicht bis zum Beenden im flüchtigen Speicher der Seite gesammelt.

Der Grund ist Verlustsicherheit: Ein Reload, ein geschlossener Tab oder ein vom Betriebssystem verworfener Hintergrundtab hätte sonst die gesamte bisherige Aufzeichnung eines Segeltags vernichtet. Nebenbei verteilt sich die Offline-Warteschlange über die Aufzeichnungsdauer, statt am Ende in einem Schwall zu entstehen. Das Beenden stellt daher nur die Positionsüberwachung ein und speichert nichts nach; ein Reload beendet die Aufzeichnung, ohne sie selbsttätig fortzusetzen, und ein erneuter Start im selben Segeltag hängt weitere Punkte an den bestehenden Bestand an. Scheitert ein einzelner Punkt, läuft die Aufzeichnung weiter — der Fehler blockiert die folgenden Punkte nicht.

Wachbleiben ist Voraussetzung, nicht Komfort. Bei ausgeschaltetem Display stellen mobile Browser watchPosition ein und drosseln Timer; es entstünden also weder Punkte noch Token-Erneuerungen, und die Sitzung liefe im Hintergrund ab. Während einer Aufzeichnung fordert die Anwendung deshalb eine Bildschirmsperre (navigator.wakeLock) an und erneuert sie nach jeder Rückkehr in den Vordergrund, weil der Browser sie im Hintergrund entzieht. Beim Sichtbarwerden wird zusätzlich das Token aktiv erneuert und die Warteschlange abgearbeitet. Eine Aufzeichnung bei dunklem Display bleibt damit ausserhalb der Möglichkeiten einer Webanwendung — dafür wäre die native Hülle nötig.

6.3 Reise exportieren

  1. Der Anwender löst den Export einer Reise aus. Das Frontend ruft /app/export/reisen/{id} auf und legt über den Accept-Header fest, ob ein PDF oder HTML gewünscht ist — dieselbe Ressource, zwei Darstellungen. Für das PDF samt Originalbildern gibt es die eigene Unterressource /app/export/reisen/{id}/zip.
  2. Der export-service beschafft die Daten: die Reise mit allen Törns, Segeltagen, Tagen für andere Aktivitäten und Wegpunkten vom yachtlogbuch-service, die zugehörigen Bilder vom picture-service, die Kartenausschnitte vom Kartendienst. Das Zugriffstoken des Anwenders wird dabei weitergereicht, sodass die Besitzprüfung des yachtlogbuch-service auch für den Export greift.
  3. Die Aggregate werden zu einer Zeitleiste verschmolzen: Segeltage und Tage für andere Aktivitäten stehen chronologisch in einer Folge.
  4. Daraus rendert der Dienst das Ergebnis — ein gesetztes PDF oder eine in sich geschlossene HTML-Datei. Wegpunkte mit Status GETRACKT bleiben im Logbuch-Export aussen vor: Sie sind Messpunkte, keine Logbucheinträge, und würden das Dokument fluten. Ihre Information steckt stattdessen in der gezeichneten Strecke.
  5. Der Browser erhält die fertige Datei zum Herunterladen.

Bilder im PDF. Der eingesetzte Renderer wertet max-width/max-height nicht aus; ein Bild würde sonst in Pixelgrösse gezeichnet und über mehrere Seiten zerschnitten. Der Dienst liest deshalb die Abmessungen aus dem Bild-Header und gibt jedem Bild eine ausgerechnete, seitenverhältnistreue Grösse mit. Geladen wird für das PDF das Thumbnail des picture-service (max. 300 × 300 px) statt des Originals — das hält das Dokument klein. Der HTML-Export bleibt bei den Originalen, weil dort keine Seitenaufteilung stört.

ZIP-Variante. /zip lädt zusätzlich die Originale, benennt sie fortlaufend (bilder/001.jpg) und packt sie neben das PDF. Die Namen stehen fest, bevor gerendert wird, denn das Template verweist relativ auf sie und schreibt sie unter jedes Bild — nicht jeder Betrachter löst solche Verweise auf. Die Originale werden dafür parallel geladen.

6.4 Rohdatenexport durch einen Administrator

Ein eigener Ablauf, weil er anderen Regeln folgt. Der Aufruf von /export/reisen/{id}/excel ist Administratoren mit der Rolle logbuch-admin vorbehalten; alle anderen erhalten 403. Der Dienst erzeugt ein ZIP mit je einer Excel-Datei für Reise, Törns, Segeltage, Tage für andere Aktivitäten und Wegpunkte, dazu die Bilder und eine Indexdatei. Anders als der Logbuch-Export enthält er alle Wegpunkte einschliesslich der getrackten — hier geht es um Vollständigkeit, nicht um Lesbarkeit. Fremdschlüssel verweisen auf die fortlaufenden Nummern innerhalb desselben Exports, nicht auf die internen UUIDs.

6.5 Löschen mit Aufwärtskaskade

Löschen ist im Yachtlogbuch nicht die Umkehrung des Anlegens. Wird der letzte Wegpunkt eines Segeltags entfernt, verliert der Segeltag seinen Inhalt und wird mitgelöscht; ist er der letzte Segeltag seines Törns, trifft es auch den Törn, und so weiter bis zur Reise. Der DeletionService bündelt diese Regel an einer Stelle. Bilder der betroffenen Entitäten werden dabei über den PictureServiceClient im picture-service mitentfernt, damit im Objektspeicher nichts zurückbleibt.

7. Verteilungssicht

7.1 Produktivbetrieb

Das System läuft auf einer einzelnen Hetzner-VM. Die Container werden über Dokploy verwaltet, das einen Traefik als Reverse Proxy davorsetzt und die TLS-Zertifikate hält. Nach aussen existiert genau ein Name: yachtlogbuch.ch.

Browser · yachtlogbuch.ch Traefik (Dokploy) TLS-Terminierung yachtlogbuch-frontend (nginx) Startseite · /docs · /app · Proxy picture-service :8090 yachtlogbuch-service :8080 export-service :8095 keycloak Realm yachtlogbuch Objektspeicher (S3) Volume minio-data PostgreSQL 17 Volume db-data PostgreSQL 17 Volume keycloak-db-data Hetzner-VM · Docker Compose
Abbildung 3: Verteilung im Produktivbetrieb. Nur der Frontend-Container hängt am Proxy-Netz; alle übrigen Dienste sind ausschliesslich intern erreichbar.
Knoten Inhalt Persistenz
Traefik (von Dokploy gestellt) TLS-Terminierung, Routing auf den Frontend-Container
yachtlogbuch-frontend nginx mit Startseite, Dokumentation, SPA und Proxy-Regeln zustandslos
yachtlogbuch-service Logbuch-API, Port 8080 Datenbank db
picture-service Bild-API, Port 8090 Objektspeicher
export-service Export-API, Port 8095 zustandslos
keycloak Identitätsanbieter samt Theme und Realm-Import Datenbank postgres-keycloak
db, postgres-keycloak PostgreSQL 17 benannte Docker-Volumes
Objektspeicher S3-kompatibel benanntes Docker-Volume

Entscheidend für die Sicherheit dieser Aufstellung: Nur der Frontend-Container ist mit dem Proxy-Netz verbunden. Datenbanken, Objektspeicher und die drei Backend-Dienste liegen ausschliesslich im internen Compose-Netz und haben von aussen keine erreichbare Adresse. Jeder Zugriff auf die APIs läuft zwangsläufig über nginx.

7.2 Wege durch nginx

Pfad Ziel
/ Startseite — je nach Cookie und Browsersprache eine der vier Sprachfassungen, oder Weiterleitung nach /app/ für Wiederkehrer
/de/, /en/, /fr/, /it/ die jeweilige Sprachfassung der Startseite
/docs/arc42/, /docs/handbuch/ diese Dokumentation
/site/ Stylesheet und übrige Assets der statischen Seiten
/app/ die Single-Page-Anwendung
/app/api/ Proxy auf yachtlogbuch-service:8080
/app/pictures Proxy auf picture-service:8090
/app/export Proxy auf export-service:8095

Keycloak liegt unter /auth/ derselben Domain, wird dorthin aber von Traefik geroutet und nicht von nginx bedient.

Weil alles unter derselben Herkunft liegt, entfällt CORS vollständig. Unbekannte Pfade werden nach /app/… weitergeleitet, damit Deep-Links auf die Anwendung auch dann funktionieren, wenn der /app-Präfix fehlt; unterhalb von /docs/ gilt das ausdrücklich nicht — dort antwortet nginx mit 404, statt in die Anwendung umzulenken.

7.3 Deployment

Der Ablauf ist bewusst knapp gehalten und in DEPLOYMENT.md im Detail beschrieben:

  1. Push auf main. GitHub Actions führt mvn verify sowie Lint und Unit-Tests des Frontends als Gate aus und baut anschliessend fünf Images nach ghcr.io, jeweils mit den Tags :latest und :<commit-sha>.
  2. In Dokploy wird die Variable IMAGE_TAG auf den Commit-SHA gesetzt.
  3. Deploy auslösen.

Der zweite Schritt ist nicht optional, sondern der eigentliche Mechanismus. Ein blosses „Redeploy" führt lediglich docker compose up -d aus; da sich die Service-Definitionen dabei nicht ändern, sieht Compose keinen Grund, neue Images zu ziehen — die Container bleiben unangetastet, auch wenn die CI gerade ein neues :latest gepusht hat. Erst das Ändern von IMAGE_TAG ist eine echte Änderung und erzwingt Pull und Neuerstellung.

Derselbe Mechanismus ist zugleich die Rollback-Strategie: IMAGE_TAG auf den vorherigen SHA setzen, erneut deployen. Solange keine Datenbankmigration dazwischenliegt, ist der Rücksprung vollständig. Migrationen sind deshalb additiv zu halten.

7.4 Entwicklungsumgebung

docker compose up -d --build --wait im Projektwurzelverzeichnis startet denselben Verbund lokal. Das Frontend ist dann unter http://localhost:3000 erreichbar, die API unter :8080, Keycloak unter :8180, der Objektspeicher unter :9000. Die Wege durch nginx sind lokal dieselben wie produktiv — gerade deshalb können die End-to-End-Tests die Routing-Regeln überhaupt prüfen.

Für schnelle Frontend-Iteration läuft Vite alternativ direkt auf dem Rechner (npm run dev, Port 5173). In dieser Betriebsart werden die Backend-Ports direkt angesprochen; dort ist kein CORS konfiguriert, weshalb sie nur für reine Oberflächenarbeit taugt.

8. Querschnittskonzepte

8.1 Authentifizierung und Autorisierung

Keycloak ist der einzige Ort, an dem Identitäten liegen. Die drei Backend-Dienste sind reine OAuth2-Resource-Server: Sie prüfen die Signatur des Zugriffstokens gegen den öffentlichen Schlüssel des Realms und leiten daraus die Berechtigungen ab. Kein Dienst kennt Passwörter, keiner führt eine eigene Benutzertabelle.

Rolle Bedeutung
logbuch-user Regulärer Zugriff auf die API — ausschliesslich auf eigene Daten
logbuch-admin Zusätzlich Actuator-Endpunkte und der Excel-Rohdatenexport

Autorisierung auf Datenebene. Rollen allein genügen nicht, weil alle Anwender dieselbe Rolle tragen. Jede Reise trägt deshalb den subject-Claim ihres Erzeugers als Besitzer. Der Wert wird beim Anlegen serverseitig gesetzt und ignoriert einen abweichenden Wert im Request-Body. Jede Abfrage filtert darüber, und zwar auch für die abhängigen Entitäten: Ein Törn, Segeltag oder Wegpunkt gehört dem Besitzer seiner Reise.

Antwort auf fremde Daten ist 404, nicht 403. Das ist eine bewusste Entscheidung: Ein 403 würde bestätigen, dass es die angefragte Reise gibt. Bei einem Logbuch — also einem Bewegungsprofil — ist schon diese Bestätigung zu viel.

Sitzungsdauer folgt dem Nutzungsprofil, nicht dem Standardwert. Keycloaks Voreinstellung lässt eine Sitzung nach 30 Minuten ohne Erneuerung verfallen. Für einen Segeltag ist das zu kurz: Wer eine halbe Stunde ein Manöver fährt, während das Gerät in der Halterung steckt, müsste sich danach neu anmelden. Der Realm setzt deshalb ssoSessionIdleTimeout auf 12 Stunden und ssoSessionMaxLifespan auf 24 Stunden. Zu beachten: Ein Realm-Import überschreibt einen bestehenden Realm nicht — die Werte müssen bei einer Änderung auch am laufenden Realm gesetzt werden.

Ein Sitzungsende leitet nicht um. Lässt sich das Zugriffstoken nicht erneuern, zeigt die Anwendung ein Hinweisband mit der Möglichkeit zur Neuanmeldung, statt sofort zur Anmeldeseite zu springen. Ein Redirect würde die Seite verlassen und dabei Formulareingaben sowie eine laufende GPS-Aufzeichnung verlieren — den Zustand also gerade dann vernichten, wenn er am schwersten wiederherzustellen ist. Erfasste Änderungen warten so lange in der Warteschlange. Ein fehlgeschlagener Erneuerungsversuch ohne Netz gilt dabei nicht als Sitzungsende, sondern nur als misslungener Versuch.

8.2 Offline-Fähigkeit

Erfasst wird dort, wo es kein Netz gibt. Das Frontend behandelt Netzverlust deshalb nicht als Fehler, sondern als Normalzustand.

Nicht navigator.onLine entscheidet, sondern die Zustellung. Das Flag meldet nur, ob eine Netzwerkschnittstelle besteht — bei schwachem Mobilfunk am Wasser also praktisch immer „online", während Anfragen in Timeouts laufen. Eine Änderung wandert deshalb auch dann in die Warteschlange, wenn sie abgeschickt wurde und keine Antwort erhielt. Ein 401 zählt ebenfalls dazu: Die Sitzung ist erneuerbar, die Daten sollen das überleben. Aus demselben Grund verwirft der Warteschlangen-Versand einen Eintrag bei 401/403 nicht, sondern lässt ihn bis nach der Neuanmeldung liegen — anders als bei einem fachlichen 4xx, das sich durch Wiederholung nicht ändert.

Die Grenze dieses Ansatzes ist bewusst gezogen: Es gibt keine Konfliktauflösung für konkurrierende Änderungen. Bei einem persönlichen Logbuch mit einem Erfasser je Reise ist der Fall selten genug, dass die Komplexität einer Auflösung nicht gerechtfertigt wäre.

8.3 Mehrsprachigkeit

Vier Sprachen — Deutsch, Englisch, Französisch, Italienisch — an drei Stellen:

Ort Umsetzung
Anwendung i18next mit Sprachdateien je Locale
Export Der export-service übersetzt Aufzählungswerte über den EnumTranslationService und rendert das Dokument in der gewählten Sprache
Startseite Vier zur Bauzeit vorgerenderte HTML-Fassungen; die Auswahl trifft nginx

Fachbegriffe der Domäne bleiben im Code deutsch. Übersetzt wird an der Oberfläche, nicht im Datenmodell.

8.4 Bilder

Bilder liegen nie in der Datenbank. Der picture-service legt Original und ein erzeugtes Thumbnail im Objektspeicher ab und gibt eine Kennung zurück; die fachliche Zuordnung hält der yachtlogbuch-service als Verweis. Das hält die Datenbank klein und die Sicherung überschaubar.

Dass beide Varianten vorliegen, nutzt der Export: das PDF bindet das Thumbnail ein und bleibt damit handlich, während die Originale nur in der ZIP-Variante mitgeliefert werden (siehe Laufzeitsicht 6.3).

Der Preis: Beim Löschen müssen zwei Systeme mitspielen. Der DeletionService ruft deshalb über den PictureServiceClient das Entfernen der Bilder an. Schlägt das fehl, bleibt ein verwaister Blob im Objektspeicher zurück — unschön, aber harmlos, und dem umgekehrten Fall eines Verweises ins Leere klar vorzuziehen.

Das Reise-Logo folgt einem eigenen, älteren Weg: Es liegt im Dateisystem des yachtlogbuch-service, die Metadaten in der Tabelle reise_logo. Diese Ungleichheit ist historisch gewachsen und in Kapitel 11 als Risiko vermerkt.

8.5 Fehlerbehandlung

Fachliche Fehler werden als DomainException mit einem ErrorCode aus einer festen Aufzählung geworfen. Ein GlobalExceptionHandler bildet sie auf HTTP-Status und eine einheitliche Fehlerstruktur ab. Die Codenamen sind Teil des API-Vertrags und müssen mit der Aufzählung in der OpenAPI-Datei übereinstimmen — das Frontend entscheidet anhand des Codes, welche Meldung es zeigt, nicht anhand des Textes.

8.6 Persistenz und Migration

Das Schema gehört Flyway. Hibernate läuft mit ddl-auto: validate und darf nichts anlegen oder ändern; weicht das Schema von den Entities ab, startet der Dienst nicht. Damit ist ausgeschlossen, dass eine Schemaänderung unbemerkt in Produktion gerät.

Migrationen sind additiv zu halten, weil das Rollback über den Image-Tag läuft und eine ältere Anwendungsversion mit dem neueren Schema starten können muss.

8.7 Vereinfachung getrackter Routen

Ein Segeltag mit kurzem Aufzeichnungsintervall liefert Hunderte bis Tausende Wegpunkte, von denen die meisten auf gerader Strecke liegen und keine zusätzliche Information tragen. Anwender können eine aufgezeichnete Route deshalb nachträglich ausdünnen lassen.

Verfahren. Ramer–Douglas–Peucker über die nach Reihenfolge sortierte Punktfolge, implementiert in TrackSimplifier im yachtlogbuch-service. Der Algorithmus läuft iterativ mit explizitem Stack — bei mehreren tausend Punkten wäre Rekursion ein Stapelüberlauf-Risiko. Abstände werden in einer äquirektangulären Projektion um die mittlere Breite des Tracks in Metern gerechnet; der Fehler dieser Näherung bleibt weit unter der kleinsten zulässigen Toleranz.

Toleranz. Die Toleranz ist die grösste Abweichung, die ein entfernter Punkt vom verbleibenden Streckenverlauf haben darf. Sie hängt nicht am Algorithmus, sondern an den Benutzereinstellungen: Drei Stufen — grob, mittel, fein — mit je einem konfigurierbaren Meterwert zwischen 1,8 m (0,01 sm) und 1800 m (1 sm), erzwungen in der Ordnung grob ≥ mittel ≥ fein.

Beschränkung auf GETRACKT. Die Optimierung liest ausschliesslich Wegpunkte mit Status GETRACKT. Geplante und beobachtete Wegpunkte gehen weder in die Berechnung ein noch werden sie verändert oder neu nummeriert — sie tragen eigene, unabhängige Reihenfolgen (siehe AE-6 zur unterschiedlichen Rolle der Ausprägungen).

Sperrpunkte. Vor dem Algorithmus wird die Punktfolge an ihren Sperrpunkten segmentiert: erster Punkt, letzter Punkt und jeder Punkt, der über Position und Uhrzeit hinaus Daten trägt — Bild, Manöver, Name, Notiz, Wetterangaben, Kompasskurs, Motor- oder Segelzustand. Vereinfacht wird nur zwischen zwei Sperrpunkten. Nachträgliches Wiedereinfügen wäre die naheliegendere Umsetzung, würde aber die Toleranzzusage der umgebenden Strecke verletzen; die Segmentierung hält sie ein. Nebenbei kann so nie ein Bild verwaisen, weshalb der picture-service unbeteiligt bleibt.

Ausführung serverseitig und zweistufig. Der Endpunkt rechnet auf Wunsch nur (vorschau: true) oder löscht in einer Transaktion und nummeriert die verbleibenden Punkte lückenlos neu. Das Löschen vieler Wegpunkte muss atomar sein — eine clientseitige Schleife aus Einzellöschungen könnte mittendrin abbrechen und einen inkonsistenten Track hinterlassen. Die Vorschau ist die einzige Schutzmassnahme vor einem unumkehrbaren Löschvorgang; ein Undo gibt es bewusst nicht.

8.8 Testkonzept

Stufe Werkzeug Umfang
Unit Surefire, *Test.java Fachlogik ohne Infrastruktur, kein Docker
Integration Failsafe, *IT.java, Testcontainers Controller gegen echte PostgreSQL bzw. echten Objektspeicher; JWT wird injiziert, Keycloak läuft nicht mit
HTTP Bruno-Sammlung Endpunktverhalten gegen die laufende Umgebung
End-to-End Playwright Anwendung im Browser gegen den vollständigen Compose-Verbund
Frontend-Unit Vitest Offline-Logik, Hilfsfunktionen

Die Integrationstests verzichten bewusst auf Keycloak und injizieren das Token direkt. Das spart Startzeit und macht die Tests unabhängig von der Realm-Konfiguration; die Kehrseite ist, dass Fehler in der Realm-Einrichtung erst den End-to-End-Tests auffallen.

9. Architekturentscheidungen

AE-1 — Drei Backend-Dienste statt eines Monolithen

Entscheidung. Der Schnitt verläuft entlang der Ressourcenarten: Logbuchdaten, Binärdaten, Renderarbeit.

Begründung. Die drei haben grundverschiedene Profile. Logbuchdaten sind klein, transaktional und relational. Bilder sind gross, unveränderlich und gehören nicht in eine Datenbank. Das Rendern eines PDF ist rechenintensiv, dauert Sekunden und darf die Erfassung nicht ausbremsen. Ein Monolith müsste für alle drei zugleich ausgelegt sein.

Preis. Beim Löschen ist eine dienstübergreifende Abstimmung nötig, und der Export beschafft seine Daten über HTTP statt aus derselben Transaktion. Beides ist beherrschbar; der Fachschnitt bliebe im Monolithen ohnehin derselbe.

AE-2 — API-First mit generierten DTOs

Entscheidung. api/yacht-logbuch-api.yaml ist der Vertrag. Backend-DTOs und Frontend-Client werden daraus generiert.

Begründung. Bei einem Entwickler, der zwischen Java und TypeScript wechselt, ist Vertragsdrift die wahrscheinlichste Fehlerquelle. Generierung macht sie unmöglich: Ein Feld, das in der Spezifikation fehlt, existiert auf beiden Seiten nicht.

Preis. Jede API-Änderung erfordert einen Generierungslauf, und die Mapper müssen nachgezogen werden.

AE-3 — Kaskadierende Löschung aufwärts

Entscheidung. Das Entfernen des letzten Kindes entfernt den Elternteil, bis hinauf zur Reise. Implementiert im DeletionService, nicht über JPA-Annotationen.

Begründung. Ein Segeltag ohne Wegpunkte ist fachlich kein Segeltag, sondern ein Überbleibsel. Die Regel ist unüblich genug, dass sie an einer nachlesbaren Stelle stehen muss statt über Entity-Annotationen verstreut.

AE-4 — 404 statt 403 für fremde Daten

Entscheidung. Zugriffe auf fremde Reisen werden behandelt, als existierten sie nicht.

Begründung. Siehe 8.1 — die blosse Existenzbestätigung ist bei Bewegungsdaten bereits eine Preisgabe.

Preis. Die Fehlersuche wird unschärfer: „Nicht gefunden" kann zweierlei bedeuten.

AE-5 — Eine einzige Herkunft im Browser

Entscheidung. nginx liefert alles aus und proxied die Dienste unter /app/…. Keine API-Subdomain.

Begründung. Es entfällt CORS vollständig — kein Preflight, keine Header-Pflege, keine Falle bei Anmeldeflüssen. Zudem genügt ein einziges Zertifikat.

Preis. nginx ist ein einzelner Punkt, an dem das Routing kaputtgehen kann, und die Konfiguration trägt entsprechend viel Verantwortung.

AE-6 — Getrackte Wegpunkte nicht im Logbuch-Export

Entscheidung. Der PDF- und HTML-Export blendet GETRACKT aus; der Excel-Rohdatenexport enthält sie vollständig.

Begründung. Ein Tracking-Punkt alle paar Minuten ergibt über eine Reise Tausende Einträge. Als Logbuchtext sind sie unlesbar, als Strecke auf der Karte dagegen genau richtig. Der Rohdatenexport verfolgt das andere Ziel — Vollständigkeit — und schliesst sie deshalb ein.

AE-7 — Startseite ausserhalb der Anwendung

Entscheidung. Die öffentliche Startseite und diese Dokumentation sind statische Dateien, die zur Bauzeit entstehen, und keine Route in der React-Anwendung.

Begründung. Die Anwendung initialisiert vor dem ersten Bild ihre Anmeldeschicht. Eine Marketing- Seite darf das nicht: Sie muss sofort sichtbar, ohne Konto nutzbar und für Suchmaschinen lesbar sein. Als vier vorgerenderte Sprachfassungen erfüllt sie das ohne jedes JavaScript-Gerüst.

Preis. Zwei Darstellungswelten im selben Verzeichnisbaum, und Inhaltsänderungen berühren vier Sprachdateien. Ein Prüfschritt im Build bricht ab, wenn eine Übersetzung Schlüssel vermissen lässt.

AE-8 — Wiederkehrer-Weiterleitung serverseitig

Entscheidung. Ob ein Besucher die Startseite oder direkt die Anwendung sieht, entscheidet nginx anhand eines Cookies — nicht ein Skript auf der Seite.

Begründung. Eine Weiterleitung per JavaScript setzt voraus, dass die Seite erst geladen und gezeichnet wird. Genau dieses Aufblitzen soll Wiederkehrern erspart bleiben. Die Entscheidung fällt deshalb vor dem ersten ausgelieferten Byte.

Preis. Die Antwort auf / hängt von Cookie und Sprachheader ab und ist damit nicht cachebar. Bei einem kleinen Dokument ist das verschmerzbar; alle übrigen Seiten und Assets bleiben voll cachebar.

10. Qualitätsanforderungen

Qualitätsmerkmal Szenario Erwartetes Verhalten
Vertraulichkeit Ein Anwender ruft die Reise eines anderen per ID ab 404, kein Hinweis auf Existenz
Vertraulichkeit Ein Anwender ohne logbuch-admin ruft den Excel-Export auf 403, kein ZIP
Verfügbarkeit Erfassung ohne Netz, Verbindung kehrt zurück Alle Änderungen werden in Erfassungsreihenfolge nachgesendet, keine geht verloren
Datenhoheit Export einer Reise ohne Törns und Segeltage Dokument bzw. ZIP entsteht mit leeren Abschnitten, ohne Fehlschlag
Nachvollziehbarkeit Reise enthält Wegpunkte aller drei Status Logbuch-Export zeigt geplante und beobachtete, Rohdatenexport alle drei
Betreibbarkeit Fehlerhafte Version in Produktion Rollback über IMAGE_TAG in wenigen Minuten, ohne Datenmigration
Integrität Schema weicht von den Entities ab Der Dienst startet nicht, statt mit falschem Schema zu laufen

11. Risiken und technische Schulden

Risiko / Schuld Auswirkung Umgang
nginx als einzelner Routing-Punkt Ein Fehler in der Reihenfolge der location-Blöcke kann Anwendung oder API unerreichbar machen Routing-Matrix wird vor jedem Deployment gegen den lokalen Container geprüft; End-to-End-Tests laufen über dieselben Regeln wie die Produktion
Reise-Logo im Dateisystem Weicht vom Objektspeicher-Weg aller anderen Bilder ab; das Volume muss gesondert gesichert werden Als Schuld vermerkt; Vereinheitlichung auf den picture-service wäre der saubere Weg
Keine Konfliktauflösung offline Parallele Änderungen an zwei Geräten können einander überschreiben Bewusst akzeptiert, siehe 8.2
Verwaiste Bilder bei Löschfehlern Blobs bleiben im Objektspeicher zurück Harmlos gegenüber dem umgekehrten Fall; ein Aufräumlauf fehlt bislang
Keine Staging-Umgebung Fehler zeigen sich erstmals in Produktion Integrations- und End-to-End-Tests als Gate; Rollback über IMAGE_TAG ist schnell
Realm-Konfiguration nur end-to-end abgedeckt Fehler in der Keycloak-Einrichtung fallen spät auf Der Realm-Import ist Teil des Keycloak-Images und damit versioniert
Externer Kartendienst Fällt er aus, fehlen Kartenausschnitte im Export Der Export läuft weiter, nur ohne Karte

12. Glossar

Begriff Bedeutung
Reise Oberste Einheit des Logbuchs; klammert Törns, trägt Titel, Zeitraum, Schiff und Logo
Törn Zusammenhängender Abschnitt innerhalb einer Reise; liegt in deren Zeitraum und überlappt keinen anderen Törn
Segeltag Ein Tag auf dem Wasser mit Häfen, Distanz, Wetter, Segelzustand und Crew
Tag für andere Aktivitäten Ein Tag der Reise ohne Fahrt — Hafentag, Wartung, Landausflug
Wegpunkt Einzelner Punkt mit Koordinate, Zeit und Notiz
GEPLANT Wegpunkt-Status: vor der Fahrt gesetzt, beabsichtigte Route
GETRACKT Wegpunkt-Status: automatisch vom GPS aufgezeichnet; im Logbuch-Export ausgeblendet
BEOBACHTET Wegpunkt-Status: von Hand gesetztes Ereignis
Aggregat Im export-service die zusammengeführte Sicht auf eine Reise über alle Dienste hinweg
Zeitleiste Verschmelzung von Segeltagen und Tagen für andere Aktivitäten zu einer chronologischen Folge
besitzer_subject Der subject-Claim des Zugriffstokens; kennzeichnet den Eigentümer einer Reise
Sequentielle ID Exportlokale fortlaufende Nummer je Entitätstyp im Excel-Export; ersetzt die interne UUID
IMAGE_TAG Deployment-Variable in Dokploy; das Setzen auf einen Commit-SHA löst Pull und Neuerstellung aus