Architecture
How Bike Trip Planner fits together and why it is built that way. Each section links to the Architecture Decision Records (ADRs) that hold the full reasoning; the index at the end groups all of them by theme. For what the product does, see Features.
The system at a glance
flowchart LR
subgraph Clients
web["Web app<br/>Next.js"]
mobile["Mobile app<br/>Expo / React Native"]
agent["AI agent<br/>MCP client"]
end
subgraph Edge["FrankenPHP (Caddy)"]
api["API Platform<br/>REST + /mcp + OAuth 2.1"]
hub["Mercure hub"]
end
workers["Messenger workers"]
redis[("Redis")]
pgapp[("PG-app<br/>users, trips, stages")]
pgref[("PG-reference<br/>PostGIS OSM + tourism")]
valhalla["Valhalla"]
prov["Provisioner (CLI)"]
ext["Komoot, Strava, RideWithGPS,<br/>Open-Meteo, Nominatim, FCM"]
web & mobile & agent --> api
web & mobile -. SSE .-> hub
api --> pgapp & pgref & redis & ext
api -- dispatch --> redis --> workers
workers --> pgapp & pgref & valhalla & ext
workers -- publish --> hub
prov --> pgref
- Three clients, one API. The web app, the native mobile app and AI agents speaking the Model Context Protocol all go through the same API Platform operations, with the same providers, processors, validation and authorization.
- A stateless PHP backend (Symfony, API Platform, PHP 8.5) served by FrankenPHP, whose Caddy also hosts the Mercure hub and proxies the web app.
- Asynchronous workers (Symfony Messenger over Redis, 2 replicas by default) do the slow work: fetching routes, computing stages, scanning the reference data, fetching weather, sending push notifications.
- Two PostgreSQL databases: PG-app holds what users create, PG-reference holds the read-only geographic index. Redis holds transient computation state, caches and the Messenger queues.
- A provisioner imports reference data out of band; Valhalla serves a routing graph built out of band.
Clients
Web app
Next.js (App Router), React and TypeScript in strict mode. Trip state lives in memory in Zustand
stores with Immer; trip data is not persisted in the browser. The session is a short-lived JWT held in
memory plus an HttpOnly refresh cookie, handled by Next.js route handlers under /api/auth/*; the
server resolves the session before rendering, so protected pages are gated server side
(ADR-007,
ADR-023,
ADR-047).
Mobile app
A native Expo / React Native app, replacing the earlier Capacitor wrapper. It authenticates with
Bearer tokens, subscribes to Mercure with an Authorization header instead of a cookie, receives
push notifications through Firebase Cloud Messaging, and keeps trips available offline with
automatic resynchronisation
(ADR-053,
ADR-054,
ADR-056,
ADR-058,
ADR-059). See
Mercure auth for non-browser clients.
AI agents (MCP)
The API exposes an MCP server at /mcp, built on API Platform's native MCP integration. It is a
third client, not an AI feature inside the product: the server runs no model. Thirteen tools map
to user intents (list, read, create, edit, share, delete trips) and each one reuses existing
operations. Agents authenticate against an OAuth 2.1 authorization server embedded in the API
(league/oauth2-server), with PKCE, Client ID Metadata Documents, audience-bound tokens and a
consent screen in the web app. Agent tokens are signed with a different key from session tokens, so
one can never be used as the other. Scopes are checked on every parsed MCP message, calls are rate
limited, and users can see and revoke the applications they authorized
(ADR-063,
ADR-064,
ADR-079,
ADR-080,
ADR-081,
ADR-082). See
Connect an AI agent and MCP tools.
AI features inside the product (Ollama, then bring-your-own-token cloud models) existed and were removed (ADR-052).
One contract for every client
The PHP resources define the schema. API Platform exports it as OpenAPI, openapi-typescript
generates core/schema.d.ts, and both front ends call the API through openapi-fetch. A backend
change that breaks the contract breaks the TypeScript build, and the CI job openapi-typegen-drift
fails if the committed types are stale
(ADR-002).
The generated types live in @btp/core, a framework-free npm workspace (the repository is a
core / pwa / mobile monorepo). It also carries the Mercure event types and the pure
reducers that reconcile events into the stores, so a reconciliation fix lands once for both apps
(ADR-055).
Computing a trip
- Ingest. A GPX upload is parsed in the request with a streaming XMLReader (constant memory,
DOCTYPE rejected), smoothed and decimated with Douglas-Peucker; a Komoot, Strava or RideWithGPS
link is fetched by a worker through a host-locked HTTP client
(ADR-004,
ADR-011).
The API answers
202 Acceptedand the rest runs on workers. - Structure. The pacing engine splits the route into daily stages, and the stages are scanned against the local reference index: accommodations, resupply, water, bike shops, stations, borders, ferries, fords, cultural points of interest and events. There is no user gate between a preview and an analysis any more (ADR-006, ADR-043).
- Enrich. Network-bound enrichments, weather first, run as separate messages and fill their block when they finish.
What makes this robust:
- Stages keep a stable identity across recomputations, so an enrichment attached to a stage survives the next edit, and enrichments are persisted group by group (ADR-066, ADR-068).
- Computation state is contract data. Each category's status is mirrored from Redis into a PostgreSQL column and exposed by the API, so a client knows what is pending, done or failed whenever it reads the trip (ADR-072).
- Each computation declares what invalidates it (the stage geometry, the stage dates), so an edit re-dispatches exactly the computations it made stale (ADR-070).
- Newer work supersedes older work. Messages carry the trip generation they belong to; a superseded computation settles and says so instead of overwriting fresher results (ADR-073).
Reading a trip
- Progressive loading. A trip is read as a light summary, then one detail resource per stage, then the route geometry only when a map is shown. Mobile fetches a stage when it is opened; web fetches all of them in parallel and fills the timeline row by row (ADR-057).
- One canonical address.
/trips/{id}answers JSON-LD, GPX or FIT depending on the format (ADR-074). - Alerts are rendered at read time. The database stores an alert's stable
code, a translation key and raw parameters; the sentence is produced in the reader's language when the trip is read. Clients key dismissal on the code (ADR-012, ADR-069, Alert engine).
Persistence: who queries, who flushes
- Queries live in repositories. A State provider or processor, a voter or a handler asks a
repository method named for what it wants (
findPageOwnedBy(),isOwnedBy(),rotate()); it never builds a query builder, DQL or SQL itself. Raw SQL stays where the statement needs it (a compare-and-swap, an insert whose unique violation is the expected outcome), but inside the repository. - A repository write is complete when the method returns. Every repository method that
writes flushes, or executes its own statement, before returning, so no caller has to remember
a flush after it. A method that only builds an entity for the caller to send first
(
MagicLinkRepository::issue()) writes nothing, and the separate method that stores it (save()) flushes. Aflush()in a processor or command is for the entities that class changed itself.
Live updates: Mercure as an invalidation channel
Workers publish an event to the trip's topic whenever something changes. Mercure is never the source of truth: everything an event carries can be read back with an authenticated GET, a client that missed events resynchronises on its next read, and a hub outage cannot fail the work that produced the event. The readiness probe therefore reports Mercure without requiring it (ADR-065). The hub speaks Mercure protocol 1.0; browsers authenticate with an HttpOnly cookie, the mobile app with a header.
Concurrent edits and retries
- Every trip has a version. Reads return it as an
ETag; edits must sendIf-Match, and get428without it or412when the trip has moved in between (ADR-067, ADR-078). - What an edit changed, and therefore what must be recomputed, is read by comparing the resource before and after the request, never from a cached fingerprint (ADR-076).
- A creation carries an
Idempotency-Key, so a retried request does not create a second trip (ADR-077). - A resource the caller may not see answers
404, never403(ADR-038).
Reference data and routing
OpenStreetMap features and tourism data (DataTourisme, OpenAgenda) are imported by the provisioner into PG-reference, and the API queries them with spatial SQL. There is no runtime Overpass dependency (ADR-040, ADR-025).
- Two datasets, two calendars. Reference data is opened one region per run
(
make provision <zone>); rows that no resolver can complete are rejected at import time, and nothing imported is ever overwritten. The routing graph is national and built separately (make routing-build <country>); Valhalla only serves it. The routing perimeter must contain the reference perimeter (ADR-049, ADR-036). - Events are the exception: perishable, they are refreshed on a schedule and purged once past (ADR-051).
- PG-reference is separate from PG-app. In production it is one shared read-only instance used by every stack; in development and CI a single PostgreSQL backs both connections. A computed trip is frozen into its stages, so it still renders if the reference rows disappear (ADR-060).
- Valhalla is only used to reroute a stage end to a chosen accommodation or point of interest (ADR-017).
Deployment and operations
Development and production run the same FrankenPHP image
(ADR-037). Production is one Oracle Cloud ARM VM
provisioned by Ansible, reached through a Cloudflare Tunnel and Traefik, and deployed by GitHub
Actions over SSH on each v* tag, with a preview per pull request
(ADR-061). PG-app is backed up nightly
off the VM (ADR-062). /api/health turns 503
when no worker is consuming messages, since nothing the API accepts would then complete
(ADR-075). See Deployment and
the runbooks.
ADR index
Superseded, revoked and withdrawn ADRs are kept for history and marked as such.
Foundations and tooling
- ADR-001 Global architecture and separation of concerns
- ADR-002 Interface contract and strict typing
- ADR-003 Local-first data persistence, versioning and migrations
- ADR-007 Frontend local state management (Zustand)
- ADR-009 Quality assurance and automated testing
- ADR-010 Developer experience and local infrastructure
- ADR-037 Dev/prod Docker convergence on FrankenPHP
- ADR-071 Dependency version policy and ceilings
Trip computation
- ADR-004 GPX parsing and decimation
- ADR-006 Pacing engine and stage generation
- ADR-016 Performance of the async pipeline
- ADR-027 Two-phase pipeline (superseded by ADR-043)
- ADR-043 Structural computation without a gate, async enrichments
- ADR-066 Stable stage identity
- ADR-067 Optimistic concurrency on trip edits
- ADR-068 Enrichment durability, by group
- ADR-070 Freshness is dispatch completeness
- ADR-072 Computation state is part of the contract
- ADR-073 Supersession settles, and says so
- ADR-076 The state before the request is the record
- ADR-077 A creation carries its own key
Reading and live updates
- ADR-055 Thin stores composing
@btp/core - ADR-057 Progressive trip loading
- ADR-065 Mercure is an invalidation channel
- ADR-069 Alerts rendered at read time
- ADR-074 A trip has an address
- ADR-078 What an ETag promises
Alerts and enrichments
- ADR-012 Rule-based alert engine
- ADR-013 Accommodation discovery and heuristic pricing
- ADR-014 Alert extensibility
- ADR-015 Dynamic engine management
- ADR-026 Multi-source data integration (partially superseded by ADR-044)
- ADR-044 Removal of the data.gouv.fr markets source
- ADR-048 In-ride assistance without AI
- ADR-051 Multi-source events and their lifecycle
Reference data and routing
- ADR-005 Caching of external APIs
- ADR-017 Valhalla routing (its Overpass part superseded by ADR-025)
- ADR-020 Dynamic Overpass region provisioning (superseded)
- ADR-025 Removal of self-hosted Overpass
- ADR-033 Nightly OSM refresh (superseded by ADR-036)
- ADR-036 Manual OSM data refresh
- ADR-040 Local-first reference data in PostGIS
- ADR-041 Provisioner resilience
- ADR-049 Zone opening and import-time completeness
- ADR-050 Terrain attribution to the ridden route
- ADR-060 PG-app / PG-reference split
Export
- ADR-008 PDF roadbook (revoked)
- ADR-018 Garmin export and device sync
- ADR-021 Enriched GPX export with waypoints
Authentication, access and privacy
- ADR-023 Passwordless magic link with JWT
- ADR-029 Early access system
- ADR-034 Usage analytics (Plausible)
- ADR-035 GDPR account erasure and portability
- ADR-038 Hide forbidden resources as 404
- ADR-047 Server-side web auth resolution
- ADR-063 Transport-agnostic authorization
MCP server
- ADR-064 MCP server as a third API client
- ADR-079 Authorizing an agent without giving it a session
- ADR-080 Thirteen tools, and everything that is not one
- ADR-081 What an agent can make the server believe, say and do
- ADR-082 Authorized applications: the table carries the dates, the tokens the truth
Mobile
- ADR-024 Capacitor Android wrapper (superseded by ADR-053)
- ADR-053 Dedicated native app
- ADR-054 Mobile design system
- ADR-056 Mercure header auth for non-browser clients
- ADR-058 Push notifications via FCM
- ADR-059 Offline auto-sync
Infrastructure and operations
- ADR-019 Deployment infrastructure (amended by ADR-061)
- ADR-022 Persistent storage
- ADR-031 Error tracking
- ADR-032 Migrations and rollback
- ADR-039 Beta right-sizing on the free tier
- ADR-061 Ansible, GitHub Actions over SSH, Traefik and Cloudflare Tunnel
- ADR-062 Backup and disaster recovery
- ADR-075 Readiness covers the consumers
Security
- ADR-011 Input validation and SSRF prevention
Withdrawn AI features (all withdrawn by ADR-052)