Untangling Java Dependencies: Why Your Architecture is a Trap

I just spent three weeks ripping apart our core billing module. Three weeks of my life I’m never getting back. The culprit? Cyclic dependencies.

Nobody sets out to write spaghetti code. It happens slowly. You inject an OrderService into your CustomerNotifier. Two months later, a junior dev needs to check a notification flag during order creation, so they inject the CustomerNotifier right back into the OrderService.

Everything compiles. Spring Boot happily wires it up behind the scenes using proxy magic. You don’t notice the trap until you try to extract a microservice, or until your test suite takes a coffee break to run. In our case, running Java 21 with Spring Boot 3.2.4, this architectural rot crept up until our local build time hit an agonizing 14m 20s.

tangled network cables - Utility pole densely packed with a myriad of electrical and communication cables.
tangled network cables – Utility pole densely packed with a myriad of electrical and communication cables.

The Anatomy of a Cycle

Let me show you exactly what this looks like. It’s embarrassing, but here we are. This is the exact pattern I had to untangle.

@Service
public class OrderManager {
    private final CustomerNotifier notifier;

    public OrderManager(CustomerNotifier notifier) {
        this.notifier = notifier;
    }

    public void process(Order order) {
        // complex business logic here
        notifier.notify(order.getCustomerId(), "Order processed successfully");
    }

    public List<Order> getRecentOrders(UUID customerId) { 
        // database fetch logic
        return new ArrayList<>(); 
    }
}

@Service
public class CustomerNotifier {
    private final OrderManager orderManager; // BOOM. There's the cycle.

    public CustomerNotifier(OrderManager orderManager) {
        this.orderManager = orderManager;
    }

    public void notify(UUID customerId, String msg) {
        // Wait, does this customer have VIP orders? Let's check!
        List<Order> history = orderManager.getRecentOrders(customerId);
        
        if (history.size() > 5) {
            // send VIP notification
        }
    }
}

This is a classic domain leak. The notification domain shouldn’t care about fetching order history. But it does. Now they are glued together. If you want to test CustomerNotifier, you have to mock OrderManager, which means you need to understand the internal state of the order system just to send an email.

Breaking the Knot with Interfaces

tangled network cables - Free Tangled Network Cables Image - Technology, Network, Cables ...
tangled network cables – Free Tangled Network Cables Image – Technology, Network, Cables …

How do you fix it? You invert the dependency. Instead of the notifier calling back into the concrete order system, we define a strict boundary using an interface. The order system dictates the contract, and the notifier consumes it.

// 1. The Contract (Interface)
public interface OrderHistoryProvider {
    List<Order> fetchCompletedForCustomer(UUID customerId);
}

// 2. The Implementation stays inside the Order domain
@Service
public class DefaultOrderHistory implements OrderHistoryProvider {
    private final OrderRepository repo;

    public DefaultOrderHistory(OrderRepository repo) {
        this.repo = repo;
    }

    @Override
    public List<Order> fetchCompletedForCustomer(UUID customerId) {
        // A practical stream example filtering domain records
        return repo.findByCustomerId(customerId).stream()
            .filter(order -> order.getStatus() == Status.COMPLETED)
            .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
            .toList();
    }
}

// 3. The Notifier only knows about the Interface
@Service
public class CustomerNotifier {
    private final OrderHistoryProvider historyProvider;

    public CustomerNotifier(OrderHistoryProvider historyProvider) {
        this.historyProvider = historyProvider;
    }
    
    public void notify(UUID customerId, String msg) {
        List<Order> history = historyProvider.fetchCompletedForCustomer(customerId);
        // process notification
    }
}

Notice the difference? CustomerNotifier no longer depends on the massive OrderManager class. It depends on a specific, narrow interface. We severed the cycle. The order module can now evolve independently of the notification logic.

The Automation Gotcha

tangled network cables - a pink building with lots of wires and a window
tangled network cables – a pink building with lots of wires and a window

Fixing the code is the easy part. Keeping it fixed is where things get ugly. But I automated it. I hooked ArchUnit 1.2.1 into our CI/CD pipeline. This is the exact test that finally stopped our devs from re-introducing cycles.

package com.company.architecture;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.company.core")
public class DependencyCycleTest {

    @ArchTest
    public static final ArchRule no_cycles_allowed = slices()
        .matching("com.company.core.(*)..")
        .should().beFreeOfCycles();
}

That 14m 20s build time? It dropped to 3m 45s yesterday. The memory usage on our test containers plummeted too, simply because Spring didn’t have to resolve a massive web of circular proxies on every context load.

Stop relying on framework magic to resolve your sloppy wiring. Define your boundaries. Use interfaces. Enforce it with tests. Your future self will thank you when you don’t have to spend a month un-screwing a monolith.