Is it acceptable for Application Command Handlers to depend on Symfony framework interfaces in a CQRS / Hexagonal architecture?

Summary

The question revolves around whether it’s acceptable for Application Command Handlers to depend directly on Symfony framework interfaces in a CQRS (Command Query Responsibility Segregation) and Hexagonal Architecture. Two approaches are presented: Option 1 (Pragmatic Symfony), where command handlers depend on Symfony interfaces, and Option 2 (Strict Hexagonal / Port + Adapter), which introduces a port interface in the Application layer for decoupling.

Root Cause

The root cause of the dilemma is the trade-off between coupling and boilerplate code.

  • Option 1 couples the Application layer to Symfony, reducing boilerplate but compromising on decoupling and testability.
  • Option 2 maintains a decoupled Application layer but introduces more boilerplate code.

Why This Happens in Real Systems

This happens in real systems due to several reasons:

  • Pragmatism vs. Purity: The need for quick development and deployment can lead to pragmatic choices like Option 1.
  • Lack of Clear Guidelines: The absence of clear, community-backed guidelines for handling coupling in CQRS + Symfony projects can cause confusion.
  • Complexity of Hexagonal Architecture: Implementing a Hexagonal Architecture correctly can be complex, leading to a preference for simpler, albeit less pure, approaches.

Real-World Impact

The real-world impact includes:

  • Tight Coupling: Makes the system less flexible and more difficult to test and maintain.
  • Increased Boilerplate: Can lead to more code to maintain, potentially increasing the chance of bugs.
  • Domain Purity: Compromising on the purity of the domain layer can make the domain logic harder to understand and evolve.

Example or Code

// Example of a Command Handler using Option 1 (Pragmatic Symfony)
use Symfony\Component\Messenger\MessageBusInterface;

final class PayOrderCommandHandler {
    public function __construct(private MessageBusInterface $bus) {}

    public function __invoke(PayOrderCommand $command): void {
        $order = new Order();
        $order->pay();
        // Dispatch an event/message via Symfony Messenger
        $this->bus->dispatch(new OrderPaidMessage($order->id()));
    }
}

// Example of a Command Handler using Option 2 (Strict Hexagonal / Port + Adapter)
interface MessageBus {
    public function dispatch(object $message): void;
}

final class PayOrderCommandHandler {
    public function __construct(private MessageBus $messageBus, private EventStore $eventStore) {}

    public function __invoke(PayOrderCommand $command): void {
        $order = new Order();
        $order->pay();
        foreach ($order->pullEvents() as $event) {
            $this->eventStore->append($event);
            $this->messageBus->dispatch($event);
        }
    }
}

How Senior Engineers Fix It

Senior engineers typically opt for Option 2 (Strict Hexagonal / Port + Adapter) to maintain a decoupled Application layer, ensuring testability, flexibility, and domain purity. They recognize the value of investing in boilerplate code for long-term maintainability and scalability. Additionally, they:

  • Implement ports and adapters to decouple the Application layer from the framework.
  • Use dependency injection to manage dependencies effectively.
  • Prioritize domain-driven design principles to keep the domain logic pure and focused on business rules.

Why Juniors Miss It

Juniors might miss the importance of decoupling due to:

  • Lack of experience with the long-term consequences of tight coupling.
  • Insufficient understanding of domain-driven design and hexagonal architecture principles.
  • Focus on short-term goals, such as rapid development, over long-term maintainability and scalability.
  • Unclear or missing guidelines on best practices for handling coupling in CQRS + Symfony projects.