Skip to content

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

  1. 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 Accepted and the rest runs on workers.
  2. 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).
  3. 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. A flush() 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 send If-Match, and get 428 without it or 412 when 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, never 403 (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)