Building a REST API with Spring Boot: Complete Beginner's Guide

Spring Boot strips away the configuration ceremony that used to make Spring applications time-consuming to set up. You add a dependency, annotate a class, and a production-grade REST endpoint is running in seconds. This guide builds a complete, working REST API from a blank project to a tested, structured service – explaining every decision along the way.

By the end you will have a runnable Spring Boot application with GET, POST, PUT, and DELETE endpoints, proper HTTP status codes, global exception handling, validation, and a structure that scales to a real project.


Prerequisites

  • Java 21 (any recent LTS will work)
  • Maven 3.8+ or Gradle 8+
  • Basic familiarity with Java classes and annotations

1. Project Setup

The fastest way to create a Spring Boot project is start.spring.io. Select Maven, Java 21, and add these dependencies: Spring Web, Spring Boot DevTools, and Validation. Download the zip, unpack it, and open in your IDE.

Your pom.xml parent and key dependencies will look like this:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>     <!-- use the latest stable release -->
</parent>

<dependencies>
    <!-- Web: includes Spring MVC, embedded Tomcat, Jackson -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Validation: JSR 380 / Hibernate Validator -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- DevTools: hot reload during development -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>

    <!-- Test: JUnit 5, Mockito, MockMvc -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

2. Project Structure

A clean structure separates concerns into distinct layers. This makes the code easy to navigate and straightforward to test each layer in isolation.

src/main/java/com/example/demo/
โ”œโ”€โ”€ DemoApplication.java         โ† Spring Boot entry point
โ”œโ”€โ”€ controller/
โ”‚   โ””โ”€โ”€ BookController.java      โ† HTTP layer: handles requests, returns responses
โ”œโ”€โ”€ service/
โ”‚   โ””โ”€โ”€ BookService.java         โ† Business logic: orchestrates the work
โ”œโ”€โ”€ model/
โ”‚   โ””โ”€โ”€ Book.java                โ† Domain model / data class
โ””โ”€โ”€ exception/
    โ”œโ”€โ”€ BookNotFoundException.java
    โ””โ”€โ”€ GlobalExceptionHandler.java

3. The Model

We model a simple Book. Java 21 records make this concise; the @NotBlank and @Positive annotations from Hibernate Validator enforce input rules before data reaches the service layer.

package com.example.demo.model;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

// A Java record is immutable by default: getters, equals, hashCode, and toString
// are generated automatically. The 'id' field is set by the service, not the caller.
public record Book(
        Long   id,

        @NotBlank(message = "Title must not be blank")
        String title,

        @NotBlank(message = "Author must not be blank")
        String author,

        @Positive(message = "Year must be a positive number")
        int    year
) {
    // Compact canonical constructor - validation annotations are checked by Spring
    // before this record reaches the service layer when used with @Valid in the controller.
}

4. The Service Layer

The service holds the in-memory store (a HashMap) and the business rules. In a real application this layer would call a repository that talks to a database.

package com.example.demo.service;

import com.example.demo.exception.BookNotFoundException;
import com.example.demo.model.Book;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

// @Service marks this as a Spring-managed bean; Spring will inject it wherever needed.
@Service
public class BookService {

    // In-memory store for this demo (replace with JPA repository for a real app)
    private final Map store = new HashMap<>();
    private final AtomicLong      idGen = new AtomicLong(1); // thread-safe ID counter

    // --- CRUD operations ---

    public List findAll() {
        return new ArrayList<>(store.values()); // return a copy
    }

    public Book findById(Long id) {
        Book book = store.get(id);
        if (book == null) {
            throw new BookNotFoundException("Book not found: id=" + id);
        }
        return book;
    }

    public Book create(Book book) {
        long id = idGen.getAndIncrement();      // assign the next ID
        Book saved = new Book(id, book.title(), book.author(), book.year());
        store.put(id, saved);
        return saved;
    }

    public Book update(Long id, Book updated) {
        findById(id);                           // throws 404 if absent
        Book replacement = new Book(id, updated.title(), updated.author(), updated.year());
        store.put(id, replacement);
        return replacement;
    }

    public void delete(Long id) {
        findById(id);                           // throws 404 if absent
        store.remove(id);
    }
}

5. The Controller

The controller maps HTTP verbs and URLs to service calls and sets the correct HTTP status code on each response. @RestController combines @Controller and @ResponseBody, so every method return value is serialised to JSON automatically by Jackson.

package com.example.demo.controller;

import com.example.demo.model.Book;
import com.example.demo.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController                          // combines @Controller + @ResponseBody
@RequestMapping("/api/books")           // base path for all endpoints in this class
public class BookController {

    private final BookService bookService;

    // Constructor injection - preferred over @Autowired field injection
    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // GET /api/books  - returns all books
    @GetMapping
    public List getAllBooks() {
        return bookService.findAll();   // 200 OK by default
    }

    // GET /api/books/{id}  - returns one book or 404
    @GetMapping("/{id}")
    public ResponseEntity getBookById(@PathVariable Long id) {
        return ResponseEntity.ok(bookService.findById(id));  // 200 OK
    }

    // POST /api/books  - creates a new book, returns 201 Created
    @PostMapping
    public ResponseEntity createBook(@Valid @RequestBody Book book) {
        // @Valid triggers JSR 380 validation on the incoming Book record.
        // If validation fails, Spring returns 400 Bad Request automatically.
        Book created = bookService.create(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created); // 201
    }

    // PUT /api/books/{id}  - replaces a book; 200 on success, 404 if not found
    @PutMapping("/{id}")
    public ResponseEntity updateBook(
            @PathVariable Long id,
            @Valid @RequestBody Book book) {
        return ResponseEntity.ok(bookService.update(id, book));
    }

    // DELETE /api/books/{id}  - removes a book; 204 No Content on success
    @DeleteMapping("/{id}")
    public ResponseEntity deleteBook(@PathVariable Long id) {
        bookService.delete(id);
        return ResponseEntity.noContent().build();  // 204 No Content
    }
}

6. Exception Handling

A global exception handler using @RestControllerAdvice intercepts exceptions thrown anywhere in the application and maps them to clean JSON error responses. Without this, Spring returns a raw 500 stacktrace.

// BookNotFoundException.java
package com.example.demo.exception;

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(String message) {
        super(message);
    }
}
// GlobalExceptionHandler.java
package com.example.demo.exception;

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

import java.util.stream.Collectors;

// @RestControllerAdvice intercepts exceptions from all @RestController classes.
// ProblemDetail (RFC 7807) is Spring Boot 3's standard error response format.
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 404 Not Found - when a book ID doesn't exist
    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleNotFound(BookNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Book Not Found");
        return pd;
    }

    // 400 Bad Request - when @Valid constraint violations occur
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        String detail = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .collect(Collectors.joining("; "));
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
        pd.setTitle("Validation Failed");
        return pd;
    }
}

7. Running and Testing the API

Start the application with mvn spring-boot:run. Spring Boot embeds Tomcat and starts on port 8080. You can then test every endpoint with curl:

# Create a book (POST)
curl -s -X POST http://localhost:8080/api/books 
  -H "Content-Type: application/json" 
  -d '{"title":"Clean Code","author":"Robert Martin","year":2008}' | jq
# Output:
# { "id": 1, "title": "Clean Code", "author": "Robert Martin", "year": 2008 }

# Get all books (GET)
curl -s http://localhost:8080/api/books | jq

# Get one book (GET)
curl -s http://localhost:8080/api/books/1 | jq

# Update a book (PUT)
curl -s -X PUT http://localhost:8080/api/books/1 
  -H "Content-Type: application/json" 
  -d '{"title":"Clean Code (2nd Ed)","author":"Robert Martin","year":2024}' | jq

# Delete a book (DELETE)
curl -s -X DELETE http://localhost:8080/api/books/1
# Returns: 204 No Content

# Test validation (POST with blank title)
curl -s -X POST http://localhost:8080/api/books 
  -H "Content-Type: application/json" 
  -d '{"title":"","author":"Unknown","year":2020}' | jq
# Output: 400 Bad Request, "title: Title must not be blank"

8. Annotation Quick Reference

AnnotationWhere usedWhat it does
@SpringBootApplicationMain classEnables auto-configuration, component scan, and configuration support
@RestControllerController classMarks as controller + serialises return values to JSON
@RequestMappingClass or methodMaps a URL prefix to the class (or full path to a method)
@GetMappingMethodMaps HTTP GET to the method
@PostMappingMethodMaps HTTP POST
@PutMappingMethodMaps HTTP PUT
@DeleteMappingMethodMaps HTTP DELETE
@PathVariableParameterBinds a URI template variable (e.g., {id}) to a method parameter
@RequestBodyParameterDeserialises the JSON request body into a Java object
@ValidParameterTriggers Bean Validation on the bound object
@ServiceClassMarks as a Spring-managed service bean
@RestControllerAdviceClassIntercepts exceptions from all controllers and maps them to responses
@ExceptionHandlerMethod in adviceMaps a specific exception type to an HTTP response

See Also

Conclusion

Spring Boot reduces REST API development to a small set of well-understood building blocks: a model, a service, a controller, and an exception handler. The framework handles serialisation, HTTP mapping, validation, and error formatting so you can focus entirely on domain logic. The structure shown here – separate packages per layer, constructor injection, global exception handling, and RFC 7807 error responses – scales from a tutorial project to a production microservice with minimal refactoring.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.