Architecture Rules¶
Architecture rules detect structural problems in your codebase that can lead to maintenance nightmares. These problems are often invisible in day-to-day work but cause significant pain when you need to refactor, test, or deploy parts of your application independently.
Circular Dependencies¶
Rule ID: architecture.circular-dependency
What it measures¶
Detects when classes depend on each other in a loop. A dependency means one class uses another (via constructor injection, method calls, type hints, etc.).
Direct cycle (size 2):
OrderService uses PaymentService, and PaymentService uses OrderService. Neither can exist without the other.
Transitive cycle (size 3+):
A depends on B, B depends on C, and C depends back on A. The loop is longer but the problem is the same.
Why it matters¶
Circular dependencies cause real problems:
- Cannot test in isolation. To test class A, you need class B, which needs class C, which needs A again.
- Cannot deploy independently. If packages A, B, and C form a cycle, they must always be deployed together.
- Tight coupling. Changes to any class in the cycle can break all other classes in the cycle.
- Harder to understand. There is no clear "top" or "bottom" -- you cannot read the code in a linear order.
Thresholds¶
| Cycle type | Severity | Meaning |
|---|---|---|
| Direct (size 2) | Error | Two classes directly depend on each other |
| Transitive (size 3+) | Warning | A longer chain of classes forms a loop |
Note
Direct cycles (A depends on B, B depends on A) are reported as Error by default because they represent the tightest coupling. Transitive cycles are reported as Warning because they are often easier to break.
Options¶
| Option | Default | Description |
|---|---|---|
enabled |
true |
Enable or disable this rule |
maxCycleSize |
0 |
Maximum cycle size to report (0 = report all sizes) |
directAsError |
true |
Treat direct cycles (size 2) as errors |
Configuration example¶
# qmx.yaml
rules:
architecture.circular-dependency:
maxCycleSize: 5 # ignore very large cycles
directAsError: true # direct cycles are errors
Example¶
// OrderService.php
class OrderService
{
public function __construct(
private PaymentService $paymentService, // depends on PaymentService
) {}
public function createOrder(Cart $cart): Order
{
$order = new Order($cart);
$this->paymentService->charge($order);
return $order;
}
public function getOrderTotal(int $orderId): float
{
// ...
return $total;
}
}
// PaymentService.php
class PaymentService
{
public function __construct(
private OrderService $orderService, // depends on OrderService -- CYCLE!
) {}
public function charge(Order $order): void
{
$total = $this->orderService->getOrderTotal($order->id);
// process payment...
}
}
OrderService depends on PaymentService, and PaymentService depends on OrderService. This is a direct cycle of size 2.
How to fix¶
-
Introduce an interface (Dependency Inversion). Make one class depend on an abstraction instead of the concrete class:
interface OrderTotalProviderInterface { public function getOrderTotal(int $orderId): float; } class OrderService implements OrderTotalProviderInterface { public function __construct( private PaymentService $paymentService, ) {} public function getOrderTotal(int $orderId): float { /* ... */ } } class PaymentService { public function __construct( private OrderTotalProviderInterface $totalProvider, // no cycle! ) {} } -
Move shared logic to a third class. If both classes need the same data, extract it:
-
Use events. Instead of direct calls, emit an event that the other service listens to:
Tip
Use the maxCycleSize option to focus on the most critical cycles first. Direct cycles (size 2) are the easiest to fix and the most harmful. Start there, then work on larger cycles.