Build a Travel Planner REST API

Build a Spring Boot REST API with full CRUD, validation, and error handling.

Introduction

30 Second Summary

Everyone has a growing list of places they want to visit someday. Keeping track of destinations, budgets, and priorities across scattered notes gets messy fast.

In this project, you will build a Travel Planner REST API using Spring Boot 4.1.0. Your API will let you add, browse, update, and delete travel destinations stored in a MySQL database, complete with proper error handling and input validation.

What You'll Build

Picture sending a request to your API with 'Kyoto, Japan' as the destination and a $2000 budget, then getting back a perfectly structured response with the new entry saved to your database.

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

  • A running API where you add a destination like 'Kyoto, Japan' with a budget and a 5-star rating, and get it back as clean, structured JSON.
  • Clean error handling that returns a proper 404 response with a helpful message when you request a destination that does not exist, instead of a raw server crash.
  • Input validation that rejects bad data like an empty name or a negative budget with a clear 400 response before anything touches the database.
  • Secret Mission: Add search and filtering so you can find destinations by country or maximum budget.

Are there any prerequisites?

This project assumes you have Java 25, MySQL, and IntelliJ IDEA Ultimate already installed and configured. You should also be comfortable with basic Java syntax and have some familiarity with Spring Boot.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Spring Boot Project

Every REST API needs a project scaffold and a running database before you can build endpoints. Without these, there is nothing to write code in and nowhere for data to live.

In this step, you'll generate a Spring Boot 4.1.0 project using Spring Initializr. You'll then create a MySQL database and connect it to your project.

In this step, get ready to:
  • Generate a Spring Boot 4.1.0 project with Maven and the required dependencies from Spring Initializr.
  • Create a MySQL database and configure the connection in your project.
  • Run the application and confirm it starts successfully.
Generate the project from Spring Initializr

Spring Initializr is a web tool that generates ready-to-run Spring Boot projects. You pick your build tool, Java version, and dependencies, and it gives you a downloadable zip with everything configured.

  • Open your browser and go to start.spring.io.
  • Select Maven for Project.
  • Select Java for Language.
  • Select 4.1.0 for Spring Boot.
  • Set Group to com.example.
  • Set Artifact to travel-planner.
  • Set Packaging to Jar.
  • Set Java to 25.

What is Spring Initializr?

Spring Initializr generates a complete project directory with a pom.xml (your Maven build file), a main application class, and the folder structure Spring Boot expects. It saves you from writing all of that boilerplate by hand.

  • Click ADD DEPENDENCIES.
  • Search for and add Spring Web.
  • Search for and add Spring Data JPA.
  • Search for and add MySQL Driver.
  • Click GENERATE to download the project as a zip file.

What do these dependencies do?

  • Spring Web provides the tools to build REST endpoints that receive and send JSON.
  • Spring Data JPA handles database access through Java objects instead of raw SQL. JPA stands for Java Persistence API.
  • MySQL Driver is the connector that lets your Java application talk to a MySQL database.

You should see travel-planner.zip in your downloads folder.

Open the project and create the database

Your project files need a home in IntelliJ, and your data needs a home in MySQL. You'll set up both now.

  • Find travel-planner.zip in your downloads folder.
  • Extract the zip file by double-clicking it (macOS) or right-clicking and selecting Extract All (Windows).
  • In IntelliJ IDEA, click File in the menu bar.
  • Click Open.
  • Navigate to the extracted travel-planner folder and select it.
  • Click OK to open the project.

What should I see?

IntelliJ may ask you to trust the project. Click Trust Project to load it with full IDE features. IntelliJ then detects the Maven project and starts downloading dependencies automatically.

You'll see a progress bar at the bottom of the window. Wait for this to finish before running the application. This usually takes 1-2 minutes on a fresh project.

While IntelliJ downloads dependencies, create the MySQL database in a separate terminal.

  • Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and searching for Terminal (macOS) or Command Prompt (Windows).
  • Connect to MySQL by running this command:
mysql -u root -p
  • Enter your MySQL root password when prompted. You will not see characters appear as you type.
  • Create the database by running this SQL command:
CREATE DATABASE travel_planner;
  • Confirm the database was created by running:
SHOW DATABASES;

You should see travel_planner in the list of databases.

Don't see travel_planner in the list?

  • Check that you included the semicolon at the end of the CREATE DATABASE statement. MySQL requires a semicolon to execute commands.
  • If authentication failed, double-check your MySQL root password.
  • Help me troubleshoot my database creation.
  • Type exit and press Enter to leave the MySQL client.
Configure the database connection and run the application

Your Spring Boot project needs to know where the database lives and how to authenticate. This is configured in a properties file that Spring reads on startup.

  • In IntelliJ's Project panel on the left, expand src/main/resources.
  • Open application.properties.
  • Replace the file's contents with the following configuration:
spring.datasource.url=jdbc:mysql://localhost:3306/travel_planner
spring.datasource.username=root
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

What does this configuration do?

  • spring.datasource.url tells Spring Boot where your MySQL database is running and which database to use.
  • spring.datasource.username and spring.datasource.password are the credentials Spring uses to log into MySQL.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables to match your Java classes. This is ideal for development.
  • spring.jpa.show-sql=true prints every SQL query Hibernate runs to the console, so you can see exactly what happens behind the scenes.
  • On the third line, replace your-password-here with your actual MySQL root password.
  • Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties with your real MySQL password in place.

ⓧ I'd like to double check the full code

Here is the complete application.properties file. Make sure yours matches, except for the password value which should be your own MySQL root password.

spring.datasource.url=jdbc:mysql://localhost:3306/travel_planner
spring.datasource.username=root
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

Before you run the application, what do you expect to see in the console? Will it start successfully, or will there be an error?

  • In IntelliJ's Project panel, expand src/main/java/com/example/travelplanner.
  • Open TravelPlannerApplication.java.
  • Click the green play button in the gutter to the left of the class declaration.
  • Select Run 'TravelPlannerApplication' from the popup menu.

The console at the bottom of IntelliJ displays the Spring Boot banner, followed by several log lines. The last line reads Started TravelPlannerApplication with no errors. This confirms your project is scaffolded correctly and the MySQL connection is live.

Seeing a connection error?

  • If the application cannot reach MySQL, make sure your MySQL server is running. Open a terminal and try mysql -u root -p again to confirm you can connect.
  • If authentication fails, open application.properties and check that the password matches your MySQL root password exactly.
  • If the database is not found, reconnect to MySQL and run CREATE DATABASE travel_planner; again.
  • If IntelliJ's progress bar is still active at the bottom, Maven is still downloading dependencies. Wait for it to finish, then run again.
  • Help me troubleshoot my Spring Boot startup error.

Your Spring Boot project is running and connected to MySQL. Next up, you'll create the data model that defines what a travel destination looks like in your database.

Create the Destination Entity and Repository

Your Spring Boot project is connected to MySQL and running successfully. Now it's time to define what data your API actually manages.

Without a structure that maps to a database table, there is nowhere for travel destinations to live. In this step, you'll create that structure using a JPA entity and a Spring Data repository.

In this step, get ready to:
  • Create a Destination entity class mapped to a MySQL table.
  • Create a DestinationRepository interface for database operations.
  • Verify that Hibernate auto-creates the destinations table in MySQL.
Create the Destination entity class

A JPA entity is a Java class that maps directly to a database table. Each field in the class becomes a column in the table, and each instance of the class becomes a row.

  • In IntelliJ's Project panel on the left, expand src/main/java/com/example/travelplanner.
  • Right-click on travelplanner, select New then Package.
  • Enter model and press Enter.
  • Right-click on the new model package, select New then Java Class.
  • Enter Destination and press Enter.
  • Replace everything in the new file with the following code:
package com.example.travelplanner.model;

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

// Maps this class to the "destinations" database table
@Entity
@Table(name = "destinations")
public class Destination {

    // Primary key, auto-generated by the database
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String country;

    private String description;

    // Estimated travel cost
    private Double budget;

    // Score from 1 to 5
    private Integer rating;
}

What does this code do?

The @Entity annotation tells JPA that this class represents a database table. @Table(name = "destinations") sets the exact table name in MySQL.

The @Id annotation marks id as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells MySQL to auto-increment the ID for each new row.

The entity needs getter and setter methods so that JPA and Spring can read and write each field. IntelliJ can generate these for you automatically.

  • Place your cursor below the rating field declaration, inside the class body.
  • Press Cmd+N (macOS) or Alt+Insert (Windows) to open the Generate menu.
  • Select Getter and Setter.
  • In the popup, select all six fields (id, name, country, description, budget, rating) and click OK.

What are getters and setters?

Getters return a field's current value (like getName()). Setters update a field's value (like setName(String name)). JPA uses these methods to populate entity objects from database rows and to extract values when saving.

  • Save Destination.java with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file appears under the model package in IntelliJ's Project panel.

Don't see the Generate menu?

Make sure your cursor is inside the class body (between the curly braces), not in the import block. The Generate shortcut only works when IntelliJ knows you're editing a class.

If Alt+Insert opens something else on Windows, go to the Code menu at the top and select Generate from there instead.

Help me generate getters and setters in IntelliJ IDEA.

✔️ Awesome, I've got everything!

Great. Double check you've saved Destination.java and move on to the next substep.

ⓧ I'd like to double check the full code

Your Destination.java should contain these parts in order:

  • The package com.example.travelplanner.model declaration and six jakarta.persistence imports.
  • The @Entity and @Table(name = "destinations") annotations on the class.
  • Six private fields: id (Long with @Id and @GeneratedValue), name (String), country (String), description (String), budget (Double), rating (Integer).
  • A getter and setter pair for each of the six fields (12 methods total).
Create the Destination repository

A Spring Data repository is an interface that gives you built-in database operations without writing any SQL. By extending JpaRepository, you get methods to save, find, update, and delete entities for free.

  • Right-click on travelplanner in the Project panel, select New then Package.
  • Enter repository and press Enter.
  • Right-click on the new repository package, select New then Java Class.
  • In the dialog, select Interface (not Class), enter DestinationRepository, and press Enter.
  • Replace everything in the new file with the following code:
package com.example.travelplanner.repository;

import com.example.travelplanner.model.Destination;
import org.springframework.data.jpa.repository.JpaRepository;

// Extends JpaRepository to get built-in CRUD methods
public interface DestinationRepository extends JpaRepository<Destination, Long> {
}

What does this code do?

JpaRepository is a Spring Data interface that provides methods like findAll(), findById(), save(), and delete(). The two type parameters <Destination, Long> tell Spring that this repository manages Destination entities and their primary key is a Long.

Spring automatically detects this interface at startup and creates a working implementation behind the scenes. You never need to write the SQL yourself.

  • Save DestinationRepository.java with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file appears under the repository package in IntelliJ's Project panel.

✔️ Awesome, I've got everything!

Great. Double check you've saved DestinationRepository.java and move on to the next substep.

ⓧ I'd like to double check the full code

package com.example.travelplanner.repository;

import com.example.travelplanner.model.Destination;
import org.springframework.data.jpa.repository.JpaRepository;

public interface DestinationRepository extends JpaRepository<Destination, Long> {
}
Run the application and verify the table

You've defined an entity mapped to a table and a repository to operate on it. Before you run the application, think about what you expect to happen.

You set spring.jpa.hibernate.ddl-auto=update in the previous step. What do you think Hibernate will do when it finds your new Destination entity on startup?

  • Click the green play button on the TravelPlannerApplication main class to run the application.

In the console output, look for a Hibernate SQL line that creates the destinations table. Because you set spring.jpa.show-sql=true, Hibernate prints every SQL statement it runs.

What should I see?

The console shows a Hibernate: create table destinations SQL statement followed by column definitions for each field. You should also see the familiar Started TravelPlannerApplication message confirming a successful startup.

Now confirm the table exists directly in MySQL.

  • Switch to your MySQL client and run the following command:
SHOW TABLES;

You should see destinations listed in the output.

  • Inspect the table's structure by running:
DESCRIBE destinations;

You should see columns for id, name, country, description, budget, and rating matching the fields you defined in your entity.

Don't see the destinations table?

Make sure you're connected to the travel_planner database. Run USE travel_planner; before SHOW TABLES;.

If the console shows an error about the Destination class, check that Destination.java is inside the com.example.travelplanner.model package. Spring Boot only scans classes within its root package.

Help me troubleshoot why Hibernate isn't creating my destinations table.

Your data model is in place and Hibernate proved it by creating the table automatically. Next up, you'll build the REST endpoints that let you create, read, update, and delete destinations through HTTP requests.

Build the CRUD API Endpoints

Your Destination entity and repository are wired up and the database table is ready. The one thing missing is a way to actually interact with your data over HTTP.

In this step, you will build a REST controller with five endpoints that let clients create, read, update, and delete travel destinations. By the end, you will also discover a gap in how the API handles bad requests.

In this step, get ready to:
  • Create a controller class with GET, POST, PUT, and DELETE endpoints.
  • Test each endpoint using IntelliJ's built-in HTTP client.
  • Discover what happens when you request a destination that does not exist.
Create the controller with GET endpoints

A controller is the layer that receives HTTP requests and sends back responses. Each method inside the controller maps to a URL and an HTTP verb like GET or POST.

  • In IntelliJ's Project panel on the left, right-click the com.example.travelplanner package.
  • Select New, then Package.
  • Name the new package controller and press Enter.
  • Right-click the new controller package, select New, then Java Class.
  • Name the class DestinationController and press Enter.
  • Replace the contents of the file with the following code:
package com.example.travelplanner.controller;

import com.example.travelplanner.model.Destination;
import com.example.travelplanner.repository.DestinationRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

// Marks this class as a REST API controller
@RestController
// All endpoints start with /api/destinations
@RequestMapping("/api/destinations")
public class DestinationController {

    private final DestinationRepository repository;

    // Spring injects the repository through the constructor
    public DestinationController(DestinationRepository repository) {
        this.repository = repository;
    }

    // GET /api/destinations - returns every saved destination
    @GetMapping
    public List<Destination> getAllDestinations() {
        return repository.findAll();
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and automatically converts return values to JSON.
  • @RequestMapping("/api/destinations") sets the base URL for every endpoint in this controller.
  • The constructor accepts a DestinationRepository and Spring automatically provides one at startup. This pattern is called constructor injection.
  • getAllDestinations() calls findAll() on the repository and Spring converts the list to a JSON array in the response.
  • Save DestinationController.java.
  • Run the application using the green play button on the TravelPlannerApplication main class.
  • Open IntelliJ's HTTP client by clicking Tools in the top menu bar, then HTTP Client, then Create Request in HTTP Client.
  • Test the GET endpoint by entering this request and clicking the green play button next to it:
GET http://localhost:8080/api/destinations

You should see an empty JSON array [] in the response panel. The endpoint works, but there are no destinations saved yet.

Not seeing the empty array?

  • Check the IntelliJ console for errors. If you see a compilation error, make sure the import statements at the top of the file match the code above exactly.
  • Confirm the URL is exactly http://localhost:8080/api/destinations. A missing s at the end will return a 404.
  • If IntelliJ shows red underlines on the annotations, wait for Maven to finish downloading dependencies. You can check progress in the bottom status bar.

Why is my GET endpoint returning an error?

Add the create endpoint

An empty list is not very useful. The POST endpoint lets clients send a JSON body with destination details and saves it to the database.

  • In DestinationController.java, find the closing brace } of the class. Add the following method above it:
    // POST /api/destinations - saves a new destination
    @PostMapping
    public ResponseEntity<Destination> createDestination(@RequestBody Destination destination) {
        Destination saved = repository.save(destination);
        return new ResponseEntity<>(saved, HttpStatus.CREATED);
    }

What does this code do?

  • @RequestBody tells Spring to read the incoming JSON body and convert it into a Destination object automatically.
  • ResponseEntity lets you control the HTTP status code. Here it returns 201 Created instead of the default 200 OK, which is the proper status for a resource that was just created.
  • IntelliJ will show red underlines on PostMapping, ResponseEntity, RequestBody, and HttpStatus. Press Option+Enter (macOS) or Alt+Enter (Windows) on each one and select Import class.
  • Save the file and restart the application.
  • In the HTTP client, create a new POST request to add your first destination:
POST http://localhost:8080/api/destinations
Content-Type: application/json

{
  "name": "Kyoto",
  "country": "Japan",
  "description": "Ancient temples and gardens",
  "budget": 2000.0,
  "rating": 5
}
  • Click the green play button next to the POST request.

You should see a 201 Created response with the destination echoed back as JSON, now including an id field that the database generated.

  • Run the GET request from earlier to confirm the destination appears in the list:
GET http://localhost:8080/api/destinations

The response now shows an array with your Kyoto destination inside it.

Getting a 415 or 400 error on POST?

  • Make sure you included the Content-Type: application/json header on the line directly below the POST URL. Without it, Spring does not know the body is JSON.
  • Check that the JSON body has proper double quotes around every key and string value. Single quotes or missing quotes will cause a parse error.

Why is my POST request failing?

Add the update and delete endpoints

Travel plans change. The PUT endpoint replaces a destination's details, and the DELETE endpoint removes one entirely.

  • In DestinationController.java, add the update method below the createDestination method, above the closing brace of the class:
    // PUT /api/destinations/{id} - replaces a destination's details
    @PutMapping("/{id}")
    public Destination updateDestination(@PathVariable Long id, @RequestBody Destination destinationDetails) {
        Destination destination = repository.findById(id).get();
        destination.setName(destinationDetails.getName());
        destination.setCountry(destinationDetails.getCountry());
        destination.setDescription(destinationDetails.getDescription());
        destination.setBudget(destinationDetails.getBudget());
        destination.setRating(destinationDetails.getRating());
        return repository.save(destination);
    }

What does this code do?

  • @PathVariable extracts the id from the URL path (e.g. /api/destinations/1).
  • The method loads the existing destination with findById(id).get(), copies each field from the request body onto it, and saves the updated version back to the database.
  • Now add the delete method directly below the update method:
    // DELETE /api/destinations/{id} - removes a destination
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteDestination(@PathVariable Long id) {
        Destination destination = repository.findById(id).get();
        repository.delete(destination);
        return ResponseEntity.noContent().build();
    }

Why does delete return ResponseEntity<Void>?

When you delete a resource, there is nothing meaningful to return in the body. ResponseEntity.noContent().build() sends back a 204 No Content status, which is the standard REST response for a successful deletion.

  • Import the new annotations by pressing Option+Enter (macOS) or Alt+Enter (Windows) on any red-underlined names and selecting Import class.
  • Save the file and restart the application.
  • Test the update endpoint by sending a PUT request that changes the budget of your Kyoto destination (use the id from the POST response earlier):
PUT http://localhost:8080/api/destinations/1
Content-Type: application/json

{
  "name": "Kyoto",
  "country": "Japan",
  "description": "Ancient temples and cherry blossoms",
  "budget": 2500.0,
  "rating": 5
}

You should see the updated destination returned with the new description and budget.

  • Test the delete endpoint by running this request:
DELETE http://localhost:8080/api/destinations/1

You should see a 204 No Content response with an empty body. Run the GET all request one more time to confirm the list is empty again.

PUT or DELETE returning a 500 error?

  • Make sure the id in the URL matches an existing destination. If you already deleted it, POST a new one first and use that new id.
  • Check that the PUT request body includes every field (name, country, description, budget, rating). Missing fields will be saved as null.

Why is my PUT or DELETE request failing?

✔️ Awesome, I've got everything!

Great. Double check that you have saved DestinationController.java and that the application is still running.

ⓧ I'd like to double check the full code

Scroll back through this step and confirm each of these sections is present in your file, in order:

  • The package declaration, imports, class annotations, constructor, and getAllDestinations() method from the first code block.
  • The getDestinationById() method is also inside the first code block, directly after getAllDestinations().
  • The createDestination() POST method from the second code block.
  • The updateDestination() PUT method from the third code block.
  • The deleteDestination() DELETE method from the fourth code block.

Make sure the file ends with a single closing brace } for the class.

Test with a missing destination

The happy path works. Every endpoint creates, reads, updates, and deletes correctly. But what happens when someone requests a destination that does not exist?

  • First, POST a new destination so you have fresh data to work with:
POST http://localhost:8080/api/destinations
Content-Type: application/json

{
  "name": "Kyoto",
  "country": "Japan",
  "description": "Ancient temples and gardens",
  "budget": 2000.0,
  "rating": 5
}

Before you run the next request, think about this: what do you expect the API to return when you ask for a destination with an ID that does not exist in the database?

  • Run this request to fetch a destination that does not exist:
GET http://localhost:8080/api/destinations/999

Instead of a clean "not found" message, you get a 500 Internal Server Error with a raw NoSuchElementException stack trace. This is the default behavior of .get() on an empty Optional. It crashes instead of handling the missing data gracefully.

Why is this a problem?

A 500 error tells the client that the server broke. It exposes internal class names and stack traces that API consumers should never see. The correct response for a missing resource is a 404 Not Found with a clear message.

This is exactly the gap you will close in the next step.

Your CRUD endpoints are fully functional for the happy path. Next up, you will add proper exception handling and input validation so the API returns clean error messages instead of ugly stack traces.

Add Exception Handling and Validation

You built working CRUD endpoints in the last step, but you also uncovered a problem. Requesting a destination ID that doesn't exist returns a raw 500 error with a full Java stack trace.

Those errors expose internal server details and confuse anyone consuming your API. In this step, you'll create custom exception handling that returns clean JSON error responses with proper HTTP status codes. You'll also add input validation to reject bad data before it reaches the database.

In this step, get ready to:
  • Create custom exception classes that return clean 404 and 400 JSON responses.
  • Wire the controller to throw proper exceptions instead of crashing.
  • Add validation annotations that reject bad input at the API boundary.
Create the exception handling classes

Right now, the .get() call in your controller throws a generic NoSuchElementException when an ID doesn't exist. Spring Boot has no handler for this, so it dumps a 500 error. You need a custom exception class and a global handler that catches it and returns a proper response.

  • In the Project tool window, right-click the com.example.travelplanner package.
  • Select New then Package.
  • Name the new package exception and press Enter.
  • Right-click the new exception package, select New then Java Class, and name it ResourceNotFoundException.
  • Replace the generated code with the following:
package com.example.travelplanner.exception;

// Custom exception for when a requested destination does not exist
public class ResourceNotFoundException extends RuntimeException {

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

What does this class do?

ResourceNotFoundException extends RuntimeException, making it an unchecked exception. The constructor accepts a message String so you can describe exactly what wasn't found.

On its own, this class does nothing visible. The GlobalExceptionHandler you create next will catch it and convert it into a 404 response.

  • Save ResourceNotFoundException.java.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

package com.example.travelplanner.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  • Create another Java class in the exception package by right-clicking it, selecting New then Java Class.
  • Name it GlobalExceptionHandler.
  • Replace the generated code with the following:
package com.example.travelplanner.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.util.HashMap;
import java.util.Map;

// Intercepts exceptions across all controllers and returns structured JSON
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Returns a 404 response when a destination is not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleResourceNotFound(ResourceNotFoundException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }
}

What does this class do?

@RestControllerAdvice tells Spring Boot to use this class as a global exception interceptor. Any exception thrown by any controller gets routed here first.

The @ExceptionHandler(ResourceNotFoundException.class) method catches your custom exception and returns a ResponseEntity with a JSON body and a 404 status code. Instead of a stack trace, the API consumer sees a clean error message.

  • Save GlobalExceptionHandler.java.
  • Run the application using the green play button on the main class.
  • Confirm the console shows Started TravelPlannerApplication with no errors. This proves your new classes compile correctly.

Seeing a compilation error?

  • Check that ResourceNotFoundException.java and GlobalExceptionHandler.java are both inside the com.example.travelplanner.exception package. The package declaration at the top of each file must match.
  • Make sure GlobalExceptionHandler imports ResourceNotFoundException from the same package. Since they share a package, no explicit import is needed for it.

Still stuck? Help me debug my GlobalExceptionHandler compilation error.

The handler class currently catches only ResourceNotFoundException. You also need it to catch validation errors, which you'll wire up later in this step.

  • In GlobalExceptionHandler.java, find the closing brace } of the handleResourceNotFound method.
  • Add the following method below it, inside the class:
    // Returns a 400 response listing each field that failed validation
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                errors.put(fieldError.getField(), fieldError.getDefaultMessage()));
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

What does this method do?

MethodArgumentNotValidException is thrown automatically by Spring when a request body fails @Valid checks. This handler catches that exception, loops through each field error, and builds a JSON map of field names to error messages.

The result is a 400 Bad Request response with a body like {"name": "Name is required", "rating": "Rating must be at least 1"} instead of a raw stack trace.

  • Save GlobalExceptionHandler.java.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

package com.example.travelplanner.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.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                errors.put(fieldError.getField(), fieldError.getDefaultMessage()));
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }
}
Update the controller to use the new exception

The exception classes are ready, but the controller still uses .get() which throws the old NoSuchElementException. You need to swap every .get() call to .orElseThrow() so it throws your new ResourceNotFoundException instead.

  • Open DestinationController.java.
  • Add these two imports at the top of the file, below the existing imports:
import com.example.travelplanner.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
  • In the getDestinationById method, find this line:
        return repository.findById(id).get();
  • Replace it with:
        return repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Destination not found with id: " + id));
  • In the updateDestination method, find this line:
        Destination destination = repository.findById(id).get();
  • Replace it with:
        Destination destination = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Destination not found with id: " + id));
  • In the deleteDestination method, find the same repository.findById(id).get() line and replace it with the identical .orElseThrow() pattern.
  • In the createDestination method signature, add @Valid before @RequestBody so it reads @Valid @RequestBody Destination destination.
  • In the updateDestination method signature, add @Valid before @RequestBody the same way.
  • Save DestinationController.java.

Why orElseThrow instead of get?

Optional.get() throws a generic NoSuchElementException that Spring Boot doesn't know how to handle. .orElseThrow() lets you throw your custom ResourceNotFoundException, which the GlobalExceptionHandler catches and converts into a clean 404 JSON response.

@Valid tells Spring to run validation checks on the incoming request body before the method executes. You'll add the actual validation rules to the Destination entity in the next substep.

✔️ Awesome, I've got everything!

Great. Confirm you've saved your file and all three .get() calls are replaced.

ⓧ I'd like to double check the full code

Your DestinationController.java should have these changes:

  • Two new imports at the top: ResourceNotFoundException and jakarta.validation.Valid.
  • getDestinationById uses .orElseThrow(() -> new ResourceNotFoundException(...)) instead of .get().
  • updateDestination uses the same .orElseThrow() pattern and has @Valid before @RequestBody.
  • deleteDestination uses the same .orElseThrow() pattern.
  • createDestination has @Valid before @RequestBody.

Time to see the fix in action. Restart the application using the green play button.

Before you test, think about what you expect. In the last step, GET /api/destinations/999 returned a 500 error. What do you think it will return now?

  • Open the IntelliJ HTTP client by clicking Tools in the top menu bar, then HTTP Client, then Create Request in HTTP Client.
  • Type the following GET request and click the green play icon in the gutter to run it:
GET http://localhost:8080/api/destinations/999

You should see a clean 404 response with a JSON body like this:

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

No more stack traces. The GlobalExceptionHandler caught the ResourceNotFoundException and returned a structured error.

Still seeing a 500 error?

  • Make sure you restarted the application after saving your changes. Spring Boot does not hot-reload Java classes by default.
  • Verify that the getDestinationById method uses .orElseThrow() and not .get(). A leftover .get() call will still throw the old exception.
  • Check that GlobalExceptionHandler has the @RestControllerAdvice annotation on the class. Without it, Spring won't register the handler.

Need more help? Help me debug why my 404 handler is not working.

Add input validation

The 404 handling is solid. But there's another gap: anyone can POST a destination with an empty name, a negative budget, or a rating of 99. The database accepts it all. Validation annotations let you define the rules on the entity itself, and Spring rejects bad input before it ever touches the database.

  • Open pom.xml.
  • Find the spring-boot-starter-web dependency block.
  • Add the following dependency directly below it:
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>

Why a separate validation starter?

spring-boot-starter-validation provides the Jakarta Bean Validation implementation. It is not included by default from Spring Initializr, so you need to add it manually. No version number is needed because Spring Boot's dependency management handles it for you.

  • Save pom.xml. IntelliJ may prompt you to reload Maven dependencies. Click the Load Maven Changes icon that appears in the top right of the editor.
  • Open Destination.java in the model package.
  • Add these three imports at the top of the file, below the existing persistence imports:
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
  • Find the field declarations in the class body. Add validation annotations above each field as shown:
    @NotBlank(message = "Name is required")
    private String name;

    @NotBlank(message = "Country is required")
    private String country;

    private String description;

    @Min(value = 0, message = "Budget must be zero or positive")
    private Double budget;

    @Min(value = 1, message = "Rating must be at least 1")
    @Max(value = 5, message = "Rating must be at most 5")
    private Integer rating;

What do these annotations do?

@NotBlank rejects null or empty strings. @Min and @Max enforce numeric boundaries. The message attribute sets the error text that appears in the 400 response when validation fails.

These annotations work together with the @Valid annotation you added to the controller. When a POST or PUT request arrives, Spring checks the body against these rules. If any rule fails, it throws MethodArgumentNotValidException, which your GlobalExceptionHandler catches.

  • Save Destination.java.

✔️ Awesome, I've got everything!

Great. Your Destination.java should now have three new imports and five validation annotations spread across four fields.

ⓧ I'd like to double check the full code

Your Destination.java should have these changes:

  • Three new imports: jakarta.validation.constraints.Max, Min, and NotBlank.
  • @NotBlank(message = "Name is required") above private String name.
  • @NotBlank(message = "Country is required") above private String country.
  • @Min(value = 0, message = "Budget must be zero or positive") above private Double budget.
  • @Min(value = 1, message = "Rating must be at least 1") and @Max(value = 5, message = "Rating must be at most 5") above private Integer rating.
  • The description field has no annotation. It stays optional.
  • All getters and setters remain unchanged.

Restart the application using the green play button. Before you test, predict: what do you think will happen when you POST a destination with an empty name?

  • In the IntelliJ HTTP client, type the following request and run it:
POST http://localhost:8080/api/destinations
Content-Type: application/json

{
  "name": "",
  "country": "",
  "budget": -100,
  "rating": 10
}

You should see a 400 Bad Request response with a JSON body listing every field that failed:

{
  "name": "Name is required",
  "country": "Country is required",
  "budget": "Budget must be zero or positive",
  "rating": "Rating must be at most 5"
}

Every validation rule fired. The API rejected the bad data with a specific, per-field error message instead of silently saving garbage into the database.

  • Confirm the 404 handler still works by running a GET to http://localhost:8080/api/destinations/999 again. You should still see the 404 JSON response.

Getting a 200 instead of a 400?

  • Make sure you added spring-boot-starter-validation to pom.xml and reloaded Maven. Without this dependency, the @Valid annotation is ignored.
  • Check that @Valid appears before @RequestBody in both the createDestination and updateDestination methods.
  • Verify that the Destination entity has @NotBlank on name and country. Without annotations on the entity, there is nothing for @Valid to enforce.

Need more help? Help me debug why my validation annotations are not working.

Your API now handles errors like a production-grade service. Missing destinations return 404 with a clear message. Bad input gets rejected at the door with field-level error details. Next up, you'll extend this API with search and filtering in the Secret Mission.

Secret mission

Add Search and Filtering

Your API returns every destination at once. What happens when you have dozens saved and only want to find destinations in Japan or under $1500? Add search and filtering to your API so users can narrow down results by country or maximum budget.

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 a MySQL database, so there are no ongoing cloud costs.

Resources you used:

  • The travel-planner project folder containing your Spring Boot application code.
  • The travel_planner MySQL database with the destinations table and test records.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend this API with authentication in a future project.

  • Your Spring Boot application will stop when you close IntelliJ or stop the run configuration. You can restart it any time by clicking the green play button on TravelPlannerApplication.
  • Your travel_planner MySQL database persists between sessions. Your destination data will still be there next time you start the app.

Pause - I'll come back to this later

Stop the running application to free up memory. Your project files and database stay intact for when you return.

  • In IntelliJ, click the red square Stop button in the top toolbar to stop the Spring Boot application.
  • Your travel-planner project folder and travel_planner MySQL database will remain on your machine. To resume, reopen the project in IntelliJ and click the green play button.

Delete - I don't want to use this again

Remove all project files and the database to start completely fresh.

  • In IntelliJ, click the red square Stop button to stop the Spring Boot application if it is still running.
  • Close the project in IntelliJ by clicking File, then Close Project.
  • Drop the MySQL database by opening a MySQL client and running this command:
DROP DATABASE travel_planner;
  • Delete the travel-planner project folder. Open a terminal, navigate to the folder where you extracted the project, and run the appropriate command for your system.

macOS

rm -rf travel-planner

Windows

rmdir /s /q travel-planner

Nice Work!

Nice Work!

Nice work! You just built a production-style REST API from scratch using Spring Boot 4.1.0 and MySQL. Your Travel Planner API can create, read, update, and delete destinations, reject bad input with clear error messages, and even search by country or budget.

You've learned how to:

  • Build a Spring Boot REST API from scratch using Spring Initializr, with a JPA entity and validation constraints mapped to a MySQL database table.
  • Implement all four CRUD operations (Create, Read, Update, Delete) as REST endpoints using Spring Data JPA repositories.
  • Add proper exception handling with @RestControllerAdvice that returns clean 404 and 400 responses instead of raw 500 errors.
  • Secret Mission: Added search and filtering by country and budget using Spring Data JPA derived query methods.

Ready to quiz yourself?