Build a Netflix-Style API with Spring Boot

Build a REST API with movie catalog, watchlist, pagination, and error handling.

Introduction

30 Second Summary

Every time you open Netflix and browse movies, search by genre, or add something to "My List," a backend API is handling those requests. The same patterns power streaming platforms, e-commerce sites, and social media feeds worldwide.

In this project, you will build a Netflix-style backend API using Spring Boot that serves a movie catalog with full CRUD operations, search, pagination, and a user watchlist feature.

What You'll Build

A REST API with 10+ endpoints that manages a movie catalog, supports filtering and pagination, and lets users manage a personal watchlist backed by an in-memory H2 database.

By the end of this project, you'll have:

  • A Spring Boot REST API with full CRUD for a movie catalog, returning proper HTTP status codes for every operation.
  • Paginated, sortable, and filterable endpoints with search by title and filter by genre.
  • A user watchlist system using JPA entity relationships and a join table to model Netflix's "My List" feature.
  • Secret Mission: Add a "Recently Added" endpoint that returns movies from the last 7 days using a custom JPQL query.

Are there any prerequisites?

You should be comfortable with Java basics like classes, interfaces, annotations, and generics. Familiarity with HTTP methods (GET, POST, PUT, DELETE) is also helpful.

Before We Start

Before We Start...

Generate and Open the Project

Every Spring Boot application starts from a scaffolded project with the right libraries pre-configured.

In this step, you'll use Spring Initializr to generate a project with the five dependencies your Netflix API needs. Then you'll open it in IntelliJ IDEA and confirm it runs.

In this step, get ready to:
  • Generate a Spring Boot project on start.spring.io with all required dependencies.
  • Open the project in IntelliJ IDEA and let Maven download libraries.
  • Run the application and verify it starts on port 8080.
Generate the project on Spring Initializr

Spring Initializr is a web-based tool that generates a ready-to-run Spring Boot project with your chosen dependencies already wired up in the build file.

  • Open your browser and navigate to https://start.spring.io.
  • Leave **Project** set to Maven and **Language** set to **Java**.
  • In the **Spring Boot** dropdown, select 3.5.14.
  • Under **Project Metadata**, set **Group** to com.example.
  • Set **Artifact** to netflix-api.
  • Set **Packaging** to **Jar**.
  • Set **Java** to 24.

Now add the five dependencies your project needs.

  • Click ADD DEPENDENCIES (or press Ctrl+B on Windows / Cmd+B on macOS).
  • Search for Spring Web and select it.
  • Search for Spring Data JPA and select it.
  • Search for H2 Database and select it.
  • Search for Validation and select it.
  • Search for Spring Boot DevTools and select it.
  • Click the GENERATE button at the bottom of the page.
  • A zip file named netflix-api.zip downloads to your computer.

What are these dependencies?

  • Spring Web gives you the web server and REST controller support.
  • Spring Data JPA handles database access using Java objects instead of raw SQL.
  • H2 Database is an in-memory database that runs inside your app. No installation needed.
  • Validation lets you enforce rules on incoming data (like requiring a title).
  • Spring Boot DevTools auto-restarts your app when you change code, speeding up development.
Open the project in IntelliJ IDEA

The downloaded zip contains a complete Maven project. You need to unzip it and open it in your IDE so Maven can download all the required libraries.

  • Unzip netflix-api.zip to a location you can find easily (like your Desktop).
  • Open IntelliJ IDEA.
  • Click Open on the Welcome screen.
  • Navigate to the unzipped netflix-api folder and select it.
  • Click OK to open the project.

IntelliJ detects the pom.xml file and begins importing the Maven project automatically.

  • Wait for the progress bar at the bottom of IntelliJ to finish. This means Maven has downloaded all dependencies.

Maven import not starting?

If IntelliJ does not automatically start importing, right-click the pom.xml file in the Project panel and select Maven then Reload Project.

If downloads fail, check that your internet connection is active. Maven needs to reach Maven Central to fetch libraries. Help me troubleshoot Maven dependency issues in IntelliJ.

Run the application and verify it starts

With dependencies downloaded, you can start the application to confirm everything is wired up correctly.

  • Open the built-in terminal in IntelliJ by clicking Terminal at the bottom of the window.
  • Start the application by running this command:
mvn spring-boot:run

What does this command do?

Maven compiles your code and launches the embedded Tomcat web server that comes with Spring Boot. The server listens on port 8080 by default.

You should see log output in the terminal ending with a line like Started NetflixApiApplication followed by a time. This confirms the application is running.

  • Open your browser and visit http://localhost:8080.

You will see a default error page. This is expected because you have not created any endpoints yet.

Application fails to start?

If you see a message about port 8080 already being in use, another process is occupying that port. Stop the other process or restart your computer and try again.

If you see compilation errors, make sure you selected Java 24 both on start.spring.io and in your IntelliJ project SDK settings. Help me fix Spring Boot startup errors.

✔️ Awesome, I've got everything!

Great. Make sure your application is still running, then stop it by pressing Ctrl+C in the terminal. You will restart it later.

ⓧ I'd like to double check the full code

At this point the only file you should check is application.properties. Spring Initializr generates it with a single line:

spring.application.name=netflix-api

Your project is scaffolded and running. Next up, you'll create the Movie entity and seed your database with sample data.

Build the Movie Entity and Seed Data

Your Spring Boot project is running and ready to go. Now you need to define what a "movie" looks like in your application's database.

In Spring Boot, you model database tables as Java classes called entities. You'll create a Movie entity that maps directly to a MOVIE table, then seed it with Netflix-style show data so you have something to query right away.

In this step, get ready to:
  • Create the Movie JPA entity class with fields for title, genre, rating, and more.
  • Create the MovieRepository interface for database access.
  • Configure the H2 database and seed it with movie data.
Create the Movie entity class

A JPA entity is a plain Java class annotated with @Entity. Each instance of the class represents one row in a database table. Each field becomes a column.

  • In IntelliJ IDEA, right-click the com.example.netflixapi package in the Project panel on the left.
  • Select New then Package.
  • Type entity and press Enter to create the com.example.netflixapi.entity package.
  • Right-click the new entity package, select New then Java Class.
  • Name it Movie and press Enter.
  • Replace the entire contents of Movie.java with the following code:
package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;

import java.time.LocalDate;

@Entity
public class Movie {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String genre;

    private String description;

    private int releaseYear;

    private double rating;

    @Column(name = "created_at")
    private LocalDate createdAt;

    public Movie() {
    }

    public Movie(String title, String genre, String description, int releaseYear, double rating) {
        this.title = title;
        this.genre = genre;
        this.description = description;
        this.releaseYear = releaseYear;
        this.rating = rating;
        this.createdAt = LocalDate.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getGenre() { return genre; }
    public void setGenre(String genre) { this.genre = genre; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public int getReleaseYear() { return releaseYear; }
    public void setReleaseYear(int releaseYear) { this.releaseYear = releaseYear; }
    public double getRating() { return rating; }
    public void setRating(double rating) { this.rating = rating; }
    public LocalDate getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDate createdAt) { this.createdAt = createdAt; }
}

What does this code do?

This class maps directly to a MOVIE table in your database. Each field becomes a column.

  • @Entity tells Hibernate (the JPA implementation) to manage this class as a database table.
  • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) make the id field an auto-incrementing primary key.
  • @Column(name = "created_at") maps the Java createdAt field to a database column named created_at (databases use underscores, Java uses camelCase).
  • The no-arg constructor is required by JPA. The parameterized constructor is for convenience when creating new movies in code.
  • Save Movie.java.

Seeing red underlines on the imports?

If IntelliJ shows errors on the jakarta.persistence imports, Maven may not have finished downloading dependencies yet.

  • Click the Maven refresh icon in the top-right corner (circular arrow), or right-click your pom.xml and select Maven then Reload Project.
  • Wait for the progress bar at the bottom of IntelliJ to finish.

Why won't my Jakarta persistence imports resolve?

Create the MovieRepository interface

Spring Data JPA eliminates boilerplate database code. Instead of writing SQL queries by hand, you define an interface that extends JpaRepository. Spring generates the implementation at runtime.

  • Right-click the com.example.netflixapi package in the Project panel.
  • Select New then Package.
  • Type repository and press Enter.
  • Right-click the new repository package, select New then Java Class.
  • Select Interface from the kind dropdown, name it MovieRepository, and press Enter.
  • Replace the entire contents with the following code:
package com.example.netflixapi.repository;

import com.example.netflixapi.entity.Movie;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MovieRepository extends JpaRepository<Movie, Long> {
}

What does this give you?

By extending JpaRepository<Movie, Long>, your interface inherits dozens of methods for free: findAll(), findById(), save(), deleteById(), and more.

The <Movie, Long> generics tell Spring this repository manages Movie entities whose primary key is of type Long.

  • Save MovieRepository.java.

You won't see a change yet. The repository needs a configured database before it can do anything.

Configure the database and add seed data

Your entity and repository are ready, but they need a database to connect to. The H2 dependency you added gives you an in-memory database that starts fresh every time the app launches.

  • Open src/main/resources/application.properties in the Project panel.
  • Replace the entire contents with the following configuration:
spring.application.name=netflix-api

# H2 Database
spring.datasource.url=jdbc:h2:mem:netflixdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Allow data.sql to run after Hibernate creates tables
spring.jpa.defer-datasource-initialization=true
spring.sql.init.mode=always

What do these settings do?

  • spring.datasource.url=jdbc:h2:mem:netflixdb creates an in-memory database called netflixdb. It disappears when you stop the app.
  • spring.jpa.hibernate.ddl-auto=create-drop tells Hibernate to create tables on startup and drop them on shutdown.
  • spring.h2.console.enabled=true gives you a web-based database viewer at /h2-console.
  • spring.jpa.defer-datasource-initialization=true ensures your seed data file runs AFTER Hibernate creates the tables. Without this, the SQL would fail because the tables don't exist yet.
  • Save application.properties.

Now you'll create a SQL file that populates the database with sample movies every time the app starts.

  • Right-click the src/main/resources folder in the Project panel.
  • Select New then File.
  • Name it data.sql and press Enter.
  • Paste the following SQL into data.sql:
INSERT INTO movie (title, genre, description, release_year, rating, created_at) VALUES
('Stranger Things', 'Sci-Fi', 'A group of kids encounter supernatural forces in their small town', 2016, 8.7, CURRENT_DATE),
('Breaking Bad', 'Drama', 'A chemistry teacher turns to a life of crime to secure his family future', 2008, 9.5, CURRENT_DATE - 1),
('The Witcher', 'Fantasy', 'A mutated monster hunter struggles to find his place in a world of humans', 2019, 8.2, CURRENT_DATE - 2),
('Money Heist', 'Thriller', 'A criminal mastermind plans the biggest heist in history', 2017, 8.3, CURRENT_DATE - 3),
('Dark', 'Sci-Fi', 'A missing child sets four families on a frantic hunt for answers', 2017, 8.8, CURRENT_DATE - 5),
('The Crown', 'Drama', 'The reign of Queen Elizabeth II from the 1940s to modern times', 2016, 8.6, CURRENT_DATE - 10),
('Narcos', 'Crime', 'The true story of the growth and spread of cocaine drug cartels', 2015, 8.8, CURRENT_DATE - 15),
('Wednesday', 'Comedy', 'Wednesday Addams investigates a murder spree at her new school', 2022, 8.1, CURRENT_DATE - 4),
('Squid Game', 'Thriller', 'Hundreds of cash-strapped players accept an invitation to compete in children games', 2021, 8.0, CURRENT_DATE - 20),
('The Queens Gambit', 'Drama', 'An orphaned chess prodigy struggles with addiction on her quest to become the greatest', 2020, 8.6, CURRENT_DATE - 30);

INSERT INTO app_user (username) VALUES ('johndoe');
INSERT INTO app_user (username) VALUES ('janedoe');

What is this seed data?

Spring Boot automatically runs any file named data.sql in src/main/resources on startup. This gives you 10 movies to query immediately.

The CURRENT_DATE - N values give each movie a different created_at date relative to today. This will be useful later for filtering by recently added content.

The two app_user inserts are for the watchlist feature you'll build in a later step. They won't cause errors now because the table will be created automatically when you add the User entity.

  • Save data.sql.
  • In IntelliJ, stop your application if it's running (click the red square in the Run panel at the bottom), then start it again by clicking the green play button.
  • Open your browser and go to http://localhost:8080/h2-console.
  • In the JDBC URL field, replace the default value with jdbc:h2:mem:netflixdb.
  • Leave User Name as sa and Password empty.
  • Click Connect.
  • In the SQL input area, type SELECT * FROM MOVIE; and click Run.

You should see a table with 10 rows showing all your seeded movies with their titles, genres, ratings, and creation dates.

H2 console showing an empty table or connection error?

  • Make sure the JDBC URL is exactly jdbc:h2:mem:netflixdb. The default value in the H2 console login page is different from what your app uses.
  • Check your IntelliJ console for errors. If you see a SQL syntax error mentioning data.sql, verify the spring.jpa.defer-datasource-initialization=true line is present in application.properties.
  • If the MOVIE table doesn't exist at all, make sure your Movie.java has the @Entity annotation on the class.

Help me debug my H2 console connection.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;

import java.time.LocalDate;

@Entity
public class Movie {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String genre;

    private String description;

    private int releaseYear;

    private double rating;

    @Column(name = "created_at")
    private LocalDate createdAt;

    public Movie() {
    }

    public Movie(String title, String genre, String description, int releaseYear, double rating) {
        this.title = title;
        this.genre = genre;
        this.description = description;
        this.releaseYear = releaseYear;
        this.rating = rating;
        this.createdAt = LocalDate.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getGenre() { return genre; }
    public void setGenre(String genre) { this.genre = genre; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public int getReleaseYear() { return releaseYear; }
    public void setReleaseYear(int releaseYear) { this.releaseYear = releaseYear; }
    public double getRating() { return rating; }
    public void setRating(double rating) { this.rating = rating; }
    public LocalDate getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDate createdAt) { this.createdAt = createdAt; }
}
package com.example.netflixapi.repository;

import com.example.netflixapi.entity.Movie;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MovieRepository extends JpaRepository<Movie, Long> {
}
spring.application.name=netflix-api

# H2 Database
spring.datasource.url=jdbc:h2:mem:netflixdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Allow data.sql to run after Hibernate creates tables
spring.jpa.defer-datasource-initialization=true
spring.sql.init.mode=always
INSERT INTO movie (title, genre, description, release_year, rating, created_at) VALUES
('Stranger Things', 'Sci-Fi', 'A group of kids encounter supernatural forces in their small town', 2016, 8.7, CURRENT_DATE),
('Breaking Bad', 'Drama', 'A chemistry teacher turns to a life of crime to secure his family future', 2008, 9.5, CURRENT_DATE - 1),
('The Witcher', 'Fantasy', 'A mutated monster hunter struggles to find his place in a world of humans', 2019, 8.2, CURRENT_DATE - 2),
('Money Heist', 'Thriller', 'A criminal mastermind plans the biggest heist in history', 2017, 8.3, CURRENT_DATE - 3),
('Dark', 'Sci-Fi', 'A missing child sets four families on a frantic hunt for answers', 2017, 8.8, CURRENT_DATE - 5),
('The Crown', 'Drama', 'The reign of Queen Elizabeth II from the 1940s to modern times', 2016, 8.6, CURRENT_DATE - 10),
('Narcos', 'Crime', 'The true story of the growth and spread of cocaine drug cartels', 2015, 8.8, CURRENT_DATE - 15),
('Wednesday', 'Comedy', 'Wednesday Addams investigates a murder spree at her new school', 2022, 8.1, CURRENT_DATE - 4),
('Squid Game', 'Thriller', 'Hundreds of cash-strapped players accept an invitation to compete in children games', 2021, 8.0, CURRENT_DATE - 20),
('The Queens Gambit', 'Drama', 'An orphaned chess prodigy struggles with addiction on her quest to become the greatest', 2020, 8.6, CURRENT_DATE - 30);

INSERT INTO app_user (username) VALUES ('johndoe');
INSERT INTO app_user (username) VALUES ('janedoe');

Your database layer is in place. You have entities, a repository, and real data to work with. Next up, you'll expose this data to the outside world through REST endpoints.

Create the Movie REST Controller

Your database layer is ready. You have a Movie entity and a repository that can talk to the H2 database.

Now you need to expose that data to the outside world through HTTP endpoints. In Spring Boot, this is done using a layered architecture: a Service class holds your business logic, and a Controller class maps incoming HTTP requests to service methods.

In this step, get ready to:
  • Create a MovieService class that handles all CRUD operations.
  • Create a MovieController that maps HTTP verbs to service methods.
  • Test every endpoint using curl in the terminal.
Create the MovieService

The service layer sits between the controller and the repository. It contains business logic like setting the createdAt date on new movies or throwing errors when a movie is not found.

  • In IntelliJ's Project sidebar, right-click on the com.example.netflixapi package.
  • Select New then Package.
  • Name the package service and press Enter.
  • Right-click on your new service package, select New then Java Class.
  • Name the class MovieService and press Enter.
  • Replace the contents of MovieService.java with the following code:
package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.repository.MovieRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
public class MovieService {

    private final MovieRepository movieRepository;

    // Constructor injection - Spring automatically provides the MovieRepository
    public MovieService(MovieRepository movieRepository) {
        this.movieRepository = movieRepository;
    }

    // Return a paginated list of all movies
    public Page<Movie> getAllMovies(Pageable pageable) {
        return movieRepository.findAll(pageable);
    }

    // Find a single movie or throw an error if it doesn't exist
    public Movie getMovieById(Long id) {
        return movieRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Movie not found with id: " + id));
    }

    // Save a new movie with today's date
    public Movie createMovie(Movie movie) {
        movie.setCreatedAt(LocalDate.now());
        return movieRepository.save(movie);
    }

    // Update an existing movie's fields
    public Movie updateMovie(Long id, Movie movieDetails) {
        Movie movie = getMovieById(id);
        movie.setTitle(movieDetails.getTitle());
        movie.setGenre(movieDetails.getGenre());
        movie.setDescription(movieDetails.getDescription());
        movie.setReleaseYear(movieDetails.getReleaseYear());
        movie.setRating(movieDetails.getRating());
        return movieRepository.save(movie);
    }

    // Delete a movie by id
    public void deleteMovie(Long id) {
        Movie movie = getMovieById(id);
        movieRepository.delete(movie);
    }
}

What does this code do?

The @Service annotation tells Spring this class contains business logic and should be managed as a bean.

  • Constructor injection lets Spring automatically pass the MovieRepository into this class when it creates it.
  • getAllMovies(Pageable) delegates to the repository's built-in findAll method, which returns paginated results automatically.
  • getMovieById uses orElseThrow to throw an error when no movie matches the given ID.
  • createMovie stamps the current date onto the movie before saving it.
  • Save MovieService.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red underlines in the import?

Make sure you created the file inside the service package under com.example.netflixapi. If Page or Pageable show errors, wait for IntelliJ to finish indexing the Maven dependencies.

help me fix import errors in my MovieService

Create the MovieController

The controller is the layer that receives HTTP requests and returns HTTP responses. Each method maps to a specific verb and URL path.

  • In IntelliJ's Project sidebar, right-click on the com.example.netflixapi package.
  • Select New then Package.
  • Name the package controller and press Enter.
  • Right-click on your new controller package, select New then Java Class.
  • Name the class MovieController and press Enter.
  • Replace the contents of MovieController.java with the following code:
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.MovieService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/movies")
public class MovieController {

    private final MovieService movieService;

    public MovieController(MovieService movieService) {
        this.movieService = movieService;
    }

    // GET /api/movies - returns paginated list of movies
    @GetMapping
    public Page<Movie> getAllMovies(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size,
            @RequestParam(defaultValue = "title") String sortBy,
            @RequestParam(defaultValue = "asc") String direction) {

        Sort sort = direction.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();
        Pageable pageable = PageRequest.of(page, size, sort);
        return movieService.getAllMovies(pageable);
    }

    // GET /api/movies/{id} - returns a single movie
    @GetMapping("/{id}")
    public Movie getMovieById(@PathVariable Long id) {
        return movieService.getMovieById(id);
    }

    // POST /api/movies - creates a new movie, returns 201
    @PostMapping
    public ResponseEntity<Movie> createMovie(@RequestBody Movie movie) {
        Movie created = movieService.createMovie(movie);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    // PUT /api/movies/{id} - updates an existing movie
    @PutMapping("/{id}")
    public Movie updateMovie(@PathVariable Long id, @RequestBody Movie movie) {
        return movieService.updateMovie(id, movie);
    }

    // DELETE /api/movies/{id} - deletes a movie, returns 204
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMovie(@PathVariable Long id) {
        movieService.deleteMovie(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

@RestController marks this class as a REST API controller. @RequestMapping("/api/movies") sets the base URL path for all endpoints inside.

  • The getAllMovies method accepts query parameters for pagination (page, size, sortBy, direction) with sensible defaults so callers can omit them.
  • @PathVariable extracts the {id} from the URL. @RequestBody reads JSON from the request body and maps it to a Movie object.
  • ResponseEntity lets you set custom HTTP status codes. POST returns 201 (Created) and DELETE returns 204 (No Content).
  • Save MovieController.java.
  • Restart your application by clicking the green Run button in IntelliJ (or press Shift+F10 on Windows / Ctrl+R on macOS).
  • In the console output, you should see lines containing Mapped that list your new endpoints.

Application won't start?

Check that MovieController.java is inside the controller package and MovieService.java is inside the service package. Both must be sub-packages of com.example.netflixapi for Spring to find them.

help me debug why my Spring Boot application won't start after adding MovieController

Test all endpoints with curl

Your API is live. Let's confirm every endpoint works by sending real HTTP requests from the terminal.

  • Open a terminal window (separate from IntelliJ's run console).
  • Fetch all movies by running this command:
curl http://localhost:8080/api/movies

You should see a JSON response containing a content array with the first 5 movies (the default page size) plus metadata fields like totalElements and totalPages.

  • Fetch a single movie by its ID by running:
curl http://localhost:8080/api/movies/1

You should see the full JSON for Stranger Things (or whichever movie has ID 1 in your database).

  • Create a new movie by running:
curl -X POST http://localhost:8080/api/movies -H "Content-Type: application/json" -d '{"title":"The Matrix","genre":"Sci-Fi","description":"A hacker discovers reality is simulated","releaseYear":1999,"rating":8.7}'

You should see the new movie returned with an auto-generated id and today's date as createdAt.

  • Update an existing movie by running:
curl -X PUT http://localhost:8080/api/movies/1 -H "Content-Type: application/json" -d '{"title":"Stranger Things","genre":"Sci-Fi","description":"Updated description","releaseYear":2016,"rating":9.0}'

You should see the movie returned with the updated description and rating.

  • Delete a movie by running:
curl -X DELETE http://localhost:8080/api/movies/1 -w "\nHTTP Status: %{http_code}\n"

You should see an empty response body with HTTP Status: 204, confirming the movie was deleted.

Why different HTTP status codes?

REST APIs use status codes to tell the client exactly what happened. 200 means success, 201 means a new resource was created, and 204 means success with no content to return.

These conventions let frontend developers and other API consumers handle each case correctly without parsing the response body.

Getting "Connection refused"?

Make sure your Spring Boot application is still running in IntelliJ. Check the console for errors. If the app crashed, restart it and try the curl command again.

help me fix connection refused when curling my Spring Boot API

✔️ Awesome, I've got everything!

Great. Double check you have saved both MovieService.java and MovieController.java before moving on.

ⓧ I'd like to double check the full code

package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.repository.MovieRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
public class MovieService {

    private final MovieRepository movieRepository;

    public MovieService(MovieRepository movieRepository) {
        this.movieRepository = movieRepository;
    }

    public Page<Movie> getAllMovies(Pageable pageable) {
        return movieRepository.findAll(pageable);
    }

    public Movie getMovieById(Long id) {
        return movieRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Movie not found with id: " + id));
    }

    public Movie createMovie(Movie movie) {
        movie.setCreatedAt(LocalDate.now());
        return movieRepository.save(movie);
    }

    public Movie updateMovie(Long id, Movie movieDetails) {
        Movie movie = getMovieById(id);
        movie.setTitle(movieDetails.getTitle());
        movie.setGenre(movieDetails.getGenre());
        movie.setDescription(movieDetails.getDescription());
        movie.setReleaseYear(movieDetails.getReleaseYear());
        movie.setRating(movieDetails.getRating());
        return movieRepository.save(movie);
    }

    public void deleteMovie(Long id) {
        Movie movie = getMovieById(id);
        movieRepository.delete(movie);
    }
}
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.MovieService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/movies")
public class MovieController {

    private final MovieService movieService;

    public MovieController(MovieService movieService) {
        this.movieService = movieService;
    }

    @GetMapping
    public Page<Movie> getAllMovies(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size,
            @RequestParam(defaultValue = "title") String sortBy,
            @RequestParam(defaultValue = "asc") String direction) {

        Sort sort = direction.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();
        Pageable pageable = PageRequest.of(page, size, sort);
        return movieService.getAllMovies(pageable);
    }

    @GetMapping("/{id}")
    public Movie getMovieById(@PathVariable Long id) {
        return movieService.getMovieById(id);
    }

    @PostMapping
    public ResponseEntity<Movie> createMovie(@RequestBody Movie movie) {
        Movie created = movieService.createMovie(movie);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public Movie updateMovie(@PathVariable Long id, @RequestBody Movie movie) {
        return movieService.updateMovie(id, movie);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMovie(@PathVariable Long id) {
        movieService.deleteMovie(id);
        return ResponseEntity.noContent().build();
    }
}

Your API is fully functional with create, read, update, and delete operations. Next up, you'll add search and filtering so users can find movies by title or genre without scrolling through the entire catalog.

Add Pagination, Sorting, and Search

Your CRUD endpoints work, but imagine Netflix dumping every single title into one response. A real streaming platform needs to serve results in manageable pages and let users search for specific content.

Your getAllMovies endpoint already supports pagination and sorting from the previous step. In this step, you'll test those features and then add search-by-title and filter-by-genre capabilities using Spring Data JPA derived query methods.

In this step, get ready to:
  • Test pagination and sorting on your existing movies endpoint.
  • Add search-by-title and filter-by-genre derived query methods.
  • Wire the new queries through your service and controller layers.
Test pagination and sorting with curl

Your getAllMovies endpoint already accepts page, size, sortBy, and direction query parameters. These control which slice of data the response returns and in what order.

  • Make sure your application is running (mvn spring-boot:run in the terminal).
  • Fetch the first page of 3 movies sorted by rating descending by running:
curl -s "http://localhost:8080/api/movies?page=0&size=3&sortBy=rating&direction=desc" | python3 -m json.tool

What do these parameters do?

  • page=0 requests the first page (zero-indexed).
  • size=3 limits the response to 3 movies per page.
  • sortBy=rating sorts by the rating field.
  • direction=desc returns highest ratings first.

The response includes metadata like totalElements, totalPages, and number (current page) so frontend apps know how many pages exist.

  • Try the second page by running:
curl -s "http://localhost:8080/api/movies?page=1&size=3&sortBy=rating&direction=desc" | python3 -m json.tool

You should see the next 3 movies with lower ratings than the first page.

Why derived query methods?

Spring Data JPA can generate SQL queries automatically from method names in your repository interface. Instead of writing raw SQL, you declare a method like findByGenreIgnoreCase and Spring parses the name to build the query at startup.

This keeps your code concise and database-agnostic. The same method works whether you're using H2, MySQL, or PostgreSQL.

Add search and filter query methods to the repository

Right now your repository only has the built-in methods from JpaRepository. You need two new methods: one to search movies by title and another to filter by genre.

  • Open MovieRepository.java in src/main/java/com/example/netflixapi/repository/.
  • Replace the entire file contents with the following code:
package com.example.netflixapi.repository;

import com.example.netflixapi.entity.Movie;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MovieRepository extends JpaRepository<Movie, Long> {

    // Find movies matching a genre (case-insensitive exact match)
    Page<Movie> findByGenreIgnoreCase(String genre, Pageable pageable);

    // Find movies whose title contains the search term (case-insensitive)
    Page<Movie> findByTitleContainingIgnoreCase(String title, Pageable pageable);
}

What does this code do?

  • findByGenreIgnoreCase generates a query like WHERE UPPER(genre) = UPPER(?1). The IgnoreCase keyword makes the match case-insensitive.
  • findByTitleContainingIgnoreCase generates a LIKE query with the parameter wrapped in % wildcards. Searching for "dark" matches "Dark" anywhere in the title.
  • Both methods accept a Pageable parameter and return Page<Movie>, so results come back paginated automatically.
  • Save MovieRepository.java.

You won't see a change yet. The next chunk wires these methods through the service layer.

  • Open MovieService.java in src/main/java/com/example/netflixapi/service/.
  • Find the closing brace } of the class (the last line) and add the following two methods just above it:
    public Page<Movie> searchByTitle(String title, Pageable pageable) {
        return movieRepository.findByTitleContainingIgnoreCase(title, pageable);
    }

    public Page<Movie> filterByGenre(String genre, Pageable pageable) {
        return movieRepository.findByGenreIgnoreCase(genre, pageable);
    }

What does this code do?

Each method delegates directly to the corresponding repository method. This thin service layer keeps your controller free of database logic and gives you a single place to add business rules later (like filtering out unlisted content).

  • Save MovieService.java.

You won't see a change yet. The next chunk exposes these methods as HTTP endpoints.

  • Open MovieController.java in src/main/java/com/example/netflixapi/controller/.
  • Find the closing brace } of the class (the last line) and add the following two endpoint methods just above it:
    @GetMapping("/search")
    public Page<Movie> searchByTitle(
            @RequestParam String title,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.searchByTitle(title, pageable);
    }

    @GetMapping("/genre/{genre}")
    public Page<Movie> filterByGenre(
            @PathVariable String genre,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.filterByGenre(genre, pageable);
    }

What does this code do?

  • GET /api/movies/search?title=dark searches movie titles for the given term. The title parameter is required (no default value).
  • GET /api/movies/genre/Drama filters movies by genre using a path variable. This keeps the URL clean and RESTful.
  • Both endpoints accept optional page and size parameters, defaulting to page 0 with 5 results.
  • Save MovieController.java.
  • Your application should auto-restart if DevTools is active. If not, stop and re-run with mvn spring-boot:run.

Seeing a compile error?

  • Make sure you added the new methods inside the class braces, not after them.
  • Check that the Page and Pageable imports are present in MovieRepository.java. IntelliJ should auto-import them, but verify they appear at the top of the file.
  • Confirm you saved all three files (Repository, Service, Controller).

Help me fix a compile error in my Spring Boot project

Test search and filter endpoints with curl
  • Search for movies with "the" in the title by running:
curl -s "http://localhost:8080/api/movies/search?title=the" | python3 -m json.tool

You should see movies like "The Witcher", "The Crown", and "The Queens Gambit" in the results. The search is case-insensitive, so "the" matches "The".

  • Filter movies by the Drama genre by running:
curl -s "http://localhost:8080/api/movies/genre/Drama" | python3 -m json.tool

You should see "Breaking Bad", "The Crown", and "The Queens Gambit" returned. Only movies with the Drama genre appear.

  • Test pagination on the genre filter by requesting page 1 with a size of 1:
curl -s "http://localhost:8080/api/movies/genre/Drama?page=1&size=1" | python3 -m json.tool

You should see a single Drama movie on the second page, with totalElements showing 3 total Drama movies exist.

Getting an empty result or 404?

  • Check that your genre spelling matches exactly what's in data.sql (Drama, Sci-Fi, Fantasy, Thriller, Crime, Comedy). The IgnoreCase keyword handles capitalization but not typos.
  • If you see a 404, make sure the application restarted after your changes. DevTools should auto-restart, but you can manually stop and re-run mvn spring-boot:run.

Help me debug my search and filter endpoints

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: MovieRepository.java, MovieService.java, and MovieController.java.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point in the project.

package com.example.netflixapi.repository;

import com.example.netflixapi.entity.Movie;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MovieRepository extends JpaRepository<Movie, Long> {

    Page<Movie> findByGenreIgnoreCase(String genre, Pageable pageable);

    Page<Movie> findByTitleContainingIgnoreCase(String title, Pageable pageable);
}
package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.repository.MovieRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
public class MovieService {

    private final MovieRepository movieRepository;

    public MovieService(MovieRepository movieRepository) {
        this.movieRepository = movieRepository;
    }

    public Page<Movie> getAllMovies(Pageable pageable) {
        return movieRepository.findAll(pageable);
    }

    public Movie getMovieById(Long id) {
        return movieRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Movie not found with id: " + id));
    }

    public Movie createMovie(Movie movie) {
        movie.setCreatedAt(LocalDate.now());
        return movieRepository.save(movie);
    }

    public Movie updateMovie(Long id, Movie movieDetails) {
        Movie movie = getMovieById(id);
        movie.setTitle(movieDetails.getTitle());
        movie.setGenre(movieDetails.getGenre());
        movie.setDescription(movieDetails.getDescription());
        movie.setReleaseYear(movieDetails.getReleaseYear());
        movie.setRating(movieDetails.getRating());
        return movieRepository.save(movie);
    }

    public void deleteMovie(Long id) {
        Movie movie = getMovieById(id);
        movieRepository.delete(movie);
    }

    public Page<Movie> searchByTitle(String title, Pageable pageable) {
        return movieRepository.findByTitleContainingIgnoreCase(title, pageable);
    }

    public Page<Movie> filterByGenre(String genre, Pageable pageable) {
        return movieRepository.findByGenreIgnoreCase(genre, pageable);
    }
}
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.MovieService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/movies")
public class MovieController {

    private final MovieService movieService;

    public MovieController(MovieService movieService) {
        this.movieService = movieService;
    }

    @GetMapping
    public Page<Movie> getAllMovies(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size,
            @RequestParam(defaultValue = "title") String sortBy,
            @RequestParam(defaultValue = "asc") String direction) {

        Sort sort = direction.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();
        Pageable pageable = PageRequest.of(page, size, sort);
        return movieService.getAllMovies(pageable);
    }

    @GetMapping("/{id}")
    public Movie getMovieById(@PathVariable Long id) {
        return movieService.getMovieById(id);
    }

    @PostMapping
    public ResponseEntity<Movie> createMovie(@RequestBody Movie movie) {
        Movie created = movieService.createMovie(movie);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public Movie updateMovie(@PathVariable Long id, @RequestBody Movie movie) {
        return movieService.updateMovie(id, movie);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMovie(@PathVariable Long id) {
        movieService.deleteMovie(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/search")
    public Page<Movie> searchByTitle(
            @RequestParam String title,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.searchByTitle(title, pageable);
    }

    @GetMapping("/genre/{genre}")
    public Page<Movie> filterByGenre(
            @PathVariable String genre,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.filterByGenre(genre, pageable);
    }
}

Your API now handles pagination, sorting, and targeted search across 10 movies. Next up, you'll build a user watchlist feature that introduces entity relationships and join tables.

Build the Watchlist Feature

Your movie API can now serve paginated, sorted, and filtered results. The next feature takes this from a simple catalog to something that feels like Netflix: a personal watchlist.

A watchlist is a classic Many-to-Many relationship. One user can save many movies, and one movie can appear on many users' lists. In this step, you'll model that relationship with JPA annotations and build the full controller-service-repository stack around it.

In this step, get ready to:
  • Create the User entity with a @ManyToMany relationship to Movie.
  • Create WatchlistService with add, remove, and list operations.
  • Create WatchlistController with endpoints to manage a user's watchlist.
Create the User entity with a @ManyToMany relationship

Netflix's "My List" feature lets each user save their own personal collection of movies. In database terms, this requires a join table that links user IDs to movie IDs.

What is a @ManyToMany relationship?

A Many-to-Many relationship means records on both sides can link to multiple records on the other side. One user can save many movies. One movie can be saved by many users.

JPA handles this by creating a separate join table (in our case, user_watchlist) that stores pairs of user_id and movie_id. You never interact with this table directly. JPA manages it through the @ManyToMany annotation.

  • In IntelliJ's project explorer, right-click the entity package (src/main/java/com/example/netflixapi/entity).
  • Select **New**, then **Java Class**.
  • Name it User and press **Enter**.
  • Replace the contents of the new file with this code:
package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;

import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "app_user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Username is required")
    private String username;

    // Many users can save many movies - JPA creates a join table to track this
    @ManyToMany
    @JoinTable(
        name = "user_watchlist",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "movie_id")
    )
    private Set<Movie> watchlist = new HashSet<>();

    public User() {
    }

    public User(String username) {
        this.username = username;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public Set<Movie> getWatchlist() { return watchlist; }
    public void setWatchlist(Set<Movie> watchlist) { this.watchlist = watchlist; }
}

What does this code do?

  • @Table(name = "app_user") maps this entity to a table called app_user because "user" is a reserved keyword in H2 and many databases.
  • @ManyToMany tells JPA this field represents a many-to-many relationship with the Movie entity.
  • @JoinTable configures the join table. joinColumns is the column pointing to the owning entity (User). inverseJoinColumns points to the other side (Movie).
  • Using Set<Movie> instead of List<Movie> prevents duplicate entries in the watchlist naturally.
  • Save User.java.

Your data.sql already seeds two users (johndoe and janedoe) into the app_user table from a previous step. No additional seed data needed.

Now create the repository so Spring Data JPA can query users from the database.

  • Right-click the repository package (src/main/java/com/example/netflixapi/repository).
  • Select **New**, then **Java Class**.
  • Change the kind to **Interface**, name it UserRepository, and press **Enter**.
  • Replace the file contents with this code:
package com.example.netflixapi.repository;

import com.example.netflixapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

// Provides CRUD operations for User entities
public interface UserRepository extends JpaRepository<User, Long> {
}

What does this code do?

Just like MovieRepository, this interface extends JpaRepository to get all standard CRUD methods (findById, save, delete) for free. No custom query methods are needed for the watchlist feature.

  • Save UserRepository.java.

Seeing import errors in User.java?

If IntelliJ shows red underlines on the imports, give it a few seconds to index the new file. You can also press **Ctrl+Shift+O** (Windows) or **Ctrl+Shift+O** (macOS) to organize imports.

If jakarta.validation.constraints.NotBlank is unresolved, check your pom.xml has the spring-boot-starter-validation dependency from Step 1.

help me fix import errors in my User entity

Create WatchlistService with add, remove, and list operations

The service layer holds all the business logic for managing a user's watchlist. It needs to look up both the user and the movie, then manipulate the user's watchlist set.

Before building the service, you need a custom exception class so the API returns meaningful errors when a user or movie is not found.

  • Right-click the netflixapi package (src/main/java/com/example/netflixapi).
  • Select **New**, then **Package**.
  • Name it exception and press **Enter**.
  • Right-click the new exception package, select **New**, then **Java Class**.
  • Name it ResourceNotFoundException and press **Enter**.
  • Replace the file contents with this code:
package com.example.netflixapi.exception;

// Custom exception for when a requested resource doesn't exist
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

This is a simple custom exception that extends RuntimeException. Throwing this from your service layer gives you a single, named exception type to catch later when you add global error handling. For now, it makes error messages clearer than a generic RuntimeException.

  • Save ResourceNotFoundException.java.

Now create the service class that uses this exception.

  • Right-click the service package (src/main/java/com/example/netflixapi/service).
  • Select **New**, then **Java Class**.
  • Name it WatchlistService and press **Enter**.
  • Replace the file contents with this code:
package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.entity.User;
import com.example.netflixapi.exception.ResourceNotFoundException;
import com.example.netflixapi.repository.MovieRepository;
import com.example.netflixapi.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.util.Set;

@Service
public class WatchlistService {

    private final UserRepository userRepository;
    private final MovieRepository movieRepository;

    // Constructor injection - Spring provides both repositories automatically
    public WatchlistService(UserRepository userRepository, MovieRepository movieRepository) {
        this.userRepository = userRepository;
        this.movieRepository = movieRepository;
    }

    // Returns all movies in the user's watchlist
    public Set<Movie> getWatchlist(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        return user.getWatchlist();
    }

    // Adds a movie to the user's watchlist and returns the updated list
    public Set<Movie> addToWatchlist(Long userId, Long movieId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + movieId));

        user.getWatchlist().add(movie);
        userRepository.save(user);
        return user.getWatchlist();
    }

    // Removes a movie from the user's watchlist and returns the updated list
    public Set<Movie> removeFromWatchlist(Long userId, Long movieId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + movieId));

        user.getWatchlist().remove(movie);
        userRepository.save(user);
        return user.getWatchlist();
    }
}

What does this code do?

  • The constructor takes both UserRepository and MovieRepository because the service needs to look up both users and movies.
  • Each method first validates that the user (and movie) exist, throwing ResourceNotFoundException if they don't.
  • addToWatchlist adds the movie to the Set, then calls save() to persist the change to the join table. Because it's a Set, adding the same movie twice is harmless.
  • removeFromWatchlist removes the movie from the Set and saves. JPA automatically deletes the row from the user_watchlist join table.
  • Save WatchlistService.java.

Seeing a red underline on ResourceNotFoundException?

Make sure you created the exception package under com.example.netflixapi (not nested inside another package). The import path should be com.example.netflixapi.exception.ResourceNotFoundException.

help me resolve this unresolved import error in WatchlistService

Create WatchlistController with endpoints

The controller exposes three endpoints under a nested URL pattern: /api/users/{userId}/watchlist. This RESTful design makes it clear that a watchlist belongs to a specific user.

  • Right-click the controller package (src/main/java/com/example/netflixapi/controller).
  • Select **New**, then **Java Class**.
  • Name it WatchlistController and press **Enter**.
  • Replace the file contents with this code:
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.WatchlistService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Set;

@RestController
@RequestMapping("/api/users/{userId}/watchlist")
public class WatchlistController {

    private final WatchlistService watchlistService;

    public WatchlistController(WatchlistService watchlistService) {
        this.watchlistService = watchlistService;
    }

    // GET /api/users/{userId}/watchlist - list all movies in watchlist
    @GetMapping
    public Set<Movie> getWatchlist(@PathVariable Long userId) {
        return watchlistService.getWatchlist(userId);
    }

    // POST /api/users/{userId}/watchlist/{movieId} - add movie to watchlist
    @PostMapping("/{movieId}")
    public Set<Movie> addToWatchlist(@PathVariable Long userId, @PathVariable Long movieId) {
        return watchlistService.addToWatchlist(userId, movieId);
    }

    // DELETE /api/users/{userId}/watchlist/{movieId} - remove movie from watchlist
    @DeleteMapping("/{movieId}")
    public ResponseEntity<Set<Movie>> removeFromWatchlist(@PathVariable Long userId, @PathVariable Long movieId) {
        Set<Movie> watchlist = watchlistService.removeFromWatchlist(userId, movieId);
        return ResponseEntity.ok(watchlist);
    }
}

What does this code do?

  • @RequestMapping("/api/users/{userId}/watchlist") sets the base path. Every method in this controller already has userId available from the URL.
  • GET with no extra path returns the full watchlist for that user.
  • POST /{movieId} adds the specified movie. No request body is needed because the movie ID is in the URL.
  • DELETE /{movieId} removes the movie. It wraps the response in ResponseEntity.ok() to explicitly return a 200 status.
  • Save WatchlistController.java.
  • Restart your application by clicking the green **Run** button in IntelliJ (or stop and re-run NetflixApiApplication).

Now test the watchlist endpoints using curl. Start by adding a movie to user 1's watchlist.

  • Add "Stranger Things" (movie ID 1) to johndoe's watchlist (user ID 1) by running this command:
curl -X POST http://localhost:8080/api/users/1/watchlist/1

You should see a JSON array containing the Stranger Things movie object.

  • Add a second movie ("Breaking Bad", ID 2) to the same watchlist by running:
curl -X POST http://localhost:8080/api/users/1/watchlist/2

You should see both movies in the response array now.

  • View the full watchlist by running:
curl http://localhost:8080/api/users/1/watchlist

You should see all movies you added to the watchlist returned as a JSON array.

  • Remove "Stranger Things" from the watchlist by running:
curl -X DELETE http://localhost:8080/api/users/1/watchlist/1

You should see only Breaking Bad remaining in the response.

Getting a 404 or 500 error?

  • If you see a 500 error, check your IntelliJ console for the stack trace. A common cause is the app_user table name not matching between your entity and data.sql.
  • If you see a 404, make sure the URL path matches exactly: /api/users/1/watchlist/1 (not /api/watchlist).
  • Make sure you restarted the application after saving all files.

help me debug my watchlist endpoint errors

✔️ Awesome, I've got everything!

Great. Double check you've saved all four new files: User.java, UserRepository.java, WatchlistService.java, and WatchlistController.java.

ⓧ I'd like to double check the full code

package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;

import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "app_user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Username is required")
    private String username;

    @ManyToMany
    @JoinTable(
        name = "user_watchlist",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "movie_id")
    )
    private Set<Movie> watchlist = new HashSet<>();

    public User() {
    }

    public User(String username) {
        this.username = username;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public Set<Movie> getWatchlist() { return watchlist; }
    public void setWatchlist(Set<Movie> watchlist) { this.watchlist = watchlist; }
}
package com.example.netflixapi.repository;

import com.example.netflixapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}
package com.example.netflixapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.entity.User;
import com.example.netflixapi.exception.ResourceNotFoundException;
import com.example.netflixapi.repository.MovieRepository;
import com.example.netflixapi.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.util.Set;

@Service
public class WatchlistService {

    private final UserRepository userRepository;
    private final MovieRepository movieRepository;

    public WatchlistService(UserRepository userRepository, MovieRepository movieRepository) {
        this.userRepository = userRepository;
        this.movieRepository = movieRepository;
    }

    public Set<Movie> getWatchlist(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        return user.getWatchlist();
    }

    public Set<Movie> addToWatchlist(Long userId, Long movieId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + movieId));

        user.getWatchlist().add(movie);
        userRepository.save(user);
        return user.getWatchlist();
    }

    public Set<Movie> removeFromWatchlist(Long userId, Long movieId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
        Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + movieId));

        user.getWatchlist().remove(movie);
        userRepository.save(user);
        return user.getWatchlist();
    }
}
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.WatchlistService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Set;

@RestController
@RequestMapping("/api/users/{userId}/watchlist")
public class WatchlistController {

    private final WatchlistService watchlistService;

    public WatchlistController(WatchlistService watchlistService) {
        this.watchlistService = watchlistService;
    }

    @GetMapping
    public Set<Movie> getWatchlist(@PathVariable Long userId) {
        return watchlistService.getWatchlist(userId);
    }

    @PostMapping("/{movieId}")
    public Set<Movie> addToWatchlist(@PathVariable Long userId, @PathVariable Long movieId) {
        return watchlistService.addToWatchlist(userId, movieId);
    }

    @DeleteMapping("/{movieId}")
    public ResponseEntity<Set<Movie>> removeFromWatchlist(@PathVariable Long userId, @PathVariable Long movieId) {
        Set<Movie> watchlist = watchlistService.removeFromWatchlist(userId, movieId);
        return ResponseEntity.ok(watchlist);
    }
}

Your API now has a full watchlist feature with add, remove, and list operations. Next up, you'll add input validation and clean error handling so your API returns helpful JSON messages instead of raw stack traces.

Add Validation and Error Handling

Your API accepts any data right now. Someone could create a movie with a blank title, a release year of 9999, or a rating of 500. In a production API, invalid data leads to corrupted databases and confused users.

In this step, you will add input validation to reject bad data before it reaches the database. You will also build a global exception handler that returns clean JSON error responses instead of raw Java stack traces.

In this step, get ready to:
  • Add Jakarta Validation annotations to the Movie entity.
  • Create a custom exception class and global exception handler.
  • Test validation and error handling with curl.
Add validation annotations to the Movie entity

Jakarta Validation lets you declare constraints directly on entity fields using annotations. When a request comes in, Spring automatically checks these constraints before your code runs.

  • Open Movie.java in the entity package.
  • Replace the existing import section at the top of the file with the following imports:
package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

import java.time.LocalDate;

What are these new imports?

The jakarta.validation.constraints package provides annotation-based validation. Each annotation declares a rule that Spring enforces automatically when the @Valid annotation is present on a controller parameter.

  • Now replace the field declarations (from private Long id; down to private LocalDate createdAt;) with the following annotated fields:
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    private String title;

    @NotBlank(message = "Genre is required")
    private String genre;

    private String description;

    @Min(value = 1888, message = "Release year must be 1888 or later")
    @Max(value = 2030, message = "Release year must be 2030 or earlier")
    private int releaseYear;

    @DecimalMin(value = "0.0", message = "Rating must be at least 0.0")
    @DecimalMax(value = "10.0", message = "Rating must be at most 10.0")
    private double rating;

    @Column(name = "created_at")
    private LocalDate createdAt;

What do these annotations enforce?

  • @NotBlank rejects null values and empty strings for title and genre.
  • @Min and @Max constrain releaseYear to a valid range (1888 was the year of the first film).
  • @DecimalMin and @DecimalMax keep rating between 0.0 and 10.0.
  • Save Movie.java.

Seeing red underlines on the annotations?

Make sure you added the jakarta.validation.constraints imports, not the older javax.validation.constraints package. Spring Boot 3.x uses the Jakarta namespace.

help me fix validation annotation import errors in my Movie entity

Create the exception handler

Right now, when someone requests a movie that does not exist, the API returns a generic 500 error with a Java stack trace. That exposes internal details and confuses API consumers. A custom exception and global handler fix both problems.

  • Create a new package called exception inside com.example.netflixapi.
  • Right-click on com.example.netflixapi in the Project panel, select New, then Package.
  • Name it com.example.netflixapi.exception.
  • Create a new Java class called ResourceNotFoundException inside the exception package with the following code:
package com.example.netflixapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this class do?

This is a custom exception that extends RuntimeException. By creating a dedicated class, you can catch it separately from other exceptions and return a specific HTTP 404 response instead of a generic 500.

  • Save ResourceNotFoundException.java.
  • Create another new Java class called GlobalExceptionHandler inside the same exception package with the following code:
package com.example.netflixapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles requests for resources that don't exist (returns 404)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());
        return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
    }

    // Handles validation failures from @Valid annotations (returns 400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 400);
        body.put("error", "Validation Failed");
        body.put("errors", fieldErrors);
        return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
    }
}

What does this handler do?

  • @RestControllerAdvice tells Spring this class intercepts exceptions from ALL controllers globally.
  • handleNotFound catches ResourceNotFoundException and returns a 404 JSON body with a timestamp, status, and descriptive message.
  • handleValidation catches MethodArgumentNotValidException (thrown when @Valid fails) and returns a 400 JSON body listing which fields failed and why.
  • Save GlobalExceptionHandler.java.

Now you need to wire these into your existing code. Two changes are needed: update MovieService to throw the new exception, and add @Valid to the controller.

  • Open MovieService.java in the service package.
  • Find this import line:
import com.example.netflixapi.repository.MovieRepository;
  • Add this new import directly above it:
import com.example.netflixapi.exception.ResourceNotFoundException;
  • Inside the getMovieById method, find this line:
            .orElseThrow(() -> new RuntimeException("Movie not found with id: " + id));
  • Replace RuntimeException with ResourceNotFoundException, so the line looks like this:
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + id));
  • Save MovieService.java.
  • Open MovieController.java in the controller package.
  • Add this import below the existing import com.example.netflixapi.service.MovieService; line:
import jakarta.validation.Valid;
  • Find the createMovie method signature. It currently looks like this:
    public ResponseEntity<Movie> createMovie(@RequestBody Movie movie) {
  • Add @Valid before @RequestBody so it reads:
    public ResponseEntity<Movie> createMovie(@Valid @RequestBody Movie movie) {
  • Find the updateMovie method signature. It currently looks like this:
    public Movie updateMovie(@PathVariable Long id, @RequestBody Movie movie) {
  • Add @Valid before @RequestBody so it reads:
    public Movie updateMovie(@PathVariable Long id, @Valid @RequestBody Movie movie) {
  • Save MovieController.java.

Why is @Valid needed?

Validation annotations on the entity define the rules. But Spring only enforces them when you explicitly mark the parameter with @Valid. Without @Valid, invalid data passes straight through to the database.

Seeing compile errors in MovieService or WatchlistService?

Both services reference ResourceNotFoundException. If you see a red underline on the class name, confirm that your exception package path is exactly com.example.netflixapi.exception and that the class name matches the import statement.

help me fix ResourceNotFoundException import errors in my Spring Boot project

Test validation and error handling with curl

Time to prove the validation and error handling actually work. You will send invalid data and request a non-existent movie to see your clean JSON error responses in action.

  • Make sure your application is running. If it stopped, restart it by running:
mvn spring-boot:run
  • Test validation by sending a movie with a blank title. Run this curl command in a separate terminal:
curl -X POST http://localhost:8080/api/movies -H "Content-Type: application/json" -d '{"title":"","genre":"Action","releaseYear":2023,"rating":8.0}'

You should see a 400 JSON response with the validation error for the title field showing "Title is required".

  • Test the not-found handler by requesting a movie ID that does not exist:
curl http://localhost:8080/api/movies/999

You should see a 404 JSON response with the message "Movie not found with id: 999".

What should I see?

The validation error response looks like structured JSON with a timestamp, status of 400, error of "Validation Failed", and an errors object mapping field names to messages. The not-found response has a status of 404 and a message field. No stack traces anywhere.

Still seeing a stack trace instead of clean JSON?

Check that GlobalExceptionHandler has the @RestControllerAdvice annotation on the class itself. Without it, Spring does not register the handler.

Also verify that the package is com.example.netflixapi.exception. Spring's component scan only picks up classes under the base package.

help me debug why my GlobalExceptionHandler is not intercepting exceptions

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: Movie.java, ResourceNotFoundException.java, GlobalExceptionHandler.java, and MovieController.java.

ⓧ I'd like to double check the full code

Here is the complete code for each file modified or created in this step.

package com.example.netflixapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

import java.time.LocalDate;

@Entity
public class Movie {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    private String title;

    @NotBlank(message = "Genre is required")
    private String genre;

    private String description;

    @Min(value = 1888, message = "Release year must be 1888 or later")
    @Max(value = 2030, message = "Release year must be 2030 or earlier")
    private int releaseYear;

    @DecimalMin(value = "0.0", message = "Rating must be at least 0.0")
    @DecimalMax(value = "10.0", message = "Rating must be at most 10.0")
    private double rating;

    @Column(name = "created_at")
    private LocalDate createdAt;

    public Movie() {
    }

    public Movie(String title, String genre, String description, int releaseYear, double rating) {
        this.title = title;
        this.genre = genre;
        this.description = description;
        this.releaseYear = releaseYear;
        this.rating = rating;
        this.createdAt = LocalDate.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getGenre() { return genre; }
    public void setGenre(String genre) { this.genre = genre; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public int getReleaseYear() { return releaseYear; }
    public void setReleaseYear(int releaseYear) { this.releaseYear = releaseYear; }
    public double getRating() { return rating; }
    public void setRating(double rating) { this.rating = rating; }
    public LocalDate getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDate createdAt) { this.createdAt = createdAt; }
}
package com.example.netflixapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.netflixapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());
        return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 400);
        body.put("error", "Validation Failed");
        body.put("errors", fieldErrors);
        return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
    }
}
package com.example.netflixapi.service;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.exception.ResourceNotFoundException;
import com.example.netflixapi.repository.MovieRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
public class MovieService {

    private final MovieRepository movieRepository;

    public MovieService(MovieRepository movieRepository) {
        this.movieRepository = movieRepository;
    }

    public Page<Movie> getAllMovies(Pageable pageable) {
        return movieRepository.findAll(pageable);
    }

    public Movie getMovieById(Long id) {
        return movieRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Movie not found with id: " + id));
    }

    public Movie createMovie(Movie movie) {
        movie.setCreatedAt(LocalDate.now());
        return movieRepository.save(movie);
    }

    public Movie updateMovie(Long id, Movie movieDetails) {
        Movie movie = getMovieById(id);
        movie.setTitle(movieDetails.getTitle());
        movie.setGenre(movieDetails.getGenre());
        movie.setDescription(movieDetails.getDescription());
        movie.setReleaseYear(movieDetails.getReleaseYear());
        movie.setRating(movieDetails.getRating());
        return movieRepository.save(movie);
    }

    public void deleteMovie(Long id) {
        Movie movie = getMovieById(id);
        movieRepository.delete(movie);
    }

    public Page<Movie> searchByTitle(String title, Pageable pageable) {
        return movieRepository.findByTitleContainingIgnoreCase(title, pageable);
    }

    public Page<Movie> filterByGenre(String genre, Pageable pageable) {
        return movieRepository.findByGenreIgnoreCase(genre, pageable);
    }
}
package com.example.netflixapi.controller;

import com.example.netflixapi.entity.Movie;
import com.example.netflixapi.service.MovieService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/movies")
public class MovieController {

    private final MovieService movieService;

    public MovieController(MovieService movieService) {
        this.movieService = movieService;
    }

    @GetMapping
    public Page<Movie> getAllMovies(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size,
            @RequestParam(defaultValue = "title") String sortBy,
            @RequestParam(defaultValue = "asc") String direction) {

        Sort sort = direction.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();
        Pageable pageable = PageRequest.of(page, size, sort);
        return movieService.getAllMovies(pageable);
    }

    @GetMapping("/{id}")
    public Movie getMovieById(@PathVariable Long id) {
        return movieService.getMovieById(id);
    }

    @PostMapping
    public ResponseEntity<Movie> createMovie(@Valid @RequestBody Movie movie) {
        Movie created = movieService.createMovie(movie);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public Movie updateMovie(@PathVariable Long id, @Valid @RequestBody Movie movie) {
        return movieService.updateMovie(id, movie);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMovie(@PathVariable Long id) {
        movieService.deleteMovie(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/search")
    public Page<Movie> searchByTitle(
            @RequestParam String title,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.searchByTitle(title, pageable);
    }

    @GetMapping("/genre/{genre}")
    public Page<Movie> filterByGenre(
            @PathVariable String genre,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        Pageable pageable = PageRequest.of(page, size);
        return movieService.filterByGenre(genre, pageable);
    }
}

Your API now rejects bad data at the door and returns helpful error messages. Next up, you will tackle the Secret Mission and add a custom query endpoint.

Secret mission

Add a Recently Added Endpoint

Netflix's homepage always highlights what's new. Can you add a "Recently Added" endpoint that returns movies from the last N days, sorted by creation date descending? This requires writing a custom JPQL query with a date parameter.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. The H2 database is in-memory and disappears automatically when you stop the application.

Decide whether to keep your project files to continue experimenting, or delete them entirely.

Resources you used:

  • The netflix-api project folder on your computer.
  • The running Spring Boot application process (stops when you close the terminal or press Ctrl+C).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the API.

  • Your project folder stays on your Desktop.
  • You can restart the API any time by running mvn spring-boot:run from the project folder.
  • The H2 database resets on every restart, so your seed data reloads automatically.

Pause - I'll come back to this later

Stop the running application to free up system resources. Your project files remain intact for next time.

  • In the terminal where your application is running, press Ctrl+C to stop the Spring Boot process.
  • Your project folder and all source code remain on disk.
  • The in-memory H2 database is gone, but it rebuilds from data.sql automatically on next startup.

Delete - I don't want to use this again

Remove all project files from your computer. Nothing else to clean up since the database lived in memory.

  • Stop the running application by pressing Ctrl+C in the terminal.
  • Delete the project folder by running the command for your operating system below.

macOS

  • Delete the project folder by running:
rm -rf ~/Desktop/netflix-api

Windows

  • Delete the project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\netflix-api

Nice Work!

Nice Work!

You just built a Netflix-style backend API from scratch using Spring Boot. This is the exact layered architecture pattern used in real-world production applications.

Here's what you accomplished:

  • Built a full CRUD REST API with proper HTTP status codes and the Controller/Service/Repository layered architecture.
  • Implemented paginated, sortable, and filterable endpoints using Spring Data JPA derived query methods.
  • Modeled a user watchlist with @ManyToMany entity relationships and added production-quality validation and global error handling.
  • Created a custom JPQL query for the "Recently Added" endpoint in the Secret Mission.

Ready to quiz yourself?