A marketplace, not a streaming service. The difference matters architecturally.
The operator was not building a streaming service where artists upload for exposure and revenue arrives later as ad share. Artists own their content. Listeners pay for it. The platform is a marketplace: product listings, cart, checkout, order history, sales reports. The business model lives in that flow.
That distinction reshapes the architecture. A streaming service's hardest problem is ingest throughput and playback latency. A marketplace's hardest problem is the commerce surface: concurrent cart operations, order consistency, checkout that doesn't lose a purchase if a background worker is slow. Get those wrong and you lose real transactions from artists who are already sceptical of a new platform.
The operator also had a plan for a mobile client — iOS and Android, built later — and wanted the API tier to be ready for it. Not retrofitted. Ready from the start, with versioned DTO-driven endpoints and a clear Admin/Mobile separation. The mobile app didn't ship in the 2022 window. The API tier did.
The brief: a full commerce surface for an artist-owned catalogue, an async core that doesn't block checkout on background work, and an API tier that won't need to be rethought when the mobile client arrives.
Two tiers, a message bus, and three stores with distinct jobs.
The system split into two Symfony applications from day one. The monorepo — the admin and web UI — handled artist profile management, product listings, artwork uploads, and the operator's back-office. The API tier was built separately, as a DTO-versioned REST service with /Mobile/v1/ and /Admin/v1/ route namespaces, 96 mobile endpoints and 130 admin endpoints, documented and ready for the mobile client whenever it arrived.
Cart and order flows run through Symfony Messenger, not through synchronous writes. AddToCartController dispatches a message; the handler processes the cart mutation asynchronously. Order records — Order and ProductOrder, with quantity and total cost tracked per line — follow the same pattern. Checkout doesn't block on telemetry writes, artwork processing, or anything else running on the bus. The operational database stays fast because the write fan-out is managed.
Three stores, each with a single responsibility. PostgreSQL is canonical state: 25 entities, 40-plus indexes on the hot tables — users, products, orders, cart items, genres, tags, artist-to-product relationships. Redis runs both the Messenger queue broker and the cache layer for hot reads. InfluxDB holds user-action telemetry. High-frequency behaviour events never touch the operational database.
Artist profiles and product descriptions — album titles, track names, genre labels, news items — are translatable at the schema level via KnpLabs DoctrineBehaviors. Adding a locale is a data migration, not a code change. The web UI ran Plyr for audio and video playback; CropperJS handled artwork crop-and-upload inline.
- F · 01Cart and checkout via Messenger
- Cart mutations and order processing run through Symfony Messenger, not synchronous writes. Checkout acknowledges immediately; the bus handles the write fan-out.
- F · 02Two-tier architecture
- Monorepo (admin and web UI) and a separate DTO-versioned API service with /Mobile/v1/ and /Admin/v1/ namespaces — 96 mobile endpoints and 130 admin endpoints — built before the mobile client existed.
- F · 03Three-store separation
- PostgreSQL for canonical state (25 entities, 40+ indexes), Redis for queue broker and cache, InfluxDB for user-action telemetry. Each store has one job.
- F · 04Translatable content at the schema level
- KnpLabs DoctrineBehaviors on product descriptions, artist profiles, genre labels, and news. Adding a locale is a migration, not a code change.
- F · 05InfluxDB telemetry pipeline
- User-action events route through Messenger handlers to InfluxDB. The pipeline is architected for full play, scrub, and skip telemetry without touching the operational database.
- F · 06Async order processing
- Order and ProductOrder entities track purchases with per-line quantity and cost. Order mutations flow through the message bus so commerce operations don't contend with each other.
Cart goes through the bus. The API exists before the app that will call it.
The decision to route cart mutations through Symfony Messenger rather than synchronous writes was not about scale. It was about correctness. Cart operations in a marketplace involve inventory checks, product state validation, and concurrent session handling. Putting those on the message bus decouples the user-facing acknowledgement from the write fan-out. The listener gets a response; the platform catches up. The operational database never races the commerce surface.
The same principle carries through to playback. PlayFileHandler and PlayedFileService decouple playback events from the operational database. User-action telemetry writes to InfluxDB through the message bus — 21 handlers total across the API tier. The InfluxDB pipeline is architected for full play, scrub, and skip telemetry; wiring those additional events is the next step the operator can take without rearchitecting anything.
Three stores is the answer when each store earns its place by doing the job it's best at — and nothing else.
The API tier was intentionally overbuilt for 2022. /Mobile/v1/ existed with 96 endpoints before a single line of mobile app code was written. The mobile client didn't ship in the 2022 window, but the operator inherited an API service that was already versioned, documented, and separated from the admin surface. The cost of doing it later — with a live product, real user data, and a drifted schema — is not comparable to the cost of doing it once at the start.
Eight months. Three repos. A commerce surface the operator can extend.
Eight months of active development. 494 commits across three repositories — the monorepo, the API tier, and a minimal HTML/Gulp landing site. The system shipped with a full artist-to-listener commerce surface: product listings, cart, checkout, order history, and sales reports. The async architecture — Messenger bus, Redis broker, InfluxDB telemetry pipeline — was in production from the first public release.
The mobile client was the one planned surface that didn't ship in the 2022 window. The API tier built to receive it is intact. The /Mobile/v1/ namespace, the DTO contracts, the auth handling — all of it is waiting for the client rather than the other way around.
The architecture the operator inherited has three clear boundaries: Postgres for state, Redis for queue and cache, InfluxDB for time-series. Each can fail, be upgraded, or be scaled independently. The next engineer doesn't need a six-hour orientation. The interesting problem — the async commerce surface — is solved. Everything after this is product work.
We built for the mobile client before it existed. When it ships, the platform won't notice.
