Spring: How to deal with circular dependencies

 


🔄 Solving Circular Dependencies in Spring Boot: A Complete Guide

When your Spring services get tangled up and can't start - here's how to fix it!


🤔 What is a Circular Dependency?

A circular dependency happens when two or more Spring beans depend on each other, creating a loop. Spring can't figure out which bean to create first, so your application fails to start.

Think of it like this: Service A needs Service B, but Service B also needs Service A - it's like two people waiting for each other to go first through a door! 🚪


💥 How Does It Happen? (Real Example)

Let's say you're building an e-commerce app:

@Service
public class OrderService {
    
    @Autowired
    private PaymentService paymentService;
    
    public void createOrder(Order order) {
        // Process order
        paymentService.processPayment(order.getPaymentInfo());
    }
}

@Service
public class PaymentService {
    
    @Autowired
    private OrderService orderService;  // 🚨 CIRCULAR DEPENDENCY!
    
    public void processPayment(PaymentInfo info) {
        // Process payment
        orderService.updateOrderStatus("PAID");  // Oops!
    }
}

Error you'll see:

The dependencies of some of the beans in the application context form a cycle:
┌─────┐
|  orderService defined in file [OrderService.class]
↑     ↓
|  paymentService defined in file [PaymentService.class]
└─────┘

✅ Solution 1: Introduce a Shared Dependency

The Idea: Create a third service that both services can use without depending on each other.

// New shared service
@Service
public class OrderStatusService {
    
    public void updateOrderStatus(String orderId, String status) {
        // Update order status in database
        System.out.println("Order " + orderId + " status updated to: " + status);
    }
}

// Fixed OrderService
@Service
public class OrderService {
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private OrderStatusService orderStatusService;  // ✅ Shared dependency
    
    public void createOrder(Order order) {
        // Process order
        paymentService.processPayment(order.getPaymentInfo());
        orderStatusService.updateOrderStatus(order.getId(), "CREATED");
    }
}

// Fixed PaymentService
@Service
public class PaymentService {
    
    @Autowired
    private OrderStatusService orderStatusService;  // ✅ Same shared dependency
    
    public void processPayment(PaymentInfo info) {
        // Process payment
        orderStatusService.updateOrderStatus(info.getOrderId(), "PAID");
        // No more direct call to OrderService!
    }
}

✨ Benefits:

  • Clean separation of concerns
  • Each service has a single responsibility
  • Easy to test and maintain

✅ Solution 2: Internal Event/Observer Architecture

The Idea: Use Spring's built-in event system to communicate between services without direct dependencies.

// Custom event
public class PaymentProcessedEvent {
    private final String orderId;
    private final String status;
    
    public PaymentProcessedEvent(String orderId, String status) {
        this.orderId = orderId;
        this.status = status;
    }
    
    // getters
    public String getOrderId() { return orderId; }
    public String getStatus() { return status; }
}

// Updated PaymentService (Publisher)
@Service
public class PaymentService {
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;  // ✅ Spring's event publisher
    
    public void processPayment(PaymentInfo info) {
        // Process payment logic
        System.out.println("Payment processed for order: " + info.getOrderId());
        
        // Publish event instead of direct call
        eventPublisher.publishEvent(
            new PaymentProcessedEvent(info.getOrderId(), "PAID")
        );
    }
}

// Updated OrderService (Listener)
@Service
public class OrderService {
    
    @Autowired
    private PaymentService paymentService;
    
    public void createOrder(Order order) {
        // Process order
        paymentService.processPayment(order.getPaymentInfo());
    }
    
    @EventListener  // ✅ Listen for payment events
    public void handlePaymentProcessed(PaymentProcessedEvent event) {
        System.out.println("Order " + event.getOrderId() + 
                          " status updated to: " + event.getStatus());
        // Update order status here
    }
}

✨ Benefits:

  • Loose coupling between services
  • Easy to add more listeners
  • Built into Spring - no external dependencies needed

✅ Solution 3: Using @Lazy Annotation

The Idea: Delay the creation of one dependency until it's actually needed.

@Service
public class OrderService {
    
    @Autowired
    @Lazy  // ✅ This breaks the circular dependency at startup
    private PaymentService paymentService;
    
    public void createOrder(Order order) {
        // PaymentService is created only when this method is called
        paymentService.processPayment(order.getPaymentInfo());
    }
}

@Service
public class PaymentService {
    
    @Autowired
    private OrderService orderService;  // Normal injection
    
    public void processPayment(PaymentInfo info) {
        orderService.updateOrderStatus("PAID");
    }
}

⚠️ Why @Lazy is NOT Recommended:

  1. Hidden Problems: It doesn't solve the root cause, just delays it
  2. Runtime Errors: If there's still a circular call, you'll get StackOverflowError at runtime
  3. Hard to Debug: Problems appear later, making them harder to trace
  4. Performance: Slight delay when the bean is first accessed

🎯 When to Use @Lazy:

  • As a temporary fix while you refactor
  • When you're 100% sure the circular call won't happen at runtime
  • For prototype-scoped beans where circular dependency is acceptable

✅ Solution 4: Using Interfaces to Break Dependencies

The Idea: Create interfaces to invert dependencies and break the tight coupling.

// Create interfaces
public interface OrderStatusUpdater {
    void updateOrderStatus(String orderId, String status);
}

public interface PaymentProcessor {
    void processPayment(PaymentInfo info);
}

// OrderService implements the interface
@Service
public class OrderService implements OrderStatusUpdater {
    
    @Autowired
    private PaymentProcessor paymentProcessor;  // ✅ Depend on interface
    
    public void createOrder(Order order) {
        paymentProcessor.processPayment(order.getPaymentInfo());
    }
    
    @Override
    public void updateOrderStatus(String orderId, String status) {
        System.out.println("Order " + orderId + " updated to: " + status);
    }
}

// PaymentService implements the interface
@Service
public class PaymentService implements PaymentProcessor {
    
    @Autowired
    private OrderStatusUpdater orderStatusUpdater;  // ✅ Depend on interface
    
    @Override
    public void processPayment(PaymentInfo info) {
        System.out.println("Processing payment...");
        orderStatusUpdater.updateOrderStatus(info.getOrderId(), "PAID");
    }
}

✨ Benefits:

  • Follows SOLID principles
  • Makes code more testable (easy to mock interfaces)
  • Reduces tight coupling
  • More flexible for future changes

🏆 Which Solution Should You Choose?

Solution Best For     Difficulty     Maintainability
Shared Dependency      Simple cases     Easy     ⭐⭐⭐⭐
Event Architecture     Complex workflows     Medium     ⭐⭐⭐⭐⭐
@Lazy     Quick fixes     Easy     ⭐⭐
Interfaces     Long-term projects     Medium     ⭐⭐⭐⭐⭐

💡 Best Practices

  1. Design First: Think about your service boundaries before coding
  2. Single Responsibility: Each service should have one clear purpose
  3. Avoid @Lazy: Use it only as a last resort or temporary fix
  4. Use Events: For complex workflows, events make your code more maintainable
  5. Interfaces: Always prefer depending on interfaces over concrete classes

🎯 Key Takeaways

  • Circular dependencies are a design smell - they often indicate poor service boundaries
  • @Lazy is a band-aid, not a cure - fix the root cause instead
  • Events and shared dependencies are usually the best long-term solutions
  • Interfaces make your code more flexible and testable

Remember: If you find yourself with circular dependencies, step back and think about whether your services are doing too much or if they're properly separated. Sometimes the best solution is to redesign your service boundaries! 🚀


Happy coding! 🎉

Comments

Popular posts from this blog

SQL Query and performance tuning - Indexing in depth

Apache Kafka - The basics