Skip to main content

Circular Dependencies in Spring Boot 4: Why Startup Fails and 4 Ways to Fix It

Spring Boot 4 refuses to start with “The dependencies of some of the beans in the application context form a cycle”. This article reproduces the failure, reads the report, and applies four fixes (redesign, events, @Lazy, ObjectProvider), plus what allow-circular-references really does on Spring Framework 7.0.9. Every claim comes from a committed test transcript.

Add one constructor parameter to a service, start the application, and Spring Boot refuses to boot. It prints a box drawn in text characters: three bean names stacked inside a border, arrows between them, and a paragraph saying that relying upon circular references is discouraged and prohibited by default. The message is accurate and complete, and it still costs people an afternoon, because it names the beans in the ring and says nothing about which of the arrows to cut. This article reproduces that failure with three ordinary services, reads the report line by line, and then applies the four fixes that actually resolve it — redesign, events, @Lazy and ObjectProvider — each against the same application so the results are comparable. It also runs the fix everyone finds first, spring.main.allow-circular-references=true, and reports what it really did on Spring Framework 7.0.9, which is not what older write-ups describe: an @Async bean inside the cycle started cleanly, an INFO line about “another thread” absorbed the exception, and one service was constructed twice. Every code block links to a file in the core-beans module of a companion repository, and every console block is quoted from a transcript that a test run wrote, not typed in by hand. There is no separate documentation folder: the deeper material sits in the collapsible “going deeper” sections next to the paragraph each one extends.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1). The start-up failures are Spring Boot runs without a web server. A few scenarios use a plain Spring context instead, and the article says where, because plain Spring allows circular references by default and Boot does not.

The report: three beans in a ring, and no hint which arrow to cut

The application is deliberately ordinary. OrderService places an order, PaymentService charges it, and NotificationService sends the confirmation. To write the e-mail, the notification service asks OrderService to describe the order, so it takes OrderService as a constructor parameter (NotificationService.java):
@Service
public class NotificationService {

    private final OrderService orders;

    public NotificationService(OrderService orders) {
        this.orders = orders;
    }

    public String confirm(String sku) {
        return "e-mail says: " + orders.describe(sku);
    }
}
OrderService takes PaymentService the same way, and PaymentService takes NotificationService, so the constructor parameters form a ring: orders need payments, payments need notifications, notifications need orders. Starting it with SpringApplication (CycleFailureTest.java) fails, and Boot prints this (from 20-boot-cycle-failure-report.txt):
***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”
|  notificationService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/NotificationService.class]
โ†‘     โ†“
|  orderService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/OrderService.class]
โ†‘     โ†“
|  paymentService defined in file [<core-beans>/target/classes/com/ankurm/corebeans/cycles/broken/PaymentService.class]
โ””โ”€โ”€โ”€โ”€โ”€โ”˜


Action:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
1 notificationService 2 orderService 3 paymentService constructor parameter constructor parameter already in creation Numbers are the order in which the container began creating each bean in the run quoted below.
The report is a picture of the ring, and it reads like one. Each row is a bean name followed by where it was defined (the transcript replaces the absolute build directory with <core-beans>, which is the only edit to it); each bean needs the one below it, the arrows between rows are formatting rather than direction, and the last row closes the box, meaning the bean at the bottom needs the one at the top. The Action paragraph is the same for every cycle. What the report cannot say is which of those requirements is the one that should not exist, because for Spring all three are equally legitimate. That is a design question, and the rest of the article is about answering it. The first row is not the culprit, either. It is simply where the container started: in the run above it happened to begin with NotificationService, so the ring is listed from there. Ask for OrderService first instead and the same ring is entered at a different point, as the next section shows.
Going deeper: where the report comes from, and the same ring written as @Bean methods

The box is drawn by a FailureAnalyzer named BeanCurrentlyInCreationFailureAnalyzer, which is present in the spring-boot 4.1.1 jar together with two nested helper classes (37-early-reference-bytecode.txt). It only runs when the failure happens inside SpringApplication.run. A test or script that builds a bare AnnotationConfigApplicationContext gets the raw exception and none of the drawing, which is one reason cycles are easier to read in a real start-up than in a unit test. The mechanism is described in the Boot reference page on SpringApplication.

The same ring written with @Bean methods (BeanMethodCycle.java) produces the same box, but the “defined in” part names the configuration class rather than a class file (22-bean-method-cycle-failure-report.txt):

โ”Œโ”€โ”€โ”€โ”€โ”€โ”
|  clock defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
โ†‘     โ†“
|  calendar defined in com.ankurm.corebeans.cycles.beanmethods.BeanMethodCycle
โ””โ”€โ”€โ”€โ”€โ”€โ”˜

The parameter names of the two @Bean methods are what create the ring: clock(Calendar calendar) and calendar(Clock clock). There is no annotation to look for, which is why a cycle of this shape survives code review more often than a constructor cycle does.

Going deeper

Why Spring cannot simply build them in some order

Constructor injection means a bean cannot exist until every one of its parameters does. To build OrderService the container needs a finished PaymentService; to build that it needs a finished NotificationService; and to build that it needs a finished OrderService. There is no order in which all three can be built, and no amount of cleverness in the container changes that, because the objects cannot be constructed without each other. What the container actually does is keep a set of the singletons it is in the middle of creating. When it is asked for a bean that is already in that set, it has found a cycle and throws BeanCurrentlyInCreationException. A second test (CycleFailureTest.java) registers a tiny InstantiationAwareBeanPostProcessor (CreationTracer.java) that logs each bean the moment creation starts, and prints the exception classes from the outside in (21-cycle-exception-and-creation-order.txt):
--- exception classes, outermost first ---
UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException

--- root cause ---
org.springframework.beans.factory.BeanCurrentlyInCreationException
Error creating bean with name 'notificationService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?

--- creation order (one line per bean the container began to create) ---
start creating notificationService
start creating orderService
start creating paymentService
The three start creating lines are the diagram from the previous section in text form: the container began notificationService, needed orderService, began that, needed paymentService, began that, and then paymentService asked for notificationService again. The root cause names the bean where the container noticed, which is the first bean in the ring, not a bean that is wrong. The three nested UnsatisfiedDependencyExceptions are the three constructor parameters it was resolving at the time. That distinction matters when the entry point changes. With spring.main.lazy-initialization=true nothing is created at start-up, so the ring is entered wherever the first request lands (23-lazy-initialization-hides-the-cycle.txt):
context started: true
getBean(OrderService.class) failed: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'orderService': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
Same three classes, same ring, but now the application starts and the exception names orderService, because that is the bean the test asked for first. Lazy initialisation did not remove the problem; it moved the failure from start-up to the first request that touches the ring, which is a worse place to find out.
Going deeper: why field and setter cycles used to be allowed, and where Boot changed the default

A bean created through its constructor cannot be shared until the constructor has returned, so a constructor cycle can never be resolved. A bean populated through fields or setters can be shared earlier: Spring creates the bare object, publishes a reference to it in an “early” cache, and injects the rest afterwards, so a bean that needs it can be handed that half-finished object. That is the trick behind the three internal singleton caches, and the core-di transcript 24 prints them from a live context.

Spring Framework still allows such cycles by default, but Spring Boot switched them off in 2.6. The 2.6 release notes say “Circular references between beans are now prohibited by default”, and Boot 4.1.1’s own configuration metadata still says so (from 36-main-property-defaults.txt):

spring.main.allow-circular-references    default=False
spring.main.lazy-initialization          default=False

A plain AnnotationConfigApplicationContext, which is what many unit tests use, does not go through SpringApplication and therefore does not get that default. A field cycle starts happily in it (core-di transcript 08 shows the same field cycle failing under Boot and starting under plain Spring). A green test built on a bare context is not evidence that the Boot application will start.

Going deeper

Turning on allow-circular-references: what it repairs, and what it hides

The Action paragraph offers a last resort: set spring.main.allow-circular-references to true. It does what it says for cycles that involve fields or setters, and it is worth seeing exactly what state the beans are in when it does. The demo (FirstService.java and SecondService.java) is two field-injected services, each with a @PostConstruct that logs what it can see:
    @PostConstruct
    void init() {
        Trace.log("SecondService @PostConstruct sees first.isReady() = " + first.isReady());
    }
With the flag off the context fails, as above. With it on, it starts, and the log shows what each bean saw (24-allow-circular-half-built-bean.txt):
--- flag on ---
started: true
start creating firstService
FirstService constructed
start creating secondService
SecondService constructed
SecondService @PostConstruct sees first.isReady() = false
FirstService @PostConstruct done, ready=true
1 firstService is constructed 2 an early reference to it is published 3 field injection: firstService needs secondService 4 secondService is constructed, receives the early firstService 5 secondService @PostConstruct: first.isReady() is false 6 firstService @PostConstruct runs, ready becomes true Left lane: firstService. Right lane: secondService.
SecondService finished its own initialisation while FirstService was still in the middle of its own: it was handed a reference to an object whose @PostConstruct had not run. The diagram is the whole trade. Nothing failed, nothing warned, and one of the two beans spent a moment in a state its author never wrote a line of code for. If SecondService had used first for anything real in that callback, it would have used a bean that was not ready. Two limits are worth knowing before reaching for the flag. First, it cannot repair a constructor cycle at all, because there is no half-built object to share yet (core-di transcript 08):
--- Spring Boot, constructor cycle, spring.main.allow-circular-references=true ---
FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
Second, it interacts with proxies, and that is where I expected the classic failure and found something more interesting. The next paragraphs are the part of this article I did not expect to write. A bean that gets proxied (by @Cacheable or @Async, say) has a problem in a cycle: the object it hands out early is the raw one, and the proxy is created afterwards. Some proxy creators solve that by producing the proxy early. AbstractAutoProxyCreator, the base of the proxy creator that @EnableCaching registers, declares getEarlyBeanReference. AbstractAdvisingBeanPostProcessor, the base class of the @Async post-processor, has no member with “early” in its name (both facts from 37-early-reference-bytecode.txt, read with javap). The @Cacheable version behaves as you would hope (CachedFirst.java, 26-allow-circular-cacheable-early-proxy.txt):
started: true
CachedFirst bean is a proxy      : true (CachedFirst$$SpringCGLIB$$0)
CachedSecond.first is a proxy    : true
CachedSecond.first == the bean   : true
two calls through the injected reference return 1 and 1, method body ran 1 time(s)
The bean that received the reference holds the proxy, not the raw object, so the cache works through it. For @Async the textbook expectation is a start-up failure with a message about the bean having been injected in its raw version. On Spring Framework 7.0.9 with an @Async method on one bean of a field cycle (AsyncFirst.java), that is not what happened (25-allow-circular-async-raw-reference.txt):
--- eager start-up (the default) ---
start creating com.ankurm.corebeans.cycles.allowed.AsyncFirst
start creating com.ankurm.corebeans.cycles.allowed.AsyncSecond
AsyncSecond constructed, identity 2f0ed952
AsyncSecond.setFirst on instance 2f0ed952 received a proxy: false
AsyncSecond constructed, identity 5a8816cc
AsyncSecond.setFirst on instance 5a8816cc received a proxy: true
started: true
AsyncFirst bean is a proxy    : true
AsyncSecond.first is a proxy  : true
AsyncSecond.first == the bean : true
log line from DefaultListableBeanFactory (INFO):
  Bean 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' marked for pre-instantiation (not lazy-init) but currently initialized by other thread - skipping it in mainline thread
The context started. AsyncSecond was constructed twice: the first instance received a raw AsyncFirst (received a proxy: false), and a second instance, built later, received the proxy. The only trace is one INFO line whose wording talks about a different thread, in a program with only one. The classic error does still exist, and the same transcript shows it, but only on a path that does not go through start-up pre-instantiation, here a getBean under lazy initialisation (same transcript, 25-allow-circular-async-raw-reference.txt):
--- spring.main.lazy-initialization=true, then getBean(AsyncFirst.class) ---
started: true
getBean(AsyncFirst.class) failed: BeanCurrentlyInCreationException
root cause: Error creating bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst': Bean with name 'com.ankurm.corebeans.cycles.allowed.AsyncFirst' has been injected into other beans [com.ankurm.corebeans.cycles.allowed.AsyncSecond] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example.
A clean start-up proves less than it used to. With the flag on, a cycle that involves an @Async bean was repaired silently at start-up on Spring Framework 7.0.9, at the price of constructing one bean twice, and produced the raw-version error on the first lazy getBean. Whether you see the exception depends on which path reaches the bean first. I did not run Framework 6 to compare, so I cannot say when this changed; the accordion below records what I can support and what I am inferring.
Going deeper: where I think the exception went, and how to check it yourself

The message that would have been thrown is still in the container: AbstractAutowireCapableBeanFactory in the 7.0.9 jar still contains both the allowRawInjectionDespiteWrapping check and the wording quoted in the lazy run above. What matters is what happens next. DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition) has an exception-table entry that catches BeanCurrentlyInCreationException around the call that instantiates a singleton (from 37-early-reference-bytecode.txt):

--- DefaultListableBeanFactory.preInstantiateSingleton(String, RootBeanDefinition): exception table ---
Exception table:
from    to  target type
157   162   165   Class org/springframework/beans/factory/BeanCurrentlyInCreationException

The handler logs the INFO line quoted earlier, and the loop moves on; when it reaches the second bean, that bean is built again and this time receives the finished proxy. That is my reading of the bytecode together with the observed double construction, not something I stepped through in a debugger, and the log wording (“initialized by other thread”) suggests the catch was written for beans initialised in the background, with the cyclic case being caught by the same handler. If you depend on this, verify it on your version with the module’s test rather than on my description.

Two practical consequences. A constructor with side effects (opening a connection, registering a listener) can run twice for a bean that is part of such a cycle. And the presence of that INFO line in a start-up log is now a reasonable thing to grep for when you suspect a cycle you did not intend.

Going deeper

Fix 1: redesign — the ring is usually a missing fourth bean

Read each arrow in the ring and ask what it is for. OrderService needs PaymentService to charge. PaymentService needs NotificationService to confirm. But NotificationService needs OrderService only for one method, describe(sku); it never places an order. That single read is the whole reason for the cycle, and it belongs in a bean of its own (OrderCatalog.java):
@Service
public class OrderCatalog {

    public String describe(String sku) {
        return "1 x " + sku;
    }
}
OrderService and NotificationService now both depend on the catalog, and neither depends on the other (NotificationService.java):
@Service
public class NotificationService {

    private final OrderCatalog catalog;

    public NotificationService(OrderCatalog catalog) {
        this.catalog = catalog;
    }

    public String confirm(String sku) {
        return "e-mail says: " + catalog.describe(sku);
    }
}
Before orderService paymentService notificationService The dashed arrow closes the ring. After orderService paymentService notificationService OrderCatalog Both beans that needed the lookup now share one bean that needs nothing.
The before panel is the ring; the after panel has none, because the arrow that closed it was really an arrow to a lookup and has been re-pointed at the lookup. The application behaves identically (27-fix-redesign.txt):
started: true
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
Going deeper: three shapes of cycle and the usual cure for each

Rings tend to fall into one of three shapes, and the shape points at the fix before you write any code.

A read. One side needs data or a computation the other side happens to own, as above. Extract it. The new bean has no reason to depend on either of the old ones, so it cannot re-create the ring. This is the cure that leaves the code better, not merely working.

A callback. One side needs to be told when the other has finished something. That arrow points the wrong way for the layer it lives in. Either turn it into an event (the next fix) or invert the dependency: the lower-level bean declares a small interface it calls, the higher-level bean implements it, and Spring wires the implementation in. The compile-time arrow now points down the layers and the ring is gone.

Shared state. Two beans read and write the same data and each reached for the other to get it. Give the state a home that both depend on. If you find yourself doing this for a large chunk of behaviour, the two beans are probably one bean, and merging them is a legitimate fix.

Going deeper

Fix 2: publish an event instead of calling back

When a bean only needs to react to something another bean did, the second bean does not need a reference to the first. PaymentService needs to tell somebody that a payment was captured, not to know that the somebody is a notification service. It can publish an event through the ApplicationEventPublisher that the container provides (PaymentService.java):
@Service
public class PaymentService {

    private final ApplicationEventPublisher events;

    public PaymentService(ApplicationEventPublisher events) {
        this.events = events;
    }

    public String charge(String sku) {
        events.publishEvent(new PaymentCaptured(sku));
        return "charged";
    }
}
The event is a plain record (PaymentCaptured.java), and NotificationService listens for it with @EventListener (NotificationService.java). It still takes OrderService through its constructor, but nothing takes NotificationService any more, so the ring has no way back:
    @EventListener
    void onPaymentCaptured(PaymentCaptured event) {
        if (event.sku().equals("boom")) {
            throw new IllegalStateException("mail server down");
        }
        Trace.log("listener on thread " + Thread.currentThread().getName()
                + ": e-mail says: " + orders.describe(event.sku()));
    }
orderService paymentService PaymentCaptured notificationService Solid arrows are constructor parameters. Dashed arrows are the event: the container delivers it, neither bean holds the other.
Solid arrows are constructor parameters, the edges the container follows when it constructs beans; the dashed ones are the event, which the container delivers at run time. The bean graph is a tree again. Running it (28-fix-events.txt):
started: true
place("kettle") -> order placed: charged
listener on thread main: e-mail says: 1 x kettle

--- what the publisher sees when the listener throws ---
place("boom") threw IllegalStateException: mail server down
An event is a call with the caller’s name removed. The listener ran on the main thread, inside the publisher’s call, and when it threw, place("boom") threw the same exception. Publishing an event does not make the reaction asynchronous, isolated or optional. It only removes the compile-time dependency. If the reaction must not fail the operation, or must wait until a transaction commits, that takes deliberate choices, which the article on Spring application events covers.
Going deeper: when an event is the wrong tool

An event fits a notification: several parties may care, the publisher does not know or want to know who, and nothing flows back. It does not fit a question. If NotificationService needed an answer from OrderService (the order’s total, say), publishing an event and hoping for a reply would just be a call written badly. In that case you are in the “read” shape from the previous section and the cure is to extract the read.

Events also make the flow harder to follow. “Who reacts to PaymentCaptured?” is a search over the codebase for listeners, not a click through a constructor. That is a fair price for decoupling two modules and a poor one for decoupling two classes that were always going to change together.

Going deeper

Fix 3: @Lazy — a proxy stands in until the first call

When the ring is real and you cannot restructure it today, @Lazy on one constructor parameter breaks the construction ring without changing what the beans call (NotificationService.java):
    /** @Lazy makes Spring inject a proxy and look the real OrderService up on the first call. */
    public NotificationService(@Lazy OrderService orders) {
        this.orders = orders;
    }
notificationService OrderService proxy generated subclass, holds no state real orderService injected at start-up looked up on the first call Nothing asks for orderService while notificationService is being built, so the ring cannot close during construction.
The container injects a generated proxy instead of looking up OrderService, and resolves the real bean the first time a method is called on it. The class that arrives is a subclass generated at run time (29-fix-lazy.txt):
started: true
NotificationService holds a com.ankurm.corebeans.cycles.lazy.OrderService$$SpringCGLIB$$0
place("kettle") -> order placed: charged, e-mail says: 1 x kettle
That works, and it is the smallest edit of the four. It also has three ways of failing that are not obvious from the annotation. It needs to subclass the type. When the parameter is a class rather than an interface, the proxy is a generated subclass, and a final class cannot be subclassed. The failure is at start-up (CycleFixesTest.java, 31-lazy-on-a-final-class.txt):
started: false
exception classes: BeanCreationException -> AopConfigException -> IllegalArgumentException
root cause: java.lang.IllegalArgumentException: Cannot subclass final class com.ankurm.corebeans.CycleFixesTest$FinalTarget
It delays the lookup, not the bean. @Lazy on a parameter tells the container not to resolve this injection until it is used. The target is still an ordinary singleton, so the context still creates it at start-up, and if it cannot be built the start-up fails exactly as before. Only when the target bean is itself lazy does the failure move to the first call (32-lazy-injection-point-versus-lazy-bean.txt):
--- @Lazy on the parameter, target is an ordinary singleton ---
started: false
root cause: cannot reach the payment provider

--- @Lazy on the parameter AND on the target bean ---
started: true
first call threw: BeanCreationException -> BeanInstantiationException -> IllegalStateException
root cause: cannot reach the payment provider
Using the proxy during construction rebuilds the ring. A @PostConstruct that calls the lazy dependency forces the lookup while the bean is still being built, and the container is back in the cycle (35-lazy-used-during-construction.txt, under Boot; a plain Spring context would have started, for the reason in the earlier accordion):
started: false
exception classes: BeanCreationException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'cycleFixesTest.LazyUserA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
Going deeper: what @Lazy on an injection point actually builds

On a parameter or field, @Lazy is handled by the container’s autowire candidate resolver, which builds a proxy whose target source calls back into the bean factory on first use. For a class type the proxy is a generated subclass (the $$SpringCGLIB$$0 name in the run above), which is the source of the final-class failure, where the exception chain is BeanCreationException -> AopConfigException -> IllegalArgumentException: Cannot subclass final class. The behaviour is described in the reference section on @Autowired and in lazy-initialized beans.

@Lazy also leaves the cycle in the object graph. NotificationService can call OrderService, which can call PaymentService, which can call NotificationService again, so a careless call chain can now recurse forever at run time instead of failing at start-up. If you choose this fix, choose the parameter where the lazy call is the rarest one.

Going deeper

Fix 4: ObjectProvider — look it up at the call site

ObjectProvider<T> is the explicit version of the same idea. Instead of a proxy pretending to be the bean, the class holds a handle and asks for the bean when it needs it (NotificationService.java):
@Service
public class NotificationService {

    private final ObjectProvider<OrderService> orders;

    /** Nothing is resolved here: the provider is a handle, not the bean. */
    public NotificationService(ObjectProvider<OrderService> orders) {
        this.orders = orders;
    }

    public String confirm(String sku) {
        return "e-mail says: " + orders.getObject().describe(sku);
    }
}
Nothing is resolved in the constructor, so nothing closes the ring, and the type does not have to be subclassable, because there is no proxy. The full run, including the two ways to defeat it (30-fix-object-provider.txt):
started: true
place("kettle") -> order placed: charged, e-mail says: 1 x kettle

--- the same provider, but getObject() is called inside the constructor ---
started: false
root cause: Error creating bean with name 'cycleFixesTest.EagerNotifier': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?

--- the provider needs no proxy, so a final class is fine ---
started: true
hello() -> hello
The second block is the trap: calling getObject() from the constructor asks for the bean while the ring is still being built, so it fails the same way a constructor parameter would. The provider has to be called where it is used, not where it is stored. The third block shows the advantage over @Lazy: a provider of a final class starts and works.
@Lazy parameterObjectProvider<T>
What the class holdsa generated proxy that looks like the beana handle; the bean is fetched by calling getObject()
Visible at the call siteno, the code reads as a normal callyes, every use says it is a lookup
Works for a final classno (start-up failure above)yes (last block above)
Can be absent or optionalnoyes, getIfAvailable() returns null
Failure if used during constructionstart-up failure (transcript 35)start-up failure (transcript 30)
Going deeper: the deferred lookup is the only thing these two have in common with each other

Both fixes leave the ring in place and only keep it from being walked during construction. That is a fair description of what an ObjectProvider is for: an optional bean, a prototype fetched per call, a bean that may be absent in some profiles. Using it purely to hide a cycle is legal and worth a comment in the code, because the next reader will otherwise assume the indirection is there for one of those reasons. The core-di transcript 13 shows the optional and multiple-candidate uses.

Going deeper

Cycles that do not look like A → B → A

The ring in a real codebase is rarely three services that obviously call each other. A common shape hides it behind a collection. A Dispatcher injects every Handler; one Handler wants to re-dispatch, so it injects the Dispatcher (HiddenCyclesTest.java):
@Component
static class Dispatcher {
    private final List<Handler> handlers;

    Dispatcher(List<Handler> handlers) {
        this.handlers = handlers;
    }

    String dispatch(String message) {
        return handlers.stream().map(h -> h.handle(message)).toList().toString();
    }
}
@Component
static class RedispatchingHandler implements Handler {
    private final Dispatcher dispatcher;

    RedispatchingHandler(Dispatcher dispatcher) {
        this.dispatcher = dispatcher;
    }

    @Override
    public String handle(String message) {
        return "seen " + message;
    }
}
Neither class mentions the other by name until the constructor, and the ring only exists because the container resolves List<Handler> to include this handler. The failure is the ordinary one, and the same provider repair works (33-dispatcher-handler-cycle.txt):
--- Handler injects the Dispatcher ---
started: false
exception classes: UnsatisfiedDependencyException -> UnsatisfiedDependencyException -> BeanCurrentlyInCreationException
root cause: Error creating bean with name 'hiddenCyclesTest.Dispatcher': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?

--- Handler injects ObjectProvider<Dispatcher> ---
started: true
dispatch("ping") -> [seen ping (dispatcher available: true)]
Going deeper: three more places a cycle hides

@DependsOn in both directions. There is no injection at all, only an ordering constraint, and the container reports it with different words (34-depends-on-cycle.txt):

started: false
exception classes: BeanCreationException
root cause: Error creating bean with name 'b' defined in com.ankurm.corebeans.HiddenCyclesTest$DependsOnCycle: Circular depends-on relationship between 'b' and 'a'

Lazy initialisation. Setting spring.main.lazy-initialization=true for a faster development start-up hides every cycle until the first request touches it (the transcript in the second section). A production profile that leaves it off and a development profile that turns it on will disagree about whether the application starts. Boot’s default is false (36-main-property-defaults.txt).

@Bean method parameters. The two-method ring in the first section’s accordion has no constructor and no annotation to grep for. If a cycle appears after a refactor that only touched configuration classes, read the parameter lists of the @Bean methods before anything else.

Going deeper

Which fix, and whether the ring should exist at all

Does one side only read something the other owns? Does one side only react after the other finishes? Is the ring part of the design (registry and its plug-ins)? Extract a bean fix 1: the cure that improves the code Publish an event fix 2: still a synchronous call ObjectProvider or @Lazy fixes 3 and 4: the ring stays allow-circular-references not a fix hides a half-built bean and can swallow an error yes yes yes
Read the questions top to bottom and stop at the first yes. The first two are the shapes from the redesign accordion, and they are cures. The third is the honest case for keeping the ring, and there the choice between the provider and the proxy is mostly about whether you want the deferral to be visible. The dashed box is separate on purpose: the flag repairs some field and setter cycles, leaves a bean half-built while it does, and on Framework 7.0.9 can turn a start-up error into an INFO line.
FixRemoves the ringCostsFails whenTranscript
Redesignyesa new bean and some editsthe “read” is actually most of the class27
Eventin the bean graph, not at run timea synchronous, exception-propagating call the reader must search forthe caller needs an answer28
@Lazyno, defers ita proxy; the type must be subclassablefinal class, use during construction29, 31, 32, 35
ObjectProviderno, defers itan explicit lookup in each methodgetObject() called in the constructor30
allow-circular-referencesnoa half-built bean; a possible silent repairany constructor cycle24, 25
Should you even keep the cycle? Usually not. Extracting a bean leaves the code with one fewer thing to explain, while @Lazy and a provider both leave the ring in place and deserve a comment saying why. A @Lazy or a provider is fine for glue you do not own or a registry that really does need to know its members, and a poor default for two application services that grew into each other. If a fix is cheap to reverse and the redesign is not, take the cheap one and write down what would justify the redesign. The one setting I would not take is the flag: it hides the question instead of answering it.

Going deeper

  • Run everything yourself: the module README has the quick-start and the index of transcripts; ./scripts/run-all.sh regenerates all of them

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.