JUN0.DEV
JUN0.DEV

Circular References and the Limits of @Lazy

Published on
  • avatarJunyoung Yang

While implementing a gift API in Kakao Tech Campus, I ran into a circular reference between services while separating product logic and option logic. The feature itself looked simple. When creating an option, I had to check whether the product existed. At the same time, ProductService also needed logic that handled option information.

At first, I thought I could just inject the needed services into each other. But when I started the application, a circular reference error happened during bean creation. Adding @Lazy made the application run, but even then it felt like I was pushing a design problem to the future.

This post is a record of not stopping at bypassing the issue with @Lazy, and instead looking again at why the circular reference happened.

Problem

The structure was tangled like this.

OptionService referenced ProductService because it had to check whether the product existed when creating an option. On the other hand, ProductService also referenced OptionService because it handled product detail and option-related logic.

Each dependency did not look strange when seen separately. But when I looked at the overall dependency direction, the two services had to know each other to work. Spring could not create the two beans at application startup, and a circular reference error occurred.

First Attempt

At first, I avoided the error by applying @Lazy and delaying bean creation.

public OptionService(
        OptionRepository optionRepository,
        @Lazy ProductService productService
) {
    this.optionRepository = optionRepository;
    this.productService = productService;
}

This worked for avoiding the startup error. Instead of injecting the real object immediately, Spring injects a proxy first and resolves the dependency when it is actually needed.

But the problem had not disappeared. The dependency was only connected later. The structure where ProductService and OptionService had to know each other still remained. In other words, I avoided the runtime error, but I did not clean up the structure.

Cause Check

The feedback I received during mentoring was simple. A circular reference can be a signal that two classes are too deeply tied together.

So I looked at the structure again with these questions.

  • Do these services really have to depend on each other?
  • Are there parts where depending only on a Repository is enough?
  • Would it be better to move product-option orchestration into a separate layer?
  • Are domain logic and lookup convenience logic mixed inside one service?

After looking at it this way, the problem was not just a Spring configuration issue. The roles of product and option logic had become blurry, and services were directly calling each other whenever a needed method existed. That made the dependency direction tangled.

Approach

First, I separated logic that did not need service-to-service calls. In the option creation flow, if all I needed was to check whether a product existed, I reduced the dependency to the minimum needed for that check.

I also considered placing flows that combine both services into a higher-level orchestration layer instead of forcing them into one of the two services. For example, if more flows needed to handle products and options together, a facade such as ProductOptionFacade could be more natural.

I tried not to keep the bidirectional dependency as-is and simply add @Lazy. It may make the application run temporarily, but as features grow, similar problems are likely to come back.

Takeaway

After this issue, I try not to think of @Lazy first. I first check why the dependency direction became tangled.

Especially in the service layer, I kept these points.

  • Do not treat circular references only as configuration problems.
  • Before services call each other, check whether their roles are separated clearly.
  • If orchestration logic grows, consider a facade or use-case layer.
  • Separate cases that only need a temporary workaround from cases where the structure should be reviewed.

In the end, this work was less about learning @Lazy itself and more about looking again at the dependency direction.