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)