Comments (6)
I get your point. But I would not regard this a "duplication".
Each layer has its own validation. We have command DTOs inside the Application
layer. Sometimes some "aggregate-spanning" validation e.g. "Unique Username" live here too.
The Policy / Specification and other Domain Model (Value Object, Entities) validation lives inside the Domain
layer.
BTW: The RegEx was easy since we could reuse the pattern from the Policy class.
One point is to give API client immediate feedback. If there were two errors e.g. socialSecurityNumber
AND firstName
he will get it at the same time.
The main idea is NOT to create the value object at all if the command has not the validation of the primitives.
It is dispatched to the command bus but never really reaches the Domain
and stays inside the Application
layer.
Only if the primitives are valid the handler calls the getters on the command and starts orchestrating with the Domain
.
In earlier scenarios we did not have the getters on the command DTO. The latter was reaching the handler after being deserialized. Then the VO factory methods were called directly in the handler. The code was not nice to look at.
At the bottom line most of the rules don't change often. There's not much more code. Value objects and Commands can be unit tested to ensure they do the same thing.
Just my...
/**
* @var int $cents
* @Assert\NotNull()
* @Assert\Min(min="2)
*/
private $cents;
;)
from php-ddd.
An onKernelException listener is clearly the way to go to convert command validation errors into a nice json response.
However, you could rely on the Symfony serializer to directly encode the ConstraintViolationListInterface
instance. You would get RFC compliant response for free, if you would like (see https://github.com/symfony/serializer/blob/master/Normalizer/ConstraintViolationListNormalizer.php).
Another question that comes to mind is : how do you define command validation metadata ?
I was suggesting to use one class constraint (instead of one property constraint for each properties) which would sequentially call all the command accessor (which creates the value objects) and aggregate the errors that way within the ConstraintViolationListInterface.
Thus, this solution removes the need to define extra command validation metadata and rely solely on what's described within the value objects constructor.
from php-ddd.
Stupid me, forgot the Command:
<?php
namespace Acme\PersonnelManagement\Application\Service\Person;
use Acme\Common\Domain\Model\BirthName;
use Acme\Common\Domain\Model\PlaceOfBirth;
use Acme\Common\Domain\Model\DateOfBirth;
use Acme\Common\Domain\Model\FirstName;
use Acme\Common\Domain\Model\LastName;
use Acme\Common\Domain\Model\PersonalName;
use Acme\Common\Domain\Model\PersonalName\NamePolicy;
use Acme\Common\Domain\Model\SocialSecurityNumber;
use Acme\Common\Domain\Model\Title;
use Acme\Common\Domain\Model\Address;
use Acme\PersonnelManagement\Domain\Model\BiographicInformation;
use Acme\Common\Domain\Model\ContactInformation;
use Acme\PersonnelManagement\Domain\Model\Person\PersonId;
use Symfony\Component\Validator\Constraints as Assert;
final class AddPerson
{
/**
* @var PersonId $personId
* @Assert\NotNull()
* @Assert\Uuid()
*/
private $personId;
/**
* @var Title $title
* @Assert\NotNull
* @Assert\NotBlank
* @Assert\Type(type="string")
*/
private $title;
/**
* @var FirstName
* @Assert\NotNull
* @Assert\NotBlank
* @Assert\Type(type="string")
* @Assert\Regex(pattern=NamePolicy::ALLOWED_PATTERN, normalizer="trim")
* @Assert\Regex(pattern=NamePolicy::FORBIDDEN_PATTERN, normalizer="trim", match=false)
*/
private $firstName;
/**
* @var LastName $lastName
* @Assert\NotNull
* @Assert\NotBlank
* @Assert\Type(type="string")
* @Assert\Regex(pattern=NamePolicy::ALLOWED_PATTERN, normalizer="trim")
* @Assert\Regex(pattern=NamePolicy::FORBIDDEN_PATTERN, normalizer="trim", match=false)
*/
private $lastName;
/**
* @var BiographicInformation
* @Assert\NotNull
* @Assert\Type(type="array")
* @Assert\Collection(
* fields = {
* "birthName" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "dateOfBirth" = {
* @Assert\DateTime(format="Y-m-d")
* },
* "placeOfBirth" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* })
*/
private $biographicInformation;
/**
* @var SocialSecurityNumber|null
* @Assert\Type(type="string")
*/
private $socialSecurityNumber;
/**
* @var ContactInformation $contactInformation
* @Assert\NotNull,
* @Assert\Type(type="array"),
* @Assert\Collection(
* fields = {
* "email" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Email,
* },
* "phone" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "mobile" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "fax" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* }
* )
*/
private $contactInformation;
/**
* @var Address
* @Assert\NotNull,
* @Assert\Type(type="array"),
* @Assert\Collection(
* fields = {
* "street" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "postcode" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "city" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "countryCode" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string"),
* @Assert\Length(min="2")
* },
* }
* )
*/
private $address;
public function __construct(array $payload)
{
$this->personId = $payload['personId'];
$this->title = $payload['title'];
$this->firstName = $payload['firstName'];
$this->lastName = $payload['lastName'];
$this->biographicInformation = $payload['biographicInformation'];
$this->socialSecurityNumber = $payload['socialSecurityNumber'];
$this->contactInformation = $payload['contactInformation'];
$this->address = $payload['address'];
}
public function personId(): PersonId
{
return PersonId::fromString($this->personId);
}
public function title(): Title
{
return Title::fromString($this->title);
}
public function personalName(): PersonalName
{
return new PersonalName(
FirstName::fromString($this->firstName),
null,
LastName::fromString($this->lastName)
);
}
public function biographicInformation(): BiographicInformation
{
return new BiographicInformation(
null !== $this->biographicInformation['birthName'] ? BirthName::fromString($this->biographicInformation['birthName']) : null,
null !== $this->biographicInformation['dateOfBirth'] ? DateOfBirth::fromString($this->biographicInformation['dateOfBirth']) : null,
null !== $this->biographicInformation['placeOfBirth'] ? PlaceOfBirth::fromString($this->biographicInformation['placeOfBirth']) : null
);
}
public function socialSecurityNumber(): ?SocialSecurityNumber
{
return null !== $this->socialSecurityNumber ? SocialSecurityNumber::fromString($this->socialSecurityNumber) : null;
}
public function contactInformation(): ContactInformation
{
return ContactInformation::fromArray($this->contactInformation);
}
public function address(): Address
{
return Address::fromArray($this->address);
}
}
In this example the firstName
is also later validated in the domain by a Policy / Specification:
<?php
namespace Acme\Common\Domain\Model\PersonalName;
final class NamePolicy
{
public const ALLOWED_PATTERN = "/^[\p{L}\-\.\s\']+$/u";
public const FORBIDDEN_PATTERN = "/[\p{Lu}]{2,}/u";
public static function isSatisfiedBy(string $name): bool
{
if (empty($name)) {
return false;
}
// Allowed are unicode letters only and `.`, `-`, `'`, no numbers.
if (1 !== preg_match(self::ALLOWED_PATTERN, $name)) {
return false;
}
// Continuous uppercase letters are not allowed.
if (1 === preg_match(self::FORBIDDEN_PATTERN, $name)) {
return false;
}
return true;
}
}
It is checked inside the FirstName
value object:
<?php
namespace Acme\Common\Domain\Model;
use ReflectionClass;
use Acme\Common\Domain\Model\PersonalName\Exception\NameContainsIllegalCharacters;
use Acme\Common\Domain\Model\PersonalName\NamePolicy;
use Acme\Common\Domain\Model\PersonalName\NameNormalizer;
final class FirstName
{
/** @var string $name */
private $name;
private function __construct(string $aName)
{
$name = NameNormalizer::withString($aName);
if (!NamePolicy::isSatisfiedBy($name)) {
throw new NameContainsIllegalCharacters();
}
$this->name = $name;
}
public static function fromString(string $name): FirstName
{
return new self($name);
}
public static function fromPayload(string $name): FirstName
{
$firstNameRef = new ReflectionClass(\get_called_class());
/** @var FirstName $firstName */
$firstName = $firstNameRef->newInstanceWithoutConstructor();
$firstName->name = $name;
return $firstName;
}
public function toString(): string
{
return $this->name;
}
}
That is a domain exception the listener can catch and translate to the user.
from php-ddd.
Some details of the examples are inconsistent e.g. the doc block should hint to the primitive values. Please ignore.
from php-ddd.
Allright, so you suggest to "duplicate" validation logic (eg: the forbbidden name pattern is checked through command validation, thanks to the Assert\Regex
constraint AND through PersonalName
instanciation).
Not saying that this is bad per se, I'm just wondering if it wouldn't be possible to only rely on validation-at-instanciation and provide the same level of information to the caller (aka http client), getting rid of extra framework-related work at the same time (the whole validation metadata definition) 🤔
from php-ddd.
Possibly related:
In practice, it is often simpler to allow a degree of duplication rather than to strive for complete consistency.
Or in the comments section on the article by @vkhorikov :
from php-ddd.
Related Issues (20)
- Repositories inside or outside Domain Services HOT 1
- Event Enriching and external changes to read-model data
- When, where and how to create Summary Events HOT 3
- Passing read models (value objects representing state) / domain service to aggregate methods HOT 6
- Unit testing value objects with internal datetime calculation HOT 14
- How to test application service command handlers dealing with read models? HOT 12
- Process Manager example with Symfony Messenger Command / Event Bus and ProophOS HOT 5
- Batch / Bulk operations handling multiple event-sourced aggregate roots HOT 3
- How to use factory methods on aggregates in CQRS - WRITE vs. READ model HOT 1
- How to keep read-models up-to-date when a name property was externally changed?
- How to upcast events with Prooph HOT 1
- Are CQRS commands part of the domain model? HOT 13
- Populate Projection with multiple tables HOT 2
- Where to call or pass a domain service? HOT 16
- How to implement the Equatable interface / Equals or SameValueAs method in value objects
- Domain Event Publisher for Doctrine Entities HOT 1
- Event Sourcing vs. Event-Driven Architecture (EDA)
- The repository pattern HOT 4
- Properties on Domain Events HOT 3
- PHP Command DTO with Symfony Constraints equivalent in Angular Forms HOT 1
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.
from php-ddd.