Building a Real CRUD API with Spring Boot
From Hello World to a full CRUD API. Learn to handle POST, PUT, DELETE requests, parse JSON bodies, validate input, and structure your Spring Boot application with controllers, services, and repositories. A practical guide for frontend developers.
Beyond Hello World
In my last post, I built my first Spring Boot API. One endpoint. Returns “Hello World.” Very impressive.
But my React app needed more than a greeting. It needed to:
- Create users
- Read user data
- Update user profiles
- Delete accounts
You know, actual CRUD operations. The stuff real applications do.
Time to level up.
The Resource: User
Every API needs a resource to manage. I started with users because every app has users.
First, the model class:
package com.mycompany.myapi.model;
public class User {
private Long id;
private String name;
private String email;
private String createdAt;
// Default constructor (required for JSON deserialization)
public User() {}
// Constructor with fields
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = LocalDateTime.now().toString();
}
// Getters and Setters
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; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
}
Yes, that’s a lot of boilerplate for four fields. Yes, I know about Lombok. We’ll get there.
The Controller: Handling HTTP Requests
Here’s where the CRUD magic happens:
package com.mycompany.myapi.controller;
import com.mycompany.myapi.model.User;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
private List<User> users = new ArrayList<>();
private AtomicLong idCounter = new AtomicLong();
// GET all users
@GetMapping
public List<User> getAllUsers() {
return users;
}
// GET single user by ID
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return users.stream()
.filter(user -> user.getId().equals(id))
.findFirst()
.orElse(null);
}
// POST create new user
@PostMapping
public User createUser(@RequestBody User user) {
user.setId(idCounter.incrementAndGet());
users.add(user);
return user;
}
// PUT update user
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
for (int i = 0; i < users.size(); i++) {
if (users.get(i).getId().equals(id)) {
updatedUser.setId(id);
users.set(i, updatedUser);
return updatedUser;
}
}
return null;
}
// DELETE user
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id) {
users.removeIf(user -> user.getId().equals(id));
}
}
Let me break down what’s happening here.
The Annotations That Do Everything
@RequestMapping(“/api/users”)
This sets the base path for all endpoints in this controller:
GET /api/users→getAllUsers()GET /api/users/1→getUserById(1)POST /api/users→createUser()
No more repeating /api/users on every method.
@PathVariable
Extracts values from the URL path:
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
// If URL is /api/users/42, then id = 42
}
In Express.js, this would be req.params.id. Same concept, different syntax.
@RequestBody
This was the big one. It parses the JSON body from the request into a Java object:
@PostMapping
public User createUser(@RequestBody User user) {
// Spring automatically converts JSON to User object
}
When React sends:
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Saurav', email: 'saurav@example.com' })
});
Spring receives a User object with name and email already populated. No manual parsing. No JSON.parse(). It just works.
Testing with React
Here’s the React code that consumed this API:
// Fetch all users
const fetchUsers = async () => {
const response = await fetch('http://localhost:8080/api/users');
const data = await response.json();
setUsers(data);
};
// Create user
const createUser = async (userData) => {
const response = await fetch('http://localhost:8080/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
const newUser = await response.json();
setUsers([...users, newUser]);
};
// Update user
const updateUser = async (id, userData) => {
const response = await fetch(`http://localhost:8080/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
const updatedUser = await response.json();
setUsers(users.map(u => u.id === id ? updatedUser : u));
};
// Delete user
const deleteUser = async (id) => {
await fetch(`http://localhost:8080/api/users/${id}`, {
method: 'DELETE',
});
setUsers(users.filter(u => u.id !== id));
};
Everything connected. Create, read, update, delete. The frontend and backend were speaking the same language.
The Problem: No Persistence
Restart the Spring Boot server. All users gone.
That’s because I was storing users in an ArrayList in memory. Every restart wiped everything. This is fine for learning, but useless for a real application.
I needed a database. But that’s a topic for another post.
Input Validation
What happens when someone sends invalid data?
fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name: '', email: 'not-an-email' })
});
Without validation, this creates a user with an empty name and invalid email. Not great.
Spring Boot has built-in validation:
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
public class User {
private Long id;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email")
private String email;
// ... rest of the class
}
Then in the controller:
@PostMapping
public User createUser(@Valid @RequestBody User user) {
// Only reaches here if validation passes
user.setId(idCounter.incrementAndGet());
users.add(user);
return user;
}
The @Valid annotation triggers validation. If it fails, Spring returns a 400 Bad Request automatically.
But the default error response is ugly. Let’s make it better:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
}
Now invalid requests return:
{
"name": "Name is required",
"email": "Must be a valid email"
}
Much better. React can display these errors in the form.
Proper HTTP Status Codes
My initial implementation returned null when a user wasn’t found. That’s not RESTful. The API should return proper status codes:
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return users.stream()
.filter(user -> user.getId().equals(id))
.findFirst()
.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
user.setId(idCounter.incrementAndGet());
users.add(user);
return ResponseEntity.status(HttpStatus.CREATED).body(user); // 201 Created
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
boolean removed = users.removeIf(u -> u.getId().equals(id));
if (removed) {
return ResponseEntity.noContent().build(); // 204 No Content
}
return ResponseEntity.notFound().build(); // 404 Not Found
}
Now:
- GET found → 200 OK
- GET not found → 404 Not Found
- POST created → 201 Created
- DELETE success → 204 No Content
- Validation failed → 400 Bad Request
React can handle these properly:
const response = await fetch(`/api/users/${id}`);
if (response.status === 404) {
setError('User not found');
} else {
const user = await response.json();
setUser(user);
}
The Service Layer: Separation of Concerns
As my controller grew, I realized it was doing too much. It was handling HTTP requests AND business logic. Time to separate concerns.
// Service layer - business logic
@Service
public class UserService {
private List<User> users = new ArrayList<>();
private AtomicLong idCounter = new AtomicLong();
public List<User> findAll() {
return users;
}
public Optional<User> findById(Long id) {
return users.stream()
.filter(user -> user.getId().equals(id))
.findFirst();
}
public User create(User user) {
user.setId(idCounter.incrementAndGet());
users.add(user);
return user;
}
public Optional<User> update(Long id, User updatedUser) {
for (int i = 0; i < users.size(); i++) {
if (users.get(i).getId().equals(id)) {
updatedUser.setId(id);
users.set(i, updatedUser);
return Optional.of(updatedUser);
}
}
return Optional.empty();
}
public boolean delete(Long id) {
return users.removeIf(user -> user.getId().equals(id));
}
}
// Controller - HTTP handling only
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
User created = userService.create(user);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// ... rest of the endpoints
}
The controller handles HTTP concerns. The service handles business logic. Clean separation.
What I Wish I’d Known Earlier
-
@RestController=@Controller+@ResponseBody. It automatically converts return values to JSON. -
ResponseEntitygives you control. Need to set headers? Custom status codes? UseResponseEntity. -
Validation happens before your code runs. If
@Validfails, your method body never executes. -
The service layer isn’t optional. Even if it feels like boilerplate at first, it makes testing and refactoring so much easier.
-
Return
Optionalfor “might not exist.” It’s Java’s way of handling nullable values without null pointer exceptions.
The Journey Continues
I had a working CRUD API. Validation. Proper status codes. Separated layers. My React app could create, read, update, and delete users.
But every time I restarted the server, all data vanished. I needed persistence. I needed a database.
Next up: connecting Spring Boot to a database with MyBatis.
P.S. — If the Java verbosity is killing you, look up Project Lombok. @Data, @Builder, @NoArgsConstructor - these annotations generate getters, setters, and constructors at compile time. It’s a game-changer.
Saurav Sitaula
Software Architect • Nepal