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
| Operation | Verb | Path | Success status |
|---|---|---|---|
| List | GET | /api/products | 200 OK |
| Read | GET | /api/products/{id} | 200 OK / 404 |
| Create | POST | /api/products | 201 Created |
| Update | PUT | /api/products/{id} | 200 OK / 404 |
| Delete | DELETE | /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.