Anti Patterns

Why Anti-Patterns Matter

Spring Framework’s flexibility creates opportunities for misuse that compile successfully but fail in production. These anti-patterns emerged from debugging thousands of Spring applications experiencing circular dependencies, memory leaks, and runtime errors. Learning to recognize and avoid them prevents costly production incidents.

Circular Dependencies

The Problem

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // => OrderService depends on PaymentService

    public void processOrder(Order order) {
        paymentService.charge(order);
    }
}

@Service
public class PaymentService {
    @Autowired
    private OrderService orderService;  // => PaymentService depends on OrderService
    // => Circular dependency: OrderService → PaymentService → OrderService

    public void refund(Payment payment) {
        Order order = orderService.findOrder(payment.getOrderId());  // => Why does payment service need order service?
    }
}

Runtime Error:

BeanCurrentlyInCreationException: Error creating bean with name 'orderService':
Requested bean is currently in creation: Is there an unresolvable circular reference?

The Solution

Extract shared logic to separate service:

@Service
public class OrderService {
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;  // => Access data directly

    public OrderService(PaymentService paymentService, OrderRepository orderRepository) {
        this.paymentService = paymentService;
        this.orderRepository = orderRepository;
    }

    public void processOrder(Order order) {
        paymentService.charge(order);
    }
}

@Service
public class PaymentService {
    private final OrderRepository orderRepository;  // => No dependency on OrderService

    public PaymentService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void refund(Payment payment) {
        Order order = orderRepository.findById(payment.getOrderId()).orElseThrow();  // => Direct repository access
    }
}

Key Insight: Circular dependencies indicate poor separation of concerns. Services should depend on repositories, not other services at the same layer.

Field Injection Overuse

The Problem

@Service
public class OrderService {
    @Autowired  // => Field injection: dependencies hidden
    private PaymentService paymentService;

    @Autowired
    private NotificationService notificationService;

    @Autowired
    private InventoryService inventoryService;

    @Autowired
    private ShippingService shippingService;  // => How many dependencies does this service have?
    // => Can't tell without reading entire class
}

Problems:

  • Dependencies not visible in API (hidden in implementation)
  • Can’t create instance for testing without Spring context
  • Encourages god classes (too many dependencies)
  • Can’t make fields final (immutability lost)

The Solution

@Service
public class OrderService {
    private final PaymentService paymentService;  // => final ensures initialization
    private final NotificationService notificationService;
    private final InventoryService inventoryService;
    private final ShippingService shippingService;

    // => Constructor injection: dependencies explicit, testable
    public OrderService(PaymentService paymentService,
                       NotificationService notificationService,
                       InventoryService inventoryService,
                       ShippingService shippingService) {
        this.paymentService = paymentService;
        this.notificationService = notificationService;
        this.inventoryService = inventoryService;
        this.shippingService = shippingService;
    }
}

When constructor gets too large (more than 5 dependencies), it signals design problem:

// Too many dependencies? Split the class
@Service
public class OrderProcessingService {
    private final PaymentService paymentService;
    private final InventoryService inventoryService;

    // Focus on order processing only
}

@Service
public class OrderNotificationService {
    private final NotificationService notificationService;

    // Focus on notifications only
}

@Transactional Misuse

Wrong Layer

Don’t put @Transactional on controllers:

@RestController
public class OrderController {
    @Autowired
    private OrderRepository orderRepository;

    @PostMapping("/orders")
    @Transactional  // => WRONG: Transaction boundary too high
                    // => Transaction stays open during HTTP response writing
    public ResponseEntity<Order> createOrder(@RequestBody Order order) {
        orderRepository.save(order);  // => Transaction active during network I/O
        return ResponseEntity.ok(order);  // => Delays transaction commit
    }
}

Problems:

  • Transaction stays open during network I/O (slow)
  • Database connections held longer than necessary
  • Higher risk of deadlocks

Use @Transactional on service layer:

@RestController
public class OrderController {
    private final OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody Order order) {
        Order created = orderService.processOrder(order);  // => Service handles transaction
        return ResponseEntity.ok(created);  // => Transaction already committed
    }
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;

    @Transactional  // => CORRECT: Transaction boundary at business logic
    public Order processOrder(Order order) {
        return orderRepository.save(order);  // => Transaction commit happens here
    }  // => Connection released immediately
}

Ignoring Rollback Rules

@Service
public class OrderService {

    @Transactional  // => Rolls back on RuntimeException only
    public void processOrder(Order order) throws IOException {
        orderRepository.save(order);
        fileService.writeReceipt(order);  // => Throws IOException (checked exception)
        // => IOException does NOT trigger rollback!
        // => Order saved even though receipt writing failed
    }
}

Solution - Specify rollback for checked exceptions:

@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)  // => Rolls back on ANY exception
    public void processOrder(Order order) throws IOException {
        orderRepository.save(order);
        fileService.writeReceipt(order);  // => IOException triggers rollback
        // => Order save rolled back if receipt fails
    }
}

Component Scanning Anti-Patterns

Scanning Too Broadly

@SpringBootApplication
@ComponentScan("com")  // => WRONG: Scans entire com.* package tree
                       // => Includes third-party libraries, test code, everything
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Problems:

  • Slow startup (scans thousands of classes)
  • Accidental bean registration from dependencies
  • Test classes registered as beans in production

Solution - Scan specific packages:

@SpringBootApplication
@ComponentScan(basePackages = {
    "com.example.service",  // => Only application packages
    "com.example.repository",
    "com.example.controller"
})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Using @Component for Everything

@Component  // => Generic, unclear purpose
public class OrderHandler {
    // Is this a service? Repository? Controller? Utility?
}

@Component  // => Loses semantic meaning
public class OrderRepository {
    // Should be @Repository for exception translation
}

Solution - Use stereotype annotations:

@Repository  // => Data access: Spring adds exception translation
public class OrderRepository {
    // => SQLException converted to DataAccessException
}

@Service  // => Business logic layer
public class OrderService {
}

@Controller  // => Web layer: Spring MVC support
public class OrderController {
}

Bean Scope Misuse

Singleton Beans with Mutable State

@Service  // => Default scope: singleton (one instance for entire app)
public class OrderService {
    private Order currentOrder;  // => WRONG: Mutable state in singleton
    // => All requests share the same instance
    // => Concurrent requests overwrite each other's data

    public void processOrder(Order order) {
        this.currentOrder = order;  // => Race condition: thread 1 sets order A
        // => Thread 2 immediately overwrites with order B
        // => Thread 1 processes order B instead of A!

        // Process order...
    }
}

Problems:

  • Thread safety violations
  • Data corruption under concurrent load
  • Intermittent bugs hard to reproduce

Solution - Keep singletons stateless:

@Service
public class OrderService {
    private final PaymentService paymentService;  // => final: immutable dependency

    // => No mutable state: thread-safe
    public Order processOrder(Order order) {
        // => order parameter: local to this method call, not shared
        Payment payment = paymentService.charge(order.getTotal());
        return order.withPayment(payment);
    }
}

@Autowired Optional Dependencies

Wrong Approach

@Service
public class NotificationService {
    @Autowired(required = false)  // => WRONG: Hides missing dependency until runtime
    private EmailService emailService;

    public void notify(User user) {
        if (emailService != null) {  // => Must check null everywhere
            emailService.send(user.getEmail(), "Welcome!");
        }
    }
}

Problems:

  • Null checks scattered throughout code
  • Easy to forget null check (NullPointerException)
  • Configuration errors go unnoticed

Solution - Use Optional explicitly:

@Service
public class NotificationService {
    private final Optional<EmailService> emailService;  // => Explicit optional dependency

    public NotificationService(@Autowired(required = false) EmailService emailService) {
        this.emailService = Optional.ofNullable(emailService);  // => Wrap in Optional once
    }

    public void notify(User user) {
        emailService.ifPresent(service ->  // => Functional style, no null checks
            service.send(user.getEmail(), "Welcome!")
        );
    }
}

Hard-Coded Configuration

The Problem

@Configuration
public class DataConfig {
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/dev");  // => Hard-coded
        config.setUsername("postgres");  // => Hard-coded
        config.setPassword("secret123");  // => Hard-coded, security risk
        config.setMaximumPoolSize(10);
        return new HikariDataSource(config);
    }
}

Problems:

  • Different values per environment (dev, staging, prod)
  • Secrets in source code
  • Requires recompilation for changes

Solution - Externalize all configuration:

@Configuration
public class DataConfig {

    @Value("${spring.datasource.url}")  // => Externalized
    private String url;

    @Value("${spring.datasource.username}")
    private String username;

    @Value("${spring.datasource.password}")  // => Can use encrypted values
    private String password;

    @Value("${spring.datasource.hikari.maximum-pool-size:10}")  // => Default: 10
    private int poolSize;

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(username);
        config.setPassword(password);
        config.setMaximumPoolSize(poolSize);
        return new HikariDataSource(config);
    }
}

application-dev.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/dev
spring.datasource.username=dev_user
spring.datasource.password=${DEV_DB_PASSWORD}

application-prod.properties:

spring.datasource.url=jdbc:postgresql://prod-db:5432/app
spring.datasource.username=prod_user
spring.datasource.password=${PROD_DB_PASSWORD}

Exception Swallowing

The Problem

@Service
public class OrderService {

    public void processOrder(Order order) {
        try {
            paymentService.charge(order.getTotal());
        } catch (PaymentException e) {
            // => WRONG: Exception swallowed silently
            // => Caller thinks payment succeeded
        }

        orderRepository.save(order);  // => Saved even though payment failed!
    }
}

Problems:

  • Data inconsistency (order saved, payment failed)
  • Silent failures hard to debug
  • Violates fail-fast principle

Solution - Let exceptions propagate or handle properly:

@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)
    public void processOrder(Order order) throws PaymentException {
        paymentService.charge(order.getTotal());  // => Let exception propagate
        orderRepository.save(order);  // => Only saved if payment succeeds
        // => Exception triggers transaction rollback
    }
}

// Or handle exception explicitly
@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)
    public void processOrder(Order order) {
        try {
            paymentService.charge(order.getTotal());
        } catch (PaymentException e) {
            // => Log error
            logger.error("Payment failed for order {}", order.getId(), e);
            // => Re-throw to trigger rollback
            throw new OrderProcessingException("Payment failed", e);
        }
        orderRepository.save(order);
    }
}

See Also

Last updated