# Costly – Architektur & Entscheidungsgrundlagen ## Ziel des Projekts Costly ist eine **Offline-fähige PWA zur Aufteilung von Gruppenausgaben**. Kernanforderungen: - Gruppen ohne Accounts (Join per Invite) - Offline Änderungen möglich - Späterer Sync ohne Datenverlust - Erweiterbar für Konflikte, Undo, Mehrgeräte-Nutzung --- ## Architektur – Überblick Costly nutzt eine **Hybrid Event-Driven Architektur**: - **Events sind die Quelle der Wahrheit** - **Relationale Tabellen sind Read-Models** - Offline-Sync basiert auf **append-only Events** ### Überblick Client ├─ Offline Outbox (Events) ├─ UI arbeitet mit aktuellem State └─ Sync: sendet Events + holt neue Events Server ├─ Event Store (group_events, expense_events, ...) ├─ Read Models (groups, expenses, ...) └─ Projektion: Events → State yaml Code kopieren --- ## Warum Events? ### Nicht: „Wie sieht der Zustand aus?“ ### Sondern: „Was ist passiert?“ Beispiel: - ❌ `Group.name = "Trip"` - ✅ `GROUP_RENAMED { from: "Urlaub", to: "Trip" }` ### Vorteile - Offline-Änderungen lassen sich sicher synchronisieren - Events sind idempotent (keine Doppeländerungen) - Konflikte sind sichtbar und erklärbar - Undo / Replay später möglich - Debugging & Audit inklusive --- ## Event-Log vs. History - `group_events` **ersetzt** klassische `group_history` - Keine doppelte Speicherung - Events *sind* die History Merksatz: > **History sagt, was ist. > Events sagen, was passiert ist.** --- ## Hybrid-Ansatz (bewusst gewählt) Costly ist **kein dogmatisches Event Sourcing**. Wir nutzen: - ✅ Events als Quelle der Wahrheit - ✅ Materialisierte Tabellen (`groups`, `expenses`) für schnelle Queries ➡️ Best of both worlds. --- ## Aktueller Konflikt-Ansatz **Last-write-wins**, basierend auf Server-Reihenfolge der Events. - Client, der später synchronisiert, kann frühere Änderungen überschreiben - Das ist **bewusst akzeptiert** fürs MVP Spätere Erweiterung möglich (siehe unten). --- ## Event-Grundstruktur ```ts GroupEvent { id: string; // vom Client generiert (UUID/ULID) groupId: string; type: string; // z.B. GROUP_CREATED, GROUP_RENAMED payload: object; // fachliche Daten actorId?: string; // deviceId / memberId createdAt: Date; // Server-Zeit } ``` ## Warum Client-generierte IDs? ### Ermöglicht Idempotenz - Events können gefahrlos erneut gesendet werden - Wichtig für Offline-Retry ### Warum es weiterhin REST-Endpunkte gibt Es gibt z. B.: - PATCH /groups/:id/name Diese Endpunkte: - validieren Input - prüfen Regeln - erzeugen intern Events Der Client muss Events nicht kennen, solange er online ist. ➡️ Später können Offline-Events direkt an /events/batch geschickt werden. ## Transaktionen – aktueller Stand Jede fachliche Aktion läuft atomar: - Event wird gespeichert - Read Model wird aktualisiert - Beides in einer DB-Transaktion Beispiel: ```sql Code kopieren renameGroup() ├─ UPDATE groups └─ INSERT group_events ``` ### Erweiterung: Optimistic Concurrency (später) Problem: - Zwei Geräte ändern offline denselben Wert Lösung: - Client sendet zusätzlich expectedLastEventId - Server prüft: - passt → akzeptieren - passt nicht → 409 Conflict Das ist ohne Architekturwechsel möglich, weil Events genutzt werden. ## Erweiterung: Transaktionen als Event-Gruppe Wenn später komplexe Änderungen kommen (z. B. mehrere Expenses auf einmal): Idee Mehrere Events gehören logisch zusammen. ```ts { transactionId: "tx-123", type: "EXPENSE_CREATED" } { transactionId: "tx-123", type: "EXPENSE_SPLIT_UPDATED" } ``` ### Möglichkeiten: Server behandelt Events mit gleicher transactionId atomar - Undo / Rollback pro Transaktion möglich - UI kann „eine Aktion“ anzeigen statt viele Events ### Erweiterung: Undo / Replay Da Events append-only sind: - Undo = Gegen-Event - Replay = Events neu anwenden → State neu aufbauen Beispiel: - GROUP_RENAMED (A → B) - Undo → GROUP_RENAMED (B → A) Wichtige Designregeln - Repositories kennen keine Events - Services orchestrieren State + Event - Events sind append-only - Read Models dürfen neu aufgebaut werden - Keine Logik im Controller Warum das alles? Weil Costly: - offline funktionieren soll - ohne Accounts auskommen soll - später wachsen können soll - aber jetzt schon stabil sein muss Diese Architektur ist der kleinste sinnvolle Schritt, um das zu erreichen. ## Status - [x]Groups als Events - [x] Rename als Event - [ ] Expenses als Events - [ ] Client-Outbox (IndexedDB) - [ ] Sync Pull (GET /events?since=...) - [ ] Conflict Handling (optional)