Building a REST API (CRUD)
Combine everything so far into a complete CRUD controller for a Student resource, backed by an in-memory list, covering create, read, update, and delete.
Introduction
You now know every individual piece needed to build a REST API: mapping annotations, path variables, request parameters, request bodies, and ResponseEntity. This lesson puts them all together into one complete controller that supports the four classic CRUD operations — Create, Read, Update, Delete — for a Student resource. To keep the focus on the web layer, data is stored in a simple in-memory list rather than a real database; you'll connect a database starting in the next few lessons.
- How to structure a controller around one resource with full CRUD support.
- How to implement create, read (single and list), update, and delete endpoints.
- How to return appropriate status codes for each operation.
- Why an in-memory store is a useful stepping stone before adding a real database.
The Student Model
Start with a plain Java class representing a student, along with a simple in-memory store to hold the data for the lifetime of the application.
public class Student { private Long id; private String name; private String email;
public Student() {}
public Student(Long id, String name, String email) { this.id = id; this.name = name; this.email = email; }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public String getName() { return name; } public void setName(String name) { this.name = name; }
public String getEmail() { return email; } public void setEmail(String email) { this.email = email; }}Create (POST)
Creating a resource accepts a @RequestBody, assigns a new id, stores the object, and returns 201 Created with the saved resource in the response body.
@PostMappingpublic ResponseEntity<Student> createStudent(@RequestBody Student student) { student.setId(nextId++); students.add(student); return ResponseEntity.status(HttpStatus.CREATED).body(student);}Read (GET)
Reads come in two flavors: fetching the full collection, and fetching a single resource by id. The single-resource lookup returns 404 when nothing matches.
@GetMappingpublic ResponseEntity<List<Student>> getAllStudents() { return ResponseEntity.ok(students);}
@GetMapping("/{id}")public ResponseEntity<Student> getStudentById(@PathVariable Long id) { return students.stream() .filter(s -> s.getId().equals(id)) .findFirst() .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build());}Update (PUT)
Updating replaces an existing student's fields with the values from the request body. If the id doesn't exist, return 404 instead of silently creating a new record.
@PutMapping("/{id}")public ResponseEntity<Student> updateStudent(@PathVariable Long id, @RequestBody Student updated) { for (Student s : students) { if (s.getId().equals(id)) { s.setName(updated.getName()); s.setEmail(updated.getEmail()); return ResponseEntity.ok(s); } } return ResponseEntity.notFound().build();}Delete (DELETE)
Deleting removes the matching student and returns 204 No Content on success, or 404 if the id wasn't found.
@DeleteMapping("/{id}")public ResponseEntity<Void> deleteStudent(@PathVariable Long id) { boolean removed = students.removeIf(s -> s.getId().equals(id)); if (!removed) { return ResponseEntity.notFound().build(); } return ResponseEntity.noContent().build();}The Full Controller
Putting every piece together into one class gives you a complete, working CRUD API for the Student resource.
@RestController@RequestMapping("/api/students")public class StudentController {
private final List<Student> students = new ArrayList<>(); private long nextId = 1;
@PostMapping public ResponseEntity<Student> createStudent(@RequestBody Student student) { student.setId(nextId++); students.add(student); return ResponseEntity.status(HttpStatus.CREATED).body(student); }
@GetMapping public ResponseEntity<List<Student>> getAllStudents() { return ResponseEntity.ok(students); }
@GetMapping("/{id}") public ResponseEntity<Student> getStudentById(@PathVariable Long id) { return students.stream() .filter(s -> s.getId().equals(id)) .findFirst() .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
@PutMapping("/{id}") public ResponseEntity<Student> updateStudent(@PathVariable Long id, @RequestBody Student updated) { for (Student s : students) { if (s.getId().equals(id)) { s.setName(updated.getName()); s.setEmail(updated.getEmail()); return ResponseEntity.ok(s); } } return ResponseEntity.notFound().build(); }
@DeleteMapping("/{id}") public ResponseEntity<Void> deleteStudent(@PathVariable Long id) { boolean removed = students.removeIf(s -> s.getId().equals(id)); if (!removed) { return ResponseEntity.notFound().build(); } return ResponseEntity.noContent().build(); }}Click Run to see what this code prints.
Common Mistakes
- Storing data in a static field instead of an instance field on a @RestController bean, which is unnecessary since Spring beans are singletons by default.
- Using a plain ArrayList without considering thread safety once you move past a learning exercise — concurrent requests can corrupt an unsynchronized list.
- Forgetting to return 404 from update and delete when the id doesn't exist, silently doing nothing instead.
- Returning the internal list directly and letting callers mutate it outside the controller's methods.
Best Practices
- Treat this in-memory version as a stepping stone — the controller's shape stays almost identical once a real database and repository are introduced.
- Return 201 for create, 200 for successful reads/updates, 204 for successful deletes, and 404 whenever a lookup by id fails.
- Keep the controller focused on HTTP concerns; in a real app, move the storage logic into a separate service class.
- Test each endpoint with a tool like Postman or curl as you build it, rather than writing the whole controller before testing anything.
Frequently Asked Questions
It lets you focus entirely on REST semantics — routing, status codes, request/response shapes — without the added complexity of database configuration. The next lessons introduce Spring Data JPA to replace this list with a real, persistent database.
No. Data disappears on every restart, and a plain ArrayList is not safe for concurrent access. It's strictly a teaching tool for this stage of the course.
In a real project, yes — that's the role of a service class, which sits between the controller and the data layer. This lesson keeps everything in the controller to keep the example self-contained.
Both are valid REST conventions. Returning the updated resource with 200 OK is common because it lets the client see the final state without a follow-up GET. Returning 204 No Content is used when the response body isn't needed.
Key Takeaways
- A complete CRUD controller combines @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PathVariable, @RequestBody, and ResponseEntity.
- Each operation should return a status code that accurately reflects what happened: 201, 200, 204, or 404.
- An in-memory list is a useful, temporary substitute for a real database while learning the web layer.
- This controller's shape will stay nearly the same once a real repository replaces the in-memory list.
Summary
You've built a complete, working CRUD REST API for a Student resource. Next, you'll replace the in-memory list with a real database by adding Spring Data JPA and defining your first @Entity class.