Designing CineMatch: My Microservices Journey
Introduction
I wanted to build something more ambitious than a typical CRUD side project: a movie discovery and recommendation platform that actually behaves like the streaming apps I use every day. Instant search, a personalized home feed, and recommendations that get smarter the more you interact with it. I'm calling it CineMatch.
That goal shaped almost every architectural decision that followed. A single monolith could technically do all of this, but the moment I wrote down the actual requirements (fast full-text search, personalized ML-driven recommendations, high-frequency behavioral event tracking, and a background job syncing against an external movie database), it became clear these are fundamentally different workloads. They have different scaling needs, different data models, and different failure modes. Bundling them into one service would mean every part of the system inherits the fragility and scaling constraints of the busiest part.
So I split CineMatch into microservices, each owning one job end to end: its own data, its own logic, its own reason to exist. This post walks through what I ended up with and, more importantly, why each piece exists.
A quick note on scope: this is the target architecture I designed toward, not necessarily everything running in production on day one. Some pieces (real-time streaming, ML re-ranking) are the kind of thing you grow into as load actually demands it, not necessarily what you'd deploy on day one of a side project. I'll probably write a follow-up post on sequencing that.
The Services, at a Glance
- API Gateway / BFF: the single front door for the app, exposed as a GraphQL API
- Auth Service: registration, login, JWT issuance and refresh
- Catalog Service: search, browsing, and movie detail pages
- Recommendation Service: personalized suggestions per user
- User Service: profiles, preferences, and watchlists
- Interaction Service: the firehose of every view, click, and rating
- Movie Ingestion Service: keeps the whole catalog in sync with TMDB
Here's the thinking behind each one.
API Gateway / BFF
This is the only thing the CineMatch frontend actually talks to. Everything else lives behind it.
I exposed it as a GraphQL API rather than a pile of REST endpoints, because the frontend's needs don't map cleanly to any one backend service. The home page alone needs trending movies, personalized picks, and the user's watchlist, all in one screen. Internally, the Gateway's resolvers still call each service over REST, but the frontend gets to ask for exactly the shape of data it needs in a single round trip, instead of stitching together three separate calls itself.
The Gateway also owns the cross-cutting concerns that don't belong to any one domain: JWT validation on every request, short-TTL response caching in Redis, and rate limiting. Centralizing these here means Auth, Catalog, and the rest can stay focused purely on their own domain logic.
Auth Service
Credentials are the one thing I didn't want anywhere near the rest of the system. Auth is intentionally boring: register, hash the password, issue a short-lived access token plus a longer-lived refresh token, and validate refresh requests. Logout blacklists the token in Redis until it naturally expires.
Isolating this wasn't about performance. It's about blast radius. If another service has a bug, I want the worst case to be "recommendations look weird," not "credentials leaked."
Catalog Service
This is the highest-traffic part of CineMatch by a wide margin. Every search, every browse, every movie detail page goes through here, so it's built for reads above all else.
Search runs on Elasticsearch rather than plain SQL LIKE queries, because full-text search across titles, overviews, keywords, and cast needs proper relevance scoring, not string matching. Movie detail pages, which need the full relational picture (cast, genres, keywords), hit PostgreSQL directly. Hot detail pages get cached in Redis, since a small number of popular movies account for a disproportionate share of traffic.
Catalog doesn't talk to TMDB directly, though. It stays reactive. It listens for a movies.updated event on Kafka and re-indexes whatever changed. This keeps Catalog decoupled from an external API's availability and rate limits entirely.
Recommendation Service
This is the part of CineMatch I was most excited to build, and also the one with the most moving pieces.
Recommendations combine two techniques: content-based filtering, which finds movies with similar embeddings using pgvector's nearest-neighbor search, and collaborative filtering, an ALS model trained by Spark that captures "users who liked X also liked Y" patterns. A request blends both: pull the nearest 50 candidates by embedding similarity, then re-rank them using the ALS model, then filter out anything the user's already watched.
The part I find most interesting is how the recency of a recommendation is handled. There's a real-time layer, too. As a user interacts with the app, a stream-processing job (Flink) keeps a live interest vector in Redis, and Recommendation reads that on every request, so the model reacts to this session, not just last week's batch retrain.
Keeping this as its own service means I can experiment with the ML strategy (swap embedding models, tune the ALS parameters, try something entirely different) without touching Catalog, User, or anything client-facing.
User Service
Profiles, preferences, and watchlists live here. It's a low-write, high-read service: people set their favorite genres once and read them constantly (Recommendation needs them for filtering, the Gateway needs them for the home feed). Isolating this data means those constant reads never contend with anything write-heavy elsewhere.
When someone adds a movie to their watchlist or updates a preference, User Service publishes an event to Kafka rather than reaching into Recommendation's data directly. That event flows through Flink into the live interest vector, keeping the two services decoupled while still staying in sync.
Interaction Service
Every view, rating, and click flows through here, and it's built completely differently from everything else, because the access pattern is completely different: extremely high-frequency, append-only writes, and almost no reads.
There are no updates here, only inserts into an append-only user_events log. That log does double duty: it streams to Kafka in real time (feeding the live-interest layer), and Spark reads it in bulk weekly to retrain the ALS model. Isolating this table protects every other service from write pressure. A spike in ratings during a big release shouldn't be able to slow down search or profile lookups.
Movie Ingestion Service
This is the only service that talks to an external API, TMDB, and it runs entirely on its own schedule, decoupled from user traffic.
It has two jobs. On first setup, it bootstraps the whole catalog: fetch genres, pull a balanced set of high-signal movies per genre, dedupe, and load everything into Postgres. After that, it runs a set of scheduled sync jobs: daily vote count updates, weekly popularity refreshes, monthly metadata corrections, and monthly checks for new releases, each one diffing against what's already stored and only writing what actually changed.
Every change it makes publishes a movies.updated event, which is how Catalog knows to re-index. This service could go down for hours and nothing user-facing would notice immediately, which is exactly the point. TMDB's availability, rate limits, and quirks are its problem to absorb, not the rest of the system's.
Closing Thoughts
Looking at CineMatch as a whole, the pattern that ties it together is: isolate by access pattern, not just by "feature." Auth is isolated for security, Interaction is isolated for write pressure, Catalog is isolated for read speed, Recommendation is isolated so its ML strategy can evolve independently. Nothing here is split because microservices are trendy. Each boundary is answering a specific question about how that piece of data actually gets used.
That said, I want to be upfront that this is very much a v1 design, not a finished product. A few things I'm actively thinking about for a follow-up post: whether the real-time recommendation pipeline is solving a problem I actually have yet, how to make the Gateway's fan-out resilient to a slow downstream service, and whether six separate Postgres instances is the right call from day one or something I should grow into. Architecture is never really done. It's a series of decisions you keep revisiting as you learn more about your actual load and your actual users.
Comments
Post a Comment