The Dependency Graph That Tells the Truth
The Dependency Graph That Tells the Truth
The Smell
The logistics platform team restructured their packages from layers to features. The new package tree looks clean. The code review approved the change. The team celebrates. Two weeks later, a developer notices that changing the billing package requires modifying tests in the shipment package. The packages are separate in the directory tree, but the dependency graph tells a different story: shipment imports 14 classes from billing, billing imports 8 classes from shipment, and the cycle makes both packages impossible to understand independently.
The directory structure changed. The dependencies did not. The restructuring was cosmetic.
The Cognitive Cost
A cyclic dependency between two packages means that understanding either package requires understanding both. The cognitive cost is not additive. It is multiplicative. If the shipment package has 12 classes and the billing package has 8 classes, a cycle between them creates a single 20-class module from the reader’s perspective. The package boundary provides no isolation because changes flow in both directions.
Fan-out, the number of packages a given package depends on, determines how much context a reader must hold when working in that package. A package with fan-out of 2 requires the reader to be aware of two external contracts. A package with fan-out of 8 requires awareness of eight. Each external dependency is a potential source of surprise: “Did the carrier package change its API? Does the billing package still return results in this format?”
The Before
// HARD TO READ: Cyclic dependency between shipment and billing packages.
// Understanding either requires understanding both.
package com.logitrack.shipment;
import com.logitrack.billing.InvoiceGenerator; // shipment → billing
import com.logitrack.billing.BillingStatus; // shipment → billing
public class ShipmentDeliveryHandler {
private final InvoiceGenerator invoiceGenerator;
public void handleDelivery(Shipment shipment) {
// When a shipment is delivered, generate an invoice
invoiceGenerator.generateForDelivery(shipment);
}
public boolean isFullyBilled(Shipment shipment) {
// Checking billing status from the shipment package
return invoiceGenerator.getStatus(shipment.id()) == BillingStatus.PAID;
}
}
package com.logitrack.billing;
import com.logitrack.shipment.Shipment; // billing → shipment
import com.logitrack.shipment.ShipmentRepository; // billing → shipment
public class InvoiceGenerator {
private final ShipmentRepository shipments;
public void generateForDelivery(Shipment shipment) {
// Reads shipment details to generate invoice
List<ShipmentItem> items = shipments.findItems(shipment.id());
// ... generate invoice from shipment data
}
}
The cycle: shipment depends on billing (to trigger invoice generation), and billing depends on shipment (to read shipment data). Neither package can be understood independently. Neither package can be tested independently. Neither package can be assigned to a separate team without coordination on every change.
The Fix
// READABLE: The cycle is broken by introducing a domain event.
// Shipment publishes an event. Billing subscribes. Neither imports the other.
package com.logitrack.shipment;
// No import from billing. Shipment does not know billing exists.
public class ShipmentDeliveryHandler {
private final ShipmentEvents events;
public void handleDelivery(Shipment shipment) {
shipment.markDelivered();
events.publish(new ShipmentDelivered(
shipment.id(),
shipment.carrierId(),
shipment.deliveredAt()
));
}
}
// The event is in a shared package. It contains only data, no behavior.
package com.logitrack.shared.event;
public record ShipmentDelivered(
ShipmentId shipmentId,
CarrierId carrierId,
Instant deliveredAt
) {}
package com.logitrack.billing;
import com.logitrack.shared.event.ShipmentDelivered;
// No import from shipment. Billing does not know shipment exists.
public class DeliveryInvoiceListener {
private final InvoiceGenerator invoiceGenerator;
@EventListener
public void onShipmentDelivered(ShipmentDelivered event) {
invoiceGenerator.generateForDelivery(
event.shipmentId(), event.carrierId());
}
}
// ArchUnit test verifying the cycle is broken and stays broken:
@Test
void no_cycles_between_feature_packages() {
slices().matching("com.logitrack.(*)..")
.should().beFreeOfCycles()
.check(classes);
// Fails with:
// Cycle detected: com.logitrack.shipment ->
// com.logitrack.billing -> com.logitrack.shipment
}
The shipment package no longer knows that billing exists. When a shipment is delivered, it publishes a domain event. The billing package listens for that event and generates an invoice. The dependency flows in one direction: both packages depend on the shared event type, but neither depends on the other.
The isFullyBilled method from the original code is removed from the shipment package. Billing status is a billing concern. If the shipment package needs to display billing status, it reads it from a read model or a status endpoint provided by the billing package’s public API, not by importing billing internals.
The ArchUnit cycle-detection test ensures this fix is permanent. If a developer adds an import from shipment to billing or vice versa, the test fails. The architectural decision is encoded as an executable assertion.
The Rule
After any package restructuring, run ArchUnit’s slices().should().beFreeOfCycles() and fix every cycle before merging. A package restructuring that moves files without breaking cycles is cosmetic. The dependency graph, not the directory tree, determines whether the restructuring improved modularity. If two packages have a cyclic dependency, break it with a domain event, a shared interface, or by moving the shared concept to a common package. The packages must be independently understandable to be independently maintainable.