Spring Framework Patterns
Purpose
This skill provides comprehensive patterns and best practices for Spring Framework and Spring Boot development. It serves as a reference guide during code reviews to ensure Spring applications follow industry standards, are maintainable, scalable, and adhere to enterprise Java conventions.
When to use this skill:
-
Conducting code reviews of Spring/Spring Boot applications
-
Designing Spring Boot application architecture
-
Writing new Spring components (controllers, services, repositories)
-
Refactoring existing Spring applications
-
Evaluating Spring configuration and setup
-
Teaching Spring best practices to team members
Context
Spring Framework is the de facto standard for enterprise Java applications. This skill documents production-ready patterns using Spring Boot 3.x+ and Spring 6.x+, emphasizing:
-
Modularity: Clear separation of concerns with proper layering
-
Maintainability: Code that's easy to understand and modify
-
Testability: Components that can be easily tested
-
Performance: Efficient use of Spring features
-
Security: Secure-by-default patterns
-
Convention over Configuration: Leveraging Spring Boot auto-configuration
This skill is designed to be referenced by the uncle-duke-java agent during code reviews and by developers when implementing Spring applications.
Prerequisites
Required Knowledge:
-
Java fundamentals (Java 17+)
-
Object-oriented programming concepts
-
Basic understanding of Spring concepts
-
Maven or Gradle basics
Required Tools:
-
JDK 17 or later
-
Spring Boot 3.x+
-
Maven or Gradle
-
IDE (IntelliJ IDEA, Eclipse, VS Code)
Expected Project Structure:
spring-boot-app/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/app/ │ │ │ ├── Application.java │ │ │ ├── config/ │ │ │ ├── controller/ │ │ │ ├── service/ │ │ │ ├── repository/ │ │ │ ├── model/ │ │ │ ├── dto/ │ │ │ ├── exception/ │ │ │ └── security/ │ │ └── resources/ │ │ ├── application.yml │ │ ├── application-dev.yml │ │ ├── application-prod.yml │ │ └── db/migration/ │ └── test/ │ └── java/ ├── pom.xml (or build.gradle) └── README.md
Instructions
Task 1: Implement Dependency Injection Best Practices
1.1 Constructor Injection (Preferred)
Rule: ALWAYS use constructor injection for required dependencies. Never use field injection in production code.
✅ Good:
@Service public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// Constructor injection - preferred approach
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public User createUser(UserDTO dto) {
User user = new User(dto.getEmail());
User savedUser = userRepository.save(user);
emailService.sendWelcomeEmail(savedUser);
return savedUser;
}
}
Why good:
-
Dependencies are immutable (final fields)
-
Dependencies are mandatory - cannot create instance without them
-
Easy to test - can inject mocks in tests
-
No reflection needed in tests
-
Constructor clearly documents all dependencies
❌ Bad:
@Service public class UserService {
// Field injection - DON'T DO THIS
@Autowired
private UserRepository userRepository;
@Autowired
private EmailService emailService;
public User createUser(UserDTO dto) {
User user = new User(dto.getEmail());
User savedUser = userRepository.save(user);
emailService.sendWelcomeEmail(savedUser);
return savedUser;
}
}
Why bad:
-
Cannot be final - mutable dependencies
-
Can create instance without dependencies (NullPointerException risk)
-
Hard to test - requires reflection or Spring context in tests
-
Hides dependencies - not clear what's required
-
Violates encapsulation
With Lombok (Acceptable):
@Service @RequiredArgsConstructor // Generates constructor for final fields public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public User createUser(UserDTO dto) {
User user = new User(dto.getEmail());
User savedUser = userRepository.save(user);
emailService.sendWelcomeEmail(savedUser);
return savedUser;
}
}
1.2 Optional Dependencies with Setter Injection
Rule: Use setter injection ONLY for optional dependencies.
✅ Good:
@Service public class NotificationService {
private final EmailService emailService; // Required
private SmsService smsService; // Optional
public NotificationService(EmailService emailService) {
this.emailService = emailService;
}
@Autowired(required = false)
public void setSmsService(SmsService smsService) {
this.smsService = smsService;
}
public void notify(User user, String message) {
emailService.send(user.getEmail(), message);
if (smsService != null && user.getPhoneNumber() != null) {
smsService.send(user.getPhoneNumber(), message);
}
}
}
1.3 Avoiding Circular Dependencies
Rule: Circular dependencies indicate design problems. Refactor instead of using @Lazy.
❌ Bad:
@Service public class OrderService { @Autowired @Lazy // Band-aid solution private PaymentService paymentService; }
@Service public class PaymentService { @Autowired @Lazy // Band-aid solution private OrderService orderService; }
✅ Good:
// Extract common logic to a new service @Service public class OrderProcessingService {
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
public OrderProcessingService(OrderRepository orderRepository,
PaymentRepository paymentRepository) {
this.orderRepository = orderRepository;
this.paymentRepository = paymentRepository;
}
public void processOrder(Order order, Payment payment) {
order.setStatus(OrderStatus.PROCESSING);
orderRepository.save(order);
payment.setOrderId(order.getId());
paymentRepository.save(payment);
}
}
@Service public class OrderService { private final OrderProcessingService processingService; // ... }
@Service public class PaymentService { private final OrderProcessingService processingService; // ... }
Task 2: Understand Bean Lifecycle and Scopes
2.1 Bean Scopes
Available Scopes:
-
singleton (default): One instance per Spring container
-
prototype : New instance each time bean is requested
-
request : One instance per HTTP request (web applications)
-
session : One instance per HTTP session (web applications)
-
application : One instance per ServletContext (web applications)
✅ Good:
// Singleton (default) - stateless services @Service @Scope("singleton") // Can omit - it's default public class UserService { // Stateless - safe to share private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
// Prototype - stateful beans @Component @Scope("prototype") public class ReportGenerator { // Stateful - each user gets their own instance private final List<String> reportLines = new ArrayList<>();
public void addLine(String line) {
reportLines.add(line);
}
public String generate() {
return String.join("\n", reportLines);
}
}
// Request scope - web layer @Component @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class RequestContext { private String requestId; private String userId;
// Getters and setters
}
❌ Bad:
// Singleton with mutable state - THREAD UNSAFE @Service public class UserService {
private User currentUser; // Shared across all requests - BAD!
public void processUser(User user) {
this.currentUser = user; // Race condition!
// Process...
}
}
2.2 Bean Lifecycle Callbacks
Rule: Use @PostConstruct for initialization, @PreDestroy for cleanup.
✅ Good:
@Service public class CacheService {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
private ScheduledExecutorService scheduler;
@PostConstruct
public void initialize() {
System.out.println("Initializing cache service...");
scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::cleanupExpiredEntries, 1, 1, TimeUnit.HOURS);
}
@PreDestroy
public void cleanup() {
System.out.println("Cleaning up cache service...");
cache.clear();
if (scheduler != null) {
scheduler.shutdown();
}
}
private void cleanupExpiredEntries() {
// Cleanup logic
}
}
2.3 Component Stereotypes
Rule: Use the most specific stereotype annotation.
@Component // Generic Spring-managed component @Service // Business logic layer @Repository // Data access layer (adds exception translation) @Controller // MVC controller (returns views) @RestController // REST API controller (returns data) @Configuration // Configuration class
✅ Good:
@Repository // Data access - enables exception translation public interface UserRepository extends JpaRepository<User, Long> { }
@Service // Business logic public class UserService { private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
@RestController // REST API @RequestMapping("/api/users") public class UserController { private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
}
@Configuration // Configuration public class SecurityConfig { // Configuration beans }
Task 3: Design REST API Controllers
3.1 Controller Structure
Rule: Keep controllers thin - delegate business logic to services.
✅ Good:
@RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserDTO>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sortBy) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
Page<UserDTO> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDTO created = userService.createUser(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.updateUser(id, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Why good:
-
RESTful URL structure
-
Proper HTTP methods and status codes
-
Pagination and sorting support
-
Validation with @Valid
-
Location header for created resources
-
Delegates logic to service layer
❌ Bad:
@RestController public class UserController {
@Autowired
private UserRepository userRepository; // Controller accessing repository directly!
@GetMapping("/getUsers") // Non-RESTful URL
public List<User> getUsers() { // Returns entities, not DTOs
return userRepository.findAll(); // Business logic in controller
}
@PostMapping("/createUser") // Non-RESTful URL
public User createUser(@RequestBody User user) { // No validation
// Business logic in controller - BAD!
if (userRepository.findByEmail(user.getEmail()).isPresent()) {
throw new RuntimeException("Email exists"); // Poor error handling
}
return userRepository.save(user); // Returns 200 instead of 201
}
}
3.2 Request/Response Patterns
Rule: Use DTOs for API contracts. Never expose entities directly.
✅ Good:
// Request DTOs public class CreateUserRequest {
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
private String name;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
// Getters and setters
}
// Response DTOs public class UserDTO {
private Long id;
private String email;
private String name;
private LocalDateTime createdAt;
private boolean active;
// No password field - security
// Getters and setters
}
// Service layer @Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserMapper userMapper;
public UserDTO createUser(CreateUserRequest request) {
// Validate
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException(request.getEmail());
}
// Map DTO to entity
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setActive(true);
user.setCreatedAt(LocalDateTime.now());
// Save
User saved = userRepository.save(user);
// Map entity to DTO
return userMapper.toDTO(saved);
}
}
3.3 HTTP Status Codes
Rule: Use correct HTTP status codes.
@RestController @RequestMapping("/api/v1/orders") @RequiredArgsConstructor public class OrderController {
private final OrderService orderService;
// 200 OK - Successful GET/PUT
@GetMapping("/{id}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
// 201 Created - Successful POST
@PostMapping
public ResponseEntity<OrderDTO> createOrder(@Valid @RequestBody CreateOrderRequest request) {
OrderDTO created = orderService.createOrder(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created); // 201 Created
}
// 204 No Content - Successful DELETE
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
orderService.deleteOrder(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
// 202 Accepted - Async processing
@PostMapping("/{id}/process")
public ResponseEntity<Void> processOrder(@PathVariable Long id) {
orderService.processOrderAsync(id);
return ResponseEntity.accepted().build(); // 202 Accepted
}
// 400 Bad Request - Validation failures (handled by @Valid)
// 401 Unauthorized - Not authenticated (handled by Security)
// 403 Forbidden - Not authorized (handled by Security)
// 404 Not Found - Resource doesn't exist
// 409 Conflict - Business rule violation
// 500 Internal Server Error - Unexpected errors
}
Task 4: Implement Data Access Layer with Spring Data JPA
4.1 Repository Interfaces
Rule: Extend appropriate Spring Data interface based on needs.
✅ Good:
// Simple CRUD - JpaRepository @Repository public interface UserRepository extends JpaRepository<User, Long> {
// Query methods - Spring Data generates implementation
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
// Custom query
@Query("SELECT u FROM User u WHERE u.createdAt > :date")
List<User> findRecentUsers(@Param("date") LocalDateTime date);
// Native query
@Query(value = "SELECT * FROM users WHERE email LIKE %:domain", nativeQuery = true)
List<User> findByEmailDomain(@Param("domain") String domain);
// Modifying query
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :date")
int deactivateInactiveUsers(@Param("date") LocalDateTime date);
}
Repository Hierarchy:
-
Repository<T, ID>
-
Marker interface, no methods
-
CrudRepository<T, ID>
-
Basic CRUD operations
-
PagingAndSortingRepository<T, ID>
-
Adds pagination and sorting
-
JpaRepository<T, ID>
-
JPA-specific features (flush, batch operations)
4.2 Custom Queries with Specifications
Rule: Use Specifications for dynamic queries instead of building query strings.
✅ Good:
// Specification public class UserSpecifications {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) ->
email == null ? null : cb.equal(root.get("email"), email);
}
public static Specification<User> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
public static Specification<User> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThan(root.get("createdAt"), date);
}
public static Specification<User> nameLike(String name) {
return (root, query, cb) ->
name == null ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
}
}
// Repository @Repository public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { }
// Service @Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
public Page<User> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
Specification<User> spec = Specification.where(null);
if (criteria.getEmail() != null) {
spec = spec.and(UserSpecifications.hasEmail(criteria.getEmail()));
}
if (criteria.isActiveOnly()) {
spec = spec.and(UserSpecifications.isActive());
}
if (criteria.getCreatedAfter() != null) {
spec = spec.and(UserSpecifications.createdAfter(criteria.getCreatedAfter()));
}
if (criteria.getName() != null) {
spec = spec.and(UserSpecifications.nameLike(criteria.getName()));
}
return userRepository.findAll(spec, pageable);
}
}
4.3 Pagination and Sorting
Rule: Always support pagination for list endpoints.
✅ Good:
@RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserDTO>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id,desc") String[] sort) {
// Parse sort parameters
List<Sort.Order> orders = Arrays.stream(sort)
.map(s -> {
String[] parts = s.split(",");
String property = parts[0];
Sort.Direction direction = parts.length > 1 && parts[1].equalsIgnoreCase("desc")
? Sort.Direction.DESC
: Sort.Direction.ASC;
return new Sort.Order(direction, property);
})
.toList();
Pageable pageable = PageRequest.of(page, size, Sort.by(orders));
Page<UserDTO> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
}
4.4 Transaction Management
Rule: Use @Transactional on service methods, not repository methods.
✅ Good:
@Service @RequiredArgsConstructor public class OrderService {
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
private final EmailService emailService;
@Transactional
public OrderDTO createOrder(CreateOrderRequest request) {
// Create order
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus(OrderStatus.PENDING);
Order savedOrder = orderRepository.save(order);
// Create payment
Payment payment = new Payment();
payment.setOrderId(savedOrder.getId());
payment.setAmount(request.getAmount());
paymentRepository.save(payment);
// If email fails, transaction rolls back
emailService.sendOrderConfirmation(savedOrder);
return mapToDTO(savedOrder);
}
@Transactional(readOnly = true) // Optimization for read-only operations
public Optional<OrderDTO> findById(Long id) {
return orderRepository.findById(id)
.map(this::mapToDTO);
}
@Transactional(isolation = Isolation.SERIALIZABLE) // For critical operations
public void processPayment(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// Process payment with highest isolation level
order.setStatus(OrderStatus.PAID);
orderRepository.save(order);
}
}
Transaction Propagation:
@Service public class UserService {
@Transactional(propagation = Propagation.REQUIRED) // Default - join existing or create new
public void updateUser(User user) {
// Uses existing transaction or creates new
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // Always create new transaction
public void auditLog(String action) {
// Independent transaction - commits even if parent rolls back
}
@Transactional(propagation = Propagation.MANDATORY) // Must have existing transaction
public void criticalOperation() {
// Throws exception if no transaction exists
}
}
Task 5: Design Service Layer
5.1 Service Boundaries
Rule: Services should represent business capabilities, not data access.
✅ Good:
// Good service boundaries @Service @RequiredArgsConstructor public class UserRegistrationService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserDTO register(RegistrationRequest request) {
// Validate
validateEmailNotExists(request.getEmail());
// Create user
User user = createUser(request);
User saved = userRepository.save(user);
// Send welcome email
emailService.sendWelcomeEmail(saved.getEmail());
return mapToDTO(saved);
}
private void validateEmailNotExists(String email) {
if (userRepository.existsByEmail(email)) {
throw new EmailAlreadyExistsException(email);
}
}
private User createUser(RegistrationRequest request) {
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
user.setPassword(passwordEncoder.encode(request.getPassword()));
return user;
}
}
@Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Optional<UserDTO> findById(Long id) {
return userRepository.findById(id).map(this::mapToDTO);
}
@Transactional(readOnly = true)
public Page<UserDTO> findAll(Pageable pageable) {
return userRepository.findAll(pageable).map(this::mapToDTO);
}
}
5.2 DTO Mapping
Rule: Keep entity-to-DTO mapping logic in one place.
✅ Good with MapStruct:
@Mapper(componentModel = "spring") public interface UserMapper {
UserDTO toDTO(User user);
List<UserDTO> toDTOs(List<User> users);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
User toEntity(CreateUserRequest request);
}
// Usage in service @Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserDTO createUser(CreateUserRequest request) {
User user = userMapper.toEntity(request);
User saved = userRepository.save(user);
return userMapper.toDTO(saved);
}
}
✅ Good without MapStruct:
@Service public class UserService {
private final UserRepository userRepository;
private UserDTO mapToDTO(User user) {
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setEmail(user.getEmail());
dto.setName(user.getName());
dto.setCreatedAt(user.getCreatedAt());
dto.setActive(user.isActive());
return dto;
}
private User mapToEntity(CreateUserRequest request) {
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
return user;
}
}
Task 6: Implement Global Exception Handling
6.1 Custom Exceptions
Rule: Create domain-specific exceptions for business errors.
✅ Good:
// Base exception public abstract class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
// Specific exceptions public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resourceName, Long id) {
super(String.format("%s with id %d not found", resourceName, id), "RESOURCE_NOT_FOUND");
}
}
public class EmailAlreadyExistsException extends BusinessException {
public EmailAlreadyExistsException(String email) {
super(String.format("Email %s already exists", email), "EMAIL_EXISTS");
}
}
public class InsufficientBalanceException extends BusinessException {
public InsufficientBalanceException(Long accountId) {
super(String.format("Insufficient balance in account %d", accountId), "INSUFFICIENT_BALANCE");
}
}
6.2 Global Exception Handler with @ControllerAdvice
Rule: Centralize exception handling in @ControllerAdvice.
✅ Good:
@RestControllerAdvice @Slf4j public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Not Found")
.message(ex.getMessage())
.errorCode(ex.getErrorCode())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
log.warn("Email already exists: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.CONFLICT.value())
.error("Conflict")
.message(ex.getMessage())
.errorCode(ex.getErrorCode())
.build();
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
log.warn("Validation failed: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ValidationErrorResponse response = ValidationErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Invalid request parameters")
.fieldErrors(errors)
.build();
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Unexpected error occurred", ex);
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("An unexpected error occurred")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
// Error response DTOs @Data @Builder public class ErrorResponse { private LocalDateTime timestamp; private int status; private String error; private String message; private String errorCode; }
@Data @Builder public class ValidationErrorResponse { private LocalDateTime timestamp; private int status; private String error; private String message; private Map<String, String> fieldErrors; }
Task 7: Implement Spring Security
7.1 Security Configuration
Rule: Use security configuration classes for centralized security setup.
✅ Good (Spring Security 6+):
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.httpBasic(Customizer.withDefaults())
.formLogin(form -> form.disable())
.logout(logout -> logout
.logoutUrl("/api/v1/auth/logout")
.logoutSuccessHandler((request, response, authentication) ->
response.setStatus(HttpServletResponse.SC_OK))
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
7.2 JWT Implementation
Rule: Implement JWT for stateless authentication in REST APIs.
✅ Good:
@Component public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:3600000}") // 1 hour default
private long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return !isTokenExpired(token);
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
private boolean isTokenExpired(String token) {
Date expiration = getClaimsFromToken(token).getExpiration();
return expiration.before(new Date());
}
}
@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getTokenFromRequest(request);
if (token != null && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
7.3 Method Security
Rule: Use method security for fine-grained authorization.
✅ Good:
@Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
@PreAuthorize("hasRole('ADMIN')")
public List<UserDTO> findAll() {
return userRepository.findAll().stream()
.map(this::mapToDTO)
.toList();
}
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
public UserDTO findById(Long userId) {
return userRepository.findById(userId)
.map(this::mapToDTO)
.orElseThrow(() -> new ResourceNotFoundException("User", userId));
}
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@PostAuthorize("returnObject.email == authentication.principal.username")
public UserDTO updateUser(Long id, UpdateUserRequest request) {
// Implementation
}
@Secured("ROLE_ADMIN")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
Task 8: Implement Testing Strategies
8.1 Unit Testing Services
Rule: Test services in isolation with mocked dependencies.
✅ Good:
@ExtendWith(MockitoExtension.class) class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void createUser_WithValidData_ShouldCreateUser() {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("test@example.com");
request.setPassword("password123");
User user = new User();
user.setId(1L);
user.setEmail(request.getEmail());
when(userRepository.existsByEmail(request.getEmail())).thenReturn(false);
when(passwordEncoder.encode(request.getPassword())).thenReturn("encodedPassword");
when(userRepository.save(any(User.class))).thenReturn(user);
// Act
UserDTO result = userService.createUser(request);
// Assert
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("test@example.com", result.getEmail());
verify(userRepository).existsByEmail(request.getEmail());
verify(passwordEncoder).encode(request.getPassword());
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail(any(User.class));
}
@Test
void createUser_WithExistingEmail_ShouldThrowException() {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("existing@example.com");
when(userRepository.existsByEmail(request.getEmail())).thenReturn(true);
// Act & Assert
assertThrows(EmailAlreadyExistsException.class, () ->
userService.createUser(request));
verify(userRepository).existsByEmail(request.getEmail());
verify(userRepository, never()).save(any(User.class));
}
}
8.2 Integration Testing with @SpringBootTest
Rule: Use @SpringBootTest for integration tests that need full context.
✅ Good:
@SpringBootTest @AutoConfigureMockMvc @Transactional // Rollback after each test class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void createUser_WithValidData_ShouldReturn201() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("test@example.com");
request.setName("Test User");
request.setPassword("password123");
// Act & Assert
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.email").value("test@example.com"))
.andExpect(jsonPath("$.name").value("Test User"))
.andExpect(jsonPath("$.id").exists());
// Verify database
assertEquals(1, userRepository.count());
}
@Test
void createUser_WithInvalidEmail_ShouldReturn400() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("invalid-email");
request.setName("Test User");
request.setPassword("password123");
// Act & Assert
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.fieldErrors.email").exists());
}
}
8.3 Testing Repositories with @DataJpaTest
Rule: Use @DataJpaTest for repository tests with in-memory database.
✅ Good:
@DataJpaTest class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByEmail_WithExistingEmail_ShouldReturnUser() {
// Arrange
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
entityManager.persist(user);
entityManager.flush();
// Act
Optional<User> found = userRepository.findByEmail("test@example.com");
// Assert
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
void findByEmail_WithNonExistingEmail_ShouldReturnEmpty() {
// Act
Optional<User> found = userRepository.findByEmail("nonexistent@example.com");
// Assert
assertFalse(found.isPresent());
}
}
8.4 Testing Controllers with @WebMvcTest
Rule: Use @WebMvcTest for controller tests without full context.
✅ Good:
@WebMvcTest(UserController.class) class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getUserById_WithExistingId_ShouldReturnUser() throws Exception {
// Arrange
UserDTO user = new UserDTO();
user.setId(1L);
user.setEmail("test@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(user));
// Act & Assert
mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("test@example.com"));
verify(userService).findById(1L);
}
@Test
void getUserById_WithNonExistingId_ShouldReturn404() throws Exception {
// Arrange
when(userService.findById(999L)).thenReturn(Optional.empty());
// Act & Assert
mockMvc.perform(get("/api/v1/users/999"))
.andExpect(status().isNotFound());
}
}
Task 9: Implement Configuration Management
9.1 External Configuration
Rule: Use application.yml for configuration, support multiple profiles.
✅ Good application.yml:
spring: application: name: my-spring-app
profiles: active: dev
datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/myapp} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:password} hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000
jpa: hibernate: ddl-auto: validate show-sql: false properties: hibernate: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect
cache: type: caffeine caffeine: spec: maximumSize=500,expireAfterAccess=600s
server: port: 8080 error: include-message: always include-binding-errors: always include-stacktrace: on_param include-exception: false
logging: level: root: INFO com.example.app: DEBUG pattern: console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
Custom application properties
app: jwt: secret: ${JWT_SECRET:default-secret-change-in-production} expiration: 3600000 email: from: noreply@example.com features: new-ui: false
application-dev.yml:
spring: jpa: show-sql: true hibernate: ddl-auto: update
logging: level: com.example.app: DEBUG org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE
app: features: new-ui: true
application-prod.yml:
spring: jpa: show-sql: false hibernate: ddl-auto: validate
logging: level: root: WARN com.example.app: INFO
app: jwt: expiration: 7200000 # 2 hours in production
9.2 @ConfigurationProperties
Rule: Use @ConfigurationProperties for type-safe configuration.
✅ Good:
@ConfigurationProperties(prefix = "app") @Validated public class AppProperties {
@NotNull
private Jwt jwt;
@NotNull
private Email email;
@NotNull
private Features features;
@Data
public static class Jwt {
@NotBlank
private String secret;
@Min(60000) // At least 1 minute
private long expiration;
}
@Data
public static class Email {
@Email
private String from;
}
@Data
public static class Features {
private boolean newUi;
}
// Getters and setters
}
// Enable configuration properties @Configuration @EnableConfigurationProperties(AppProperties.class) public class AppConfig { }
// Usage @Service @RequiredArgsConstructor public class EmailService {
private final AppProperties appProperties;
public void sendEmail(String to, String subject, String body) {
String from = appProperties.getEmail().getFrom();
// Send email logic
}
}
Task 10: Implement Caching
10.1 Enable Caching
Rule: Use Spring Cache abstraction for declarative caching.
✅ Good:
@Configuration @EnableCaching public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager(
"users", "products", "orders"
);
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats());
return cacheManager;
}
}
@Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
@Cacheable(value = "users", key = "#id")
public Optional<UserDTO> findById(Long id) {
return userRepository.findById(id)
.map(this::mapToDTO);
}
@CachePut(value = "users", key = "#result.id")
public UserDTO updateUser(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
user.setName(request.getName());
User updated = userRepository.save(user);
return mapToDTO(updated);
}
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
// Cache cleared
}
}
10.2 Cache Key Strategies
Rule: Use explicit cache keys for complex scenarios.
✅ Good:
@Service public class ProductService {
// Simple key
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
// ...
}
// Composite key
@Cacheable(value = "products", key = "#category + '-' + #priceRange")
public List<Product> findByCategoryAndPrice(String category, String priceRange) {
// ...
}
// Custom KeyGenerator
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public List<Product> search(ProductSearchCriteria criteria) {
// ...
}
}
@Component("customKeyGenerator") public class CustomKeyGenerator implements org.springframework.cache.interceptor.KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return target.getClass().getSimpleName() + "_"
+ method.getName() + "_"
+ Arrays.stream(params).map(String::valueOf).collect(Collectors.joining("_"));
}
}
Task 11: Implement Async Processing
11.1 Enable Async
Rule: Use @Async for non-blocking operations.
✅ Good:
@Configuration @EnableAsync public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
@Service @RequiredArgsConstructor public class EmailService {
@Async("taskExecutor")
public void sendWelcomeEmail(User user) {
// Runs asynchronously
System.out.println("Sending email to: " + user.getEmail());
// Email sending logic
}
@Async("taskExecutor")
public CompletableFuture<EmailStatus> sendEmailWithResult(String to, String subject, String body) {
// Async with result
try {
// Send email
return CompletableFuture.completedFuture(EmailStatus.SENT);
} catch (Exception e) {
return CompletableFuture.completedFuture(EmailStatus.FAILED);
}
}
}
11.2 Event-Driven Architecture
Rule: Use Spring Events for decoupling components.
✅ Good:
// Event public class UserRegisteredEvent { private final User user;
public UserRegisteredEvent(User user) {
this.user = user;
}
public User getUser() {
return user;
}
}
// Publisher @Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public UserDTO register(RegistrationRequest request) {
User user = new User();
user.setEmail(request.getEmail());
User saved = userRepository.save(user);
// Publish event
eventPublisher.publishEvent(new UserRegisteredEvent(saved));
return mapToDTO(saved);
}
}
// Listeners @Component @Slf4j public class UserRegistrationEventListener {
@Async
@EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserRegistered(UserRegisteredEvent event) {
log.info("User registered: {}", event.getUser().getEmail());
// Send welcome email
}
}
@Component public class AuditEventListener {
@EventListener
public void handleUserRegistered(UserRegisteredEvent event) {
// Log to audit trail
}
}
Task 12: Implement AOP
12.1 Logging Aspect
Rule: Use AOP for cross-cutting concerns like logging, auditing, and performance monitoring.
✅ Good:
@Aspect @Component @Slf4j public class LoggingAspect {
@Around("execution(* com.example.app.service.*.*(..))")
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
log.debug("Entering {}.{}", className, methodName);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
log.debug("Exiting {}.{} - Duration: {}ms", className, methodName, duration);
return result;
} catch (Exception e) {
log.error("Exception in {}.{}: {}", className, methodName, e.getMessage());
throw e;
}
}
@Before("@annotation(com.example.app.annotation.Audit)")
public void auditMethod(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("Audit: Method {} called with args: {}", methodName, Arrays.toString(args));
}
}
// Custom annotation @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Audit { }
// Usage @Service public class UserService {
@Audit
public UserDTO createUser(CreateUserRequest request) {
// Method will be audited
}
}
Common Anti-Patterns
Anti-Pattern 1: Field Injection
❌ Bad:
@Service public class UserService { @Autowired private UserRepository userRepository; // Field injection }
✅ Good:
@Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; // Constructor injection }
Anti-Pattern 2: Service Layer Bypassing
❌ Bad:
@RestController public class UserController { @Autowired private UserRepository userRepository; // Controller accessing repository directly
@GetMapping("/users")
public List<User> getUsers() {
return userRepository.findAll(); // Business logic in controller
}
}
✅ Good:
@RestController @RequiredArgsConstructor public class UserController { private final UserService userService; // Access through service layer
@GetMapping("/users")
public ResponseEntity<List<UserDTO>> getUsers() {
return ResponseEntity.ok(userService.findAll());
}
}
Anti-Pattern 3: Exposing Entities Directly
❌ Bad:
@RestController public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id); // Exposing entity
}
}
✅ Good:
@RestController public class UserController {
@GetMapping("/users/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id)); // Return DTO
}
}
Anti-Pattern 4: @Transactional on Repository Methods
❌ Bad:
@Repository public interface UserRepository extends JpaRepository<User, Long> {
@Transactional // Don't put @Transactional on repository
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
void deactivate(@Param("id") Long id);
}
✅ Good:
@Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
@Transactional // Put @Transactional on service methods
public void deactivateUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
user.setActive(false);
userRepository.save(user);
}
}
Anti-Pattern 5: Not Using Connection Pooling
❌ Bad:
No connection pooling configuration
spring: datasource: url: jdbc:postgresql://localhost:5432/myapp
✅ Good:
spring: datasource: url: jdbc:postgresql://localhost:5432/myapp hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000
Anti-Pattern 6: Too Many Responsibilities in Controllers
❌ Bad:
@RestController public class UserController {
@PostMapping("/users")
public User createUser(@RequestBody User user) {
// Validation in controller
if (user.getEmail() == null) {
throw new RuntimeException("Email required");
}
// Business logic in controller
if (userRepository.existsByEmail(user.getEmail())) {
throw new RuntimeException("Email exists");
}
// Data access in controller
User saved = userRepository.save(user);
// Email sending in controller
emailService.send(user.getEmail(), "Welcome!");
return saved;
}
}
✅ Good:
@RestController @RequiredArgsConstructor public class UserController {
private final UserService userService;
@PostMapping("/users")
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDTO created = userService.createUser(request);
return ResponseEntity.created(location).body(created);
}
}
@Service @RequiredArgsConstructor public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
@Transactional
public UserDTO createUser(CreateUserRequest request) {
// All business logic in service
validateEmail(request.getEmail());
User user = createUserEntity(request);
User saved = userRepository.save(user);
emailService.sendWelcomeEmail(saved);
return mapToDTO(saved);
}
}
Anti-Pattern 7: Storing Business Logic in Entities
❌ Bad:
@Entity public class Order {
@Autowired // Don't inject dependencies in entities!
private PaymentService paymentService;
public void process() {
// Business logic in entity - BAD!
if (this.total > 1000) {
paymentService.processLargeOrder(this);
} else {
paymentService.processSmallOrder(this);
}
}
}
✅ Good:
@Entity public class Order { // Pure data model - no business logic private Long id; private BigDecimal total; private OrderStatus status; // Getters and setters }
@Service @RequiredArgsConstructor public class OrderService {
private final PaymentService paymentService;
private final OrderRepository orderRepository;
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
if (order.getTotal().compareTo(BigDecimal.valueOf(1000)) > 0) {
paymentService.processLargeOrder(order);
} else {
paymentService.processSmallOrder(order);
}
}
}
Checklist
Use this checklist during code reviews:
Dependency Injection
-
Constructor injection used for required dependencies
-
Fields are final where possible
-
No field injection (@Autowired on fields)
-
No circular dependencies
-
Setter injection only for optional dependencies
Component Structure
-
Correct stereotype annotations (@Service, @Repository, @Controller, @RestController)
-
Controllers are thin (delegate to services)
-
Business logic in service layer
-
Data access through repositories only
-
Proper layering (Controller → Service → Repository)
REST API Design
-
RESTful URL structure
-
Correct HTTP methods (GET, POST, PUT, DELETE)
-
Correct HTTP status codes
-
DTOs used for requests/responses (not entities)
-
@Valid used for request validation
-
Pagination supported for list endpoints
-
Location header for created resources
Data Access
-
Appropriate repository interface (JpaRepository, etc.)
-
Query methods follow Spring Data naming conventions
-
@Transactional on service methods (not repositories)
-
readOnly=true for read operations
-
Connection pooling configured (HikariCP)
-
No N+1 query problems
Configuration
-
External configuration in application.yml
-
Profile-specific configuration files
-
@ConfigurationProperties for type-safe config
-
Sensitive data in environment variables
-
Proper defaults for all properties
Exception Handling
-
Custom exceptions for business errors
-
@RestControllerAdvice for global exception handling
-
Proper HTTP status codes for different exceptions
-
Validation errors handled properly
-
No generic Exception catching without re-throwing
Security
-
Security configuration in @Configuration class
-
Password encoder configured (BCrypt)
-
Method security enabled where needed
-
CSRF protection enabled (or disabled with justification)
-
JWT properly implemented for stateless auth
Testing
-
Unit tests for services with mocked dependencies
-
Integration tests with @SpringBootTest
-
Repository tests with @DataJpaTest
-
Controller tests with @WebMvcTest
-
Test coverage > 80%
Performance
-
Caching enabled where appropriate
-
Async processing for long-running operations
-
Lazy loading configured properly
-
Database indexes on frequently queried fields
Code Quality
-
No code duplication
-
Meaningful names for classes, methods, variables
-
Methods are focused and not too long
-
Proper logging (not System.out.println)
-
No commented-out code
Related Skills
- uncle-duke-java: Java code review agent that uses this skill as reference
References
Official Documentation
-
Spring Framework Documentation
-
Spring Boot Documentation
-
Spring Data JPA Documentation
-
Spring Security Documentation
Best Practices Guides
-
Baeldung Spring Tutorials
-
Spring Boot Best Practices
-
RESTful API Design Best Practices
Version: 1.0 Last Updated: 2025-12-24 Maintainer: Development Team