-
Notifications
You must be signed in to change notification settings - Fork 248
Validation of Domain Entities in Magento
It’s a common question, “Where do I put validation?” Simple answer: put it where it’s needed.
But it’s not just a question of “where”, but of “when”, “what” and “why”. If we treat our entities as data holders, we might think to put all of our validation there, in the form of reactive validation, where the entity is allowed to be put into an “invalid” state.
Validation depends completely on the context of the operation you’re trying to perform. Life becomes much more complicated if we start having “IsValid” properties on our entities. [Validation in a DDD world by Jimmy Bogard]
In domain-driven design (DDD) there is a consideration of the always valid entity (entities which are always in a valid state). There is no need to allow the entity to enter an invalid state. From the DDD perspective, validation rules can be viewed as invariants (the logical assertions that are held to always be true ).
If you let your entities become invalid, you’ve most likely lost the context of what operation was being performed on the entity in the first place. An entity may change state for a wide variety of reasons for a wide variety of operations, so certain attributes may be valid in one operation and invalid in the next.
Instead of thinking of changing state on an entity, we need to move up to a higher level of command/query separation, where we perform and execute commands on one or many entities. Instead of answering the question, “is this object valid”, try and answer the question, “Can this operation be performed?”. Because our Service Contracts (command objects) know answers to all of the “Who, what, why” questions, they are in the best position to perform contextual validation based on the operation the user is trying to execute.
There are two major types of validation:
- Task-based where you don’t need to map validation errors to UI elements (happens in Magento Service Contract Commands).
- CRUD-y where you do need to do that (happens in Magento Repositories).
Based on the above it's decided to follow the practice of having always valid entities in the system. And make the Service Contract Command Services being responsible for Domain Entities validity. If the Entity is being created in a code of business logic, a creational pattern (Factory/Builder) which is used for entity instantiation should take a responsibility of preparation and validation of entity before the creation.
For the sake of convenience, we've introduced two classes to the Framework (not merged into Mainline yet).
Validation Result (Magento\Framework\Validation\ValidationResult) - to provide an ability return more than one error which happened in the scope of Entity validation process. And isValid()
method which helps to decide whether validation passed successfully.
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
namespace Magento\Framework\Validation;
/**
* @api
*/
class ValidationResult
{
/**
* @var array
*/
private $errors = [];
/**
* @param array $errors
*/
public function __construct(array $errors)
{
$this->errors = $errors;
}
/**
* @return bool
*/
public function isValid()
{
return empty($this->errors);
}
/**
* @return array
*/
public function getErrors()
{
return $this->errors;
}
}
Custom Exception Type (Magento\Framework\Validation\ValidationException) which could be thrown out of Service Contracts, providing an ability to return more than one error message to a client. For example, could be useful sending Form Data when more than 1 field not passing validation checks and that should be highlighted on the front-end.
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
namespace Magento\Framework\Validation;
use Magento\Framework\Exception\AggregateExceptionInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Phrase;
/**
* Validation exception with possibility to set several error messages
*
* @api
*/
class ValidationException extends LocalizedException implements AggregateExceptionInterface
{
/**
* @var ValidationResult|null
*/
private $validationResult;
/**
* @param Phrase $phrase
* @param \Exception $cause
* @param int $code
* @param ValidationResult|null $validationResult
*/
public function __construct(
Phrase $phrase,
\Exception $cause = null,
$code = 0,
ValidationResult $validationResult = null
) {
parent::__construct($phrase, $cause, $code);
$this->validationResult = $validationResult;
}
/**
* @inheritdoc
*/
public function getErrors()
{
$localizedErrors = [];
if (null !== $this->validationResult) {
foreach ($this->validationResult->getErrors() as $error) {
$localizedErrors[] = new LocalizedException($error);
}
}
return $localizedErrors;
}
}
Our Service Contracts start to throw ValidationException as a part of contract
namespace Magento\InventoryApi\Api;
/**
* @api
*/
interface SourceRepositoryInterface
{
/**
* Save Source data
*
* @param \Magento\InventoryApi\Api\Data\SourceInterface $source
* @return int
* @throws \Magento\Framework\Validation\ValidationException
* @throws \Magento\Framework\Exception\CouldNotSaveException
*/
public function save(SourceInterface $source);
Implementation of Save Command (Magento\Inventory\Model\Source\Command\Save) looks like
/**
* @param SourceValidatorInterface $sourceValidator
* @param SourceResourceModel $sourceResource
* @param LoggerInterface $logger
*/
public function __construct(
SourceValidatorInterface $sourceValidator,
SourceResourceModel $sourceResource,
LoggerInterface $logger
) {
$this->sourceValidator = $sourceValidator;
$this->sourceResource = $sourceResource;
$this->logger = $logger;
}
/**
* @inheritdoc
*/
public function execute(SourceInterface $source)
{
$validationResult = $this->sourceValidator->validate($source);
if (!$validationResult->isValid()) {
throw new ValidationException(__('Validation Failed'), null, 0, $validationResult);
}
try {
$this->sourceResource->save($source);
return $source->getSourceId();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
throw new CouldNotSaveException(__('Could not save Source'), $e);
}
}
There are several things you can pay attention to:
- Dedicated interface for Source Entity Validation - Magento\Inventory\Model\Source\Validator\SourceValidatorInterface. Each Entity has its own validation interface. For example, Stock has own validator - StockValidatorInterface.
- Such implementation of
execute
command could be considered as an implementation of Execute/CanExecute pattern - Validation service returns ValidationResult object which represents a result of all Validators checks over current entity.
SourceValidatorInterface looks like:
/**
* Extension point for base validation
*
* @api
*/
interface SourceValidatorInterface
{
/**
* @param SourceInterface $source
* @return ValidationResult
*/
public function validate(SourceInterface $source);
}
Implementation of SourceValidationInterface which looks like a Composite Object provided through the DI preference
class ValidatorChain implements SourceValidatorInterface
{
/**
* @var ValidationResultFactory
*/
private $validationResultFactory;
/**
* @var SourceValidatorInterface[]
*/
private $validators;
/**
* @param ValidationResultFactory $validationResultFactory
* @param SourceValidatorInterface[] $validators
* @throws LocalizedException
*/
public function __construct(
ValidationResultFactory $validationResultFactory,
array $validators = []
) {
$this->validationResultFactory = $validationResultFactory;
foreach ($validators as $validator) {
if (!$validator instanceof SourceValidatorInterface) {
throw new LocalizedException(
__('Source Validator must implement SourceValidatorInterface.')
);
}
}
$this->validators = $validators;
}
/**
* @inheritdoc
*/
public function validate(SourceInterface $source)
{
$errors = [];
foreach ($this->validators as $validator) {
$validationResult = $validator->validate($source);
if (!$validationResult->isValid()) {
$errors = array_merge($errors, $validationResult->getErrors());
}
}
return $this->validationResultFactory->create(['errors' => $errors]);
}
}
This composite object is extensible through DI.xml via adding custom validators to the array $validators = []
constructor parameter. This customization looks like:
<preference for="Magento\Inventory\Model\Source\Validator\SourceValidatorInterface" type="Magento\Inventory\Model\Source\Validator\ValidatorChain"/>
<type name="Magento\Inventory\Model\Source\Validator\ValidatorChain">
<arguments>
<argument name="validators" xsi:type="array">
<item name="name" xsi:type="object">Magento\Inventory\Model\Source\Validator\NameValidator</item>
<item name="postcode" xsi:type="object">Magento\Inventory\Model\Source\Validator\PostcodeValidator</item>
<item name="country" xsi:type="object">Magento\Inventory\Model\Source\Validator\CountryValidator</item>
<item name="carrier_links" xsi:type="object">Magento\Inventory\Model\Source\Validator\CarrierLinks</item>
</argument>
</arguments>
</type>
There are 4 custom validators added for Source Entity. Each of this validator should implement SourceValidatorInterface.
Example of CarrierLinkValidator for Sources:
/**
* Check that carrier links is valid
*/
class CarrierLinks implements SourceValidatorInterface
{
/**
* @var ValidationResultFactory
*/
private $validationResultFactory;
/**
* @param ValidationResultFactory $validationResultFactory
*/
public function __construct(ValidationResultFactory $validationResultFactory)
{
$this->validationResultFactory = $validationResultFactory;
}
/**
* @inheritdoc
*/
public function validate(SourceInterface $source)
{
$value = $source->getCarrierLinks();
$errors = [];
if (null !== $value) {
if (!is_array($value)) {
$errors[] = __('"%field" must be list of SourceCarrierLinkInterface.', [
'field' => SourceInterface::CARRIER_LINKS,
]);
} else if (count($value) && $source->isUseDefaultCarrierConfig()) {
$errors[] = __('You can\'t configure "%field" because you have chosen Global Shipping configuration.', [
'field' => SourceInterface::CARRIER_LINKS,
]);
}
}
return $this->validationResultFactory->create(['errors' => $errors]);
}
}
If Controller has to handle validation results, for example, for highlighting form fields which need to be corrected.
Next approach could be used (Magento\Inventory\Controller\Adminhtml\Stock\Save)
/**
* @inheritdoc
*/
public function execute()
{
$resultRedirect = $this->resultRedirectFactory->create();
$requestData = $this->getRequest()->getParams();
if ($this->getRequest()->isPost() && !empty($requestData['general'])) {
try {
$stockId = $requestData['general'][StockInterface::STOCK_ID] ?? null;
$stockId = $this->processSave($stockId, $requestData);
$this->messageManager->addSuccessMessage(__('The Stock has been saved.'));
$this->processRedirectAfterSuccessSave($resultRedirect, $stockId);
} catch (NoSuchEntityException $e) {
$this->messageManager->addErrorMessage(__('The Stock does not exist.'));
$this->processRedirectAfterFailureSave($resultRedirect);
} catch (ValidationException $e) {
foreach ($e->getErrors() as $localizedError) {
$this->messageManager->addErrorMessage($localizedError->getMessage());
}
$this->processRedirectAfterFailureSave($resultRedirect, $stockId);
}
} else {
$this->messageManager->addErrorMessage(__('Wrong request.'));
$this->processRedirectAfterFailureSave($resultRedirect);
}
return $resultRedirect;
}
Multi-Source Inventory developed by Magento 2 Community
- Technical Vision. Catalog Inventory
- Installation Guide
- List of Inventory APIs and their legacy analogs
- MSI Roadmap
- Known Issues in Order Lifecycle
- MSI User Guide
- 2.3 LIVE User Guide
- MSI Release Notes and Installation
- Overview
- Get Started with MSI
- MSI features and processes
- Global and Product Settings
- Configure Source Selection Algorithm
- Create Sources
- Create Stock
- Assign Inventory and Product Notifications
- Configure MSI backorders
- MSI Import and Export Product Data
- Mass Action Tool
- Shipment and Order Management
- CLI reference
- Reports and MSI
- MSI FAQs
- DevDocs Documentation
- Manage Inventory Management Modules (install/upgrade info)
- Inventory Management
- Reservations
- Inventory CLI reference
- Inventory API reference
- Inventory In-Store Pickup API reference
- Order Processing with Inventory Management
- Managing sources
- Managing stocks
- Link and unlink stocks and sources
- Manage source items
- Perform bulk actions
- Manage Low-Quantity Notifications
- Check salable quantities
- Manage source selection algorithms
- User Stories
- Support of Store Pickup for MSI
- Product list assignment per Source
- Source assignment per Product
- Stocks to Sales Channel Mapping
- Adapt Product Import/Export to support multi Sourcing
- Introduce SourceCode attribute for Source and SourceItem entities
- Assign Source Selector for Processing of Returns Credit Memo
- User Scenarios:
- Technical Designs:
- Module Structure in MSI
- When should an interface go into the Model directory and when should it go in the Api directory?
- Source and Stock Item configuration Design and DB structure
- Stock and Source Configuration design
- Open Technical Questions
- Inconsistent saving of Stock Data
- Source API
- Source WebAPI
- Sources to Sales Channels mapping
- Service Contracts MSI
- Salable Quantity Calculation and Mechanism of Reservations
- StockItem indexation
- Web API and How To cover them with Functional Testing
- Source Selection Algorithms
- Validation of Domain Entities
- PHP 7 Syntax usage for Magento contribution
- The first step towards pre generated IDs. And how this will improve your Integration tests
- The Concept of Default Source and Domain Driven Design
- Extension Point of Product Import/Export
- Source Selection Algorithm
- SourceItem Entity Extension
- Design Document for changing SerializerInterface
- Stock Management for Order Cancelation
- Admin UI
- MFTF Extension Tests
- Weekly MSI Demos
- Tutorials