CRUD Endpoints

Build create, read, update and delete endpoints for one resource with the right methods, statuses and DTOs, backed by a Spring Data repository.

Syntax@GetMapping List<ProductResponse> list() @GetMapping("/{id}") ProductResponse get(...) @PostMapping ResponseEntity<ProductResponse> create(...) @PutMapping("/{id}") ProductResponse replace(...) @DeleteMapping("/{id}") ResponseEntity<Void> delete(...)

Most of a REST API is CRUD: create, read, update and delete for one resource at a time. The conventions are worth following exactly, because client libraries, caches and other developers already expect them.

OperationRequestSuccess
ListGET /api/products200 with an array or a page
Read oneGET /api/products/{id}200, or 404
CreatePOST /api/products201 with Location
ReplacePUT /api/products/{id}200 with the new state
Partial updatePATCH /api/products/{id}200 with the new state
DeleteDELETE /api/products/{id}204

PUT, PATCH and idempotency

PUT replaces the whole resource with what the client sends; fields left out are cleared or reset. PATCH changes only the fields present. GET, PUT and DELETE are idempotent: sending the same request twice leaves the server in the same state, so clients and proxies may retry them after a timeout. POST is not, which is why a double-clicked "Place order" button creates two orders unless you add an idempotency key.

A simple PATCH with a record of nullable fields cannot tell "leave this alone" from "set this to null". That is fine for fields that may never be null. When clearing matters, use JSON Merge Patch handling or a dedicated action endpoint.

Shape of the code

Requests come in as records, responses go out as records, and the entity stays inside. The controller here talks to the repository directly to keep the lesson short; in a real application a service sits between them and owns transactions and business rules. Create a separate request record for create and update when the writable fields differ, and never accept the id in a create body.

Mistakes that reach production

  • Unbounded list endpoints. findAll() works with 50 rows in development and returns 2 million in production. Page from day one, and clamp both the page size and the page number: PageRequest.of throws for a negative page, which turns ?page=-1 into a 500.
  • Returning the entity. Lazy relationships fail to serialise or trigger dozens of queries, and every new column becomes public API.
  • Answering a delete of a missing row differently from endpoint to endpoint. Many teams return 204 for an absent row, since the outcome the client wanted holds; others return 404. Whichever you choose, document it and be consistent.

Example

Example · java
import java.net.URI;
import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@Entity
class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private long pricePence;

    protected Product() {
    }

    Product(String name, long pricePence) {
        this.name = name;
        this.pricePence = pricePence;
    }

    Long getId() { return id; }
    String getName() { return name; }
    long getPricePence() { return pricePence; }

    void replaceWith(String name, long pricePence) {
        this.name = name;
        this.pricePence = pricePence;
    }
}

interface ProductRepository extends JpaRepository<Product, Long> {
}

record ProductRequest(String name, long pricePence) {}

record ProductResponse(long id, String name, long pricePence) {
    static ProductResponse from(Product p) {
        return new ProductResponse(p.getId(), p.getName(), p.getPricePence());
    }
}

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductRepository products;

    public ProductController(ProductRepository products) {
        this.products = products;
    }

    @GetMapping
    public List<ProductResponse> list(@RequestParam(defaultValue = "0") int page,
                                      @RequestParam(defaultValue = "20") int size) {
        var pageable = PageRequest.of(Math.max(page, 0), Math.clamp(size, 1, 100), Sort.by("id"));
        return products.findAll(pageable).map(ProductResponse::from).getContent();
    }

    @GetMapping("/{id}")
    public ProductResponse get(@PathVariable long id) {
        return ProductResponse.from(find(id));
    }

    @PostMapping
    public ResponseEntity<ProductResponse> create(@RequestBody ProductRequest request) {
        Product saved = products.save(new Product(request.name(), request.pricePence()));
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(saved));
    }

    @PutMapping("/{id}")
    public ProductResponse replace(@PathVariable long id, @RequestBody ProductRequest request) {
        Product product = find(id);
        product.replaceWith(request.name(), request.pricePence());
        return ProductResponse.from(products.save(product));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        products.deleteById(id);
        return ResponseEntity.noContent().build();
    }

    private Product find(long id) {
        return products.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "product " + id));
    }
}

When to use it

  • Expose a product catalogue for an admin screen with list, view, create, edit and delete.
  • Let a mobile app update just the display name on a profile with PATCH without resending the whole profile.
  • Lock down the create contract (201, Location, body) in a fast web slice test before refactoring the service.

More examples

PATCH with a record of optional fields

<code>{"bio": "Analyst"}</code> changes the bio and keeps the other fields. <code>computeIfPresent</code> applies the change atomically for this in-memory store; with a database you would load, change and save inside a transaction, ideally with optimistic locking. The limitation is visible in the code: there is no way to set <code>bio</code> back to null, so only use this shape for fields that are never cleared.

Example · java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

record Profile(long id, String displayName, String bio, String timeZone) {}

// Every field is optional: null means "leave unchanged".
record ProfilePatch(String displayName, String bio, String timeZone) {}

@RestController
public class ProfileController {

    private final Map<Long, Profile> profiles = new ConcurrentHashMap<>(
            Map.of(1L, new Profile(1, "Ada", "Mathematician", "Europe/London")));

    @PatchMapping("/api/profiles/{id}")
    public Profile patch(@PathVariable long id, @RequestBody ProfilePatch patch) {
        Profile updated = profiles.computeIfPresent(id, (key, p) -> new Profile(
                p.id(),
                patch.displayName() != null ? patch.displayName() : p.displayName(),
                patch.bio() != null ? patch.bio() : p.bio(),
                patch.timeZone() != null ? patch.timeZone() : p.timeZone()));
        if (updated == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return updated;
    }
}

The whole lifecycle with curl

Running the full cycle by hand once is the quickest way to spot a wrong status. <code>-i</code> prints the status line and headers, which is where most CRUD bugs show: a 200 where 201 belongs, a missing <code>Location</code>, or a delete that returns the deleted object. Keep a script like this, or an <code>.http</code> file, in the repository for new team members.

Example · bash
# Create: 201 and a Location header
curl -i -X POST localhost:8080/api/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"Blue kettle","pricePence":2999}'
# HTTP/1.1 201
# Location: http://localhost:8080/api/products/1

# Read
curl -s localhost:8080/api/products/1
# {"id":1,"name":"Blue kettle","pricePence":2999}

# Replace: every field is sent
curl -s -X PUT localhost:8080/api/products/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Blue kettle (1.7 l)","pricePence":3499}'

# Delete: 204, no body
curl -i -X DELETE localhost:8080/api/products/1
# HTTP/1.1 204

# Read after delete: 404
curl -i localhost:8080/api/products/1

Testing create with MockMvcTester

The test pins the contract a client depends on: status, <code>Location</code> and body. <code>@MockitoBean</code> (in <code>org.springframework.test.context.bean.override.mockito</code>) replaces the service in the slice, so no database is needed. Write one test like this per endpoint and status; it catches the quiet change from 201 to 200 that breaks a front end.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;

import java.net.URI;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

record NewBook(String title) {}

record Book(long id, String title) {}

interface BookService {
    Book create(NewBook request);
}

@RestController
class BookController {
    private final BookService books;

    BookController(BookService books) {
        this.books = books;
    }

    @PostMapping("/api/books")
    ResponseEntity<Book> create(@RequestBody NewBook request) {
        Book saved = books.create(request);
        return ResponseEntity.created(URI.create("/api/books/" + saved.id())).body(saved);
    }
}

@WebMvcTest(BookController.class)
public class BookControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    BookService books;

    @Test
    void createReturns201WithLocation() {
        given(books.create(any())).willReturn(new Book(7, "Dune"));

        assertThat(mvc.post().uri("/api/books")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"Dune\"}"))
                .hasStatus(HttpStatus.CREATED)
                .hasHeader("Location", "/api/books/7")
                .bodyJson().extractingPath("$.title").isEqualTo("Dune");
    }
}

Discussion

  • Be the first to comment on this lesson.