Spring BootCRUD Operations
Spring Boot

CRUD Operations

CRUD — Create, Read, Update, Delete — is the standard set of operations for managing persistent resources in a REST API. In Spring Boot, each operation maps to an HTTP method, a controller handler, a service method, and a repository call. This entry covers the complete implementation of a production-grade CRUD REST controller with validation, error handling, and correct HTTP semantics.

CRUD to HTTP Method Mapping

Each CRUD operation maps to one or more HTTP methods. The mapping is not arbitrary — it follows REST's uniform interface constraint and HTTP's defined method semantics. Using the correct method ensures caches, proxies, and clients can make correct assumptions about each operation.
Shell
# Operation   HTTP Method   URI              Success Status
# ─────────────────────────────────────────────────────────────
# Create      POST          /users           201 Created
# Read one    GET           /users/{id}      200 OK
# Read many   GET           /users           200 OK
# Update all  PUT           /users/{id}      200 OK / 204 No Content
# Update part PATCH         /users/{id}      200 OK / 204 No Content
# Delete      DELETE        /users/{id}      204 No Content

# ── Create ─────────────────────────────────────────────────────────────
POST /users
Content-Type: application/json
{ "name": "Alice", "email": "alice@example.com" }

HTTP/1.1 201 Created
Location: /users/43
{ "id": 43, "name": "Alice", "email": "alice@example.com" }

# ── Read one ───────────────────────────────────────────────────────────
GET /users/43
HTTP/1.1 200 OK
{ "id": 43, "name": "Alice", "email": "alice@example.com" }

# ── Read many ──────────────────────────────────────────────────────────
GET /users?role=admin&page=0&size=20
HTTP/1.1 200 OK
{ "content": [...], "totalElements": 3, "totalPages": 1 }

# ── Update (full replace) ──────────────────────────────────────────────
PUT /users/43
{ "name": "Alice Smith", "email": "alice@example.com" }
HTTP/1.1 200 OK

# ── Update (partial) ───────────────────────────────────────────────────
PATCH /users/43
{ "name": "Alice Smith" }
HTTP/1.1 200 OK

# ── Delete ─────────────────────────────────────────────────────────────
DELETE /users/43
HTTP/1.1 204 No Content

Project Structure

A production CRUD feature follows a layered architecture: controller (HTTP), service (business logic), repository (data access), entity (JPA), and DTOs (request/response shapes). Keeping these layers separate makes each independently testable and replaceable.
Shell
src/main/java/com/example/
├── user/
│   ├── User.java                  # JPA entity
│   ├── UserRepository.java        # Spring Data JPA repository
│   ├── UserService.java           # business logic interface
│   ├── UserServiceImpl.java       # business logic implementation
│   ├── UserController.java        # REST controller
│   ├── dto/
│   │   ├── CreateUserRequest.java # POST request body
│   │   ├── UpdateUserRequest.java # PUT request body
│   │   ├── PatchUserRequest.java  # PATCH request body
│   │   └── UserResponse.java      # response body (never expose entity directly)
│   └── exception/
│       └── UserNotFoundException.java
└── common/
    └── exception/
        └── GlobalExceptionHandler.java

Entity and Repository

The JPA entity maps to the database table. The repository extends JpaRepository, which provides all CRUD methods out of the box — no SQL required for standard operations.
Java
// ── Entity ────────────────────────────────────────────────────────────
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role = Role.USER;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        createdAt = updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    public enum Role { USER, ADMIN }
}

// ── Repository ────────────────────────────────────────────────────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // JpaRepository already provides:
    // save(entity)         — insert or update
    // findById(id)         — returns Optional<User>
    // findAll()            — returns List<User>
    // findAll(pageable)    — returns Page<User>
    // deleteById(id)
    // existsById(id)
    // count()

    // Custom queries:
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    Page<User> findByRole(User.Role role, Pageable pageable);

    @Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
    List<User> searchByName(@Param("name") String name);
}

DTOs — Request and Response Objects

Never expose JPA entities directly in API responses. DTOs decouple the API contract from the database schema, prevent accidental exposure of sensitive fields, and allow request validation annotations without polluting the entity.
Java
// ── Create request ────────────────────────────────────────────────────
public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "Must be a valid email address")
    String email,

    @NotNull
    User.Role role
) { }

// ── Full update request (PUT) — all fields required ───────────────────
public record UpdateUserRequest(
    @NotBlank @Size(min = 2, max = 100)
    String name,

    @NotBlank @Email
    String email,

    @NotNull
    User.Role role
) { }

// ── Partial update request (PATCH) — all fields optional ──────────────
public record PatchUserRequest(
    @Size(min = 2, max = 100)
    String name,         // null = do not update this field

    @Email
    String email,

    User.Role role
) { }

// ── Response DTO — controls exactly what the API exposes ──────────────
public record UserResponse(
    Long id,
    String name,
    String email,
    User.Role role,
    LocalDateTime createdAt,
    LocalDateTime updatedAt
) {
    // Static factory — maps entity to response DTO:
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getRole(),
            user.getCreatedAt(),
            user.getUpdatedAt()
        );
    }
}

Service Layer

The service layer owns business logic — validation, duplicate checking, mapping between DTOs and entities, and transaction boundaries. Controllers delegate to the service; the service delegates to the repository.
Java
public interface UserService {
    UserResponse create(CreateUserRequest request);
    UserResponse findById(Long id);
    Page<UserResponse> findAll(Pageable pageable);
    UserResponse update(Long id, UpdateUserRequest request);
    UserResponse patch(Long id, PatchUserRequest request);
    void delete(Long id);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)   // default: read-only; override per write method
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserResponse create(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new EmailAlreadyExistsException(request.email());
        }
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        user.setRole(request.role());
        return UserResponse.from(userRepository.save(user));
    }

    @Override
    public UserResponse findById(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Override
    public Page<UserResponse> findAll(Pageable pageable) {
        return userRepository.findAll(pageable).map(UserResponse::from);
    }

    @Override
    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));

        if (!user.getEmail().equals(request.email())
                && userRepository.existsByEmail(request.email())) {
            throw new EmailAlreadyExistsException(request.email());
        }

        user.setName(request.name());
        user.setEmail(request.email());
        user.setRole(request.role());
        return UserResponse.from(userRepository.save(user));
    }

    @Override
    @Transactional
    public UserResponse patch(Long id, PatchUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));

        // Only update fields that are non-null in the request:
        if (request.name() != null)  user.setName(request.name());
        if (request.email() != null) {
            if (!user.getEmail().equals(request.email())
                    && userRepository.existsByEmail(request.email())) {
                throw new EmailAlreadyExistsException(request.email());
            }
            user.setEmail(request.email());
        }
        if (request.role() != null)  user.setRole(request.role());

        return UserResponse.from(userRepository.save(user));
    }

    @Override
    @Transactional
    public void delete(Long id) {
        if (!userRepository.existsById(id)) {
            throw new UserNotFoundException(id);
        }
        userRepository.deleteById(id);
    }
}

REST Controller

The controller maps HTTP requests to service calls and returns correct HTTP status codes, headers, and response bodies. It contains no business logic — only HTTP concerns.
Java
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserController {

    private final UserService userService;

    // ── CREATE — POST /users ───────────────────────────────────────────
    @PostMapping
    public ResponseEntity<UserResponse> create(
            @RequestBody @Valid CreateUserRequest request,
            UriComponentsBuilder uriBuilder) {

        UserResponse created = userService.create(request);

        URI location = uriBuilder
            .path("/users/{id}")
            .buildAndExpand(created.id())
            .toUri();

        return ResponseEntity.created(location).body(created);
        // 201 Created + Location: /users/43 + body
    }

    // ── READ ONE — GET /users/{id} ─────────────────────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
        // 200 OK + body, or UserNotFoundException → 404
    }

    // ── READ MANY — GET /users ─────────────────────────────────────────
    @GetMapping
    public ResponseEntity<Page<UserResponse>> findAll(
            @PageableDefault(size = 20, sort = "createdAt",
                             direction = Sort.Direction.DESC) Pageable pageable) {

        return ResponseEntity.ok(userService.findAll(pageable));
        // 200 OK + paginated body
        // GET /users?page=0&size=10&sort=name,asc
    }

    // ── FULL UPDATE — PUT /users/{id} ──────────────────────────────────
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable Long id,
            @RequestBody @Valid UpdateUserRequest request) {

        return ResponseEntity.ok(userService.update(id, request));
        // 200 OK + updated body, or 404
    }

    // ── PARTIAL UPDATE — PATCH /users/{id} ────────────────────────────
    @PatchMapping("/{id}")
    public ResponseEntity<UserResponse> patch(
            @PathVariable Long id,
            @RequestBody @Valid PatchUserRequest request) {

        return ResponseEntity.ok(userService.patch(id, request));
        // 200 OK + updated body, or 404
    }

    // ── DELETE — DELETE /users/{id} ────────────────────────────────────
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
        // 204 No Content, or 404
    }
}

Exception Handling

Centralize exception-to-HTTP-status mapping in a @RestControllerAdvice class. This keeps controllers clean and ensures consistent error response structure across the entire API.
Java
// ── Custom exceptions ─────────────────────────────────────────────────
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User not found: " + id);
    }
}

public class EmailAlreadyExistsException extends RuntimeException {
    public EmailAlreadyExistsException(String email) {
        super("Email already in use: " + email);
    }
}

// ── Standardised error response body ──────────────────────────────────
public record ErrorResponse(
    int status,
    String error,
    String message,
    Instant timestamp,
    Map<String, String> fieldErrors   // populated for validation failures
) {
    public static ErrorResponse of(int status, String error, String message) {
        return new ErrorResponse(status, error, message, Instant.now(), Map.of());
    }
}

// ── Global exception handler ───────────────────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 404 — resource not found:
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
    }

    // 409 — business rule conflict:
    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ResponseEntity<ErrorResponse> handleConflict(EmailAlreadyExistsException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict", ex.getMessage()));
    }

    // 400@Valid / @Validated failure on @RequestBody:
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {

        Map<String, String> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid"
            ));

        ErrorResponse body = new ErrorResponse(
            400, "Validation Failed",
            "One or more fields failed validation",
            Instant.now(), fieldErrors
        );
        return ResponseEntity.badRequest().body(body);
    }

    // 400 — malformed JSON or type mismatch:
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleUnreadable(
            HttpMessageNotReadableException ex) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "Bad Request", "Malformed request body"));
    }

    // 500catch-all for unhandled exceptions:
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "An unexpected error occurred"));
    }
}

Pagination and Sorting

Spring Data's Pageable integrates directly with @GetMapping endpoints. Clients control page number, page size, and sort order via query parameters — the controller and service need no custom parsing logic.
Shell
# ── Query parameter conventions ───────────────────────────────────────
GET /users                          # page 0, size 20, default sort
GET /users?page=2&size=10           # page 2 (0-indexed), 10 per page
GET /users?sort=name,asc            # sort by name ascending
GET /users?sort=createdAt,desc      # sort by createdAt descending
GET /users?sort=role,asc&sort=name,asc  # multi-column sort

# ── Response structure (Spring Data Page) ─────────────────────────────
{
  "content": [
    { "id": 1, "name": "Alice", "email": "alice@example.com" },
    { "id": 2, "name": "Bob",   "email": "bob@example.com" }
  ],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20,
    "sort": { "sorted": true, "orders": [{ "property": "name", "direction": "ASC" }] }
  },
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false,
  "numberOfElements": 20
}

// ── Controller — @PageableDefault sets defaults ────────────────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
        @PageableDefault(
            size = 20,
            sort = "createdAt",
            direction = Sort.Direction.DESC
        ) Pageable pageable) {
    return ResponseEntity.ok(userService.findAll(pageable));
}

// ── Service — pass Pageable directly to the repository ────────────────
public Page<UserResponse> findAll(Pageable pageable) {
    return userRepository.findAll(pageable).map(UserResponse::from);
}

// ── Restrict sortable fields (prevent sorting on arbitrary columns): ───
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
        @PageableDefault(size = 20) Pageable pageable) {

    // Whitelist allowed sort fields:
    Set<String> allowed = Set.of("name", "email", "createdAt");
    pageable.getSort().forEach(order -> {
        if (!allowed.contains(order.getProperty())) {
            throw new IllegalArgumentException(
                "Sorting by '" + order.getProperty() + "' is not permitted");
        }
    });
    return ResponseEntity.ok(userService.findAll(pageable));
}

Complete Request/Response Examples

End-to-end HTTP examples for each CRUD operation — showing request headers, body, and the expected response for both success and error cases.
Shell
# ── POST /users — create ──────────────────────────────────────────────
POST /users HTTP/1.1
Content-Type: application/json

{ "name": "Alice Smith", "email": "alice@example.com", "role": "USER" }

# Success:
HTTP/1.1 201 Created
Location: /users/43
Content-Type: application/json

{ "id": 43, "name": "Alice Smith", "email": "alice@example.com",
  "role": "USER", "createdAt": "2024-03-15T10:30:00Z", "updatedAt": "2024-03-15T10:30:00Z" }

# Validation failure:
HTTP/1.1 400 Bad Request
{ "status": 400, "error": "Validation Failed",
  "message": "One or more fields failed validation",
  "fieldErrors": { "email": "Must be a valid email address", "name": "Name is required" } }

# Duplicate email:
HTTP/1.1 409 Conflict
{ "status": 409, "error": "Conflict", "message": "Email already in use: alice@example.com" }

# ── GET /users/43 — read one ───────────────────────────────────────────
GET /users/43 HTTP/1.1

# Success:
HTTP/1.1 200 OK
{ "id": 43, "name": "Alice Smith", "email": "alice@example.com", "role": "USER" }

# Not found:
HTTP/1.1 404 Not Found
{ "status": 404, "error": "Not Found", "message": "User not found: 43" }

# ── PUT /users/43 — full update ────────────────────────────────────────
PUT /users/43 HTTP/1.1
Content-Type: application/json

{ "name": "Alice Johnson", "email": "alice.j@example.com", "role": "ADMIN" }

HTTP/1.1 200 OK
{ "id": 43, "name": "Alice Johnson", "email": "alice.j@example.com", "role": "ADMIN" }

# ── PATCH /users/43 — partial update ──────────────────────────────────
PATCH /users/43 HTTP/1.1
Content-Type: application/json

{ "name": "Alice J." }    # only name changes — email and role untouched

HTTP/1.1 200 OK
{ "id": 43, "name": "Alice J.", "email": "alice.j@example.com", "role": "ADMIN" }

# ── DELETE /users/43 ──────────────────────────────────────────────────
DELETE /users/43 HTTP/1.1

HTTP/1.1 204 No Content   # no body

# Already deleted:
HTTP/1.1 404 Not Found
{ "status": 404, "error": "Not Found", "message": "User not found: 43" }