Home / Java & Spring Boot / Validation, Error Handling & CRUD

Lesson 5 of 6

Expanding Your API — Validation, Error Handling & CRUD

Estimated time: 2–2.5 hours

What You Will Learn

  • Add input validation with Java annotations
  • Handle errors gracefully with consistent responses
  • Understand HTTP status codes
  • Build full CRUD (Create, Read, Update, Delete) endpoints
  • Build a simple admin page to manage messages

Part 1 — Input Validation

Your contact form API is working. People can submit their name, email, and message, and Spring Boot saves everything to your SQLite database. That is a huge accomplishment. But there is a problem — a serious one. Right now, your API will happily accept anything. Someone could submit an empty form with no name, no email, and no message, and your application would save that blank record to the database without complaint. Someone could type "asdfghjkl" into the email field, and your application would store it as if it were a real email address. Someone could submit a message that is just a single space character, and it would go right into the database.

This is not just an annoyance — it is a real problem. Imagine you are a business owner checking the messages people have sent through your contact form. You open your admin dashboard and see a dozen entries with blank names, garbage email addresses, and empty messages. You cannot respond to any of them. The database is cluttered with useless data. And if you tried to send a reply to "not-an-email," your email server would bounce it back with an error.

The solution is input validation — checking that the data someone sends meets certain rules before you save it. If the data does not meet the rules, you reject the request and tell the user exactly what they need to fix. This is a fundamental practice in professional software development. Every application you use — from Gmail to Amazon to Instagram — validates input before processing it.

You might be thinking, "Can't I just validate on the frontend? I already have JavaScript checking the form before it submits." And yes, frontend validation is great for user experience — it gives instant feedback without waiting for a server response. But frontend validation is not enough on its own. Anyone with basic technical knowledge can bypass your frontend entirely. They could use a tool like Postman or curl to send requests directly to your API, completely skipping your HTML form and its JavaScript validation. That is why you always need server-side validation too. The rule of thumb is: validate on the frontend for user experience, validate on the backend for security.

Java and Spring Boot make server-side validation remarkably easy through something called the Jakarta Validation API (previously known as "Bean Validation"). Instead of writing a bunch of if statements to check each field manually, you simply add annotations to your entity class, and Spring Boot handles the rest automatically. These annotations are like little labels you attach to each field that say "this field must not be blank" or "this field must be a valid email address."

Before you can use these validation annotations, you need to add one more dependency to your project. Open your pom.xml file and add the following inside the <dependencies> section, right next to your other dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

This is the Spring Boot Validation starter. It pulls in the Jakarta Validation library and everything Spring Boot needs to automatically validate incoming requests. After adding this dependency, make sure to reload your Maven project (in IntelliJ, click the small Maven refresh icon; in VS Code, run ./mvnw dependency:resolve in the terminal).

Now let us update your ContactMessage entity class to include validation annotations. In the previous lesson, your entity had simple fields with no rules attached. We are going to add three annotations: @NotBlank, @Email, and @Size. Each one enforces a different kind of rule. Here is the updated entity:

package org.codersfarm.contactapi;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Entity
public class ContactMessage {

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

    @NotBlank(message = "Name is required")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Please provide a valid email address")
    private String email;

    @NotBlank(message = "Message is required")
    @Size(min = 10, message = "Message must be at least 10 characters")
    private String message;

    private boolean read = false;

    // Default constructor required by JPA
    public ContactMessage() {}

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }

    public boolean isRead() { return read; }
    public void setRead(boolean read) { this.read = read; }
}

Let us walk through each validation annotation so you understand exactly what it does. @NotBlank(message = "Name is required") checks that the name field is not null, not an empty string (""), and not just whitespace (like " "). If someone submits a form without filling in their name, this annotation catches it. The message parameter inside the parentheses is the error message that will be included in the response when validation fails — it tells the user exactly what went wrong.

The email field has two annotations stacked on top of each other. @NotBlank ensures the field is not empty, and @Email checks that the value looks like a valid email address (it contains an @ symbol and a domain). You can stack as many validation annotations as you need on a single field. They all must pass for the field to be considered valid. If someone submits "hello" as their email, @Email will reject it because "hello" is not a valid email format.

The message field also has two annotations. @NotBlank ensures it is not empty, and @Size(min = 10) enforces a minimum length of 10 characters. This prevents someone from submitting a message like "hi" or "ok" — messages that are too short to be meaningful. You could also set a max value if you wanted to limit how long messages can be (for example, @Size(min = 10, max = 1000)).

Notice that we also added a new read field with a default value of false. This boolean field will track whether an admin has read each message. We will use this later when we build the full CRUD operations. For now, just know that every new message starts as "unread."

Adding annotations to the entity is only half the job. You also need to tell your controller to actually check the annotations when a request comes in. You do this by adding the @Valid annotation to the method parameter in your controller. Here is the updated POST endpoint:

package org.codersfarm.contactapi;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/contact")
@CrossOrigin(origins = "*")
public class ContactController {

    private final ContactMessageRepository repository;

    public ContactController(ContactMessageRepository repository) {
        this.repository = repository;
    }

    // CREATE — submit a new message
    @PostMapping
    public ResponseEntity<Map<String, String>> submitMessage(
            @Valid @RequestBody ContactMessage message) {
        repository.save(message);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(Map.of("status", "Message received! Thank you, "
                        + message.getName() + "."));
    }

    // READ ALL — get all messages
    @GetMapping
    public List<ContactMessage> getAllMessages() {
        return repository.findAll();
    }
}

The critical change is the @Valid annotation placed right before @RequestBody ContactMessage message. This tiny keyword tells Spring Boot: "Before you run this method, check all the validation annotations on the ContactMessage object. If any of them fail, do not run the method at all — reject the request immediately." Without @Valid, Spring Boot would completely ignore your @NotBlank, @Email, and @Size annotations. The entity would look like it has validation rules, but nothing would actually enforce them. This is a common mistake beginners make, so remember: annotations on the entity define the rules, @Valid on the controller enforces them.

Now restart your Spring Boot application and test it. Try sending a POST request to http://localhost:8080/api/contact with an empty JSON body like {}. Instead of saving a blank record, Spring Boot will now reject the request with a 400 Bad Request status and return error details listing every field that failed validation. Try sending {"name": "Alice", "email": "not-an-email", "message": "Hi"} and you will get errors for both the invalid email and the too-short message. Your API is now much more robust — it only accepts data that meets your rules.

Part 2 — Error Handling

Now that your API validates input, let us talk about what happens when validation fails. If you tested the validation in the previous section, you probably noticed that the error response Spring Boot returns by default is... not great. It is a massive blob of JSON that includes internal details like the full Java class name, a timestamp in a format nobody wants to read, and a generic "Bad Request" message. It looks something like this:

// Default Spring Boot error response (ugly and unhelpful)
{
    "timestamp": "2025-01-15T14:23:45.123+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/api/contact"
}

This default response has several problems. First, it does not tell the user which fields failed or why. If someone submits a form with three errors, they have no idea what to fix. Second, it can expose internal details about your application that you do not want outsiders to see. In a production application, you never want to leak information about your internal structure, class names, or stack traces — that information could help an attacker understand how to exploit your system.

What we want instead is a clean, consistent, helpful error response. Something like this:

// What we WANT our error responses to look like
{
    "error": "Validation failed",
    "details": [
        "Name is required",
        "Please provide a valid email address",
        "Message must be at least 10 characters"
    ]
}

This is much better. The user can see exactly what went wrong and knows exactly what to fix. The response uses a consistent format — every error response from your API will always have an "error" field with a summary and a "details" array with specific messages. Consistency matters because the frontend code that consumes your API can rely on the response always having the same shape.

To achieve this, Spring Boot provides a powerful feature called @ControllerAdvice. A class annotated with @ControllerAdvice acts as a global exception handler — it catches errors from any controller in your entire application and lets you decide exactly how to respond. Think of it like a safety net under a trapeze artist. No matter which controller throws an error, the @ControllerAdvice class catches it and transforms it into a clean response.

Before we write the code, let us quickly review the most important HTTP status codes. When your server sends a response, it always includes a status code — a three-digit number that tells the client whether the request succeeded or failed, and why. You have already seen some of these, but here is a reference table you will use constantly as a developer:

Status Code Name What It Means
200 OK The request succeeded. This is the most common response for successful GET requests.
201 Created The request succeeded and a new resource was created. Used after successful POST requests (like submitting a contact form).
400 Bad Request The server could not understand the request because the data was invalid. This is what you return when validation fails.
404 Not Found The requested resource does not exist. For example, asking for message #999 when it does not exist.
500 Internal Server Error Something went wrong on the server. This is a catch-all for unexpected errors — bugs in your code, database failures, etc.

Status codes in the 200 range mean success. Codes in the 400 range mean the client made a mistake (bad input, unauthorized, resource not found). Codes in the 500 range mean the server made a mistake (bugs, crashes, database down). This distinction is important: a 400-level error says "you did something wrong," while a 500-level error says "we messed up."

Now let us build the global exception handler. Create a new file called GlobalExceptionHandler.java in the same package as your other classes:

package org.codersfarm.contactapi;

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

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

@ControllerAdvice
public class GlobalExceptionHandler {

    // Handle validation errors (400 Bad Request)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        List<String> details = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getDefaultMessage())
                .toList();

        Map<String, Object> response = new HashMap<>();
        response.put("error", "Validation failed");
        response.put("details", details);

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(response);
    }

    // Handle all other unexpected errors (500 Internal Server Error)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneralErrors(
            Exception ex) {

        Map<String, Object> response = new HashMap<>();
        response.put("error", "Something went wrong on our end");
        response.put("details", List.of(
                "Please try again later or contact support."));

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }
}

Let us break this class down piece by piece. The @ControllerAdvice annotation at the top of the class tells Spring Boot: "This class provides global error handling for all controllers." You do not need to register it anywhere or wire it up manually — Spring Boot automatically finds it through component scanning, just like it finds your controllers.

The first method, handleValidationErrors, is annotated with @ExceptionHandler(MethodArgumentNotValidException.class). This tells Spring Boot: "Whenever a MethodArgumentNotValidException is thrown anywhere in the application, run this method instead of using the default error handling." A MethodArgumentNotValidException is the specific exception that Spring throws when @Valid detects that the incoming data fails validation. Inside the method, we extract all the error messages from the exception object using ex.getBindingResult().getFieldErrors(). This gives us a list of every field that failed, and we pull out each field's custom error message (the ones you wrote in the @NotBlank and @Size annotations). We then build a clean JSON response with an "error" summary and a "details" array.

The second method, handleGeneralErrors, is the safety net. It catches Exception.class — the parent of all exceptions in Java. If anything goes wrong that is not a validation error (a database crash, a null pointer, a file not found), this method catches it. Notice that we intentionally do not include the actual exception message in the response. Why? Because internal error messages could contain database table names, file paths, or other technical details that you do not want to expose to end users. Instead, we return a friendly, generic message. In a real application, you would also log the actual exception details to a log file so developers can investigate later.

Both methods return a ResponseEntity. This is a Spring class that gives you full control over the HTTP response, including the status code, headers, and body. ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) says "send a 400 status code with this JSON body." This is how you control exactly what the client receives.

Restart your application and test the validation again. Now when you submit invalid data, you will get the clean, helpful error response format we designed. Every error from your API — whether it is a validation failure, a missing resource, or an unexpected crash — will follow the same consistent pattern. Frontend developers working with your API will love this because they can write one piece of error-handling code that works for every endpoint.

Tip: In a production application, you would add more specific exception handlers. For example, you might handle HttpMessageNotReadableException (which occurs when someone sends malformed JSON) with a message like "Invalid JSON format. Please check your request body." The more specific your error messages are, the easier your API is to use.

Part 3 — Full CRUD Operations

So far, your API can do two things: create a new contact message (POST) and read all messages (GET). That is a good start, but a real application needs more. What if you want to look at one specific message? What if you want to mark a message as read? What if you want to delete a spam message? These operations are so common in software development that they have a name: CRUD.

CRUD stands for Create, Read, Update, Delete — the four fundamental operations you can perform on any piece of data. Almost every application you have ever used is built on CRUD. When you post a tweet, that is Create. When you scroll your feed, that is Read. When you edit a tweet (or a profile), that is Update. When you delete a tweet, that is Delete. An online store lets you create orders, read product listings, update your cart, and delete items from your wishlist. CRUD is everywhere.

In REST APIs, each CRUD operation maps to a specific HTTP method:

CRUD and HTTP Methods

  • CreatePOST /api/contact — Submit a new message
  • Read (all)GET /api/contact — Get all messages
  • Read (one)GET /api/contact/{id} — Get a single message by its ID
  • UpdatePUT /api/contact/{id} — Update an existing message
  • DeleteDELETE /api/contact/{id} — Delete a message

Notice the {id} in the URLs above. This is called a path variable — it is a placeholder that gets replaced with an actual value when you make a request. For example, GET /api/contact/3 means "get the message with ID 3." Path variables let you target a specific resource instead of getting all resources at once. Spring Boot makes it easy to capture these values using the @PathVariable annotation, which we will see in the code below.

We already have Create and Read All from previous lessons. Let us add the remaining three operations. First, the Read One endpoint — getting a single message by its ID:

// READ ONE — get a single message by ID
@GetMapping("/{id}")
public ResponseEntity<?> getMessageById(@PathVariable Long id) {
    return repository.findById(id)
            .map(message -> ResponseEntity.ok(message))
            .orElse(ResponseEntity
                    .status(HttpStatus.NOT_FOUND)
                    .build());
}

Let us break this down. The @GetMapping("/{id}") annotation maps GET requests to /api/contact/5, /api/contact/42, or any other number. The {id} part in the path becomes a variable. The @PathVariable Long id parameter tells Spring Boot: "Take whatever value is in the {id} position of the URL and pass it to this method as a Long called id." So if someone requests /api/contact/5, the id variable will have the value 5.

Inside the method, repository.findById(id) asks the database to find a message with that ID. This method returns an Optional — a Java class that represents "there might be a value here, or there might not." If the message exists, .map(message -> ResponseEntity.ok(message)) wraps it in a 200 OK response and sends it back. If the message does not exist, .orElse(ResponseEntity.status(HttpStatus.NOT_FOUND).build()) returns a 404 Not Found response. This is proper REST behavior — when someone asks for a resource that does not exist, you tell them it was not found.

Next, the Update endpoint. This lets you modify an existing message. The most common use case for our contact form is marking a message as "read" after an admin has seen it:

// UPDATE — update an existing message
@PutMapping("/{id}")
public ResponseEntity<?> updateMessage(
        @PathVariable Long id,
        @Valid @RequestBody ContactMessage updatedMessage) {

    return repository.findById(id)
            .map(existing -> {
                existing.setName(updatedMessage.getName());
                existing.setEmail(updatedMessage.getEmail());
                existing.setMessage(updatedMessage.getMessage());
                existing.setRead(updatedMessage.isRead());
                repository.save(existing);
                return ResponseEntity.ok(existing);
            })
            .orElse(ResponseEntity
                    .status(HttpStatus.NOT_FOUND)
                    .build());
}

The @PutMapping("/{id}") annotation maps HTTP PUT requests to this method. PUT is the standard HTTP method for updating a resource. The method takes two parameters: the id from the URL path and the updated message data from the request body. Notice that @Valid is on the request body here too — even when updating, we still validate the input. We do not want someone to update a message with an empty name or an invalid email.

The logic follows the same pattern as Read One: find the message by ID, and if it exists, update its fields and save it. The repository.save(existing) call works for both creating new records and updating existing ones — if the object already has an ID, JPA knows to perform an UPDATE instead of an INSERT. If the message with the given ID does not exist, we return a 404.

Finally, the Delete endpoint:

// DELETE — remove a message
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteMessage(@PathVariable Long id) {
    if (repository.existsById(id)) {
        repository.deleteById(id);
        return ResponseEntity.ok(
                Map.of("status", "Message deleted successfully"));
    }
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(Map.of("error", "Message not found"));
}

The @DeleteMapping("/{id}") annotation maps HTTP DELETE requests. The logic is straightforward: first check if a message with that ID exists using repository.existsById(id). If it does, delete it and return a success message. If it does not, return a 404 error. We check for existence first because calling deleteById on a non-existent ID would silently do nothing, which could confuse the frontend — the client would think the deletion succeeded even though there was nothing to delete.

Now let us put it all together. Here is the complete ContactController with all five CRUD endpoints:

package org.codersfarm.contactapi;

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

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/contact")
@CrossOrigin(origins = "*")
public class ContactController {

    private final ContactMessageRepository repository;

    public ContactController(ContactMessageRepository repository) {
        this.repository = repository;
    }

    // CREATE — submit a new message
    @PostMapping
    public ResponseEntity<Map<String, String>> submitMessage(
            @Valid @RequestBody ContactMessage message) {
        repository.save(message);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(Map.of("status", "Message received! Thank you, "
                        + message.getName() + "."));
    }

    // READ ALL — get all messages
    @GetMapping
    public List<ContactMessage> getAllMessages() {
        return repository.findAll();
    }

    // READ ONE — get a single message by ID
    @GetMapping("/{id}")
    public ResponseEntity<?> getMessageById(@PathVariable Long id) {
        return repository.findById(id)
                .map(message -> ResponseEntity.ok(message))
                .orElse(ResponseEntity
                        .status(HttpStatus.NOT_FOUND)
                        .build());
    }

    // UPDATE — update an existing message
    @PutMapping("/{id}")
    public ResponseEntity<?> updateMessage(
            @PathVariable Long id,
            @Valid @RequestBody ContactMessage updatedMessage) {
        return repository.findById(id)
                .map(existing -> {
                    existing.setName(updatedMessage.getName());
                    existing.setEmail(updatedMessage.getEmail());
                    existing.setMessage(updatedMessage.getMessage());
                    existing.setRead(updatedMessage.isRead());
                    repository.save(existing);
                    return ResponseEntity.ok(existing);
                })
                .orElse(ResponseEntity
                        .status(HttpStatus.NOT_FOUND)
                        .build());
    }

    // DELETE — remove a message
    @DeleteMapping("/{id}")
    public ResponseEntity<?> deleteMessage(@PathVariable Long id) {
        if (repository.existsById(id)) {
            repository.deleteById(id);
            return ResponseEntity.ok(
                    Map.of("status", "Message deleted successfully"));
        }
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(Map.of("error", "Message not found"));
    }
}

Look at what you have built. Five endpoints covering every CRUD operation, with input validation on the create and update routes, proper HTTP status codes, and clean error responses. This is exactly the kind of API that professional developers build every day. The patterns you are learning here — @PathVariable for targeting specific resources, ResponseEntity for controlling status codes, @Valid for enforcing data rules — are used in virtually every Spring Boot project in the real world.

To test these endpoints, you can use a tool like Postman or curl from the command line. Here are some example curl commands:

# Create a new message
curl -X POST http://localhost:8080/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com","message":"Hello, this is a test message!"}'

# Get all messages
curl http://localhost:8080/api/contact

# Get message with ID 1
curl http://localhost:8080/api/contact/1

# Update message with ID 1 (mark as read)
curl -X PUT http://localhost:8080/api/contact/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com","message":"Hello, this is a test message!","read":true}'

# Delete message with ID 1
curl -X DELETE http://localhost:8080/api/contact/1
Key Insight: Notice how each endpoint uses a different HTTP method (GET, POST, PUT, DELETE) but the same base URL (/api/contact). This is the REST convention — the HTTP method tells the server what action to perform, and the URL tells it which resource to perform it on. The same URL can do different things depending on the method used to access it.

Part 4 — Connecting to the Frontend

You have built a powerful API with validation, error handling, and full CRUD operations. But so far, the only way to interact with it has been through tools like curl or Postman. Let us bring it to life by building a simple admin dashboard — a web page that lets you view all submitted messages, mark them as read, and delete them. This is exactly the kind of internal tool that businesses build to manage the data coming through their contact forms.

The admin dashboard will use plain JavaScript with the fetch() API — the same techniques you learned in earlier web lessons. It will make HTTP requests to your Spring Boot API and display the results. This is the full-stack connection in action: your HTML form sends data to the backend, the backend validates and stores it, and now the admin dashboard reads it back.

The code below is a working admin dashboard. It fetches all messages from your API, displays each one with its read status, and provides buttons to mark messages as read or delete them. You can run it right here in the browser to see the console output. Of course, for the fetch requests to actually work, your Spring Boot server needs to be running on localhost:8080. If it is not running, you will see an error message explaining that.

Take a moment to read through the code before running it. Notice how each function uses a different HTTP method — GET for loading, PUT for updating, and DELETE for removing. These map directly to the endpoints you just built in your controller. The frontend and backend are speaking the same language.

// Simple Admin Dashboard
// Run this while your Spring Boot server is running!

const API_URL = 'http://localhost:8080/api/contact';

// Fetch and display all messages
async function loadMessages() {
  try {
    const response = await fetch(API_URL);
    const messages = await response.json();

    if (messages.length === 0) {
      console.log('No messages yet. Submit some through your contact form!');
      return;
    }

    console.log('=== Contact Messages ===\n');
    messages.forEach(msg => {
      const status = msg.read ? '[Read]' : '[New]';
      console.log(`${status} #${msg.id} - ${msg.name} (${msg.email})`);
      console.log(`  "${msg.message}"`);
      console.log('---');
    });

    console.log(`\nTotal: ${messages.length} message(s)`);
  } catch (error) {
    console.log('Error: ' + error.message);
    console.log('Make sure your Spring Boot server is running!');
  }
}

// Mark a message as read
async function markAsRead(id) {
  try {
    // First, get the current message
    const getResponse = await fetch(`${API_URL}/${id}`);
    if (!getResponse.ok) {
      console.log(`Message #${id} not found.`);
      return;
    }
    const message = await getResponse.json();

    // Update it with read = true
    message.read = true;
    const putResponse = await fetch(`${API_URL}/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(message)
    });

    if (putResponse.ok) {
      console.log(`Message #${id} marked as read!`);
    } else {
      console.log(`Failed to update message #${id}.`);
    }
  } catch (error) {
    console.log('Error: ' + error.message);
  }
}

// Delete a message
async function deleteMessage(id) {
  try {
    const response = await fetch(`${API_URL}/${id}`, {
      method: 'DELETE'
    });

    if (response.ok) {
      const result = await response.json();
      console.log(result.status);
    } else {
      console.log(`Message #${id} not found.`);
    }
  } catch (error) {
    console.log('Error: ' + error.message);
  }
}

// Run the dashboard
loadMessages();

Let us walk through the key parts of this code. The loadMessages() function sends a GET request to /api/contact, which triggers your getAllMessages() controller method. It receives an array of message objects, loops through each one, and prints the status (read or new), the ID, the sender's name and email, and the message content. This is the "Read All" part of CRUD in action.

The markAsRead(id) function demonstrates the Update operation. It first fetches the existing message with a GET request (so it has all the current field values), sets the read property to true, and then sends the entire updated object back with a PUT request. Remember, your PUT endpoint expects a complete, valid ContactMessage object because @Valid is on that parameter. You cannot just send {"read": true} — you need to include all the required fields (name, email, message) to pass validation.

The deleteMessage(id) function sends a DELETE request to /api/contact/{id}. If the message exists, it gets deleted and the server returns a success message. If it does not exist, the server returns a 404, and the function tells you the message was not found. Clean and straightforward.

To try the full workflow, start your Spring Boot server, then use the contact form (or curl) to submit a few test messages. Come back to this editor and run the code to see your messages displayed. Then try changing the last line from loadMessages() to markAsRead(1) and run it again to mark the first message as read. Change it to deleteMessage(1) to delete it. Finally, change it back to loadMessages() to confirm the changes took effect. You are interacting with your database through JavaScript — this is full-stack development.

In a real application, you would build this admin dashboard as a proper HTML page with styled buttons and a table layout, rather than printing to the console. But the underlying JavaScript is exactly the same — fetch() with different HTTP methods. The console approach lets you focus on the logic without worrying about HTML and CSS right now.

Important: The @CrossOrigin(origins = "*") annotation on your controller is what allows the browser-based JavaScript to talk to your Spring Boot server. Without it, the browser would block the requests due to CORS (Cross-Origin Resource Sharing) security rules. The "*" means "allow requests from any origin." This is fine for development, but in production you would restrict it to your specific domain (like @CrossOrigin(origins = "https://yoursite.com")).

Part 5 — Reviewing the Full Stack

Take a step back and appreciate what you have built over the last several lessons. You started with nothing but a blank HTML file and the desire to learn. Now you have a fully functional full-stack application. Let us trace the entire journey of a contact form submission, from the moment a user clicks "Submit" to the moment an admin reads the message:

The Full-Stack Flow

  1. HTML form — The user types their name, email, and message into a form you built with HTML and CSS.
  2. JavaScript fetch() — When they click "Submit," your JavaScript gathers the form data, converts it to JSON, and sends a POST request to your API.
  3. Spring Boot controller — Your ContactController receives the request and the @Valid annotation triggers input validation.
  4. Jakarta Validation — The @NotBlank, @Email, and @Size annotations check every field. If anything is invalid, the GlobalExceptionHandler sends back a clean error response.
  5. JPA Repository — If validation passes, repository.save() stores the message in your SQLite database.
  6. SQLite database — The message is persisted to disk. It will survive server restarts and will be there tomorrow, next week, and next year.
  7. Response — The server sends back a 201 Created response, confirming the message was saved. Your frontend JavaScript displays a success message to the user.
  8. Admin dashboard — Later, an admin loads the dashboard, which fetches all messages from the API and displays them. They can mark messages as read or delete spam.

This is a production-quality pattern. The same architecture — frontend form, REST API, validation, database storage, admin dashboard — is used by companies of every size, from startups to Fortune 500 corporations. The specific technologies might differ (React instead of plain HTML, PostgreSQL instead of SQLite, Kubernetes instead of running locally), but the pattern is identical. You have learned the fundamentals that transfer to any technology stack.

You also now have a solid understanding of concepts that many developers struggle with: the HTTP request/response cycle, REST conventions, JSON data exchange, server-side validation, error handling with proper status codes, and CRUD operations with a relational database. These are not just Spring Boot concepts — they are universal software engineering concepts that apply whether you are building in Java, Python, JavaScript, Go, or any other language.

You have built something genuinely impressive. A few lessons ago, you were writing your first Hello from Spring Boot! endpoint. Now you have a validated, database-backed REST API with full CRUD operations and a frontend that talks to it. That is real software engineering. In the next and final lesson, we will look at where to go from here — how to deploy your application, what to learn next, and how to continue growing as a developer.

Quiz — Check Your Understanding

1. What annotation tells Spring to validate the incoming request body?

Correct answer: C. The @Valid annotation is placed before the @RequestBody parameter in your controller method. It tells Spring Boot to check all the validation annotations (@NotBlank, @Email, @Size, etc.) on the incoming object before running the method. Without @Valid, the validation annotations on your entity are ignored.

2. What HTTP status code means "the resource was not found"?

Correct answer: C. A 404 Not Found status code tells the client that the resource they requested does not exist. For example, if someone sends a GET request to /api/contact/999 and there is no message with ID 999, your API should return a 404. Status codes in the 400 range indicate that the client made an error.

3. What does CRUD stand for?

Correct answer: B. CRUD stands for Create, Read, Update, Delete — the four fundamental operations for managing data. In REST APIs, these map to HTTP methods: POST (Create), GET (Read), PUT (Update), and DELETE (Delete). Almost every application you use is built on these four operations.

Finished this lesson?

← Previous: Adding SQLite Next: What's Next →