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.