Four hundred lines in a checkout action, half of them inside one try. That is one way a project dies. The other is OrderManager: thirty methods, six injected services, and nobody can say what the class is for, because it is for everything about orders.
Both diseases have the same cure and it is embarrassingly simple. One use case, one class, one public method.
final class PlaceOrder
{
public function __construct(
OrderRepository $orders,
PaymentGateway $payments,
EventDispatcher $events
) { ... }
public function handle(PlaceOrderCommand $command): OrderId
{
// the whole story, top to bottom
}
}
The shape answers three questions by itself. What comes in: one command object, a thing you can log, validate, put in a queue. What it needs: the constructor lists dependencies of this use case, not of orders in general, so when PlaceOrder suddenly needs the mailer you see it and can ask why. What comes out: one result. Reading handle() from top to bottom tells the whole story of placing an order. No chapter hidden in a base class or a trait.
The transaction boundary becomes obvious too. The use case is the unit of work, so the transaction wraps handle() and nothing else. With OrderManager you never know. Method A opens a transaction, method B assumes one exists, method C is called from both. I spent a day on exactly that bug.
Is this CQRS, hexagonal, clean architecture? I do not care. No buses, no interfaces for classes with one implementation. Just a folder of verbs: PlaceOrder, CancelOrder, RefundOrder. A new developer opens that folder and reads what the system does, like a table of contents.
Thin controllers were always the goal. Nobody said where the meat goes. This is where.