Architecture philosophy
The system is polyglot by design. Rather than force one runtime to cover every workload, each service uses the language that fits — and the services stay independently deployable behind clean HTTP boundaries. The whole thing rests on a posture of Security + Integrity: identities are protected against impersonation, and the data the platform records is append-only, auditable, exportable, and not retroactively alterable — including by the operator. Every technology choice below serves that posture.
The stack at a glance
- Core platform — Symfony 7 (PHP 8.3+) · domain logic, auth, billing, admin, public API. Live.
- Event ingestion — Rust (Axum + Tokio) · high-volume play-event intake with fraud-analysis and enrichment seams. Live (R1–R3).
- Audio analysis — Python (librosa) · track structure auto-detection (intro / verse / hook / drop / bridge / outro). Live.
- Recommendations — Python (scikit-learn) · fan-overlap discovery, engagement scoring. Planned.
- Predictions — Python (Prophet) · trend forecasting and growth projection. Planned.
- Real-time — Bun.js + WebSocket · live play counts and dashboard updates. Planned.
- Analytics storage — ClickHouse · columnar play-event store.
- Transactional storage — PostgreSQL 16 · accounts, entitlements, rules, trust state.
- Object storage — Cloudflare R2 · audio + cover art.
- Frontend · framework-agnostic player + dashboard,
consuming clean APIs. A typed, Vitest-covered HTML5 player package
(
@delistening/player) and telemetry SDK ship today.
Why Rust — for ingestion, and only ingestion
Ingestion is the single load-bearing slot. Every other service has natural backpressure: Symfony slows under load, Python ML lags, a WebSocket layer drops to polling. Ingestion's only failure mode is losing events — exactly what the platform's "validated plays" promise cannot survive. Rust buys headroom rather than tuning: Tokio's work-stealing scheduler with no GC pauses yields predictable tail latency where Python or Node would spike. Other slots deliberately use the language that fits them best — Rust isn't a hammer applied everywhere.
Why clients post directly to Rust
Players — web today, native mobile tomorrow — POST telemetry directly to the ingestion service. Symfony stays on the domain and access-check paths, off the firehose. Proxying events through PHP would either move the load-bearing slot off Rust (PHP-FPM doesn't have Rust's tail-latency profile under spike) or force duplicating ingestion-grade headroom inside PHP. Naming this as an invariant stops a future "just a quick PHP hop for auth" from silently regressing the throughput ceiling. (See ADR-0001.)
Why ClickHouse, not PostgreSQL, for analytics
Play events are high-volume, append-only, and queried with time-series aggregations — a workload PostgreSQL handles poorly at scale. ClickHouse is purpose-built: columnar storage, vectorized execution, and materialized views for real-time aggregation, with a MergeTree engine partitioned by month and ordered by artist / track / time for fast dashboard queries. PostgreSQL keeps what it's best at — transactional integrity for accounts, entitlements, and rules.
Why Cloudflare R2, not S3
The platform streams audio to listeners repeatedly. On S3, egress fees scale linearly with plays — a cost model that punishes growth for an indie platform. R2 is S3-compatible (same SDKs, same signed-URL patterns) but charges zero egress, so streaming cost stays predictable regardless of scale.
Why asymmetric service auth
Internal services authenticate with EdDSA (Ed25519) keypairs, not shared secrets. Each service holds only its own signing key, so compromising one service can't be used to forge requests as another — the trust boundary between services survives a single-service breach. Browser-to-ingestion telemetry is authorised the same way, with short-lived signed tokens. (See ADR-0002.)
Why Railway
All services deploy to Railway as one project: managed PostgreSQL and Redis,
persistent WebSocket support, and a private internal network so services talk
over *.railway.internal instead of public-internet hops. One
platform, zero-latency private networking, predictable indie-scale cost.
A framework-agnostic frontend
No frontend framework is a hard architectural dependency. Symfony and the ingestion service expose clean APIs any client can consume; the public player is free to evolve independently. A native mobile companion is on the roadmap — several promised listener signals (motion-driven engagement, precise location context, an offline play queue) simply aren't obtainable from a responsive web player, so the native client is part of the insight package, not a nice-to-have.
How quality is held
- Static analysis at the strictest tiers — PHPStan max on the Symfony core, clippy-pedantic on the Rust service.
- Tests run inside containers built from the production Dockerfiles, so local behaviour mirrors production rather than a host-only approximation.
- A canonical cross-service event schema is the single contract between Rust ingestion and every downstream consumer — versioned, never vendored.
- Architecture Decision Records (
docs/adr/) capture the load-bearing choices and the reasoning, so the "why" survives the commit that made it.
And the build process itself
Delistening is human-directed and AI-built: the vision, the thesis, and the architecture decisions are set by people; the engineering is carried out by AI agents held to that thesis. The discipline above — strict static analysis, production-mirrored tests, decision records — is what keeps an agentic build honest. The repository, with its full architecture documentation, is itself part of the story.