-
Notifications
You must be signed in to change notification settings - Fork 11
Description
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:
- Where and how to handle command (superficial) and domain validation? #44
- Primitive types only in command #14
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.