Building a CRUD REST API
The previous lessons taught you the individual building blocks — @RestController, path variables, @RequestBody, and ResponseEntity. Now we put them all together and build a complete, production-shaped resource API from scratch. By the end of this lesson you will have a fully working Product API that supports Create, Read (single and list), Update, and Delete, with correct HTTP semantics and clean separation between layers.
The Three-Layer Approach
Even in a modest API it pays to separate responsibilities:
- Controller — handles HTTP: parses requests, delegates to the service, and shapes the response.
- Service — owns business logic. The controller should never touch a repository directly.
- Repository — data access. We use Spring Data JPA here, but the pattern works with JDBC or any other persistence layer.
Why this matters: If you put business logic in the controller, testing it means starting an HTTP server. If it lives in a plain @Service bean, you can test it with a simple unit test and no web layer at all.
The Entity and Repository
Start with a JPA entity and a Spring Data repository:
// Product.java
package com.example.shop.product;
import jakarta.persistence.*;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private double price;
// constructors, getters, setters omitted for brevity
}
// ProductRepository.java
package com.example.shop.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> { }
JpaRepository already provides findAll(), findById(), save(), and deleteById() — you get CRUD for free.
The Service Layer
The service translates business intent into repository calls and throws domain exceptions when something goes wrong:
// ProductService.java
package com.example.shop.product;
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<Product> findAll() {
return repo.findAll();
}
@Transactional(readOnly = true)
public Product findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
public Product create(Product product) {
product.setId(null); // prevent client-supplied IDs
return repo.save(product);
}
public Product update(Long id, Product incoming) {
Product existing = findById(id);
existing.setName(incoming.getName());
existing.setDescription(incoming.getDescription());
existing.setPrice(incoming.getPrice());
return repo.save(existing);
}
public void delete(Long id) {
Product existing = findById(id); // confirms existence first
repo.delete(existing);
}
}
Mark read-only operations with @Transactional(readOnly = true). This tells the JPA provider it can skip dirty-checking, which reduces memory pressure on large result sets. The database driver may also choose a faster execution path.
The Controller
The controller maps five HTTP operations onto the service. Notice how thin it stays — no business logic, only HTTP translation:
// ProductController.java
package com.example.shop.product;
import org.springframework.http.HttpStatus;
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;
}
// GET /api/products
@GetMapping
public List<Product> list() {
return service.findAll();
}
// GET /api/products/{id}
@GetMapping("/{id}")
public ResponseEntity<Product> get(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
// POST /api/products
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
Product saved = service.create(product);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.getId())
.toUri();
return ResponseEntity.created(location).body(saved);
}
// PUT /api/products/{id}
@PutMapping("/{id}")
public ResponseEntity<Product> update(
@PathVariable Long id,
@RequestBody Product product) {
return ResponseEntity.ok(service.update(id, product));
}
// DELETE /api/products/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
Building the Location Header on POST
When a client POSTs a new resource, the response should include a Location header pointing to the newly created resource. ServletUriComponentsBuilder.fromCurrentRequest() captures the current request URL (e.g. /api/products) and appends the new ID, producing /api/products/42. The 201 Created status plus this header is what REST clients and API gateways expect.
Handling "Not Found" with a Custom Exception
The service throws ProductNotFoundException when a product does not exist. Wire it to a 404 response with a small exception handler:
// ProductNotFoundException.java
package com.example.shop.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product not found: " + id);
}
}
// GlobalExceptionHandler.java
package com.example.shop;
import com.example.shop.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, Object> handleNotFound(ProductNotFoundException ex) {
return Map.of(
"timestamp", Instant.now().toString(),
"status", 404,
"error", "Not Found",
"message", ex.getMessage()
);
}
}
Do not let Spring's default error page leak stack traces. The default /error endpoint includes exception class names and internal paths. A @RestControllerAdvice class gives you full control over what the client sees. In production, log the stack trace server-side and return only a safe message to the caller.
Verifying Your API with curl
With the application running on port 8080 you can exercise all five endpoints:
# Create
curl -s -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Keyboard","description":"Mechanical","price":129.99}'
# List all
curl -s http://localhost:8080/api/products
# Get one
curl -s http://localhost:8080/api/products/1
# Update
curl -s -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Keyboard","description":"Wireless Mechanical","price":149.99}'
# Delete
curl -s -X DELETE http://localhost:8080/api/products/1
Summary
A clean CRUD API in Spring Boot is the sum of four parts: a JPA entity and repository for persistence, a @Service bean for business logic, a @RestController for HTTP translation, and a @RestControllerAdvice for error handling. The controller stays thin, the service stays testable, and every operation returns the HTTP status code that REST clients actually expect — 200 for reads and updates, 201 for creates, 204 for deletes, and 404 when the resource is not found. This skeleton scales directly into a production microservice.