Breaking the Cycle: Solving Circular Dependency Problems in Spring Boot
In Spring Boot development, encountering circular dependencies is a common challenge. This issue arises when two beans depend on each other, creating a loop that leaves Spring unable to determine the correct injection order during application startup.
In this article, we’ll explore a couple of strategies to resolve circular dependency problems in Spring Boot.
Circular Bean References
To illustrate circular bean references, let’s consider two classes: A
and B
. These classes are interdependent, meaning each depends on the other to function.
A
calls B
for x functionality and B
calls A
for y functionality.
import org.springframework.stereotype.Component;
@Component
public class A {
private final B b;
public A(B b) {
this.b = b;
}
public void doSomething() {
System.out.println("Class A is doing something");
b.assist();
}
public void doSomethingElse() {
System.out.println("Class A is doing something else");
}
}
@Component
public class B {
private final A a;
public B(A a) {
this.a = a;
}
public void assist() {
System.out.println("Class B is assisting");
a.doSomethingElse();
}
}
In the code above, A
depends on B
because A
's constructor requires an instance of B
. Simultaneously, B
depends on A
because B
's constructor requires an instance of A
.
When Spring attempts to create these beans, it gets stuck in a loop, as it cannot resolve which bean to create first. The application will not start.
The Problem
Let’s take as reference an imaginary situation. Imagine we are building an e-commerce application tailored for freelancers. In this application, freelancers organize their services into categories. This setup involves two entities: CategoryEntity
and ServiceEntity
. These entities share a Many-To-One relationship, where a single category can contain multiple services.
@Entity
@Table(name = "category_table")
public class CategoryEntity extends BaseEntity {
private static final long serialVersionUID = 1L;
@Column(nullable = false)
private String name;
@Column(nullable = true)
private String description;
// other properties
}
@Entity
@Table(name = "service_table")
public class ServiceEntity extends BaseEntity {
private static final long serialVersionUID = 1L;
@Column(nullable = false)
private String name;
@Column(nullable = true)
private String description;
@ManyToOne
@JoinColumn(name = "category_id", referencedColumnName = "id")
private CategoryEntity category;
// other properties
}
Now, let’s consider adding a crucial new feature to our application: the ability to softly delete a category. But how should this functionality behave? Typically, when a category is deleted, all associated services should also be removed to maintain data consistency.
In the service layer, we have created the CategoryService
and the ServiceService
to interact with the respective repository layers.
Our ServiceService
service is already pointing the CategoryService
service because it grabs from it information about the categories associated to the services.
@Service
public class ServiceService {
private final ServiceRepository serviceRepository;
private final CategoryService categoryService;
public ServiceService(ServiceRepository serviceRepository,
CategoryService categoryService) {
this.serviceRepository = serviceRepository;
this.categoryService = categoryService;
}
@Override
public ServiceEntity create(Long businessId, CreateServiceRequest request) {
ServiceEntity service = ServiceConverter.convertCreateRequestToEntity(request);
service.setCategory(categoryService.getByCategoryId(request.getCategoryId()));
return this.serviceRepository.save(service);
}
// in soft delete, this only updates the deleted_at field
// that stores a timestamp of when the action occurred
@Override
public void deleteByCategoryId(Long categoryId) {
this.serviceRepository.deleteByCategoryId(categoryId, LocalDateTime.now());
}
// Additional methods to interact with services
}
If we attempt to delete services from the CategoryService
by using the ServiceService
class, our application will fail to start due to the circular dependencies discussed in the previous section.
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long categoryId) {
CategoryEntity category = this.getByCategoryId(categoryId);
category.setDeletedAt(LocalDateTime.now());
this.save(category);
serviceService.deleteByCategoryId(categoryId);
}
What could be the solutions and which one is the best?
Solutions
There are several solutions to this problem. In this section, I will introduce two of these approaches.
Using lazy initialization
One of the most common and straightforward approaches, often recommended online, is to use the @Lazy
annotation.
@Service
public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
private final ServiceService serviceService;
public CategoryServiceImpl(CategoryRepository categoryRepository,
@Lazy ServiceService serviceService) {
this.categoryRepository = categoryRepository;
this.serviceService = serviceService;
}
// ...
}
By making one of the dependencies lazily initialized, Spring defers its creation until it is actually needed. Simply said, only when the delete
method of CategoryService
will be called, Spring will inject the ServiceService
bean.
Even though @Lazy
can solve the issue, I do not like it because it can mask underlying architectural issues in your code, such as tightly coupled components or poorly designed dependencies. Moreover, if lazy initialization is invoked in a context where the required bean isn’t ready, it can result in runtime errors or incomplete application states.
That’s why it is often better to refactor the design to eliminate circular dependencies altogether.
Introduce the Business layer
The next solution I’d like to suggest is design refactoring. By design refactoring, I mean introducing an additional layer above the existing services — a business layer.
This approach helps to decouple the services and breaks the circular dependency by placing shared logic and interactions in the business layer. This way, both CategoryService
and ServiceService
can communicate through the business layer, avoiding direct mutual dependencies.
As you can in the image above, ServiceService
and CategoryService
do not know anything about the CategoryBusinessService
and this makes them stable from any changes happening in the CategoryBusinessService
.
On the other hand, by moving the business logic to the CategoryBusinessService
class, you reduce the direct coupling between services, making it easier to maintain and test the application.
@Service
@RequiredArgsConstructor
public class CategoryBusinessServiceImpl implements CategoryBusinessService {
private final CategoryService categoryService;
private final ServiceService serviceService;
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteCategoryById(Long categoryId) {
categoryService.delete(categoryId);
serviceService.deleteByCategoryId(categoryId);
}
}
Conclusion
In conclusion, refactoring the design by introducing a business layer is an effective solution to resolve circular dependencies.
This approach decouples the services, allowing them to interact through the business layer while maintaining a clean and maintainable architecture.
It enhances modularity, testability, and scalability, ensuring the application remains flexible as it grows.