Skip to content

Are CQRS commands part of the domain model? #58

@webdevilopers

Description

@webdevilopers

Came from: https://twitter.com/webdevilopers/status/1374292878713831428 by @domainascode

A classic discussion. And it really depends on how you define your commands. For instance our „commands“ start as Application DTO and convert primitives to Value Objects and are then passed to the Handler.
If your commands go directly to the Model, then Domain is a good place.

Refs:

This is our current approach using the Symfony Messenger as Command Bus incl. the Doctrine Middleware.

Controller

Here User JSON is passed to the Command. In addition the User ID ("accountId") is added from the security layer.

<?php

namespace Trexxon\Places\Infrastructure\Symfony\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Trexxon\Places\Application\Service\Spot\AddSpot;

final class AddSpotController
{
    private AuthorizationCheckerInterface $authorizationChecker;

    private TokenStorageInterface $tokenStorage;

    private MessageBusInterface $commandBus;

    public function __construct(
        AuthorizationCheckerInterface $authorizationChecker,
        TokenStorageInterface $tokenStorage,
        MessageBusInterface $commandBus
    )
    {
        $this->authorizationChecker = $authorizationChecker;
        $this->tokenStorage = $tokenStorage;
        $this->commandBus = $commandBus;
    }

    public function __invoke(string $placeId, Request $request): Response
    {
        if (!$this->authorizationChecker->isGranted('ROLE_HOST')) {
            throw new AccessDeniedHttpException();
        }

        $command = AddSpot::fromPayload(array_merge(
            [
                'accountId' => $this->tokenStorage->getToken()->getUser()->userId()->toString(),
                'placeId' => $placeId
            ],
            json_decode($request->getContent(), true)
        ));

        $this->commandBus->dispatch($command);

        return new JsonResponse(null, Response::HTTP_CREATED);
    }
}

Trait

This trait allows the command to accept any payload e.g. a User JSON request and populate the command automatically if the property is defined there.

<?php declare(strict_types=1);

namespace Trexxon\Common\Application\Services;

trait PayloadMessage
{
    private function __construct()
    {}

    /**
     * @param array $payload
     *
     * @return static
     * @todo Return static.
     * @see  https://stitcher.io/blog/php-8-before-and-after#static-instead-of-doc-blocks
     */
    public static function fromPayload(array $payload)
    {
        $self = new static();

        foreach (get_class_vars(get_class($self)) as $varName => $varValue) {
            if (array_key_exists($varName, $payload)) {
                $self->$varName = $payload[$varName];
            }
        }

        return $self;
    }
}

Command

Here the "allowed" properties are defined. Together with annotations for the Validation Middleware that is fired when the message is dispatched. Only primitives are used.
But for convenience the Command returns Domain Value Objects via getters instead of building them inside the Command Handler.

<?php

namespace Trexxon\Places\Application\Service\Spot;

use Symfony\Component\Validator\Constraints as Assert;
use Trexxon\Account\Domain\Model\Account\AccountId;
use Trexxon\Common\Application\Services\PayloadMessage;
use Trexxon\Places\Domain\Model\Place\PlaceId;
use Trexxon\Places\Domain\Model\Spot\SpotId;
use Trexxon\Places\Domain\Model\Spot\SpotName;

final class AddSpot
{
    use PayloadMessage;

    /**
     * @Assert\NotBlank()
     * @Assert\Uuid()
     */
    private string $accountId;

    /**
     * @Assert\NotBlank()
     * @Assert\Uuid()
     */
    private string $placeId;

    /**
     * @Assert\NotBlank()
     * @Assert\Uuid()
     */
    private string $spotId;

    /**
     * @Assert\NotBlank()
     * @Assert\Type(type="string")
     */
    private string $name;

    public function accountId(): AccountId
    {
        return AccountId::fromString($this->accountId);
    }

    public function placeId(): PlaceId
    {
        return PlaceId::fromString($this->placeId);
    }

    public function spotId(): SpotId
    {
        return SpotId::fromString($this->spotId);
    }

    public function name(): SpotName
    {
        return SpotName::fromString($this->name);
    }
}

As @matthiasnoback states:

My general rule is to have only immediately serializable data in a DTO (that is, primitive types). I know some people who add "getters" to a DTO which produce actual (domain) objects based on those primitive values.
I don't do it like that, but always explicitly convert to objects inside the command handler.

Or:

Also, a command is a DTO, which isn't a domain model. It just represents the data you need to handle it. In that sense it supports having alternative implementations in the infrastructure layer (e.g. a web controller, and a Behat FeatureContext) which can use it.

/cc @mrook @kapitancho @nicolaibaaring

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions