Hala Park

Parking in Damascus, even without a network

Solo — architecture, backend, web, mobile

Built with

  • C#
  • .NET 9
  • ASP.NET Core
  • JWT
  • EF Core
  • PostgreSQL
  • PostGIS
  • Wolverine
  • SignalR
  • React Native
  • WatermelonDB
  • Next.js
The Hala Park admin console showing the Damascus map with drawn parking zones
Status
Pilot, in operation
Decisions
13 documented ADRs

A parking platform for Damascus. Operators keep the occupancy of their sites up to date, drivers find free spaces. Because the network there drops constantly, the operator app keeps working offline and reconciles once a connection returns.

The problem

Looking for parking in Damascus means driving around until you find something. Operators themselves have no reliable picture of how full their sites are. Two things make that harder than it sounds. First, the network drops constantly, and a tool that only works online is useless out in the yard. Second, the business runs on cash and shouted instructions: cars leave without anyone closing anything. Software that tries to derive an occupancy number from that is wrong within two hours. And then nobody trusts it any more.

Map view of the driver app showing free and full parking sites in Damascus
Occupancy by hour for a full parking site
The driver app shows distance and free spaces. For full sites, also when they tend to free up again.

Layered, not tangled

The architecture

The backend is a single .NET 9 process, split into layers: domain, application, infrastructure, persistence, API. The domain knows nothing about the database or HTTP. It is aggregates, value objects and strongly-typed ids. Above it sit use-case services that return `ErrorOr<T>` instead of throwing. That puts expected failures in the signature rather than the control flow. Incoming DTOs are checked by FluentValidation. Data access goes through `IDataContext` and specifications, and the parts of the system talk to each other over Wolverine integration events instead of reaching into each other's tables.

A reversed decision

It started as a modular monolith: four bounded contexts, each with its own `DbContext`, Postgres schema and migration history. In practice the EF configuration ended up duplicated, once per module and once in the shared context. Rather than maintain that duplication, I dissolved the modules and committed to the layering. That reverses a deliberate, accepted decision, so it got its own ADR that explicitly supersedes the old one. Thirteen such decisions live in the repo, each with its context, the alternatives and what follows from it.

Events, not row edits

Offline-first

When both sides mutate the same rows, you end up with last-write-wins and conflicts nobody can untangle. So the client doesn't write rows. It appends events: `SessionStarted`, `SessionEnded`, `PaymentRecorded`. They are only ever inserted, never changed and never deleted. Each event carries a ULID the client generates. That is how the server spots a repeated delivery and processes it exactly once. Sync runs over WatermelonDB's pull/push protocol. Writes that check first and then act are serialised by the database using advisory locks.

The operator app on two phones: the list of parking sessions and the phone-number login
Every entry is an appended event with its own ULID. The phone may send the same batch three times; the session still counts once.

Who owns the truth?

The hardest decision wasn't a technical one. You could derive occupancy from the open sessions. Clean in theory, wrong in this market: cars leave without anyone closing a session, cash-paying guests get waved through, devices are offline. The number would slowly drift away from reality. So the operator sets it, and corrects it when it stops matching. Sessions remain the basis for billing and traceability. But the number on the dashboard is the one a person standing in the car park actually sees.

Admin console showing occupancy per site

Maps & geodata

Locations and radius searches live as real geography in PostgreSQL with PostGIS, wired up through NetTopologySuite. A nearby search is therefore a database query, not arithmetic in the application. The map needs no commercial tile service: Planetiler builds a PMTiles archive of Damascus from OpenStreetMap data, served by a self-hosted Martin instance. No vendor account, no per-request cost, no location data going to a third party. And the map still works when a service changes its pricing.

Operations

The whole stack comes up locally with a single `docker compose up`: API, database, tile server and admin console. GitHub Actions builds and tests on every push. A staging environment runs on a server of my own behind Caddy, with an Android build to hand out. The things that usually go wrong in production are settled up front: ids are ULIDs, timestamps are always UTC, money is stored as integer minor units. Live updates run over SignalR. A Redis backplane and MQTT are ready if the load grows.

Starting a parking session with a dial for the duration
A running parking session with the time left
Start it, let it run, extend it. Amounts are computed as integer minor units, so nothing goes missing in rounding.