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 π±
}
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" β 
CreateOrderUse Case - β
 "User views order details" β 
GetOrderUse Case - β
 "Admin cancels an order" β 
CancelOrderUse 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
π‘ 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...
}
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
βββββββββββββββββββββββββββββ
π₯ 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'
    ) {}
}
π‘ 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()
        );
    }
}
β 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()
            ]
        ];
    }
}
βββββββββββββββββββββββββββββ
π 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! β
    }
}
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);
    }
}
π‘ 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);
    }
}
β 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;
    }
}
π‘ 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...
}
Testing nightmare:
$service = new OrderService(
    $em, $validator, $dispatcher, $mailer, $logger, 
    $x, $y, $z, $a, $b, $c, $d, $e, $f
); // Mock 15 dependencies? No thanks.
β 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());
    }
}
Testing is a breeze:
$handler = new CreateOrderHandler(
    new InMemoryOrderRepository(),
    new InMemoryEventBus()
); // 2 mocks. Done. β
βββββββββββββββββββββββββββββ
π¦ 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
βββββββββββββββββββββββββββββ
π 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
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! π
    
Top comments (0)