Building a Real CRUD API with Spring Boot

SS Saurav Sitaula

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/usersgetAllUsers()
  • GET /api/users/1getUserById(1)
  • POST /api/userscreateUser()

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

  1. @RestController = @Controller + @ResponseBody. It automatically converts return values to JSON.

  2. ResponseEntity gives you control. Need to set headers? Custom status codes? Use ResponseEntity.

  3. Validation happens before your code runs. If @Valid fails, your method body never executes.

  4. The service layer isn’t optional. Even if it feels like boilerplate at first, it makes testing and refactoring so much easier.

  5. Return Optional for “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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism