| name | spring-boot-patterns |
| description | Spring Boot best practices and patterns. Use when creating controllers, services, repositories, or when user asks about Spring Boot architecture, REST APIs, exception handling, or JPA patterns. |
Spring Boot Patterns and Best Practices
Project Structure
src/main/java/com/example/myapp/
├── MyAppApplication.java
├── config/
│ ├── SecurityConfig.java
│ ├── WebConfig.java
│ └── AppProperties.java
├── controller/
│ ├── UserController.java
│ └── OrderController.java
├── service/
│ ├── UserService.java
│ ├── UserServiceImpl.java
│ ├── OrderService.java
│ └── OrderServiceImpl.java
├── repository/
│ ├── UserRepository.java
│ └── OrderRepository.java
├── entity/
│ ├── User.java
│ └── Order.java
├── dto/
│ ├── request/
│ │ ├── CreateUserRequest.java
│ │ └── CreateOrderRequest.java
│ ├── response/
│ │ ├── UserResponse.java
│ │ └── OrderResponse.java
│ └── mapper/
│ ├── UserMapper.java
│ └── OrderMapper.java
├── exception/
│ ├── ResourceNotFoundException.java
│ ├── BusinessException.java
│ └── GlobalExceptionHandler.java
└── util/
└── DateUtils.java
Controller Patterns
REST Controller Template
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public Page<UserResponse> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortOrders(sort)));
return userService.findAll(pageable);
}
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
UserResponse created = userService.create(request);
URI location = URI.create("/api/v1/users/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public UserResponse updateUser(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
return userService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
private Sort.Order[] parseSortOrders(String[] sort) {
return Arrays.stream(sort)
.map(s -> {
String[] parts = s.split(",");
return parts.length > 1 && parts[1].equalsIgnoreCase("asc")
? Sort.Order.asc(parts[0])
: Sort.Order.desc(parts[0]);
})
.toArray(Sort.Order[]::new);
}
}
Controller Best Practices
| Practice | Reason |
|---|
Use @Valid on request bodies | Triggers Bean Validation |
Return ResponseEntity for POST (201) | Set Location header and status |
Use @ResponseStatus for DELETE (204) | No body needed |
| Keep controllers thin | Delegate logic to services |
| Use DTOs, not entities | Decouple API from data model |
Use Pageable for lists | Consistent pagination |
Controller Anti-Patterns
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
User user = userRepository.findById(request.getUserId()).orElseThrow();
if (!user.isActive()) throw new BusinessException("User inactive");
Order order = new Order();
order.setUser(user);
order.setTotal(request.getItems().stream()
.mapToDouble(i -> i.getPrice() * i.getQuantity())
.sum());
Order saved = orderRepository.save(order);
emailService.sendConfirmation(user.getEmail(), saved);
return ResponseEntity.status(201).body(saved);
}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest request) {
OrderResponse order = orderService.create(request);
URI location = URI.create("/api/v1/orders/" + order.id());
return ResponseEntity.created(location).body(order);
}
Service Patterns
Interface + Implementation
public interface UserService {
Page<UserResponse> findAll(Pageable pageable);
UserResponse findById(Long id);
UserResponse create(CreateUserRequest request);
UserResponse update(Long id, UpdateUserRequest request);
void delete(Long id);
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
@Transactional(readOnly = true)
public Page<UserResponse> findAll(Pageable pageable) {
return userRepository.findAll(pageable)
.map(userMapper::toResponse);
}
@Override
@Transactional(readOnly = true)
public UserResponse findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return userMapper.toResponse(user);
}
@Override
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("User", "email", request.email());
}
User user = userMapper.toEntity(request);
User saved = userRepository.save(user);
return userMapper.toResponse(saved);
}
@Override
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
userMapper.updateEntity(user, request);
User saved = userRepository.save(user);
return userMapper.toResponse(saved);
}
@Override
@Transactional
public void delete(Long id) {
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User", "id", id);
}
userRepository.deleteById(id);
}
}
Repository Patterns
JPA Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
Page<User> findByLastNameContainingIgnoreCase(String lastName, Pageable pageable);
@Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt > :since")
List<User> findRecentActiveUsers(@Param("since") LocalDateTime since);
@Query("SELECT u FROM User u JOIN FETCH u.roles WHERE u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);
@Query(value = "SELECT * FROM users WHERE LOWER(email) LIKE LOWER(CONCAT('%', :domain))",
nativeQuery = true)
List<User> findByEmailDomain(@Param("domain") String domain);
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
}
DTO Patterns
Request / Response Records with Validation
public record CreateUserRequest(
@NotBlank(message = "First name is required")
@Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
String firstName,
@NotBlank(message = "Last name is required")
@Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
String lastName,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@Size(min = 8, message = "Password must be at least 8 characters")
String password
) {}
public record UserResponse(
Long id,
String firstName,
String lastName,
String email,
boolean active,
LocalDateTime createdAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getFirstName(),
user.getLastName(),
user.getEmail(),
user.isActive(),
user.getCreatedAt()
);
}
}
public record UpdateUserRequest(
@Size(min = 2, max = 50)
String firstName,
@Size(min = 2, max = 50)
String lastName,
@Email
String email
) {}
MapStruct Mapper
@Mapper(componentModel = "spring")
public interface UserMapper {
UserResponse toResponse(User user);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "active", constant = "true")
User toEntity(CreateUserRequest request);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(@MappingTarget User user, UpdateUserRequest request);
}
Exception Handling
Custom Exceptions
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
}
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s already exists with %s: '%s'", resourceName, fieldName, fieldValue));
}
}
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
GlobalExceptionHandler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found", ex.getMessage(), request.getRequestURI()));
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(
DuplicateResourceException ex, HttpServletRequest request) {
log.warn("Duplicate resource: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of(409, "Conflict", ex.getMessage(), request.getRequestURI()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> new ErrorResponse.FieldError(
fe.getField(), fe.getDefaultMessage(), fe.getRejectedValue()))
.toList();
return ResponseEntity.badRequest()
.body(ErrorResponse.withFieldErrors(400, "Validation Failed",
"Request validation failed", request.getRequestURI(), fieldErrors));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex, HttpServletRequest request) {
log.warn("Business rule violation: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of(422, "Business Rule Violation",
ex.getMessage(), request.getRequestURI()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(
Exception ex, HttpServletRequest request) {
log.error("Unexpected error: {}", ex.getMessage(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error",
"An unexpected error occurred", request.getRequestURI()));
}
}
public record ErrorResponse(
int status,
String error,
String message,
String path,
LocalDateTime timestamp,
List<FieldError> fieldErrors
) {
public record FieldError(String field, String message, Object rejectedValue) {}
public static ErrorResponse of(int status, String error, String message, String path) {
return new ErrorResponse(status, error, message, path, LocalDateTime.now(), null);
}
public static ErrorResponse withFieldErrors(int status, String error, String message,
String path, List<FieldError> fieldErrors) {
return new ErrorResponse(status, error, message, path, LocalDateTime.now(), fieldErrors);
}
}
Configuration Patterns
application.yml
spring:
application:
name: my-service
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:myuser}
password: ${DB_PASSWORD:mypassword}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
default_batch_fetch_size: 20
format_sql: true
server:
port: 8080
error:
include-message: always
app:
security:
jwt-secret: ${JWT_SECRET}
jwt-expiration: 3600
pagination:
default-page-size: 20
max-page-size: 100
ConfigurationProperties Class
@Configuration
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
@Valid
private Security security = new Security();
@Valid
private Pagination pagination = new Pagination();
public static class Security {
@NotBlank
private String jwtSecret;
@Min(60)
private int jwtExpiration = 3600;
}
public static class Pagination {
@Min(1)
private int defaultPageSize = 20;
@Min(1) @Max(500)
private int maxPageSize = 100;
}
}
Profile-Specific Configuration
spring:
jpa:
open-in-view: false
---
spring:
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
h2:
console:
enabled: true
logging:
level:
com.example: DEBUG
---
spring:
jpa:
hibernate:
ddl-auto: validate
show-sql: false
logging:
level:
root: WARN
com.example: INFO
Common Annotations Quick Reference
| Annotation | Layer | Purpose |
|---|
@RestController | Controller | REST controller (combines @Controller + @ResponseBody) |
@RequestMapping | Controller | Base URL mapping |
@GetMapping / @PostMapping | Controller | HTTP method mapping |
@PathVariable | Controller | URL path parameter |
@RequestParam | Controller | Query parameter |
@RequestBody | Controller | JSON request body |
@Valid | Controller | Trigger Bean Validation |
@ResponseStatus | Controller | Set HTTP status code |
@Service | Service | Service layer bean |
@Transactional | Service | Transaction management |
@Repository | Repository | Data access bean |
@Entity | Entity | JPA entity |
@Table | Entity | Table mapping |
@Id / @GeneratedValue | Entity | Primary key |
@Column | Entity | Column mapping |
@ManyToOne / @OneToMany | Entity | Relationship mapping |
@Component | Any | Generic Spring bean |
@Configuration | Config | Configuration class |
@ConfigurationProperties | Config | Type-safe configuration |
@RestControllerAdvice | Exception | Global exception handler |
@ExceptionHandler | Exception | Handle specific exception |
Testing Patterns
Controller Test with MockMvc
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getUser_WhenExists_Returns200() throws Exception {
UserResponse response = new UserResponse(1L, "John", "Doe",
"john@example.com", true, LocalDateTime.now());
given(userService.findById(1L)).willReturn(response);
mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
@Test
void getUser_WhenNotFound_Returns404() throws Exception {
given(userService.findById(99L))
.willThrow(new ResourceNotFoundException("User", "id", 99L));
mockMvc.perform(get("/api/v1/users/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404));
}
@Test
void createUser_WithValidData_Returns201() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"John", "Doe", "john@example.com", "password123");
UserResponse response = new UserResponse(1L, "John", "Doe",
"john@example.com", true, LocalDateTime.now());
given(userService.create(any())).willReturn(response);
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(1));
}
@Test
void createUser_WithInvalidData_Returns400() throws Exception {
CreateUserRequest request = new CreateUserRequest("", "", "invalid", "short");
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.fieldErrors").isArray());
}
}
Service Test with Mockito
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
private UserRepository userRepository;
@Mock
private UserMapper userMapper;
@InjectMocks
private UserServiceImpl userService;
@Test
void findById_WhenExists_ReturnsUser() {
User user = new User();
user.setId(1L);
user.setFirstName("John");
UserResponse expected = new UserResponse(1L, "John", "Doe",
"john@example.com", true, LocalDateTime.now());
given(userRepository.findById(1L)).willReturn(Optional.of(user));
given(userMapper.toResponse(user)).willReturn(expected);
UserResponse result = userService.findById(1L);
assertThat(result.firstName()).isEqualTo("John");
verify(userRepository).findById(1L);
}
@Test
void findById_WhenNotExists_ThrowsException() {
given(userRepository.findById(99L)).willReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("User not found");
}
@Test
void create_WhenEmailExists_ThrowsDuplicate() {
CreateUserRequest request = new CreateUserRequest(
"John", "Doe", "existing@example.com", "password123");
given(userRepository.existsByEmail("existing@example.com")).willReturn(true);
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(DuplicateResourceException.class);
verify(userRepository, never()).save(any());
}
}
Integration Test with Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void createAndGetUser() {
CreateUserRequest request = new CreateUserRequest(
"John", "Doe", "john@example.com", "password123");
ResponseEntity<UserResponse> createResponse = restTemplate.postForEntity(
"/api/v1/users", request, UserResponse.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(createResponse.getHeaders().getLocation()).isNotNull();
Long userId = createResponse.getBody().id();
ResponseEntity<UserResponse> getResponse = restTemplate.getForEntity(
"/api/v1/users/" + userId, UserResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().firstName()).isEqualTo("John");
assertThat(getResponse.getBody().email()).isEqualTo("john@example.com");
}
@Test
void createUser_DuplicateEmail_Returns409() {
CreateUserRequest request = new CreateUserRequest(
"John", "Doe", "john@example.com", "password123");
restTemplate.postForEntity("/api/v1/users", request, UserResponse.class);
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/api/v1/users", request, ErrorResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}
Quick Reference Card
| Task | Approach |
|---|
| Create REST endpoint | @RestController + @RequestMapping |
| Validate input | @Valid + Bean Validation annotations on DTO |
| Handle exceptions | @RestControllerAdvice + @ExceptionHandler |
| Database access | JpaRepository interface |
| Transaction management | @Transactional on service methods |
| Map entity to DTO | MapStruct @Mapper or manual from() method |
| Configuration | @ConfigurationProperties + application.yml |
| Test controller | @WebMvcTest + MockMvc |
| Test service | @ExtendWith(MockitoExtension.class) + @Mock |
| Integration test | @SpringBootTest + Testcontainers |
| Pagination | Pageable parameter + Page<T> return |
| Environment config | ${ENV_VAR:default} in YAML |
| Profile switching | spring.profiles.active or SPRING_PROFILES_ACTIVE |