4.6 KiB
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_eventsersetzt klassischegroup_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
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:
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.
{
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
- Groups als Events
- Rename als Event
- Expenses als Events
- Client-Outbox (IndexedDB)
- Sync Pull (GET /events?since=...)
- Conflict Handling (optional)