Complete CRUD REST API (All Layers)
This page puts everything together: entity, repository, DTOs, service, controller, validation and error handling, in a clean layered structure. Copy it as the base of your own project.
Layers
HTTP client
│ JSON
▼
Controller (ProductController) only web concerns: URLs, status codes, DTOs
▼
Service (ProductService) business rules, @Transactional, mapping
▼
Repository (ProductRepository) Spring Data JPA
▼
Database (H2 / PostgreSQL)application.yml
spring:
datasource:
url: jdbc:h2:mem:shop
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
h2:
console:
enabled: trueEntity and repository
@Entity
@Table(name = "products")
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
protected Product() { }
public Product(String name, BigDecimal price) { this.name = name; this.price = price; }
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public void update(String name, BigDecimal price) { this.name = name; this.price = price; }
}
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsByNameIgnoreCase(String name);
}DTOs
public record ProductRequest(
@NotBlank @Size(max = 100) String name,
@NotNull @Positive BigDecimal price) { }
public record ProductResponse(Long id, String name, BigDecimal price) {
public static ProductResponse from(Product p) {
return new ProductResponse(p.getId(), p.getName(), p.getPrice());
}
}Service
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) { this.repository = repository; }
@Transactional
public ProductResponse create(ProductRequest request) {
if (repository.existsByNameIgnoreCase(request.name())) {
throw new ConflictException("Product already exists: " + request.name());
}
return ProductResponse.from(repository.save(new Product(request.name(), request.price())));
}
@Transactional(readOnly = true)
public Page<ProductResponse> list(Pageable pageable) {
return repository.findAll(pageable).map(ProductResponse::from);
}
@Transactional(readOnly = true)
public ProductResponse get(Long id) {
return ProductResponse.from(find(id));
}
@Transactional
public ProductResponse update(Long id, ProductRequest request) {
Product product = find(id);
product.update(request.name(), request.price()); // saved by dirty checking
return ProductResponse.from(product);
}
@Transactional
public void delete(Long id) {
repository.delete(find(id));
}
private Product find(Long id) {
return repository.findById(id).orElseThrow(() -> new NotFoundException("Product " + id + " not found"));
}
}Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) { this.service = service; }
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
ProductResponse created = service.create(request);
URI location = URI.create("/api/products/" + created.id());
return ResponseEntity.created(location).body(created);
}
@GetMapping
public Page<ProductResponse> list(@PageableDefault(size = 10, sort = "id") Pageable pageable) {
return service.list(pageable);
}
@GetMapping("/{id}")
public ProductResponse get(@PathVariable Long id) { return service.get(id); }
@PutMapping("/{id}")
public ProductResponse update(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}Exceptions
public class NotFoundException extends RuntimeException {
public NotFoundException(String m) { super(m); }
}
public class ConflictException extends RuntimeException {
public ConflictException(String m) { super(m); }
}
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail notFound(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(ConflictException.class)
public ProblemDetail conflict(ConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalid(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", errors);
return pd;
}
}Try it
curl -X POST localhost:8080/api/products -H "Content-Type: application/json" \
-d '{"name":"Laptop","price":55000}'
curl "localhost:8080/api/products?page=0&size=5&sort=price,desc"
curl -X PUT localhost:8080/api/products/1 -H "Content-Type: application/json" \
-d '{"name":"Laptop Pro","price":75000}'
curl -X DELETE localhost:8080/api/products/1Tip: Pageable responses are serialized as Page objects. In new projects consider returning your own small page DTO (content, page, size, total) so the JSON format does not depend on Spring internals.
Add a search endpoint with paging
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(page.getContent(), page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages());
}
}
// repository
Page<Product> findByNameContainingIgnoreCaseAndPriceBetween(String name, BigDecimal min, BigDecimal max, Pageable pageable);
// service
@Transactional(readOnly = true)
public PageResponse<ProductResponse> search(String name, BigDecimal min, BigDecimal max, Pageable pageable) {
Pageable safe = PageRequest.of(pageable.getPageNumber(), Math.min(pageable.getPageSize(), 100), pageable.getSort()); // cap the size
return PageResponse.from(repository
.findByNameContainingIgnoreCaseAndPriceBetween(name == null ? "" : name, min == null ? BigDecimal.ZERO : min,
max == null ? new BigDecimal("999999999") : max, safe)
.map(ProductResponse::from));
}
// controller
@GetMapping("/search")
public PageResponse<ProductResponse> search(@RequestParam(required = false) String name,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@PageableDefault(size = 20, sort = "id") Pageable pageable) {
return service.search(name, minPrice, maxPrice, pageable);
}Integration test of the whole API
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ProductApiTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Test
void createReadUpdateDelete() throws Exception {
String body = mapper.writeValueAsString(new ProductRequest("Laptop", new BigDecimal("55000")));
String location = mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Laptop"))
.andReturn().getResponse().getHeader("Location");
mvc.perform(get(location)).andExpect(status().isOk()).andExpect(jsonPath("$.price").value(55000));
String update = mapper.writeValueAsString(new ProductRequest("Laptop Pro", new BigDecimal("75000")));
mvc.perform(put(location).contentType(MediaType.APPLICATION_JSON).content(update))
.andExpect(status().isOk()).andExpect(jsonPath("$.name").value("Laptop Pro"));
mvc.perform(delete(location)).andExpect(status().isNoContent());
mvc.perform(get(location)).andExpect(status().isNotFound());
}
@Test
void validationErrorsReturn400() throws Exception {
mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"\",\"price\":-1}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.name").exists())
.andExpect(jsonPath("$.errors.price").exists());
}
@Test
void duplicateNameReturns409() throws Exception {
String body = mapper.writeValueAsString(new ProductRequest("Unique", BigDecimal.TEN));
mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isCreated());
mvc.perform(post("/api/products").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isConflict());
}
}Package by feature version of the same project
com.example.shop
├── ShopApplication.java
├── product/
│ ├── Product.java ProductRepository.java ProductService.java ProductController.java
│ ├── ProductRequest.java ProductResponse.java (DTO records)
│ └── ProductNotFoundException.java
├── common/
│ ├── ApiExceptionHandler.java PageResponse.java
│ └── config/ (OpenApiConfig, SecurityConfig, JacksonConfig)
└── resources/ application.yml, db/migration/V1__create_products.sqlRun it with a real database using Docker Compose
# compose.yaml
services:
db:
image: postgres:16
environment: { POSTGRES_DB: shop, POSTGRES_USER: app, POSTGRES_PASSWORD: secret }
ports: ["5432:5432"]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U app -d shop"], interval: 5s, retries: 10 }
# Spring Boot 3.1+ can start it for you: add spring-boot-docker-compose (runtime, optional) and Boot runs `docker compose up` and wires the datasource automatically.# application-postgres.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: app
password: secret
jpa:
hibernate.ddl-auto: validate
flyway:
enabled: trueWhat to add next (professional checklist)
- Security: JWT resource server or sessions, roles per endpoint (see the Spring Security course).
- OpenAPI documentation and a Postman collection generated from it.
- Flyway migrations instead of ddl-auto, plus Testcontainers tests.
- Idempotency-Key support for POST, optimistic locking with @Version and ETag for PUT.
- Structured logging with correlation id, Actuator health, metrics and tracing.
- Dockerfile, health probes, graceful shutdown and CI pipeline with tests and dependency scan.
Common mistake: Putting business rules in the controller or using the entity as request body. Keep controllers thin, use DTOs, and keep transactions and rules in the service layer.
Interview questions
Why use DTOs when I already have entities?
DTOs keep the API stable when the database changes, hide internal fields, prevent over-posting and avoid lazy loading problems.
Where should validation live?
Input format validation goes on DTOs with Bean Validation. Business rules (for example duplicate names) belong in the service.
Why readOnly = true on read methods?
It documents intent, lets Hibernate skip dirty checking, and some databases can use read replicas.
Why cap the page size in the service?
A client could request size=1000000 and load the whole table into memory. Cap it on the server, for example at 100.
Should the controller call the repository directly for simple CRUD?
Prefer the service layer even for simple cases. It gives one place for transactions, business rules and mapping, and the controller stays thin.