Skip to content

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 --> PaymentService --> OrderService

OrderService uses PaymentService, and PaymentService uses OrderService. Neither can exist without the other.

Transitive cycle (size 3+):

A --> B --> C --> A

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

  1. 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!
        ) {}
    }
    
  2. Move shared logic to a third class. If both classes need the same data, extract it:

    class OrderRepository
    {
        public function getTotal(int $orderId): float { /* ... */ }
    }
    
    // Both services depend on OrderRepository, not on each other
    
  3. Use events. Instead of direct calls, emit an event that the other service listens to:

    class OrderService
    {
        public function createOrder(Cart $cart): Order
        {
            $order = new Order($cart);
            $this->eventDispatcher->dispatch(new OrderCreated($order));
            return $order;
        }
    }
    
    // PaymentService listens for OrderCreated -- no direct dependency
    

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.