Skip to main content
Spring Boot, while designed to simplify Java application development, can still be misused. Here are the most important anti-patterns to avoid when developing Spring Boot applications.
// Anti-pattern: Field injection
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private EmailService emailService;
    
    // Methods using injected dependencies
}

// Better approach: Constructor injection
@Service
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    @Autowired // Optional in newer Spring versions
    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
    
    // Methods using injected dependencies
}
Field injection makes your code harder to test and hides dependencies. Use constructor injection to make dependencies explicit and enable easier unit testing.
// Anti-pattern: Hardcoded configuration values
@Service
public class EmailService {
    private final String smtpServer = "smtp.example.com";
    private final int port = 587;
    private final String username = "user";
    private final String password = "password";
    
    // Service methods
}

// Better approach: Use @ConfigurationProperties
@Configuration
@ConfigurationProperties(prefix = "mail")
public class EmailProperties {
    private String smtpServer;
    private int port;
    private String username;
    private String password;
    
    // Getters and setters
}

@Service
public class EmailService {
    private final EmailProperties emailProperties;
    
    public EmailService(EmailProperties emailProperties) {
        this.emailProperties = emailProperties;
    }
    
    // Service methods using emailProperties
}
Hardcoded configuration values make your application inflexible and difficult to deploy in different environments. Use @ConfigurationProperties to externalize configuration.
// Anti-pattern: Misusing @Transactional
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final EmailService emailService;
    
    @Autowired
    public OrderService(OrderRepository orderRepository, EmailService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }
    
    @Transactional
    public void processOrder(Order order) {
        orderRepository.save(order);
        emailService.sendOrderConfirmation(order); // Might fail and roll back DB transaction
    }
}

// Better approach: Separate transactional boundaries
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final EmailService emailService;
    
    @Autowired
    public OrderService(OrderRepository orderRepository, EmailService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }
    
    public void processOrder(Order order) {
        saveOrder(order);
        try {
            emailService.sendOrderConfirmation(order);
        } catch (Exception e) {
            // Log error but don't roll back transaction
            log.error("Failed to send confirmation email", e);
        }
    }
    
    @Transactional
    private void saveOrder(Order order) {
        orderRepository.save(order);
    }
}
Misusing @Transactional can lead to unexpected rollbacks or transaction leaks. Be mindful of transaction boundaries and separate transactional operations from non-transactional ones.
<!-- Anti-pattern: Manually managing dependencies -->
<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>${jackson.version}</version>
    </dependency>
    <!-- Many more dependencies -->
</dependencies>

<!-- Better approach: Use Spring Boot starters -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
Manually managing dependencies can lead to version conflicts and missing transitive dependencies. Use Spring Boot starters to get curated, compatible dependency sets.
// Anti-pattern: Manual JDBC or JPA code
@Repository
public class UserRepositoryImpl implements UserRepository {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    @Override
    public User findById(Long id) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?",
            new Object[]{id},
            (rs, rowNum) -> new User(
                rs.getLong("id"),
                rs.getString("username"),
                rs.getString("email")
            )
        );
    }
    
    // Many more manual implementations
}

// Better approach: Use Spring Data repositories
public interface UserRepository extends JpaRepository<User, Long> {
    // All basic CRUD operations are automatically implemented
    
    // Custom query methods
    Optional<User> findByEmail(String email);
    List<User> findByLastNameOrderByFirstNameAsc(String lastName);
    
    @Query("SELECT u FROM User u WHERE u.status = :status")
    List<User> findByStatus(@Param("status") UserStatus status);
}
Writing manual data access code is error-prone and time-consuming. Use Spring Data repositories to automatically implement common data access patterns.
// Anti-pattern: Poor exception handling
@RestController
@RequestMapping("/api/users")
public class UserController {
    @Autowired
    private UserService userService;
    
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id); // Might throw exception
    }
}

// Better approach: Proper exception handling
@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;
    
    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(user -> ResponseEntity.ok(user))
            .orElseThrow(() -> new ResourceNotFoundException("User not found with id " + id));
    }
}

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }
    
    // Other exception handlers
}
Not handling exceptions properly leads to poor user experience and security issues. Use @ControllerAdvice and @ExceptionHandler to handle exceptions globally.
// Anti-pattern: Environment-specific configuration in code
@Configuration
public class DatabaseConfig {
    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        if (System.getProperty("env").equals("dev")) {
            dataSource.setUrl("jdbc:h2:mem:testdb");
            // Dev configuration
        } else {
            dataSource.setUrl("jdbc:mysql://production-db:3306/app");
            // Production configuration
        }
        return dataSource;
    }
}

// Better approach: Use Spring profiles
@Configuration
@Profile("dev")
public class DevDatabaseConfig {
    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl("jdbc:h2:mem:testdb");
        // Dev configuration
        return dataSource;
    }
}

@Configuration
@Profile("prod")
public class ProdDatabaseConfig {
    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl("jdbc:mysql://production-db:3306/app");
        // Production configuration
        return dataSource;
    }
}
Hardcoding environment-specific configuration makes deployment difficult. Use Spring profiles to separate configuration for different environments.
// Anti-pattern: Custom health checks and metrics
@RestController
@RequestMapping("/system")
public class SystemController {
    @Autowired
    private DataSource dataSource;
    
    @GetMapping("/health")
    public Map<String, Object> health() {
        Map<String, Object> health = new HashMap<>();
        try (Connection conn = dataSource.getConnection()) {
            health.put("database", "UP");
        } catch (SQLException e) {
            health.put("database", "DOWN");
        }
        // More manual health checks
        return health;
    }
}

// Better approach: Use Spring Boot Actuator
// In pom.xml or build.gradle
// <dependency>
//     <groupId>org.springframework.boot</groupId>
//     <artifactId>spring-boot-starter-actuator</artifactId>
// </dependency>

// In application.properties or application.yml
// management.endpoints.web.exposure.include=health,info,metrics
// management.endpoint.health.show-details=always

// Custom health indicator if needed
@Component
public class CustomHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        // Check health logic
        if (isHealthy()) {
            return Health.up().withDetail("details", "Service is running smoothly").build();
        }
        return Health.down().withDetail("details", "Service is experiencing issues").build();
    }
}
Implementing custom monitoring endpoints is unnecessary and lacks features. Use Spring Boot Actuator for production-ready features like health checks, metrics, and monitoring.
// Anti-pattern: Custom security implementation
@Component
public class CustomAuthFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String token = httpRequest.getHeader("Authorization");
        
        // Manual token validation
        if (token != null && validateToken(token)) {
            chain.doFilter(request, response);
        } else {
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
    }
    
    private boolean validateToken(String token) {
        // Custom token validation logic
        return true;
    }
}

// Better approach: Use Spring Security
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()
            .antMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .apply(new JwtConfigurer(tokenProvider));
    }
}
Custom security implementations are prone to vulnerabilities. Use Spring Security for robust, well-tested security features.
// Anti-pattern: Manual test setup
public class UserServiceTest {
    private UserRepository userRepository;
    private EmailService emailService;
    private UserService userService;
    
    @Before
    public void setup() {
        userRepository = mock(UserRepository.class);
        emailService = mock(EmailService.class);
        userService = new UserService(userRepository, emailService);
    }
    
    @Test
    public void testFindById() {
        // Test implementation
    }
}

// Better approach: Use Spring Boot Test
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceIntegrationTest {
    @Autowired
    private UserService userService;
    
    @MockBean
    private EmailService emailService;
    
    @Test
    public void testFindById() {
        // Test implementation with Spring Boot test features
    }
}

// Or for slices of the application
@RunWith(SpringRunner.class)
@WebMvcTest(UserController.class)
public class UserControllerTest {
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    public void testGetUser() throws Exception {
        // Test with MockMvc
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1));
    }
}
Manual test setup is verbose and error-prone. Use Spring Boot’s testing features like @SpringBootTest, @WebMvcTest, and @DataJpaTest for more effective testing.
<!-- Add to your pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>
# application.properties for development
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=true
Not using Spring Boot DevTools during development leads to slower development cycles. DevTools provides automatic restart, live reload, and other development-time features that improve productivity.
// Anti-pattern: Manual caching
@Service
public class ProductService {
    private final ProductRepository productRepository;
    private final Map<Long, Product> productCache = new ConcurrentHashMap<>();
    
    @Autowired
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
    
    public Product getProductById(Long id) {
        if (productCache.containsKey(id)) {
            return productCache.get(id);
        }
        
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
        productCache.put(id, product);
        return product;
    }
    
    public void updateProduct(Product product) {
        productRepository.save(product);
        productCache.put(product.getId(), product);
    }
}

// Better approach: Use Spring Boot Caching
@Configuration
@EnableCaching
public class CachingConfig {
    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("products");
    }
}

@Service
public class ProductService {
    private final ProductRepository productRepository;
    
    @Autowired
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
    
    @Cacheable("products")
    public Product getProductById(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    }
    
    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    @CacheEvict(value = "products", key = "#id")
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }
}
Manual caching is error-prone and difficult to maintain. Use Spring Boot’s caching abstraction with annotations like @Cacheable, @CachePut, and @CacheEvict for declarative caching.
// Anti-pattern: Manual validation
@RestController
@RequestMapping("/api/users")
public class UserController {
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody UserDto userDto) {
        // Manual validation
        if (userDto.getEmail() == null || !userDto.getEmail().contains("@")) {
            throw new BadRequestException("Invalid email");
        }
        if (userDto.getPassword() == null || userDto.getPassword().length() < 8) {
            throw new BadRequestException("Password must be at least 8 characters");
        }
        // More validations...
        
        // Process valid user
        User user = userService.createUser(userDto);
        return ResponseEntity.ok(user);
    }
}

// Better approach: Use Bean Validation
public class UserDto {
    @NotBlank(message = "Name is required")
    private String name;
    
    @Email(message = "Invalid email format")
    @NotBlank(message = "Email is required")
    private String email;
    
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
    
    // Getters and setters
}

@RestController
@RequestMapping("/api/users")
public class UserController {
    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody UserDto userDto) {
        // Validation is handled automatically
        User user = userService.createUser(userDto);
        return ResponseEntity.ok(user);
    }
}

// Global validation error handler
@ControllerAdvice
public class ValidationExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return ResponseEntity.badRequest().body(errors);
    }
}
Manual validation is error-prone and verbose. Use Bean Validation (JSR-380) with annotations like @Valid, @NotNull, @Size, etc., for declarative validation.
// Anti-pattern: Custom health check endpoints
@RestController
@RequestMapping("/health")
public class HealthCheckController {
    @Autowired
    private DataSource dataSource;
    
    @GetMapping
    public Map<String, Object> checkHealth() {
        Map<String, Object> health = new HashMap<>();
        health.put("status", "UP");
        
        try (Connection conn = dataSource.getConnection()) {
            health.put("database", "UP");
        } catch (Exception e) {
            health.put("database", "DOWN");
            health.put("status", "DOWN");
        }
        
        // More health checks
        return health;
    }
}

// Better approach: Use Spring Boot Actuator
// In application.properties
// management.endpoint.health.show-details=always
// management.endpoints.web.exposure.include=health,info,metrics

// Custom health indicator
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
    private final DataSource dataSource;
    
    @Autowired
    public DatabaseHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            // Execute a simple query
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("SELECT 1");
            }
            return Health.up().withDetail("database", "Available").build();
        } catch (Exception e) {
            return Health.down()
                .withDetail("database", "Unavailable")
                .withException(e)
                .build();
        }
    }
}
Custom health check endpoints lack standardization and features. Use Spring Boot Actuator’s health endpoints and custom health indicators for comprehensive health monitoring.
I