Build a Bookmark Manager REST API

Build a full CRUD REST API with Spring Boot, JPA, and H2 database.

Introduction

30 Second Summary

Every developer bookmarks useful links, but those bookmarks live scattered across browsers and devices with no way to search or organize them programmatically. What if you could build your own API to store, retrieve, and manage bookmarks from anywhere?

In this project, you will build a REST API using Spring Boot that performs full CRUD operations on bookmarks, complete with input validation and proper error handling.

What You'll Build

You will send HTTP requests to your running API and watch it create, retrieve, update, and delete bookmarks, returning clean JSON responses with the correct status codes every time.

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

  • A running API where you can POST a new bookmark with a URL, title, and description, then see it returned with an auto-generated ID.
  • Full CRUD operations that let you list all bookmarks, fetch one by ID, update its details, and delete it, with proper HTTP status codes for each action.
  • Clean error handling that returns a meaningful 404 JSON response when a bookmark does not exist, instead of a generic server error.
  • Secret Mission: Add a tag system where bookmarks can have multiple tags, and filter your collection by tag name.

Are there any prerequisites?

You should be comfortable with Java OOP fundamentals (classes, interfaces, annotations). You will also need JDK 17 and IntelliJ IDEA installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any hands-on work begins.

Generate and Open Your Spring Boot Project

Every Spring Boot application starts with a project skeleton that wires up the build tool, dependencies, and folder structure.

Instead of creating all of this by hand, you will use Spring Initializr to generate a ready-to-run project in seconds. Then you will open it in IntelliJ IDEA and confirm it starts cleanly.

In this step, get ready to:
  • Generate a Spring Boot project with the right dependencies.
  • Open the project in IntelliJ IDEA and let Maven download dependencies.
  • Run the application and confirm it starts on port 8080.
Generate the project on Spring Initializr

Spring Initializr is a web tool that builds a complete Maven project with your chosen dependencies already configured in the build file.

  • Open your browser and navigate to https://start.spring.io.
  • Under Project, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 4.1.0.
  • In the Project Metadata section, set Group to com.example.
  • Set Artifact to bookmark-manager.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES (or press Ctrl+B).
  • Search for and add Spring Web.
  • Search for and add Spring Data JPA.
  • Search for and add H2 Database.
  • Search for and add Validation.
  • Click GENERATE to download the ZIP file.

A file called bookmark-manager.zip downloads to your computer.

What did Spring Initializr just create?

The ZIP contains a complete Maven project with a pom.xml (build configuration), a main application class, and an application.properties file.

The four dependencies you added give you an embedded web server (Spring Web), database access (JPA), an in-memory database (H2), and input validation.

Open the project in IntelliJ IDEA

Now you need to extract the ZIP and open the project so Maven can download all the dependency JARs.

  • Extract the bookmark-manager.zip file to a location you can easily find (your Desktop works well).
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • In IntelliJ, click File in the top menu bar.
  • Click Open.
  • Navigate to and select the extracted bookmark-manager folder, then click OK.
  • If IntelliJ asks whether to trust the project, click Trust Project.

Watch the progress bar at the bottom of IntelliJ. Maven is downloading your project dependencies. This may take a minute on first run.

Progress bar stuck or showing errors?

  • Make sure you have a working internet connection. Maven needs to download JARs from a remote repository.
  • If you see "Cannot resolve symbol" errors in the editor, wait for the progress bar to finish. IntelliJ shows errors until all dependencies are downloaded.
  • Try clicking File then Invalidate Caches / Restart if the import seems frozen.

Why can't IntelliJ find my dependencies?

Run the application

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

  • In the Project panel on the left, expand src > main > java > com.example.bookmarkmanager.
  • Double-click BookmarkManagerApplication to open the file.
  • Click the green play button (triangle) in the gutter next to the main method.
  • Select Run 'BookmarkManagerApplication.main()'.

What happens when you click Run?

IntelliJ compiles your Java code, then Spring Boot starts an embedded Tomcat web server. The application occupies the Run panel at the bottom of IntelliJ. You will leave it running while you work in the next steps.

Before you look at the console output, what do you expect to see if everything worked?

Check the Run panel at the bottom of IntelliJ. You should see two key lines in the log output:

  • A line containing Started BookmarkManagerApplication confirms the app launched successfully.
  • A line containing Tomcat started on port 8080 confirms the web server is listening for requests.

Don't see those messages?

  • If you see "Port 8080 already in use", another process is occupying that port. Close other servers or IDEs that might be running on 8080.
  • If you see compilation errors, confirm Maven finished downloading dependencies (no progress bar at the bottom).
  • Make sure you selected Java 17 in Spring Initializr. A mismatch between your JDK version and the project setting causes build failures.

Why won't my Spring Boot application start?

Your Spring Boot project is alive and listening on port 8080. Next up, you will create your first REST endpoint and see it respond with real JSON data.

Create Your First Endpoint and See It Respond

Your Spring Boot application is running, but it does not do anything useful yet. Before building the full bookmark API, you need to prove that Spring Boot can receive an HTTP request and return JSON.

In this step, you will create a database entity, connect it to a repository, and expose it through a controller. By the end, you will hit a URL in your browser and see a real JSON response come back.

In this step, get ready to:
  • Create a JPA entity to represent bookmarks in the database.
  • Create a repository and controller to handle HTTP requests.
  • Configure the H2 database and test the endpoint.
Create the Bookmark entity

Every REST API needs a data model. In Spring Boot, a JPA entity is a Java class that maps directly to a database table. Each instance of the class becomes a row in that table.

  • In the IntelliJ Project panel on the left, right-click the com.example.bookmarkmanager package inside src/main/java.
  • Select New, then Java Class.
  • Name the class Bookmark and press Enter.
  • Replace the entire contents of the new file with this code:
package com.example.bookmarkmanager;

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

@Entity
public class Bookmark {

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

    private String url;
    private String title;
    private String description;

    // No-args constructor required by JPA
    public Bookmark() {
    }

    // Constructor for creating new bookmarks
    public Bookmark(String url, String title, String description) {
        this.url = url;
        this.title = title;
        this.description = description;
    }
}

What does this code do?

  • `@Entity` tells JPA to create a database table from this class.
  • `@Id` and `@GeneratedValue` mark the `id` field as the primary key that auto-increments whenever you save a new bookmark.
  • The no-args constructor is required by JPA to create objects when reading from the database.
  • The three-argument constructor is for your own code to create bookmarks with data.
  • Add the following getters and setters below the second constructor, but before the final closing brace of the class:
    // Getters and setters for all fields
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

Why does JPA need getters and setters?

JPA uses these methods to read field values when building JSON responses and to set field values when saving data from incoming requests. Without them, Spring Boot cannot serialize your entity to JSON or populate fields from a request body.

  • Save Bookmark.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Restart the application by clicking the green Rerun button in the Run panel at the bottom of IntelliJ.
  • Confirm the console shows Started BookmarkManagerApplication with no errors.

Seeing a compilation error?

  • Check that your imports use jakarta.persistence (not javax.persistence). Spring Boot 4.1.0 uses the Jakarta namespace.
  • Make sure the class file is inside the com.example.bookmarkmanager package folder, not at the project root.
  • Confirm you have both the opening and closing braces for the class and that the getters are inside the class body.

Still stuck? Help me debug my Bookmark entity compilation error in Spring Boot.

Create the repository and controller

The entity defines your data shape. Now you need two more pieces: a repository to talk to the database and a controller to handle HTTP requests from the outside world.

  • Right-click the com.example.bookmarkmanager package again and select New, then Java Class.
  • In the creation dialog, change the kind dropdown from Class to Interface.
  • Name it BookmarkRepository and press Enter.
  • Replace the file contents with this code:
package com.example.bookmarkmanager;

import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA provides CRUD methods automatically
public interface BookmarkRepository extends JpaRepository<Bookmark, Long> {
}

What does this code do?

By extending JpaRepository, this interface automatically gets methods like findAll(), findById(), save(), and delete(). You do not need to write any implementation code. Spring Data JPA generates it at runtime.

  • Create another new Java class in the same package. Name it BookmarkController (leave the kind as Class).
  • Replace the file contents with this code:
package com.example.bookmarkmanager;

import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/bookmarks")
public class BookmarkController {

    private final BookmarkRepository repository;

    // Constructor injection of the repository
    public BookmarkController(BookmarkRepository repository) {
        this.repository = repository;
    }

    // GET /api/bookmarks - returns all bookmarks as JSON
    @GetMapping
    public List<Bookmark> getAllBookmarks() {
        return repository.findAll();
    }
}

What does this code do?

  • `@RestController` tells Spring this class handles HTTP requests and returns JSON responses directly.
  • `@RequestMapping("/api/bookmarks")` sets the base URL path for all endpoints in this controller.
  • The constructor receives the repository through constructor injection. Spring automatically creates and passes the repository instance.
  • `@GetMapping` maps HTTP GET requests to the getAllBookmarks() method, which queries the database and returns the results as JSON.
  • Save both files by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Restart the application by clicking the green Rerun button in the Run panel.
  • Confirm the console shows Started BookmarkManagerApplication with no errors.

Application fails to start?

  • Check that BookmarkRepository is an interface (not a class). It must use the interface keyword.
  • Verify the type parameters match: JpaRepository<Bookmark, Long>. The first is your entity class, the second is the ID type.
  • Make sure all three files are in the same package: com.example.bookmarkmanager.

Need more help? Help me debug why my Spring Boot app fails to start after adding a repository and controller.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to the next substep.

ⓧ I'd like to double check the full code

Your Bookmark.java file should contain these sections in order: the package and imports, the @Entity class with four fields, two constructors, and eight getter/setter methods. Scroll up and confirm each chunk is present.

Here is the full BookmarkRepository.java for comparison:

package com.example.bookmarkmanager;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookmarkRepository extends JpaRepository<Bookmark, Long> {
}

And here is the full BookmarkController.java:

package com.example.bookmarkmanager;

import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/bookmarks")
public class BookmarkController {

    private final BookmarkRepository repository;

    public BookmarkController(BookmarkRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public List<Bookmark> getAllBookmarks() {
        return repository.findAll();
    }
}
Configure H2 and test the endpoint

Your application starts, but the database is using Spring Boot's unnamed defaults. Configuring H2 explicitly gives you a named database and a web console where you can browse your data directly in the browser.

  • Open src/main/resources/application.properties in the editor.
  • Replace its contents with the following configuration:
spring.application.name=bookmark-manager
# H2 in-memory database configuration
spring.datasource.url=jdbc:h2:mem:bookmarks
spring.datasource.username=sa
spring.datasource.password=
# Enable the H2 web console for browsing data
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# Auto-create tables from entity classes
spring.jpa.hibernate.ddl-auto=update
# Show SQL queries in the console for visibility
spring.jpa.show-sql=true

What do these properties do?

  • `spring.datasource.url=jdbc:h2:mem:bookmarks` creates a named in-memory database. The data lives only while the application is running.
  • `spring.h2.console.enabled=true` turns on a browser-based database viewer at /h2-console.
  • `spring.jpa.hibernate.ddl-auto=update` tells JPA to create or update database tables automatically based on your entity classes.
  • `spring.jpa.show-sql=true` prints every SQL query to the console so you can see what JPA does behind the scenes.
  • Save application.properties.
  • Restart the application by clicking the green Rerun button.

Before you test, what do you think you will get back when you request all bookmarks from an empty database?

  • Open your browser and navigate to http://localhost:8080/api/bookmarks.

You should see an empty JSON array: []. Spring Boot queried the H2 database, found no bookmarks, and returned the result as JSON.

Seeing a Whitelabel Error page?

  • Check the URL is exactly http://localhost:8080/api/bookmarks. A typo in the path gives a 404 error page.
  • Make sure the console shows Started BookmarkManagerApplication. If it shows errors, fix those first.
  • Verify your controller has @RequestMapping("/api/bookmarks") on the class and @GetMapping on the method.

Still not working? Help me figure out why my /api/bookmarks endpoint returns a Whitelabel Error.

Browse your database in the H2 console

You can also visit http://localhost:8080/h2-console in your browser. Set the JDBC URL field to jdbc:h2:mem:bookmarks and click Connect.

You will see a BOOKMARK table that JPA created automatically from your entity class. It is empty for now, but you will fill it in the next step.

Your first endpoint is live and returning data from the database. Next, you will build the full set of CRUD operations so you can create, update, and delete bookmarks.

Build the Full CRUD Operations

Your API can list bookmarks, but that is all it can do right now. A real bookmark manager needs to create, retrieve, update, and delete entries.

In this step you will build the remaining four operations and introduce a service layer between your controller and repository. This layered architecture keeps business logic separate from HTTP handling, which is the pattern every production Spring Boot app follows.

In this step, get ready to:
  • Create a service layer that holds your business logic.
  • Add POST, GET by ID, PUT, and DELETE endpoints to your controller.
  • Test the full CRUD cycle by sending HTTP requests and seeing JSON responses.
Add the service layer

Right now your controller talks directly to the repository. Adding a service class in between gives you a dedicated place for business logic like "throw an error if the bookmark does not exist."

  • In IntelliJ, right-click the com.example.bookmarkmanager package in the Project panel on the left.
  • Select New then Java Class.
  • Name it BookmarkService and press Enter.
  • Replace the generated contents with the following code:
package com.example.bookmarkmanager;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class BookmarkService {

    private final BookmarkRepository repository;

    public BookmarkService(BookmarkRepository repository) {
        this.repository = repository;
    }

    public List<Bookmark> getAllBookmarks() {
        return repository.findAll();
    }

    public Bookmark getBookmarkById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new RuntimeException("Bookmark not found with id: " + id));
    }

    public Bookmark createBookmark(Bookmark bookmark) {
        return repository.save(bookmark);
    }
}

What does this code do?

The @Service annotation tells Spring to manage this class as a bean, so it can be injected into your controller.

  • The constructor receives the repository via constructor injection. Spring automatically passes it in.
  • getAllBookmarks() delegates straight to the repository's built-in findAll() method.
  • getBookmarkById() uses orElseThrow to throw an exception when the ID does not exist.
  • createBookmark() saves a new bookmark and returns the persisted version (which now has an auto-generated ID).
  • Now add the update and delete methods below createBookmark, before the closing } of the class:
    public Bookmark updateBookmark(Long id, Bookmark bookmarkDetails) {
        Bookmark bookmark = repository.findById(id)
                .orElseThrow(() -> new RuntimeException("Bookmark not found with id: " + id));
        bookmark.setUrl(bookmarkDetails.getUrl());
        bookmark.setTitle(bookmarkDetails.getTitle());
        bookmark.setDescription(bookmarkDetails.getDescription());
        return repository.save(bookmark);
    }

    public void deleteBookmark(Long id) {
        Bookmark bookmark = repository.findById(id)
                .orElseThrow(() -> new RuntimeException("Bookmark not found with id: " + id));
        repository.delete(bookmark);
    }

What do update and delete do?

  • updateBookmark() fetches the existing bookmark, overwrites its fields with the new values, and saves it back.
  • deleteBookmark() fetches the bookmark first (so it can throw if it does not exist), then removes it from the database.
  • Save BookmarkService.java.
  • Click the green play button in IntelliJ to restart your application.
  • Confirm the console still shows Started BookmarkManagerApplication with no errors.

Seeing a compile error?

  • Check that your class name is exactly BookmarkService and the file lives in the same package as your other classes (com.example.bookmarkmanager).
  • Make sure the closing } of the class appears after all five methods.
  • help me fix a compile error in my BookmarkService class
Update the controller to use the service

Your controller currently injects the repository directly. You need to swap that dependency for the new service and add the remaining endpoints.

  • Open BookmarkController.java and replace its entire contents with the following code:
package com.example.bookmarkmanager;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/bookmarks")
public class BookmarkController {

    private final BookmarkService service;

    public BookmarkController(BookmarkService service) {
        this.service = service;
    }

    @GetMapping
    public List<Bookmark> getAllBookmarks() {
        return service.getAllBookmarks();
    }
}

What changed so far?

  • The constructor now receives BookmarkService instead of BookmarkRepository. The controller no longer knows about database details.
  • The import for HttpStatus is added because the new endpoints will return specific status codes like 201 and 204.
  • Add the remaining four endpoint methods below getAllBookmarks(), before the closing } of the class:
    @GetMapping("/{id}")
    public Bookmark getBookmarkById(@PathVariable Long id) {
        return service.getBookmarkById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Bookmark createBookmark(@RequestBody Bookmark bookmark) {
        return service.createBookmark(bookmark);
    }

    @PutMapping("/{id}")
    public Bookmark updateBookmark(@PathVariable Long id, @RequestBody Bookmark bookmark) {
        return service.updateBookmark(id, bookmark);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteBookmark(@PathVariable Long id) {
        service.deleteBookmark(id);
    }

What do these annotations do?

  • @PostMapping handles POST requests. @ResponseStatus(HttpStatus.CREATED) returns a 201 status code instead of the default 200, signalling a new resource was created.
  • @PathVariable extracts the {id} from the URL and passes it as a method parameter.
  • @RequestBody tells Spring to deserialize the incoming JSON into a Bookmark object automatically.
  • @DeleteMapping with HttpStatus.NO_CONTENT returns 204, meaning "success, but nothing to send back."
  • Save BookmarkController.java.
  • Click the green play button to restart the application.
  • Confirm the console shows Started BookmarkManagerApplication again.

Application fails to start?

  • If you see an error about an unsatisfied dependency, make sure you removed the old BookmarkRepository field and constructor parameter from the controller.
  • Check that BookmarkService.java is saved and in the same package.
  • help me fix a dependency injection error in my Spring Boot bookmark controller

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to test your endpoints.

ⓧ I'd like to double check the full code

Scroll up and confirm your BookmarkService.java contains all five methods (getAllBookmarks, getBookmarkById, createBookmark, updateBookmark, deleteBookmark) in order.

For BookmarkController.java, confirm it injects BookmarkService (not BookmarkRepository) and has all five endpoint methods: getAllBookmarks, getBookmarkById, createBookmark, updateBookmark, and deleteBookmark.

Test all operations

Your API now exposes five endpoints. Time to exercise the full create-read-update-delete cycle and see real JSON flowing back.

  • Open a terminal in IntelliJ by clicking View then Tool Windows then Terminal.
  • Create a new bookmark by running this command:
curl -X POST http://localhost:8080/api/bookmarks -H "Content-Type: application/json" -d "{\"url\":\"https://spring.io\",\"title\":\"Spring\",\"description\":\"Spring framework\"}"

You should see JSON returned with your bookmark and an auto-generated id field:

{"id":1,"url":"https://spring.io","title":"Spring","description":"Spring framework"}

Why does the response include an ID?

You never sent an id in your request. JPA auto-generated it when saving to the database (thanks to @GeneratedValue on the entity). The response proves the bookmark was persisted.

  • Retrieve all bookmarks by running:
curl http://localhost:8080/api/bookmarks

You should see a JSON array containing the bookmark you just created.

  • Retrieve the single bookmark by ID:
curl http://localhost:8080/api/bookmarks/1

You should see the same bookmark object returned on its own.

  • Update the bookmark's title by running:
curl -X PUT http://localhost:8080/api/bookmarks/1 -H "Content-Type: application/json" -d "{\"url\":\"https://spring.io\",\"title\":\"Spring Framework\",\"description\":\"Official Spring site\"}"

The response shows your bookmark with the updated title and description.

  • Delete the bookmark:
curl -X DELETE http://localhost:8080/api/bookmarks/1

You get no response body back (204 No Content). Confirm the deletion worked by listing all bookmarks again:

curl http://localhost:8080/api/bookmarks

You should see an empty array [] again, proving the bookmark was removed.

Now try something. Before you run the next command, what do you think will happen if you request a bookmark that does not exist?

  • Request a non-existent bookmark by running:
curl http://localhost:8080/api/bookmarks/999

You see a long, ugly JSON response with "status":500 and a generic error page. This is Spring Boot's default Whitelabel Error that exposes internal details instead of a clear message.

Why does this matter?

A 500 error tells the client "something broke on the server" when the real issue is simply "that bookmark does not exist." A proper API should return a 404 with a helpful message.

You will fix this in the next step with a custom exception handler. For now, the ugly 500 is expected.

POST returns a 500 instead of your bookmark?

  • Check that your JSON uses escaped double quotes correctly. On Windows CMD, you may need to use single quotes around the -d payload or save the JSON to a file.
  • Make sure the application restarted after your controller changes. Look for Started BookmarkManagerApplication in the console.
  • help me debug why my POST request to the bookmarks API returns a 500 error

Your API now handles all five CRUD operations and returns proper JSON. Next up, you will replace that ugly 500 error with a clean 404 response that tells callers exactly what went wrong.

Handle Errors Properly

Your CRUD API handles bookmarks perfectly when they exist. But what happens when a client requests a bookmark that doesn't?

Right now, Spring Boot dumps a generic 500 error with a stack trace. Real APIs return clear, structured error responses with the correct HTTP status code. In this step, you'll build exception handling that returns a clean JSON 404.

In this step, get ready to:
  • Create a custom exception class for missing bookmarks.
  • Build a global exception handler that returns clean JSON errors.
  • Update your service to throw the new exception and verify the 404 response.

Before fixing anything, let's see the problem firsthand.

What do you think will happen if you request a bookmark with an ID that doesn't exist, like 999? Take a guess before you run this.

  • Request a non-existent bookmark by running this command in a terminal:
curl http://localhost:8080/api/bookmarks/999

You get back a 500 Internal Server Error with a Whitelabel Error Page or a JSON body containing a stack trace. No useful information for the client about what actually went wrong.

Why is this a problem?

A 500 error means "something broke on the server." The client has no way to tell if the bookmark doesn't exist (404) or if the server crashed. Exposing a stack trace also leaks internal implementation details.

Create a custom exception

Spring Boot can intercept specific exception types and map them to HTTP responses. First, you need a dedicated exception class that signals "bookmark not found."

  • In IntelliJ, right-click the com.example.bookmarkmanager package in the project tree.
  • Select New, then Java Class.
  • Name it BookmarkNotFoundException and press Enter.
  • Replace the generated content with this code:
package com.example.bookmarkmanager;

public class BookmarkNotFoundException extends RuntimeException {

    public BookmarkNotFoundException(Long id) {
        super("Bookmark not found with id: " + id);
    }
}

What does this code do?

  • BookmarkNotFoundException extends RuntimeException, making it an unchecked exception that Spring can intercept.
  • The constructor takes the missing bookmark's id and builds a human-readable message like "Bookmark not found with id: 999".
  • Save BookmarkNotFoundException.java.
  • You should see BookmarkNotFoundException.java appear in the project tree alongside Bookmark.java.
Create a global exception handler

The exception class exists, but Spring Boot doesn't know what HTTP response to return when it's thrown. A @RestControllerAdvice class intercepts exceptions globally and maps them to HTTP responses.

  • Create another new Java class in the same package, named GlobalExceptionHandler.
  • Replace the generated content with this code:
package com.example.bookmarkmanager;

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

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookmarkNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNotFound(BookmarkNotFoundException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

What does this code do?

  • @RestControllerAdvice tells Spring this class handles exceptions thrown by any controller in the application.
  • @ExceptionHandler(BookmarkNotFoundException.class) marks the method that runs whenever that specific exception is thrown.
  • The method builds a JSON map with an "error" key and returns it with HTTP status 404 (Not Found).
  • Save GlobalExceptionHandler.java.
  • Restart your application by clicking the green rerun button in IntelliJ's Run panel.
  • Confirm the console shows "Started BookmarkManagerApplication" with no errors.

Seeing a compilation error?

  • Check that both new files are in the com.example.bookmarkmanager package. If IntelliJ placed them in the wrong package, the import will fail.
  • Verify that BookmarkNotFoundException is spelled identically in both files.
  • Help me debug my Spring Boot compilation error.

The handler is ready, but your service still throws a generic RuntimeException. The handler only catches BookmarkNotFoundException, so you need to update the service to throw the right type.

  • Open BookmarkService.java.
  • Find this line inside the getBookmarkById method:
.orElseThrow(() -> new RuntimeException("Bookmark not found with id: " + id));
  • Replace it with:
.orElseThrow(() -> new BookmarkNotFoundException(id));
  • Repeat this same replacement in the updateBookmark method.
  • Repeat this same replacement in the deleteBookmark method.
  • You can also remove the "Bookmark not found with id: " + id string from those lines. The exception class builds the message itself now.
  • Save BookmarkService.java.

Why replace all three?

The getBookmarkById, updateBookmark, and deleteBookmark methods all look up a bookmark by ID. If any of them can't find it, the client should get the same clear 404 response.

✔️ Awesome, I've got everything!

Make sure you've saved BookmarkService.java with all three replacements.

ⓧ I'd like to double check the full code

Here is the complete BookmarkNotFoundException.java:

package com.example.bookmarkmanager;

public class BookmarkNotFoundException extends RuntimeException {

    public BookmarkNotFoundException(Long id) {
        super("Bookmark not found with id: " + id);
    }
}

Here is the complete GlobalExceptionHandler.java:

package com.example.bookmarkmanager;

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

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookmarkNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNotFound(BookmarkNotFoundException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

Here is the complete BookmarkService.java. Confirm all three orElseThrow calls use BookmarkNotFoundException:

package com.example.bookmarkmanager;

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class BookmarkService {

    private final BookmarkRepository repository;

    public BookmarkService(BookmarkRepository repository) {
        this.repository = repository;
    }

    public List<Bookmark> getAllBookmarks() {
        return repository.findAll();
    }
    public Bookmark getBookmarkById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new BookmarkNotFoundException(id));
    }

    public Bookmark createBookmark(Bookmark bookmark) {
        return repository.save(bookmark);
    }

    public Bookmark updateBookmark(Long id, Bookmark bookmarkDetails) {
        Bookmark bookmark = repository.findById(id)
                .orElseThrow(() -> new BookmarkNotFoundException(id));
        bookmark.setUrl(bookmarkDetails.getUrl());
        bookmark.setTitle(bookmarkDetails.getTitle());
        bookmark.setDescription(bookmarkDetails.getDescription());
        return repository.save(bookmark);
    }

    public void deleteBookmark(Long id) {
        Bookmark bookmark = repository.findById(id)
                .orElseThrow(() -> new BookmarkNotFoundException(id));
        repository.delete(bookmark);
    }
}
Test the error handling

Time to see if your exception handler works. Before you run the request, predict: will the response still be a 500, or will it be different now?

  • Restart your application by clicking the green rerun button in IntelliJ's Run panel.
  • Request the non-existent bookmark again by running this command:
curl http://localhost:8080/api/bookmarks/999

You should see a clean JSON response with HTTP status 404:

{"error":"Bookmark not found with id: 999"}

No more 500 errors or stack traces. The client gets a clear message about exactly what went wrong.

Still seeing a 500 error?

  • Make sure you replaced RuntimeException with BookmarkNotFoundException in all three methods of BookmarkService.java. The handler only catches the custom exception type.
  • Confirm you restarted the application after saving. Changes only take effect after a restart.
  • Check that GlobalExceptionHandler has the @RestControllerAdvice annotation on the class. Without it, Spring ignores the handler.
  • Help me fix my Spring Boot exception handler that isn't catching my custom exception.

Your API now returns clean, meaningful error responses. Next up, you'll add input validation to reject bad data before it ever reaches the database.

Add Input Validation

Your API now handles missing bookmarks gracefully with clear 404 responses. But there is another gap: it currently accepts any data, including empty URLs and blank titles.

In a production API, saving garbage data silently is worse than crashing. In this step, you will add validation annotations that reject invalid input before it reaches your database.

In this step, get ready to:
  • Add validation annotations to your Bookmark entity.
  • Enable validation in your controller with @Valid.
  • Return clean 400 error responses with field-specific messages.
Add validation annotations to the entity

Jakarta Validation lets you declare rules directly on your entity fields using annotations. Spring Boot includes Hibernate Validator (the default validation implementation) when you add the Validation dependency.

  • Open Bookmark.java in IntelliJ.
  • Replace everything from the package line through the field declarations (stopping before the constructors) with the following code:
package com.example.bookmarkmanager;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;

@Entity
public class Bookmark {

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

    @NotBlank(message = "URL is required")
    @URL(message = "Must be a valid URL")
    private String url;

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

    private String description;

What do these annotations do?

  • @NotBlank rejects null values, empty strings, and strings that are only whitespace.
  • @URL checks that the value is a properly formatted URL (from Hibernate Validator).
  • The message attribute on each annotation defines the error text returned to the client.
  • Leave the constructors, getters, and setters unchanged below this section.
  • Save Bookmark.java.
Enable validation in the controller

The annotations you just added define the rules, but Spring does not enforce them automatically. You need to add @Valid to tell Spring to check the request body against those rules before calling your method.

  • Open BookmarkController.java.
  • Add this import at the top of the file, below the existing imports:
import jakarta.validation.Valid;
  • Find the createBookmark method. Locate this line:
    public Bookmark createBookmark(@RequestBody Bookmark bookmark) {
  • Add @Valid before @RequestBody, so the line now looks like this:
    public Bookmark createBookmark(@Valid @RequestBody Bookmark bookmark) {
  • Find the updateBookmark method. Locate this line:
  • Add @Valid before @RequestBody in the same way:
    public Bookmark updateBookmark(@PathVariable Long id, @Valid @RequestBody Bookmark bookmark) {
  • Save BookmarkController.java.
  • Restart the application by clicking the green re-run button in IntelliJ.
  • Test validation by running this command in your terminal:
curl -X POST http://localhost:8080/api/bookmarks -H "Content-Type: application/json" -d "{\"url\":\"\",\"title\":\"\",\"description\":\"\"}"

You will see a 400 Bad Request response. The response body contains Spring Boot's default error format with generic fields like timestamp and status.

Why is the error response not clean?

Validation is working, but Spring Boot's default error handler formats the response. It does not extract individual field error messages into a simple map. You will fix that next by adding a custom handler for validation errors.

  • Open GlobalExceptionHandler.java.
  • Add this import at the top of the file, below the existing imports:
import org.springframework.web.bind.MethodArgumentNotValidException;
  • Add the following method inside the class, below the existing handleNotFound method:
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                errors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }

What does this handler do?

  • When @Valid fails, Spring throws MethodArgumentNotValidException.
  • The handler extracts each field error from the BindingResult and builds a simple map of field name to error message.
  • It returns that map with HTTP status 400, giving the client a clear list of what went wrong.
  • Save GlobalExceptionHandler.java.
  • Restart the application in IntelliJ.

Seeing a compile error?

  • Check that you added the import org.springframework.web.bind.MethodArgumentNotValidException; line at the top of GlobalExceptionHandler.java.
  • Confirm the method is inside the class braces, not outside them.

Help me fix a compile error in my GlobalExceptionHandler

Test validation

Before you run the test, think about what you expect to happen. What HTTP status should an empty request body return?

  • Test with empty fields by running:
curl -X POST http://localhost:8080/api/bookmarks -H "Content-Type: application/json" -d "{\"url\":\"\",\"title\":\"\",\"description\":\"\"}"

You will see a 400 response with field-specific error messages listing which fields are invalid:

{"url":"URL is required","title":"Title is required"}
  • Test with an invalid URL format by running:
curl -X POST http://localhost:8080/api/bookmarks -H "Content-Type: application/json" -d "{\"url\":\"not-a-url\",\"title\":\"Test\",\"description\":\"Testing\"}"

You will see a 400 response with the URL validation message:

{"url":"Must be a valid URL"}
  • Confirm the happy path still works by creating a valid bookmark:
curl -X POST http://localhost:8080/api/bookmarks -H "Content-Type: application/json" -d "{\"url\":\"https://spring.io\",\"title\":\"Spring\",\"description\":\"Spring framework\"}"

You will see a 201 response with the saved bookmark and an auto-generated ID.

Still getting a 200 or 500 instead of 400?

  • Make sure you added @Valid before @RequestBody in both the createBookmark and updateBookmark methods.
  • Check that Bookmark.java has @NotBlank on both url and title fields.
  • Verify you restarted the application after saving all files.

Help me debug why my validation annotations are not returning 400 errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: Bookmark.java, BookmarkController.java, and GlobalExceptionHandler.java.

ⓧ I'd like to double check the full code

Here is the full code for each file you modified in this step. Scroll up and confirm each section matches.

Bookmark.java:

package com.example.bookmarkmanager;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.URL;

@Entity
public class Bookmark {

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

    @NotBlank(message = "URL is required")
    @URL(message = "Must be a valid URL")
    private String url;

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

    private String description;

The constructors, getters, and setters remain unchanged from before.

BookmarkController.java:

package com.example.bookmarkmanager;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/bookmarks")
public class BookmarkController {

    private final BookmarkService service;

    public BookmarkController(BookmarkService service) {
        this.service = service;
    }

    @GetMapping
    public List<Bookmark> getAllBookmarks() {
        return service.getAllBookmarks();
    }

    @GetMapping("/{id}")
    public Bookmark getBookmarkById(@PathVariable Long id) {
        return service.getBookmarkById(id);
    }
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Bookmark createBookmark(@Valid @RequestBody Bookmark bookmark) {
        return service.createBookmark(bookmark);
    }

    @PutMapping("/{id}")
    public Bookmark updateBookmark(@PathVariable Long id, @Valid @RequestBody Bookmark bookmark) {
        return service.updateBookmark(id, bookmark);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteBookmark(@PathVariable Long id) {
        service.deleteBookmark(id);
    }
}

GlobalExceptionHandler.java:

package com.example.bookmarkmanager;

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.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookmarkNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNotFound(BookmarkNotFoundException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                errors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }
}

Your API now rejects bad data before it ever reaches the database. Between 404 handling and input validation, your API communicates exactly what went wrong to any client that calls it.

Secret mission

Add Tags and Filtering

Your bookmarks are flat. Real bookmark managers let you tag entries and filter by topic. Can you add a tag system so bookmarks can carry multiple tags, then filter the GET endpoint by tag name?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with an in-memory database, so there are no ongoing costs.

Resources you used:

  • Spring Boot application running in IntelliJ IDEA on localhost:8080.
  • H2 in-memory database (automatically discarded when the application stops).
  • The bookmark-manager project folder on your computer.

Keep everything running

No action needed. Choose this if you are still actively building or testing your API.

  • Your Spring Boot application continues running in IntelliJ on port 8080.
  • Your H2 database retains all bookmarks you have created during this session.
  • You can keep sending requests with curl or the IntelliJ HTTP Client.

Pause - I'll come back to this later

Stop the running application to free up memory. Your project files stay on disk so you can restart any time.

  • In IntelliJ, click the red square stop button in the top toolbar to stop the application.
  • The H2 in-memory database is automatically discarded when the application stops. Any bookmarks you created are gone.
  • To resume later, open the bookmark-manager project in IntelliJ and click the green play button next to the main method in BookmarkManagerApplication.java.

Delete - I don't want to use this again

Remove all project files and start fresh. Since the database is in-memory, stopping the app handles data cleanup automatically.

  • In IntelliJ, click the red square stop button to stop the application.
  • Close the project in IntelliJ by clicking File, then Close Project.
  • Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/bookmark-manager

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\bookmark-manager
  • That is everything. No cloud resources, accounts, or persistent databases need cleanup.

Nice Work!

Nice Work!

You just built a complete REST API from scratch using Spring Boot. Your Bookmark Manager handles full CRUD operations, validates input, and returns meaningful error responses.

You've learned how to:

  • Build a REST API with Spring Boot that handles GET, POST, PUT, and DELETE operations with proper HTTP status codes.
  • Structure a layered architecture with controller, service, and repository layers that keeps your code organised and testable.
  • Add global exception handling and input validation so your API returns clean error messages instead of stack traces.
  • Secret Mission: Added a tag system using @ElementCollection and custom repository queries to filter bookmarks by tag.

Ready to quiz yourself?