Breaking the Cycle: Solving Circular Dependency Problems in Spring Boot

Lejdi Prifti
5 min readDec 9, 2024

--

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.

Thank you for reading!

Connect with me on LinkedIn & X!

--

--

Lejdi Prifti
Lejdi Prifti

Written by Lejdi Prifti

Senior Software Engineer @ Linfa | Building high-quality software solutions

No responses yet