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

- 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.


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.

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.

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.

