Hala Park

Parken in Damaskus, auch ohne Netz

Solo — Architektur, Backend, Web, Mobile

Gebaut mit

  • C#
  • .NET 9
  • ASP.NET Core
  • JWT
  • EF Core
  • PostgreSQL
  • PostGIS
  • Wolverine
  • SignalR
  • React Native
  • WatermelonDB
  • Next.js
Die Hala-Park-Adminkonsole mit der Karte von Damaskus und eingezeichneten Parkzonen
Status
Pilot, im Betrieb
Entscheidungen
13 dokumentierte ADRs

Eine Parkraum-Plattform für Damaskus. Betreiber pflegen die Belegung ihrer Flächen, Fahrer finden freie Plätze. Weil das Netz dort ständig wegbricht, arbeitet die App der Betreiber offline weiter und gleicht ab, sobald wieder Verbindung besteht.

Das Problem

Wer in Damaskus einen Parkplatz sucht, fährt so lange durch die Stadt, bis er einen findet. Die Betreiber wissen selbst nicht verlässlich, wie voll ihre Flächen gerade sind. Zwei Dinge machen das schwerer, als es klingt. Erstens bricht das Netz ständig weg, und ein Werkzeug, das nur online funktioniert, ist auf dem Hof wertlos. Zweitens läuft der Betrieb bar und über Zuruf: Autos fahren raus, ohne dass jemand etwas abschließt. Software, die daraus eine Belegungszahl ableiten will, liegt nach zwei Stunden daneben. Und dann glaubt sie niemand mehr.

Kartenansicht der Fahrer-App mit freien und vollen Parkplätzen in Damaskus
Auslastung nach Uhrzeit für einen vollen Parkplatz
Die Fahrer-App zeigt Entfernung und freie Plätze. Bei vollen Standorten auch, wann dort erfahrungsgemäß wieder etwas frei wird.

Geschichtet, nicht verschachtelt

Die Architektur

Das Backend ist ein einzelner .NET-9-Prozess, aufgeteilt in Schichten: Domäne, Anwendung, Infrastruktur, Persistenz, API. Die Domäne kennt weder Datenbank noch HTTP. Sie besteht aus Aggregaten, Value Objects und typisierten IDs. Darüber liegen Use-Case-Services, die statt Exceptions ein `ErrorOr<T>` zurückgeben. So stehen erwartbare Fehlschläge in der Signatur und nicht im Kontrollfluss. Eingehende DTOs prüft FluentValidation. Der Datenzugriff läuft über `IDataContext` und Specifications, und die Teile des Systems verständigen sich über Wolverine-Integrationsevents, statt sich gegenseitig in die Tabellen zu greifen.

Eine revidierte Entscheidung

Ursprünglich war das ein modularer Monolith: vier Bounded Contexts, jeder mit eigenem `DbContext`, eigenem Postgres-Schema und eigener Migrationshistorie. In der Praxis lag die EF-Konfiguration am Ende doppelt vor, einmal je Modul und einmal im gemeinsamen Kontext. Statt die Duplizierung zu pflegen, habe ich die Module aufgelöst und die Schichtung durchgezogen. Das kehrt eine bewusst getroffene Entscheidung um, also hat es einen eigenen ADR bekommen, der die alte ausdrücklich ablöst. Dreizehn solcher Entscheidungen liegen im Repo, jeweils mit Kontext, Alternativen und dem, was daraus folgt.

Ereignisse statt Zeilenänderungen

Offline-first

Wenn beide Seiten dieselben Datensätze ändern, endet das in Last-Write-Wins und in Konflikten, die niemand mehr auflösen kann. Deshalb schreibt der Client keine Zeilen. Er hängt Ereignisse an: `SessionStarted`, `SessionEnded`, `PaymentRecorded`. Die werden nur eingefügt, nie geändert und nie gelöscht. Jedes Ereignis trägt eine ULID, die der Client erzeugt. Darüber erkennt der Server doppelte Übertragungen und verarbeitet sie genau einmal. Synchronisiert wird über das Pull/Push-Protokoll von WatermelonDB. Schreibvorgänge, die erst prüfen und dann handeln, serialisiert die Datenbank über Advisory Locks.

Die Betreiber-App auf zwei Telefonen: die Liste der Parkvorgänge und der Login per Telefonnummer
Jeder Eintrag ist ein angehängtes Ereignis mit eigener ULID. Das Telefon darf dieselbe Übertragung dreimal schicken, gezählt wird der Vorgang trotzdem einmal.

Wem gehört die Wahrheit?

Die schwierigste Entscheidung war keine technische. Man könnte die Belegung aus den offenen Parkvorgängen errechnen. In der Theorie sauber, in diesem Markt aber falsch: Autos fahren raus, ohne dass jemand etwas abschließt, bar bezahlte Gäste werden durchgewunken, Geräte sind offline. Die Zahl würde langsam von der Realität wegdriften. Also setzt der Betreiber sie selbst und korrigiert sie, wenn sie nicht mehr stimmt. Die Vorgänge bleiben die Grundlage für Abrechnung und Nachvollziehbarkeit. Aber die Zahl auf dem Dashboard ist die, die jemand vor Ort auch sieht.

Adminkonsole mit der Belegung je Standort

Karten & Geodaten

Standorte und Umkreissuchen liegen als echte Geografie in PostgreSQL mit PostGIS, angebunden über NetTopologySuite. Die Umkreissuche ist damit eine Datenbankabfrage und keine Rechnerei in der Anwendung. Die Karte kommt ohne kommerziellen Kartendienst aus: Planetiler baut aus OpenStreetMap-Daten ein PMTiles-Archiv für Damaskus, das ein eigener Martin-Server ausliefert. Kein Anbieter-Konto, keine Abrufkosten, keine Standortdaten, die an Dritte gehen. Und die Karte funktioniert auch dann noch, wenn ein Dienst seine Preise ändert.

Betrieb

Der ganze Stapel startet lokal mit einem `docker compose up`: API, Datenbank, Kartenserver und Admin-Oberfläche. GitHub Actions baut und testet bei jedem Push. Auf einem eigenen Server läuft eine Staging-Umgebung hinter Caddy, samt Android-App zum Verteilen. Was im Betrieb sonst gern schiefgeht, ist von Anfang an festgelegt: IDs sind ULIDs, Zeitstempel immer UTC, Geldbeträge ganzzahlig in der kleinsten Einheit. Live-Aktualisierungen laufen über SignalR. Ein Redis-Backplane und MQTT liegen bereit, falls die Last steigt.

Parkvorgang starten, mit Wählscheibe für die Dauer
Laufender Parkvorgang mit verbleibender Zeit
Starten, laufen lassen, verlängern. Beträge rechnet das System ganzzahlig in der kleinsten Einheit, damit beim Runden nichts verloren geht.