Building Block View
See the container diagram.
Whitebox Overall System
Section titled “Whitebox Overall System”Motivation
Keep the first release as one deployable backend plus two clients. Split only what the quality goals force: a hot cache for station availability, separate from the trip ledger.
Contained Building Blocks
| Building block | Responsibility | Main interfaces |
|---|---|---|
| Mobile app | Rider unlock, map, trip history | HTTPS → API |
| Operator console | Station fill view, alerts, trip lookup | HTTPS → API |
| CityCycle API | One Node.js process on Fastify: unlock, return, billing, inventory | HTTPS JSON; MQTT to docks; Redis; PostgreSQL; TimescaleDB; payment provider |
| PostgreSQL | System of record for stations, bikes, trips, payments | Used only by the API |
| Redis | Station availability snapshot and short-lived unlock locks | Used only by the API |
| TimescaleDB | Battery sample history after ADR 0003 | Used only by the API telemetry module |
Important Interfaces
GET /stations?near=lat,lng— map. Reads Redis.POST /trips/unlock— rider unlock; returns when the dock acks or times out.POST /trips/{id}/return— dock-confirmed return; starts payment capture.GET /trips/{id}— rider or support looks up one trip.GET /operator/stations— city-wide fill levels for redistribution.- MQTT
station/{id}/events— dock and undock events from hardware. - MQTT
station/{id}/commands— unlock commands to hardware.
Every JSON route declares a Fastify schema. A payload that fails the schema never reaches PostgreSQL.
Mobile app
Section titled “Mobile app”Purpose: let a rider find a bike, unlock it, and see the open trip.
Interfaces: CityCycle API over HTTPS.
Quality: unlock latency is dominated by the API and the dock, not by the app.
Open issues: offline mode is not supported in v1.
Operator console
Section titled “Operator console”Purpose: show live fill levels and support trip lookup.
Interfaces: CityCycle API over HTTPS.
Quality: city-wide list under 2 s (reads Redis via the API).
CityCycle API
Section titled “CityCycle API”Purpose: all domain behavior, in one Node.js 22 process.
Interfaces: HTTPS for clients; MQTT for docks; Redis; PostgreSQL; TimescaleDB; payment provider.
See ADR 0004.
Directory: services/api (not present in this docs-only example).
Risks: a single deployable unit means a bad release affects unlock and billing together. Mitigated by a small surface and a fast rollback.
PostgreSQL
Section titled “PostgreSQL”Purpose: durable trips, inventory, and payment records.
See ADR 0001 and ADR 0005.
Tables in the app schema: riders, stations, docks, bikes, trips, payments. A trip row points at a bike, a start dock, an end dock, and a rider. Payment rows store the provider reference and the state (pending, captured, refunded), not the card number.
The Fastify process connects as citycycle_app. Schema changes are a migration job using citycycle_migrator.
Purpose: availability counts and unlock locks.
See ADR 0002.
Risk: if Redis is down, unlock falls back to PostgreSQL with higher latency; the map may show a degraded banner.
TimescaleDB
Section titled “TimescaleDB”Purpose: e-bike battery sample history. A second managed instance, not an extension on the trip cluster.
See ADR 0003.
Table: battery_samples (hypertable on observed_at), 90-day retention, compression after 7 days. Role citycycle_telemetry can insert and select, nothing else.
Risk: if Timescale is down, unlock still works from bikes.battery_percent; operator drain charts degrade.
Level 2
Section titled “Level 2”Inside the one Fastify process, five modules. They are folders, not deployable units.
| Module | Does | Talks to |
|---|---|---|
http |
Routes, schema, auth hook, idempotency key | The other modules |
trips |
Trip state: unlock requested, started, returned, pending payment, closed | PostgreSQL |
stations |
Station, dock, and bike records | PostgreSQL, Redis for the map read |
docks |
MQTT commands and events | Dock broker |
billing |
Capture and refund after return | Payment provider, PostgreSQL |
Battery samples from ADR 0003 get a sixth module, telemetry. It INSERTs into Timescale battery_samples and UPDATEs bikes.battery_percent on PostgreSQL. It does not write a row per sample into trips. Operator drain charts use GET /operator/bikes/{id}/battery. Unlock does not.