Skip to content

Building REST APIs with Spring

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Building REST APIs with Spring. We cover key concepts, practical examples, and best practices to help you master this topic.

RESTful Design Principles

Representational State Transfer (REST) is an architectural style for designing networked applications. REST APIs use HTTP methods as verbs (GET, POST, PUT, DELETE, PATCH) and URLs as nouns representing resources. A well-designed REST API is intuitive, stateless, and leverages HTTP status codes to communicate results.

Spring Boot makes building REST APIs straightforward with @RestController, which combines @Controller and @ResponseBody to automatically serialize return values to JSON. Combined with Spring's validation, Exception Handling, and HATEOAS support, you can build production-quality APIs with minimal code.

flowchart LR
    Client[Client] -->|HTTP Request| API[REST API]
    API -->|Validation| Controller
    Controller --> Service
    Service --> Repository
    Repository --> DB[(Database)]
    Controller -->|HTTP Response| Client
    
    subgraph API
        Controller[@RestController]
        EH[@ControllerAdvice
Error Handler] Validation[Jakarta Validation] end

Creating a Resource API

Let's build a complete REST API for managing books.

The Entity and DTO

// Entity
@Entity
@Table(name = "books")
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String title;
    
    @Column(nullable = false)
    private String author;
    
    @Column(unique = true)
    private String isbn;
    
    private double price;
    
    private int publicationYear;
    
    // getters and setters
}

// DTO (Record)
public record BookRequest(
    @NotBlank String title,
    @NotBlank String author,
    @Pattern(regexp = "^(?:\\d{9}X|\\d{13})$") String isbn,
    @Positive double price,
    @Min(1900) @Max(2026) int publicationYear
) {}

public record BookResponse(
    Long id, String title, String author,
    String isbn, double price, int publicationYear
) {}

The Controller

@RestController
@RequestMapping("/api/v1/books")
public class BookController {
    
    private final BookService bookService;
    
    public BookController(BookService bookService) {
        this.bookService = bookService;
    }
    
    @GetMapping
    public ResponseEntity<Page<BookResponse>> getAllBooks(
            @PageableDefault(size = 20, sort = "title") Pageable pageable,
            @RequestParam(required = false) String author) {
        
        Page<BookResponse> books = bookService.findAll(author, pageable);
        return ResponseEntity.ok(books);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
        return ResponseEntity.ok(bookService.findById(id));
    }
    
    @PostMapping
    public ResponseEntity<BookResponse> createBook(
            @Valid @RequestBody BookRequest request) {
        BookResponse created = bookService.create(request);
        URI location = URI.create("/api/v1/books/" + created.id());
        return ResponseEntity.created(location).body(created);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<BookResponse> updateBook(
            @PathVariable Long id, @Valid @RequestBody BookRequest request) {
        return ResponseEntity.ok(bookService.update(id, request));
    }
    
    @PatchMapping("/{id}")
    public ResponseEntity<BookResponse> partialUpdate(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        return ResponseEntity.ok(bookService.partialUpdate(id, updates));
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Validation

Built-in Validation Annotations

public record CreateOrderRequest(
    @NotNull Long customerId,
    
    @NotEmpty
    @Size(min = 1, max = 50)
    List<@Valid OrderItemRequest> items,
    
    @Future LocalDateTime deliveryDate,
    
    @Pattern(regexp = "^(PENDING|PAID|SHIPPED)$")
    String status,
    
    @Email String notificationEmail,
    
    @PositiveOrZero double discount,
    
    @NotNull @Currency("USD") String currency
) {}

Custom Validator

@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = IsbnValidator.class)
@interface ValidIsbn {
    String message() default "Invalid ISBN";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class IsbnValidator implements ConstraintValidator<ValidIsbn, String> {
    @Override
    public boolean isValid(String isbn, ConstraintValidatorContext context) {
        if (isbn == null) return false;
        return isbn.matches("^(?:\\d{9}X|\\d{13})$") && 
               validateChecksum(isbn);
    }
    
    private boolean validateChecksum(String isbn) {
        // ISBN-10 or ISBN-13 checksum validation
        return true;
    }
}

Error Handling

Global Exception Handler

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        var errors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
            .toList();
        return ResponseEntity.badRequest()
            .body(new ValidationErrorResponse("VALIDATION_FAILED", errors));
    }
    
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex) {
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_FAILED", ex.getMessage()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
    }
}

record ErrorResponse(String code, String message) {}
record FieldError(String field, String message) {}
record ValidationErrorResponse(String code, List<FieldError> errors) {}

API Documentation with OpenAPI

Springdoc-openapi generates OpenAPI 3.0 documentation automatically.

Dependency

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>

Customizing the Docs

@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Bookstore API")
                .version("1.0.0")
                .description("REST API for managing bookstore inventory"))
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")));
    }
}

Documenting Endpoints

@RestController
@RequestMapping("/api/v1/books")
@Tag(name = "Books", description = "Book management endpoints")
public class BookController {
    
    @GetMapping("/{id}")
    @Operation(summary = "Get book by ID", description = "Returns details of a single book")
    @ApiResponse(responseCode = "200", description = "Book found")
    @ApiResponse(responseCode = "404", description = "Book not found")
    public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
        // ...
    }
}

Content Negotiation

@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE, 
                                           MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
    // Response format based on Accept header
}

Common Mistakes

1. Exposing Entity Classes Directly

Returning entities directly in API responses couples your database schema to your API contract. Use DTOs instead.

// Bad: returns entity with all fields including password hash
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { ... }

// Good: returns only needed data
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) { ... }

2. Not Using HTTP Status Codes Properly

Situation Status Code
Success 200 OK
Created 201 Created
No content 204 No Content
Bad request 400 Bad Request
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden
Not found 404 Not Found
Conflict 409 Conflict
Validation error 422 Unprocessable Entity
Server error 500 Internal Server Error

3. Ignoring Idempotency

GET, PUT, DELETE, and HEAD should be idempotent. POST is not idempotent.

// PUT replaces the resource - calling it multiple times has the same effect
@PutMapping("/books/{id}")
public ResponseEntity<BookResponse> updateBook(@PathVariable Long id, 
        @Valid @RequestBody BookRequest request) {
    // Always results in the same state
}

4. Returning Stack Traces in Production

Never return exception stack traces to API consumers. Always log the full error server-side and return a user-friendly error message.

5. Not Versioning APIs

@RequestMapping("/api/v1/books")
// Later: @RequestMapping("/api/v2/books")

Versioning allows you to evolve your API without breaking existing clients.

6. Missing Rate Limiting

Production APIs should implement rate limiting to protect against abuse. Use Spring Cloud Gateway, Bucket4j, or a dedicated rate-limiting service.

Practice Questions

  1. What is the difference between @PutMapping and @PatchMapping?
  2. How do you handle validation errors in a REST API?
  3. Why should you separate entities from DTOs?
  4. What is HATEOAS and when would you use it?
  5. How does content negotiation work in Spring REST APIs?

Challenge: Build a versioned REST API (v1 and v2) for a library system. v1 returns flat JSON. v2 returns nested JSON with _links (HAL format). Both versions share the same service layer. Implement proper error handling, validation, pagination, and OpenAPI documentation.

FAQ

What is the difference between REST and RESTful?

REST is the architectural style. RESTful describes an API that follows REST principles: stateless, resource-based, using HTTP methods uniformly, and leveraging hypermedia links.

Should I use GET or POST for complex queries with many parameters?

Use GET with query parameters for filtering, sorting, and paging. For very complex queries, you can use POST with a request body and create a /search endpoint.

How do I handle authentication in REST APIs?

REST APIs typically use token-based authentication: JWT (JSON Web Tokens) or OAuth2. Spring Security provides built-in support for both, including resource server configuration.

What is the difference between @Controller and @RestController?

@RestController is a convenience annotation that combines @Controller and @ResponseBody. It automatically serializes return values to the response body, typically as JSON.

How do I test REST APIs?

Use @WebMvcTest for controller-layer tests with MockMvc. Use Mockito to mock service dependencies. For integration tests, use @SpringBootTest with TestRestTemplate or WebTestClient.

Mini Project: Library Management REST API

Build a complete REST API for a library management system with the following features:

  • CRUD for books, members, and loans
  • Search books by title, author, ISBN, or genre with filtering and sorting
  • Borrow and return books with due date tracking (PATCH for updating loan status)
  • Late fee calculation based on return date
  • Member borrowing history with pagination
  • OpenAPI documentation with all endpoints documented
  • Global exception handling for validation errors and business rule violations
  • Version 2 of the API (at /api/v2) that returns HAL-formatted responses with links

Use DTOs, services, repositories, and proper HTTP status codes throughout.

What's Next

You can now build sophisticated REST APIs. In the next lesson, we will zoom out and explore Microservices Architecture with Java, including service discovery, API gateways, and inter-service communication patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro