| name | spring-boot-patterns |
| description | Spring Boot best practices and patterns. Use when creating controllers, services or repositories, or when the user asks about Spring Boot layering, wiring, configuration or exception handling. For JPA and Hibernate behaviour, use jpa-patterns instead. |
| license | MIT |
Spring Boot Patterns Skill
Best practices and patterns for Spring Boot applications.
When to Use
- User says "create controller" / "add service" / "Spring Boot help"
- Reviewing Spring Boot code
- Setting up new Spring Boot project structure
Project Structure
src/main/java/com/example/myapp/
├── MyAppApplication.java # @SpringBootApplication
├── config/ # Configuration classes
│ ├── SecurityConfig.java
│ └── WebConfig.java
├── controller/ # REST controllers
│ └── UserController.java
├── service/ # Business logic
│ ├── UserService.java
│ └── impl/
│ └── UserServiceImpl.java
├── repository/ # Data access
│ └── UserRepository.java
├── model/ # Entities
│ └── User.java
├── dto/ # Data transfer objects
│ ├── request/
│ │ └── CreateUserRequest.java
│ └── response/
│ └── UserResponse.java
├── exception/ # Custom exceptions
│ ├── ResourceNotFoundException.java
│ └── GlobalExceptionHandler.java
└── util/ # Utilities
└── DateUtils.java
Controller Patterns
REST Controller Template
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<List<UserResponse>> getAll() {
return ResponseEntity.ok(userService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> create(
@Valid @RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
Controller Best Practices
| Practice | Example |
|---|
| Versioned API | /api/v1/users |
| Plural nouns | /users not /user |
| HTTP methods | GET=read, POST=create, PUT=update, DELETE=delete |
| Status codes | 200=OK, 201=Created, 204=NoContent, 404=NotFound |
| Validation | @Valid on request body |
❌ Anti-patterns
@PostMapping
public User create(@RequestBody User user) {
user.setCreatedAt(LocalDateTime.now());
return userRepository.save(user);
}
@GetMapping("/{id}")
public User getById(@PathVariable Long id) {
return userRepository.findById(id).get();
}
Service Patterns
Service Interface + Implementation
public interface UserService {
List<UserResponse> findAll();
UserResponse findById(Long id);
UserResponse create(CreateUserRequest request);
UserResponse update(Long id, UpdateUserRequest request);
void delete(Long id);
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public List<UserResponse> findAll() {
return userRepository.findAll().stream()
.map(userMapper::toResponse)
.toList();
}
@Override
public UserResponse findById(Long id) {
return userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@Override
@Transactional
UserResponse {
userMapper.toEntity(request);
userRepository.save(user);
userMapper.toResponse(saved);
}
{
(!userRepository.existsById(id)) {
(, id);
}
userRepository.deleteById(id);
}
}
Service Best Practices
- Interface + Impl for testability
@Transactional(readOnly = true) at class level
@Transactional for write methods
- Throw domain exceptions, not generic ones
- Use mappers (MapStruct) for entity ↔ DTO conversion
Repository Patterns
JPA Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByActiveTrue();
@Query("SELECT u FROM User u WHERE u.department.id = :deptId")
List<User> findByDepartmentId(@Param("deptId") Long departmentId);
@Query(value = "SELECT * FROM users WHERE created_at > :date",
nativeQuery = true)
List<User> findRecentUsers(@Param("date") LocalDate date);
boolean existsByEmail(String email);
long countByActiveTrue();
}
Repository Best Practices
- Use derived queries when possible
Optional for single results
existsBy instead of findBy for existence checks
- Avoid native queries unless necessary
- Use
@EntityGraph for fetch optimization
DTO Patterns
Request/Response DTOs
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
String name,
@NotBlank
@Email(message = "Invalid email format")
String email,
@NotNull
@Min(18)
Integer age
) {}
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {}
MapStruct Mapper
@Mapper(componentModel = "spring")
public interface UserMapper {
UserResponse toResponse(User entity);
List<UserResponse> toResponseList(List<User> entities);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
User toEntity(CreateUserRequest request);
}
Exception Handling
Custom Exceptions
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Long id) {
super(String.format("%s not found with id: %d", resource, id));
}
}
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
}
Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", errors.toString()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
public record ErrorResponse(String code, String message) {}
Configuration Patterns
Application Properties
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
app:
jwt:
secret: ${JWT_SECRET}
expiration: 86400000
Configuration Properties Class
@Configuration
@ConfigurationProperties(prefix = "app.jwt")
@Validated
public class JwtProperties {
@NotBlank
private String secret;
@Min(60000)
private long expiration;
}
Profile-Specific Configuration
src/main/resources/
├── application.yml # Common config
├── application-dev.yml # Development
├── application-test.yml # Testing
└── application-prod.yml # Production
Common Annotations Quick Reference
| Annotation | Purpose |
|---|
@RestController | REST controller (combines @Controller + @ResponseBody) |
@Service | Business logic component |
@Repository | Data access component |
@Configuration | Configuration class |
@RequiredArgsConstructor | Lombok: constructor injection |
@Transactional | Transaction management |
@Valid | Trigger validation |
@ConfigurationProperties | Bind properties to class |
@Profile("dev") | Profile-specific bean |
@Scheduled | Scheduled tasks |
Testing Patterns
Controller Test (MockMvc)
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
when(userService.findById(1L))
.thenReturn(new UserResponse(1L, "John", "john@example.com", null));
mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"));
}
}
Service Test
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
private UserRepository userRepository;
@Mock
private UserMapper userMapper;
@InjectMocks
private UserServiceImpl userService;
@Test
void shouldThrowWhenUserNotFound() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(1L))
.isInstanceOf(ResourceNotFoundException.class);
}
}
Integration Test
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class UserIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateUser() throws Exception {
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "John", "email": "john@example.com", "age": 25}
"""))
.andExpect(status().isCreated());
}
}
Quick Reference Card
| Layer | Responsibility | Annotations |
|---|
| Controller | HTTP handling, validation | @RestController, @Valid |
| Service | Business logic, transactions | @Service, @Transactional |
| Repository | Data access | @Repository, extends JpaRepository |
| DTO | Data transfer | Records with validation annotations |
| Config | Configuration | @Configuration, @ConfigurationProperties |
| Exception | Error handling | @RestControllerAdvice |