194 lines
4.6 KiB
Markdown
194 lines
4.6 KiB
Markdown
# 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) |