Home / Spring Boot / Data and Transactions / Complete CRUD REST API (All Layers)

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: true

Entity 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/1

Tip: 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.sql

Run 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: true

What to add next (professional checklist)

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.