The Coding Lab
<- All posts

Here is the backend to my IMDb Clone!

Exploring the modular Spring Boot backend for an IMDb clone with PostgreSQL, OpenSearch, accounts, ratings, watchlists, and rebuildable search projections.

Revision note: I originally published this article in 2023. The project has evolved since then: PostgreSQL replaced MySQL, OpenSearch replaced Elasticsearch, RustFS replaced MinIO as the S3-compatible object store, and server-side Spring Session replaced JWT authentication. I revised the article to describe the current architecture and data model while preserving its original motivation.

When I began learning software development outside a professional environment, I searched for open-source applications that were larger than a tutorial but still understandable. Movie database clones were a common recommendation, yet many examples stopped after a few CRUD endpoints.

I wanted the backend of my IMDb Clone to exercise the boundaries that make an application interesting: a real catalog, authentication, user-owned data, full-text search, media storage, schema evolution, and repeatable local infrastructure.

The catalog is based on public IMDb data and can be enriched with additional movie metadata and images. The important part is not the number of rows. It is deciding which system owns each kind of data and how those systems remain consistent.

IMDb Clone backend architecture

The backend is one Spring Boot deployment, but it has three different persistence responsibilities:

  • PostgreSQL is the transactional source of truth for movies, accounts, ratings, comments, sessions, and background tasks.
  • OpenSearch stores a query-oriented projection of movie data for lexical and semantic retrieval.
  • RustFS exposes an S3-compatible API for posters, backdrops, and profile images; PostgreSQL stores only their object tokens.

This separation is the central architectural idea. A relational database, a search engine, and an object store are not interchangeable databases. Each is used for the workload it represents best.

What the backend provides

The application covers several connected domains:

  • a movie catalog with IMDb and TMDB identifiers, titles, genres, runtime, descriptions, ratings, and media references;
  • OpenSearch-based title, filter, and semantic search;
  • account registration plus password, social, and passkey authentication;
  • server-side sessions, roles, email confirmation, and password reset;
  • user ratings, watched movies, watchlists, and comments;
  • poster, backdrop, and profile-image storage through an S3-compatible boundary;
  • recommendation and discovery flows built on catalog and engagement data.

These features are not implemented as one large collection of controllers and repositories. The code is organized as a modular monolith.

Keep one deployment without creating one big module

The main packages represent business capabilities such as catalog, engagement, account, identity, media, and recommendation. Each module exposes a small API while its services, repositories, mappers, and persistence classes remain under internal packages.

The catalog follows this shape:

catalog/
├── api/          public records and service interfaces
├── internal/     implementations, persistence, and search adapters
└── web/          HTTP controllers

A controller depends on a public interface such as MovieService or MovieSearch; it does not reach directly into another module’s repository. Cross-module dependencies are declared, and an architecture test asks Spring Modulith to verify them:

@Test
void verifiesApplicationModules() {
    ApplicationModules.of(Application.class).verify();
}

Package names alone are documentation. An executable architecture test turns them into a constraint. This keeps the operational simplicity of one deployment and one transactional database while preventing every feature from depending on every implementation detail.

Start infrastructure and application separately

The repository contains a command index for local development. After cloning it, start the stateful services and the backend in separate terminals:

$ git clone https://github.com/NiklasTiede/imdb-clone.git
$ cd imdb-clone
$ make docker-compose-dev-up
$ ./gradlew bootRun

Docker Compose starts PostgreSQL, OpenSearch, S3-compatible object storage, and the embedding service used by semantic search. The Spring application remains a normal Gradle process, which keeps debugging and restart cycles independent from the stateful services.

Flyway applies the SQL migrations in src/main/resources/db/migration during startup. Schema creation is therefore part of the application lifecycle rather than an undocumented manual step or an editable SQL dump.

Movie data is seeded separately. The repository provides a lightweight development seed and a larger catalog pipeline, both versioned independently from the application. Separating schema migration from data import has two advantages: the backend can start without waiting for a large catalog, and seed data can evolve without pretending to be database structure.

The repository’s local-development guide contains the current seed command and prerequisites.

Once the application is running, its OpenAPI document and Swagger UI expose the HTTP contract:

http://localhost:8080/v3/api-docs.yaml
http://localhost:8080/v3/swagger-ui.html

A search request crosses a deliberate read boundary

Movie search accepts free text, pagination, and structured filters. For example, this request searches for Nightcrawler while leaving all optional filters open:

$ curl --request POST \
    --url 'http://localhost:8080/api/search/movies?query=nightcrawler&page=0&size=5' \
    --header 'Content-Type: application/json' \
    --data '{}'

The controller validates the request and delegates to the catalog’s search interface. OpenSearch returns a page of movie documents, which the backend maps to the public MovieRecord response. PostgreSQL does not execute this text search, and OpenSearch is not asked to own account or rating transactions.

That distinction becomes more important on writes. Creating or changing a movie first commits the authoritative row in PostgreSQL and schedules a projection task. Reduced to the essential steps, the flow is:

private Movie performSave(Movie movie) {
    Movie savedMovie = movieRepository.save(movie);
    movieSearchProjectionTasks.enqueueUpsert(savedMovie.getId());
    return savedMovie;
}

The durable task later reads the movie by ID, maps it into a MovieSearchDocument, adds its search embedding, and writes that projection to OpenSearch. Deletion follows the same pattern with a delete operation. The task record participates in the PostgreSQL transaction, so a rolled-back catalog update does not leave behind a projection task for data that never committed.

This is intentionally not a dual-write in which PostgreSQL and OpenSearch are equal authorities. There is one source record and one disposable retrieval model. An administrator can start a complete reindex job, which pages through PostgreSQL and rebuilds the OpenSearch index. If the mapping changes or the index is lost, the catalog remains intact.

PostgreSQLOpenSearch
Owns movie and user stateOwns a movie search projection
Enforces transactions and relationshipsOptimizes text, filter, and vector retrieval
Updated by domain operationsUpdated asynchronously by projection tasks
Must be backed up as authoritative dataCan be rebuilt from PostgreSQL

The useful design principle is not merely “use a search engine.” It is to decide whether a second store is authoritative or derived and make recovery behavior match that decision.

Let the data model enforce domain rules

The core PostgreSQL model has two centers: account and movie. Engagement tables connect them, while identity tables attach authentication methods and verification state to an account.

Core IMDb Clone data model

Ratings and watched movies are relationships

A user can rate the same movie once. The rating table encodes that invariant with a composite primary key. Reduced to the relevant fields, the mapping is:

@Embeddable
public class RatingId implements Serializable {
    private Long movieId;
    private Long accountId;
}

@Entity
public class Rating {
    @EmbeddedId
    private RatingId id;

    private BigDecimal rating;
}

The database key (movie_id, account_id) makes a duplicate rating impossible even if two requests race. The watched_movie table uses the same key shape because it also represents one relationship between an account and a movie.

Comments have a different rule. A user may write more than one comment across movies, so each comment has its own generated ID plus foreign keys to the account and movie. The schema expresses the difference between an entity with its own identity and a unique relationship between two entities.

Foreign keys use cascading deletes where the dependent record has no useful meaning without its parent. PostgreSQL does not automatically index every referencing column, so the migrations add indexes for account-, movie-, and time-oriented access paths used by the application.

Keep account identity separate from credentials

The account table is the anchor for a user’s profile and roles. Authentication details live in more focused structures:

  • local_credential stores the password hash and enforces one local credential per account;
  • account_identity_provider links Google or GitHub identities without merging provider-specific IDs into the profile;
  • WebAuthn tables store passkey credentials;
  • verification_token stores hashed, expiring tokens for confirmation and password-reset flows;
  • Spring Session tables persist authenticated server-side sessions.

This model lets one account support several authentication methods. It also avoids treating the public profile, a password hash, a social-provider identity, and an active browser session as the same kind of data.

Keep external identifiers and internal relationships distinct

Movies have an internal database ID as well as unique IMDb and optional TMDB identifiers. Internal foreign keys use the database ID; external IDs remain integration keys. That separation allows enrichment from external datasets without making every relationship depend on a third party’s identifier format.

The movie row also stores the application’s rating aggregate as a sum and count. A rating update changes the user’s rating and applies a delta to the aggregate inside the domain flow, then schedules a fresh search projection. Storing the sum avoids reconstructing it from a rounded average when a rating changes.

Genre values are stored as a bitmask, while movie type is stored as text. These are different tradeoffs: the bitmask keeps a compact set of known flags, whereas a string enum avoids coupling persisted data to Java enum ordinals. Neither representation is universally better; each needs an explicit compatibility rule.

Authentication is a server-side state transition

The original backend returned a JWT after login. The current application uses server-side sessions stored through Spring Session JDBC.

On successful password authentication, the backend creates the security context, rotates the session ID, persists the context, and returns an account-session representation. The browser authenticates subsequent requests with the session cookie, while state-changing browser requests are protected by CSRF tokens.

Protected engagement endpoints do not accept an arbitrary account ID from the request. For example, the rating endpoint combines the movie ID and score from the URL with the current authenticated principal:

PUT /api/movie-rating/{movieId}/rating-score/{score}

Authorization decides whether the caller may rate a movie, and the service derives accountId from the authenticated user. This is a small but important boundary: ownership comes from the security context, not from client-controlled identity data.

Test the real persistence boundaries

The test suite separates fast unit tests from container-backed integration tests. The integration foundation starts PostgreSQL, OpenSearch, and RustFS containers, lets Flyway create the schema, and loads deterministic test data.

That makes several architectural claims executable:

  • repository tests run against PostgreSQL rather than an in-memory database with different SQL behavior;
  • search tests rebuild OpenSearch from PostgreSQL and verify both document count and index mappings;
  • controller tests exercise authorization, pagination, and serialized response contracts;
  • projection tests verify that task scheduling participates in the current database transaction;
  • the Spring Modulith test rejects forbidden module dependencies.

Mocks are still useful for focused domain behavior—for example, proving that saving a movie schedules an upsert. Containers are used where the boundary itself is the subject of the test. Choosing between them by risk produces a more useful suite than declaring either unit or integration testing universally superior.

What this project taught me

The backend contains many individual Spring techniques, but the reusable lessons sit between them:

  1. Assign one clear owner to each kind of data.
  2. Treat search indexes as projections when the relational model is authoritative.
  3. Put uniqueness and referential integrity in the database, not only in service checks.
  4. Separate public module contracts from persistence implementations.
  5. Version schema migrations and large seed data independently.
  6. Derive resource ownership from the authenticated principal.
  7. Test each boundary with the smallest test that can fail for the right reason.

A realistic backend is not defined by the number of endpoints or technologies in its README. It becomes interesting when its boundaries make failure, consistency, ownership, and change understandable. That is what turned this IMDb Clone from a CRUD exercise into a project from which I could keep learning.

Further reading