costly/costly-api/README.md
Bastian Wagner e05ab13d0d group events
2025-12-19 13:14:04 +01:00

4.6 KiB
Raw Permalink Blame History

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

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)