Scientyfic World

Write Structured content faster — Get your copy of DITA Decoded today. Buy it Now

Decoupling JavaFX + Spring Boot ViewModels to Avoid Circular Dependencies

In a JavaFX + Spring Boot application structured using the MVVM (Model–View–ViewModel) pattern, maintaining clean separation of concerns becomes crucial—especially when coordinating logic across multiple ViewModels. Consider a multi-step login flow. Two ViewModels manage distinct responsibilities:

  • SharedLoginViewModel: Governs the overall workflow, such as which step is currently active, and transitions between those steps.
  • TokenCreationViewModel: Handles token creation logic, including form validation, credential submission, and token generation.

After a token is successfully created, the application must advance to the next workflow step—e.g., moving from the token entry view to a verification or confirmation stage. From an architectural perspective, this means the token ViewModel must somehow notify the login workflow ViewModel that the token creation step has completed.

This requirement creates a coupling challenge. If TokenCreationViewModel directly invokes methods on SharedLoginViewModel, Spring Boot’s dependency injection system fails with a circular dependency error. This happens because both ViewModels end up depending on each other—either directly or transitively—resulting in Spring being unable to instantiate the beans.

The goal is to enable communication from TokenCreationViewModel to SharedLoginViewModel (to trigger workflow progression), but without violating MVVM boundaries or introducing unresolvable circular references in the Spring context.

This blog explores several architectural strategies for achieving that decoupling—evaluating their trade-offs and showing implementation details—so that your ViewModels remain isolated, composable, and compliant with both MVVM principles and Spring’s bean lifecycle rules.

The Circular Dependency Problem

A naive implementation might have one ViewModel directly hold a reference to the other (via Spring’s dependency injection). For example, injecting SharedLoginViewModel into TokenCreationViewModel (or vice versa) so that one can call methods on the other. However, this leads to a circular reference where each depends on the other, which Spring’s IoC container cannot resolve:

Error: “Requested bean is currently in creation: Is there an unresolvable circular reference?”

In MVVM architecture, circular dependencies indicate a design smell. It tightly couples components that should be independent. As one expert notes, needing two-way references between ViewModels is often a code smell, and there are cleaner ways to communicate between them.

Attempted Solutions and Why They Fell Short

Let’s review a few approaches that were tried to decouple these ViewModels, and discuss their pros/cons:

  • Direct Injection:
    Injecting one ViewModel into the other was the simplest idea, but it outright caused the circular dependency described above. This is not viable in Spring because the beans can’t be constructed when they depend on each other.
  • Spring Events (Publish/Subscribe):
    Using Spring’s application event system decouples the sender and receiver. In this approach, TokenCreationViewModel publishes an event (e.g. TokenCreatedEvent), and SharedLoginViewModel listens for that event to advance the workflow. This works – the ViewModels don’t know about each other at all, only about the event type – but it felt like overkill for such a simple case. Defining custom event classes and listeners adds complexity if all we need is a straightforward method call. Spring’s event mechanism is powerful for broader decoupling needs, but for a single step transition it introduces a lot of boilerplate.
  • Interface Injection (LoginNavigationService):
    A custom interface LoginNavigationService was created with methods like advanceStep() and cancel(). SharedLoginViewModel implements this interface (meaning it provides the actual workflow-advancing behavior), and TokenCreationViewModel is injected with a LoginNavigationService instead of directly with the SharedLoginViewModel. At runtime, Spring injects the SharedLoginViewModel (since it is a LoginNavigationService) into the token ViewModel via the interface, as shown below:
public interface LoginNavigationService {
    void advanceStep();
    void cancel();
}

@Component
public class SharedLoginViewModel implements LoginNavigationService {
    @Override
    public void advanceStep() {
        // Advance the workflow to the next step
    }
    @Override
    public void cancel() {
        // Cancel the workflow
    }
}

@Component
public class TokenCreationViewModel {
    private final LoginNavigationService navigationService;
    @Autowired
    public TokenCreationViewModel(LoginNavigationService navigationService) {
        this.navigationService = navigationService;
    }
    public void handleTokenCreated() {
        // Token creation succeeded, notify workflow to advance
        navigationService.advanceStep();
    }
}
JavaScript

This interface approach breaks the direct dependency – TokenCreationViewModel doesn’t need to know about SharedLoginViewModel, only the interface. It uses dependency inversion to invert the relationship: the higher-level workflow (SharedLogin) now fulfills an interface that lower-level components use. In practice, this eliminated the circular bean reference. However, the developer felt it was “awkward” and wasn’t sure if it’s good practice. Essentially, the login ViewModel is doubling as a service implementation for navigation.

Is the Interface Approach Good Practice?

Using an interface (like LoginNavigationService) to decouple is a well-known technique to avoid circular dependencies. It adheres to the Dependency Inversion Principle (DIP) – high-level components (workflow logic) and low-level components (token creation logic) communicate through an abstraction. This is generally considered good design. In fact, breaking one side of a circular dependency by introducing an interface is a common fix in Spring applications.

Pros of the interface approach:

  • Loose Coupling: The token ViewModel doesn’t need to know who or what will handle the advanceStep() call. It could be a SharedLoginViewModel or any other future implementation. This makes components more modular and testable.
  • Simple Contract: The interface defines a minimal contract (in this case, just two methods) for what’s needed to advance or cancel the login process. This keeps communication focused and clear.

Cons / Considerations:

  • Conceptual Overhead: Introducing an interface for what might be a one-to-one communication can feel like extra indirection. In our example, there is effectively only one implementation of LoginNavigationService. Some developers find it odd to create an abstraction that has only a single implementor. It “feels” like just a roundabout way to call a method on SharedLoginViewModel.
  • MVVM Purity: In MVVM, ideally ViewModels shouldn’t directly control each other. They might instead share Models or use a mediator. With the interface approach, we’ve improved coupling at the code level, but logically the TokenCreation VM is still commanding the SharedLogin VM (just through an interface). This begs the question: could there be an even cleaner separation where the SharedLoginVM simply observes changes instead of being told what to do?

In summary, the interface approach is a valid and commonly used solution to break circular dependencies. It’s essentially implementing a callback. If the awkwardness is merely about having an extra interface, it may be a small price for decoupling. However, it’s worth exploring alternatives that align even closer with MVVM principles, such as using observable state or mediator patterns.

Alternative Strategies to Decouple ViewModels

Beyond interfaces and event listeners, there are other patterns to allow two ViewModels to communicate without knowing about each other. Here are a few approaches, along with their implications:

1. Shared Observable State (Observer Pattern)

Instead of one ViewModel explicitly telling the other to advance, we can use a shared piece of state that both ViewModels know about. This state can be a simple model or even just an observable property. The idea is:

  • TokenCreationViewModel updates a shared state (for example, setting a flag or status when the token is created).
  • SharedLoginViewModel observes that state and reacts by advancing the workflow.

This approach leverages the Observer pattern and is very much in spirit with MVVM: one part of the system reacts to changes in data, rather than being directly commanded. As one JavaFX expert noted, if the login workflow depends on the token state, then “the login view should have a reference to the token state and should observe it … and respond to changes accordingly.”. In other words, the SharedLoginViewModel can monitor a token creation status without the token ViewModel needing to call it directly.

Implementation idea: Create a separate model class or a simple shared data object. For example, a LoginWorkflowModel that holds the current step or a flag indicating “token created”. This model can be a Spring singleton bean or passed to both VMs. If using JavaFX, it could use JavaFX Properties for easy observation.

@Component
public class LoginWorkflowModel {
    // Observable property indicating the current step or token status
    private final BooleanProperty tokenCreated = new SimpleBooleanProperty(false);
    public BooleanProperty tokenCreatedProperty() { return tokenCreated; }
    public void setTokenCreated(boolean created) { tokenCreated.set(created); }
    public boolean isTokenCreated() { return tokenCreated.get(); }
}
JavaScript

Both ViewModels would have this LoginWorkflowModel injected:

@Component
public class TokenCreationViewModel {
    private final LoginWorkflowModel workflowModel;
    @Autowired
    public TokenCreationViewModel(LoginWorkflowModel model) {
        this.workflowModel = model;
    }
    public void handleTokenCreated() {
        // ... token creation logic ...
        workflowModel.setTokenCreated(true); // update shared state
    }
}

@Component
public class SharedLoginViewModel {
    public SharedLoginViewModel(LoginWorkflowModel model) {
        // Observe changes in the workflow model's state
        model.tokenCreatedProperty().addListener((obs, oldVal, newVal) -> {
            if (newVal) {
                advanceStep(); // react to the token creation event
            }
        });
    }
    public void advanceStep() {
        // Advance the workflow to the next step
    }
}
JavaScript

In this design, neither ViewModel references the other – both rely on the LoginWorkflowModel. The SharedLoginVM advances when the model’s state indicates it should. This is a clean separation: TokenCreationVM only knows about updating the model, and SharedLoginVM only knows about observing the model. This approach aligns with MVVM’s emphasis on state and bindings rather than direct viewmodel-to-viewmodel calls.

Pros: Very decoupled; easy to follow data flow (token VM -> model -> login VM). JavaFX properties or observers ensure that updates propagate automatically. No need for explicit event classes or interfaces in simple cases.

Cons: Requires a shared model object, which is an additional class and might be unnecessary if the state is trivial. Also, if not carefully managed, shared state could become a dumping ground for data; keep it focused (e.g., just the pieces of state needed for coordination).

2. Mediator or Coordinator Component

Another approach is to introduce a mediator object that handles communication between ViewModels. The Mediator pattern centralizes the interaction so that colleagues (the ViewModels) do not talk to each other directly. In our scenario, instead of TokenCreationViewModel calling advanceStep() on the workflow, it would call something like LoginWorkflowMediator.notifyTokenCreated(). The mediator, in turn, would invoke the appropriate logic to advance the workflow (for example, by calling a method on SharedLoginVM or updating a model).

You can implement this in a few ways:

  • Dedicated Mediator Class: Write a class (e.g., LoginFlowMediator) that has methods for events in the workflow. Both ViewModels get a reference to this mediator (injected via Spring or passed when constructing the views). For instance, TokenCreationViewModel calls mediator.tokenCreated(), and inside that method, the mediator calls sharedLoginViewModel.advanceStep(). The SharedLoginVM could be injected into the mediator, or the mediator could itself implement the LoginNavigationService interface and delegate calls.
  • Event Bus or Event Aggregator: This is a generalized mediator. Libraries like Google Guava’s EventBus or Spring’s ApplicationEventPublisher serve as a central event dispatch system. One part of the app publishes an event, and any number of subscribers can respond. In this case, you might use a lightweight event bus (or even a static Observable/Subject if using a reactive library) within the app to pass messages. The mediator doesn’t have to be custom-written; the event system is the mediator.

For example, using Spring’s event infrastructure (which was partially attempted):

// Define a custom event class
public class TokenCreatedEvent extends ApplicationEvent {
    public TokenCreatedEvent(Object source) { super(source); }
}

// In TokenCreationViewModel
@Autowired 
private ApplicationEventPublisher eventPublisher;

public void handleTokenCreated() {
    // ... token creation logic ...
    eventPublisher.publishEvent(new TokenCreatedEvent(this));
}

// In SharedLoginViewModel (as a Spring @EventListener)
@EventListener
public void onTokenCreated(TokenCreatedEvent event) {
    advanceStep();
}
JavaScript

This again achieves decoupling (the two beans never reference each other, only the event), but as mentioned, it has more overhead. A simpler mediator could be a singleton bean with callback registration:

@Component
public class LoginWorkflowMediator {
    private final List<Runnable> tokenCreatedListeners = new CopyOnWriteArrayList<>();
    public void addTokenCreatedListener(Runnable listener) {
        tokenCreatedListeners.add(listener);
    }
    public void notifyTokenCreated() {
        tokenCreatedListeners.forEach(Runnable::run);
    }
}
JavaScript

Then, in SharedLoginViewModel constructor, register a listener:

mediator.addTokenCreatedListener(this::advanceStep);

And in TokenCreationViewModel when done:

mediator.notifyTokenCreated();

This custom mediator avoids circular references (both VMs talk to the mediator, not each other) and doesn’t require formal event classes. It’s essentially the observer pattern implemented manually. The downside is you must manage registering/unregistering listeners (in a desktop app, these VMs likely live as long as the scene, so it’s manageable). Also, global mediators or event buses must be used carefully to avoid turning into a free-for-all global variable. But used in a focused way (specific to the login workflow), it keeps communication well-defined.

3. Shared Service Instead of ViewModel-to-ViewModel

A variation of the interface approach is to refactor the logic into a shared service or domain model that both ViewModels use, rather than having one ViewModel implement an interface. For example, create a LoginWorkflowService (Spring @Service) that has methods like advanceStep() and holds the current step state. Both ViewModels would have this service injected:

  • The TokenCreationViewModel calls loginWorkflowService.advanceStep() when done.
  • The SharedLoginViewModel might also call service methods or query the service for current state to know what to display.

In this case, the workflow advancement is handled by a dedicated component that is neither of the ViewModels. This removes any direct linkage between the two ViewModels; they only share a dependency on the service. It also adheres to a common MVVM guideline: keep business logic out of the ViewModels when possible, and let the model or domain layer handle it. As noted by one commenter, “ViewModels can communicate with each other via any object that outlives both of them” – a service or application-scoped model is such an object in this design.

Trade-off: Introducing a service for just advancing a step might be extra complexity if the SharedLoginViewModel was simple. But if the workflow logic grows (e.g., more steps, validations, persistence of state), having a dedicated service is beneficial. The ViewModels become lighter, focusing only on presentation-related logic, and the service coordinates the workflow. This also makes unit testing the workflow easier (since it’s in a plain service class).

4. Using mvvmFX or Framework Scopes (Advanced)

If you are using a framework like mvvmFX (a library for JavaFX MVVM), it provides a concept of Scopes and communication patterns that can help share data between ViewModels in a structured way. For instance, you can create a scope that is shared between the login flow ViewModels and put the workflow state in that scope. This is an advanced topic and framework-specific, but worth mentioning if you plan to use such frameworks; they basically implement the shared state or event aggregator patterns under the hood.

Best Practices for ViewModel Communication

No matter which solution you choose, the goal is to avoid tight coupling and preserve the testability and modularity of your ViewModels. Here are some best practice considerations in this context:

  • Favor Data Binding Over Direct Calls: In MVVM, if one component needs to react to another, try to express that through observable state or property binding. It’s a natural fit for UI scenarios. The SharedLoginViewModel observing a token-created flag is a prime example of this pattern in action.
  • Limit the Scope of Communication: If using an event bus or mediator, keep the events specific. Define a clear contract (interface methods, event types, or model fields) for what is being communicated. This makes the interaction explicit and easier to follow.
  • Dependency Inversion for Decoupling: Introducing an interface (like LoginNavigationService) or a service layer is a legitimate approach to invert dependencies. Don’t be afraid to use an interface simply to decouple two classes – it’s often a sign of good architecture, not over-engineering. The awkwardness can be mitigated by good naming and clear documentation of the interface’s purpose.
  • Avoid Circular UI Logic: Ensure that the logic doesn’t unintentionally create cycles. For example, if using an observable flag, make sure advancing the step resets or moves past the flag so it won’t trigger repeatedly. Design the communication as a one-way street: one ViewModel broadcasts or updates state, the other listens and reacts.

By decoupling the ViewModels, you not only solve the immediate Spring Boot circular dependency issue, but you also make the code more aligned with MVVM principles. Each ViewModel can be developed and tested in isolation: the TokenCreationViewModel can be tested to see if it publishes the right event or updates the model on token creation, and the SharedLoginViewModel can be tested to advance steps when that event or state change occurs – all without creating the actual other ViewModel in tests.

Conclusion

Decoupling ViewModels in a JavaFX + Spring Boot MVVM application requires a thoughtful approach to communication. In our login workflow scenario, several strategies can achieve the goal:

  • Using an interface as a mediator between ViewModels (the LoginNavigationService approach) is a solid, if slightly indirect, solution that leverages dependency inversion.
  • Embracing observable shared state lets ViewModels remain blissfully unaware of each other, interacting only through a common model or property changes.
  • Implementing a mediator or event bus provides a flexible publish/subscribe mechanism, which can be scaled if more components join the conversation (though it may be overkill for simple cases).
  • Refactoring to a shared service or domain model can cleanly separate workflow logic from UI logic, which is beneficial for maintainability.

The LoginNavigationService interface approach we started with is not a bad practice at all – it effectively solved the circular dependency. But it’s important to evaluate if it’s the simplest and most maintainable solution for your application. Many developers lean towards using observer patterns in MVVM because it keeps the workflow reactive to state, rather than imperative calls between ViewModels.

In summary, to decouple your ViewModels without circular dependencies, pick the strategy that fits your application size and complexity:

  • For a small app or one-off interaction, an interface or direct callback might be perfectly fine.
  • For a moderate app following MVVM, consider a shared observable state or lightweight mediator to keep the design clean.
  • For a large app with many moving parts, an event-driven architecture or dedicated coordination service might pay off in scalability.

Each sentence in this guide has aimed to add technical value and clarity. By applying these patterns, you can advance your login workflow (or any multi-ViewModel interaction) cleanly and confidently – without Spring complaining about circular references and without sacrificing the principles of MVVM that make your UI code manageable.

FAQs:

What causes circular dependencies between ViewModels in Spring Boot?

Circular dependencies occur when two or more Spring beans directly depend on each other for instantiation. In MVVM applications, this often happens when one ViewModel injects another to call methods, and vice versa. Spring’s container cannot resolve such mutual dependencies during the bean creation phase.

Is it bad practice to inject one ViewModel into another in MVVM?

Yes. MVVM architecture promotes isolation between ViewModels to preserve modularity and testability. Injecting one ViewModel into another tightly couples them, introduces lifecycle risks, and violates separation of concerns.

Can I use Spring Events to decouple ViewModels in JavaFX?

Yes. You can publish a custom event (e.g., TokenCreatedEvent) from one ViewModel and consume it in another using @EventListener. This decouples the components but may be excessive for simple state transitions due to added boilerplate and class overhead.

Is using an interface (like LoginNavigationService) a good way to decouple ViewModels?

Yes. Injecting an interface implemented by a higher-level ViewModel (e.g., SharedLoginViewModel) into a lower-level ViewModel (e.g., TokenCreationViewModel) is a valid application of the Dependency Inversion Principle. It eliminates direct references and resolves circular dependency issues cleanly.

How can I share state between ViewModels without injecting each other?

Use a shared model or service that holds observable state (e.g., LoginWorkflowModel with JavaFX properties). Each ViewModel interacts only with this shared object, not with each other. This allows reactive communication without coupling.

What is a mediator in MVVM and how can I use it in JavaFX + Spring Boot?

A mediator is a standalone component that coordinates communication between ViewModels. Instead of one ViewModel calling another, both communicate through the mediator. This can be a Spring bean, a plain Java class, or an event bus pattern depending on the app size and complexity.

Snehasish Konger
Snehasish Konger

Snehasish Konger is a passionate technical writer and the founder of Scientyfic World, a platform dedicated to sharing knowledge on science, technology, and web development. With expertise in React.js, Firebase, and SEO, he creates user-friendly content to help students and tech enthusiasts. Snehasish is also the author of books like DITA Decoded and Mastering the Art of Technical Writing, reflecting his commitment to making complex topics accessible to all.

Articles: 230