Skip to content
Spring Boot sb web 4 min read

Full CRUD REST API

This page assembles everything into one realistic resource: a complete CRUD (Create, Read, Update, Delete) API for a Product. It layers a controller, a service, and a Spring Data JPA repository, uses DTOs for the wire format, validates input, returns correct status codes, and handles errors centrally.

Architecture

A clean layered design keeps each part focused:

  • Controller — HTTP mapping, status codes, DTO in/out. No business logic.
  • Service — business rules and transactions; maps between DTO and entity.
  • Repository — persistence via Spring Data JPA.
HTTP  →  Controller  →  Service  →  Repository  →  Database
        (DTO)           (rules)     (entity)

The entity

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class Product {

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

    private String name;
    private BigDecimal price;
    private String description;

    // getters and setters
}

The DTOs

Records keep request and response shapes immutable and explicit. Validation constraints live on the request DTO.

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank String name,
        @NotNull @Positive BigDecimal price,
        @Size(max = 280) String description) {}

public record ProductResponse(
        Long id,
        String name,
        BigDecimal price,
        String description) {}

The repository

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

public interface ProductRepository extends JpaRepository<Product, Long> {
}

The custom exception

public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(Long id) {
        super("Product " + id + " not found");
    }
}

The service

The service owns transactions and the entity ↔ DTO mapping. @Transactional ensures each operation is atomic.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
@Transactional
public class ProductService {

    private final ProductRepository repo;

    public ProductService(ProductRepository repo) {
        this.repo = repo;
    }

    @Transactional(readOnly = true)
    public List<ProductResponse> findAll() {
        return repo.findAll().stream().map(this::toResponse).toList();
    }

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        return repo.findById(id).map(this::toResponse)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }

    public ProductResponse create(ProductRequest req) {
        Product p = new Product();
        apply(p, req);
        return toResponse(repo.save(p));
    }

    public ProductResponse update(Long id, ProductRequest req) {
        Product p = repo.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
        apply(p, req);
        return toResponse(repo.save(p));
    }

    public void delete(Long id) {
        if (!repo.existsById(id)) throw new ProductNotFoundException(id);
        repo.deleteById(id);
    }

    private void apply(Product p, ProductRequest req) {
        p.setName(req.name());
        p.setPrice(req.price());
        p.setDescription(req.description());
    }

    private ProductResponse toResponse(Product p) {
        return new ProductResponse(p.getId(), p.getName(), p.getPrice(), p.getDescription());
    }
}

The controller

The controller is thin: it maps HTTP to service calls and chooses status codes.

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public List<ProductResponse> findAll() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return service.findById(id);
    }

    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest body) {
        ProductResponse created = service.create(body);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public ProductResponse update(@PathVariable Long id,
                                  @Valid @RequestBody ProductRequest body) {
        return service.update(id, body);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Centralized error handling

A @RestControllerAdvice maps exceptions to clean, consistent responses. See Controller Advice for the full pattern.

import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.*;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail handleNotFound(ProductNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return errors;
    }
}

The API in action

Create:

curl -i -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Mechanical Keyboard","price":89.99,"description":"Tactile switches"}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/products/1

{ "id": 1, "name": "Mechanical Keyboard", "price": 89.99, "description": "Tactile switches" }

Validation failure:

curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"","price":-5}'
{ "name": "must not be blank", "price": "must be greater than 0" }

Not found:

curl http://localhost:8080/api/products/999
{ "type": "about:blank", "title": "Not Found", "status": 404, "detail": "Product 999 not found" }

Verb / status summary

OperationVerbPathSuccess status
ListGET/api/products200 OK
ReadGET/api/products/{id}200 OK / 404
CreatePOST/api/products201 Created
UpdatePUT/api/products/{id}200 OK / 404
DeleteDELETE/api/products/{id}204 No Content / 404

Tip: Keep validation on the request DTO, mapping in the service, and status decisions in the controller. Each layer then has exactly one reason to change.

Last updated June 13, 2026
Was this helpful?