LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 1522 min read

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.

What You Will Learn
  • 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.

@PostMapping
public 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.

@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());
}

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();
}
}
Example Session

Click Run to see what this code prints.

Common Mistakes

Avoid These 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.

Next Lesson →

Spring Boot with Spring Data JPA