Building REST APIs with Spring Boot

Building a CRUD REST API

18 min Lesson 7 of 13

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.