For our new service, I decided to split the command dispatcher to have a separate query dispatcher (CQS style), but then controller constructors started to look weird with these two dependencies, especially because for each request one of them would be useless. This is the story of how I extracted actions from their controller to create super-tiny super-focused super-cute action-controllers. SRP FTW!
Splitting the command dispatcher was also an opportunity to improve performance. After all, the fewer dependencies are initialized, the faster the action can be reached. Also, event subscribers related to queries are very different from those related to commands. For instance, you only want to deal with cache invalidation after a command is executed.
Extracting actions from their controller
Because the action to execute (the method to call) was resolved from within the controller,
controllers already had the simplest signature. I kept that signature for the new Action
interface:
<?php
namespace HelloFresh\MenuService\Presentation\HTTP;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface Action
{
public function __invoke(Request $request): Response;
}
Thanks to the command dispatcher, the logic of each action is kept to a minimum. Extracting them was fairly easy. It was more a matter of putting things in the right place. I ended up with the following classes:
App\HelloAction
App\StatusAction
CommandActionAbstract implements Action
├── CreateActionAbstract
│ └── Menu\CreateAction
│── UpdateActionAbstract
│ └── Menu\UpdateAction
└── DeleteActionAbstract
└── Menu\DeleteAction
QueryActionAbstract implements Action
├── ListActionAbstract
│ └── Menu\ListAction
└── ShowActionAbstract
└── Menu\ShowAction
Query actions are now using the query dispatcher, and command actions the command
dispatcher. Only the second-level classes implement the Action
interface, third-level classes
only implement a method to create the query or command.
For instance, the QueryActionAbstract
class is the base class for all query actions. Query
dispatching and response creation concerns are hidden away in a QueryResponder
class. The
implementation of the Action
interface is left to the extending class, but the class provides the
respondToQuery
method that takes a query object and returns a response.
<?php
namespace HelloFresh\MenuService\Presentation\HTTP\Action;
use HelloFresh\MenuService\Presentation\HTTP\Action;
use HelloFresh\MenuService\Presentation\HTTP\QueryResponder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class QueryActionAbstract implements Action
{
private $queryResponder;
public function __construct(QueryResponder $queryResponder)
{
$this->queryResponder = $queryResponder;
}
abstract public function __invoke(Request $request): Response;
protected function respondToQuery(object $query, callable $respond = null): Response
{
return $this->queryResponder->respondToQuery($query, $respond);
}
}
The ShowActionAbstract
class is the base class of all show actions. It implements the Action
interface but has an abstract method that the extending class must implement to create the query
object.
<?php
namespace HelloFresh\MenuService\Presentation\HTTP\Action\Entity;
use HelloFresh\MenuService\Application\Command\Entity\ShowEntityAbstract;
use HelloFresh\MenuService\Presentation\HTTP\Action;
use HelloFresh\MenuService\Presentation\HTTP\RequestAttributes;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class ShowActionAbstract extends Action\QueryActionAbstract
{
final public function __invoke(Request $request): Response
{
[ $id ] = array_values($request->attributes->get(RequestAttributes::PATH_PARAMS));
return $this->respondToQuery(
$this->createQuery($request, $id)
);
}
abstract protected function createQuery(Request $request, $id): ShowEntityAbstract;
}
Finally, the Menu\ShowAction
is the extracted action. Its only responsibility is to return a
query object created from the parameters it receives.
<?php
namespace HelloFresh\MenuService\Presentation\HTTP\Action\Menu;
use HelloFresh\MenuService\Application\Command\Entity\ShowEntityAbstract;
use HelloFresh\MenuService\Application\Command\Menu\ShowMenu;
use HelloFresh\MenuService\Domain\ValueObject\Locale;
use HelloFresh\MenuService\Presentation\HTTP\Action\Entity\ShowActionAbstract;
use HelloFresh\MenuService\Presentation\HTTP\RequestAttributes;
use Symfony\Component\HttpFoundation\Request;
class ShowAction extends ShowActionAbstract
{
protected function createQuery(Request $request, string $id): ShowEntityAbstract
{
return new ShowMenu($id, $this->requireLocale($request));
}
private function requireLocale(Request $request): Locale
{
return $request->attributes->get(RequestAttributes::LOCALE);
}
}
Testing
Because the only job of the action is to create a query or command object, and because the queries and commands are already thoroughly tested with both unit tests are integration tests, we only have to verify that the query/command object is created as expected.
The following excerpt demonstrates how Menu\ListAction
is tested:
<?php
namespace HelloFresh\MenuService\Presentation\HTTP\Action\Menu;
use HelloFresh\MenuService\Application\Command\Menu\ListMenus;
use HelloFresh\MenuService\Domain\Menu\MenuQuery;
use HelloFresh\MenuService\Domain\ValueObject\Locale;
use HelloFresh\MenuService\Presentation\HTTP\Action\HasListParams;
use HelloFresh\MenuService\Presentation\HTTP\Action\ListActionTestCase;
use HelloFresh\MenuService\Presentation\HTTP\QueryResponder;
use Symfony\Component\HttpFoundation\Request;
/**
* @group unit
*/
class ListActionTest extends ListActionTestCase
{
/**
* @dataProvider provideAction
*/
public function testAction(array $params, callable $createQuery)
{
$this->assertAction(
$params,
$createQuery,
function (QueryResponder $queryResponder) {
return new ListAction($queryResponder);
}
);
}
public function provideAction(): array
{
return [
// there's a bunch of test cases before this one.
[
[
ListAction::PARAM_COUNTRY => 'BE',
ListAction::PARAM_PRODUCT => 'classic-box',
ListAction::PARAM_WEEK => '2018-W03',
ListAction::PARAM_SORT => 'week'
],
function (Locale $locale, int $take, int $skip) {
return new ListMenus(
$locale,
[
MenuQuery::CRITERION_COUNTRY => 'BE',
MenuQuery::CRITERION_PRODUCT => 'classic-box',
MenuQuery::CRITERION_WEEK => '2018-W03',
],
[
MenuQuery::SORT_WEEK => MenuQuery::ORDER_ASC,
],
$take,
$skip,
[]
);
}
],
];
}
// ...
}
Actions as services
Like the controllers before them, actions are defined as services, with the tag action
. They are
gathered by a DIC compiler pass to create an action locator service.
services:
_defaults:
autowire: true
tags: [ action ]
action.app.hello:
class: HelloFresh\MenuService\Presentation\HTTP\Action\App\HelloAction
action.app.status:
class: HelloFresh\MenuService\Presentation\HTTP\Action\App\StatusAction
action.menu.show:
class: HelloFresh\MenuService\Presentation\HTTP\Action\Menu\ShowAction
action.menu.list:
class: HelloFresh\MenuService\Presentation\HTTP\Action\Menu\ListAction
# ...
Action locator
Actions are retrieved using an action locator. It implements ContainerInterface
, except it
guarantees that Action
instances are returned.
<?php
/* @var Psr\Container\ContainerInterface $actionLocator */
$action = $actionLocator->get('action.app.hello');
Routing
Regarding routing, I did not have much to change because of the way I'm defining routes. A quick
update to the RouteCollector::entity()
method was all it took to connect everything. Here's the
routing configuration FYI:
<?php
/* @var HelloFresh\MenuService\Presentation\HTTP\RouteCollector $r */
$r
->get('/', 'action.app.hello')
->get('/status', 'action.app.status')
->entity('menu')
;
tl;dr
Controllerless actions were easy to implement, thanks in part to the command dispatcher pattern and the way the application is structured. We now have super-tiny super-focused actions that have a single responsibility. They are easy to maintain, easy to test, use less resources, and take less time to get ready.