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 ContentProject 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.javaEntity 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"));
}
// 500 — catch-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" }