DEV Community

Igor Nosatov
Igor Nosatov

Posted on

⚑ CQRS & Use Cases: Why Your Service Layer is a Mess (And How to Fix It)

TL;DR

πŸ’‘ Services become God Objects after 6 months

⚠️ Your OrderService has 47 methods? That's the problem

βœ… One Use Case = One Handler = One Responsibility

πŸ”₯ CQRS separates reads from writes (game changer)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

😱 The Problem: Service Layer Bloat

I once inherited a project with OrderService. It had 63 methods.

class OrderService 
{
    public function createOrder() { }
    public function updateOrder() { }
    public function cancelOrder() { }
    public function getOrder() { }
    public function listOrders() { }
    public function filterOrders() { }
    public function exportOrders() { }
    public function calculateDiscount() { }
    public function sendNotification() { }
    // ... 54 more methods 😱
}
Enter fullscreen mode Exit fullscreen mode

The horror:
⚠️ Impossible to test (too many dependencies)

⚠️ Violates Single Responsibility Principle

⚠️ Can't reuse logic (everything tightly coupled)

⚠️ Merge conflicts every PR

⚠️ New developer? Good luck understanding this

πŸ’¬ "God Objects are the technical debt that never gets paid back."
β€” Every senior developer who's been burned

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎯 The Solution: Use Cases + CQRS

What's a Use Case?

Use Case = One thing a user does in your system.

Think in user stories:

  • βœ… "User creates an order" β†’ CreateOrder Use Case
  • βœ… "User views order details" β†’ GetOrder Use Case
  • βœ… "Admin cancels an order" β†’ CancelOrder Use Case

Not "Order management" (too vague).

CQRS in 30 Seconds

CQRS = Command Query Responsibility Segregation

Split your operations into two types:

Commands (Write)          Queries (Read)
     ↓                         ↓
  Changes state          Only reads data
  Returns void          Returns data (DTO)

CreateOrder              GetOrder
UpdateOrder              ListOrders
DeleteOrder              SearchOrders
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key insight: Optimize differently! Writes need transactions. Reads need speed.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ—οΈ Use Case Structure

Before: The Monolith Service

// God Object anti-pattern
class OrderService 
{
    public function createOrder(array $data): Order 
    {
        // 200 lines of validation, business logic, persistence...
    }

    public function updateOrder(int $id, array $data): Order 
    {
        // 150 lines...
    }

    public function getOrder(int $id): Order 
    {
        // Mixed with business logic...
    }

    // 40 more methods...
}
Enter fullscreen mode Exit fullscreen mode

After: One Use Case = One Handler

Application/UseCase/
β”œβ”€β”€ CreateOrder/
β”‚   β”œβ”€β”€ CreateOrderCommand.php
β”‚   β”œβ”€β”€ CreateOrderHandler.php
β”‚   └── CreateOrderResponse.php
β”‚
β”œβ”€β”€ GetOrder/
β”‚   β”œβ”€β”€ GetOrderQuery.php
β”‚   β”œβ”€β”€ GetOrderHandler.php
β”‚   └── GetOrderResponse.php
β”‚
└── CancelOrder/
    β”œβ”€β”€ CancelOrderCommand.php
    └── CancelOrderHandler.php
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ”₯ Real Example: CreateOrder Use Case

Step 1: Command (Input DTO)

// Application/UseCase/CreateOrder/CreateOrderCommand.php
final class CreateOrderCommand 
{
    public function __construct(
        public readonly string $customerEmail,
        public readonly array $items,
        public readonly string $currency = 'USD'
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ It's just data. No logic. No validation yet.

Step 2: Handler (The Orchestrator)

// Application/UseCase/CreateOrder/CreateOrderHandler.php
final class CreateOrderHandler 
{
    public function __construct(
        private OrderRepositoryInterface $orderRepo,
        private ProductRepositoryInterface $productRepo,
        private EventBus $eventBus
    ) {}

    public function __invoke(CreateOrderCommand $cmd): CreateOrderResponse 
    {
        // 1. Validate & Map to Domain
        $email = Email::fromString($cmd->customerEmail);
        $currency = $cmd->currency;
        $total = Money::zero($currency);

        // 2. Calculate total (business logic)
        foreach ($cmd->items as $item) {
            $product = $this->productRepo->findById($item['id']);
            $price = Money::from($product->getPrice(), $currency);
            $quantity = Quantity::fromInt($item['qty']);

            $total = $total->add($price->multiply($quantity));
        }

        // 3. Create Domain Entity
        $order = Order::create(
            OrderId::generate(),
            $email,
            $total
        );

        // 4. Persist
        $this->orderRepo->save($order);

        // 5. Dispatch Events
        $this->eventBus->dispatch(new OrderCreated($order->getId()));

        // 6. Return Response
        return new CreateOrderResponse(
            $order->getId(),
            $order->getTotal()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Notice: Handler orchestrates but delegates logic to Domain.

Step 3: Response (Output DTO)

// Application/UseCase/CreateOrder/CreateOrderResponse.php
final class CreateOrderResponse 
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly Money $total
    ) {}

    public function toArray(): array 
    {
        return [
            'orderId' => $this->orderId->toString(),
            'total' => [
                'amount' => $this->total->getAmount(),
                'currency' => $this->total->getCurrency()
            ]
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ“Š CQRS: Command vs Query

Command Example (Write)

// Application/UseCase/CancelOrder/CancelOrderCommand.php
final class CancelOrderCommand 
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly string $reason
    ) {}
}

// Handler
final class CancelOrderHandler 
{
    public function __invoke(CancelOrderCommand $cmd): void 
    {
        $order = $this->orderRepo->findById($cmd->orderId);

        // Business logic in Domain
        $order->cancel($cmd->reason);

        $this->orderRepo->save($order);
        $this->eventBus->dispatch(new OrderCancelled($order->getId()));

        // Commands return void! βœ…
    }
}
Enter fullscreen mode Exit fullscreen mode

Query Example (Read)

// Application/UseCase/GetOrder/GetOrderQuery.php
final class GetOrderQuery 
{
    public function __construct(
        public readonly OrderId $orderId
    ) {}
}

// Handler
final class GetOrderHandler 
{
    public function __invoke(GetOrderQuery $query): GetOrderResponse 
    {
        // Direct DB read (bypass Domain for performance)
        $data = $this->connection->fetchAssociative(
            'SELECT * FROM orders WHERE id = :id',
            ['id' => $query->orderId->toString()]
        );

        return GetOrderResponse::fromArray($data);
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Key difference: Queries can skip Domain layer for performance!

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎨 How to Use in Controllers

Symfony Example

// Infrastructure/Presentation/Controller/CreateOrderController.php
final class CreateOrderController extends AbstractController 
{
    #[Route('/api/orders', methods: ['POST'])]
    public function __invoke(
        Request $request,
        CreateOrderHandler $handler
    ): JsonResponse {
        // 1. Build Command from Request
        $cmd = new CreateOrderCommand(
            customerEmail: $request->request->get('email'),
            items: $request->request->get('items'),
            currency: $request->request->get('currency', 'USD')
        );

        // 2. Execute Use Case
        $response = $handler($cmd);

        // 3. Return JSON
        return $this->json($response->toArray(), 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Controller is dumb. No logic. Just input β†’ handler β†’ output.

CLI Command Example

// Infrastructure/CLI/CreateOrderCommand.php
final class CreateOrderCliCommand extends Command 
{
    public function __construct(
        private CreateOrderHandler $handler
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int 
    {
        $cmd = new CreateOrderCommand(
            customerEmail: $input->getArgument('email'),
            items: json_decode($input->getArgument('items'), true)
        );

        $response = $this->handler->__invoke($cmd);

        $output->writeln("Order created: " . $response->orderId->toString());

        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Same handler for HTTP and CLI! That's the power of Use Cases.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ”₯ Before/After Comparison

❌ Before: Service Layer Chaos

class OrderService 
{
    // 15 dependencies injected 😱
    public function __construct(
        private EntityManager $em,
        private Validator $validator,
        private EventDispatcher $dispatcher,
        private Mailer $mailer,
        private Logger $logger,
        // ... 10 more
    ) {}

    public function createOrder(array $data): Order 
    {
        // Validation
        $errors = $this->validator->validate($data);
        if (count($errors) > 0) { /* ... */ }

        // Business logic (100+ lines)
        // ...

        // Persistence
        $this->em->persist($order);
        $this->em->flush();

        // Side effects
        $this->mailer->send(/* ... */);
        $this->logger->info(/* ... */);

        return $order;
    }

    // 46 more methods...
}
Enter fullscreen mode Exit fullscreen mode

Testing nightmare:

$service = new OrderService(
    $em, $validator, $dispatcher, $mailer, $logger, 
    $x, $y, $z, $a, $b, $c, $d, $e, $f
); // Mock 15 dependencies? No thanks.
Enter fullscreen mode Exit fullscreen mode

βœ… After: Clean Use Cases

// Each handler has only what it needs
final class CreateOrderHandler 
{
    public function __construct(
        private OrderRepositoryInterface $repo,
        private EventBus $eventBus
    ) {} // Only 2 dependencies! βœ…

    public function __invoke(CreateOrderCommand $cmd): CreateOrderResponse 
    {
        $order = Order::create(
            Email::fromString($cmd->customerEmail),
            $this->calculateTotal($cmd->items)
        );

        $this->repo->save($order);
        $this->eventBus->dispatch(new OrderCreated($order->getId()));

        return new CreateOrderResponse($order->getId());
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing is a breeze:

$handler = new CreateOrderHandler(
    new InMemoryOrderRepository(),
    new InMemoryEventBus()
); // 2 mocks. Done. βœ…
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ“¦ Complete Folder Structure

Application/UseCase/
β”‚
β”œβ”€β”€ Command/                  # ← Changes state
β”‚   β”œβ”€β”€ CreateOrder/
β”‚   β”‚   β”œβ”€β”€ CreateOrderCommand.php
β”‚   β”‚   β”œβ”€β”€ CreateOrderHandler.php
β”‚   β”‚   └── CreateOrderResponse.php
β”‚   β”‚
β”‚   β”œβ”€β”€ UpdateOrder/
β”‚   β”‚   β”œβ”€β”€ UpdateOrderCommand.php
β”‚   β”‚   └── UpdateOrderHandler.php
β”‚   β”‚
β”‚   └── CancelOrder/
β”‚       β”œβ”€β”€ CancelOrderCommand.php
β”‚       └── CancelOrderHandler.php
β”‚
└── Query/                    # ← Only reads
    β”œβ”€β”€ GetOrder/
    β”‚   β”œβ”€β”€ GetOrderQuery.php
    β”‚   β”œβ”€β”€ GetOrderHandler.php
    β”‚   └── GetOrderResponse.php
    β”‚
    β”œβ”€β”€ ListOrders/
    β”‚   β”œβ”€β”€ ListOrdersQuery.php
    β”‚   └── ListOrdersHandler.php
    β”‚
    └── SearchOrders/
        β”œβ”€β”€ SearchOrdersQuery.php
        └── SearchOrdersHandler.php
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎁 Naming Cheat Sheet

❌ Bad Name βœ… Good Name Why
OrderCase CreateOrderHandler Clear action
UserCase RegisterUserHandler Specific use case
Case/ UseCase/ Standard DDD term
OrderCommand CreateOrderCommand Command is input, not action
getOrders() ListOrdersQuery Explicit read operation

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸš€ Migration Strategy

Week 1: Create UseCase/ folder, add one handler for new features

Week 2: Move most-used method from OrderService to handler

Week 3: Deprecate OrderService methods one by one

Week 4: Delete OrderService πŸŽ‰

Pro tip: Don't refactor everything. New code β†’ new structure. Let old code retire naturally.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ”₯ Quick Decision Tree

When to create a new Use Case:

Is this a new user action?
  ↓ YES
Does it modify data?
  ↓ YES β†’ Create Command/Handler
  ↓ NO  β†’ Create Query/Handler
Enter fullscreen mode Exit fullscreen mode

Examples:

  • "User purchases subscription" β†’ PurchaseSubscriptionCommand
  • "Admin views analytics" β†’ GetAnalyticsQuery
  • "System sends reminder email" β†’ SendReminderCommand

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ’¬ Let's Discuss

How many methods does your largest Service class have?

Be honest: Is it over 20? Over 50? πŸ˜…

Drop your biggest "Service class horror story" in the comments! And if you've successfully refactored to Use Cases, share your experience! πŸ‘‡

CQRS #CleanArchitecture #DDD #PHP #Symfony #SoftwareEngineering #UseCases

Top comments (0)