Domain validation using the notification pattern
Asked Answered
G

4

6

Historically I have performed validation of my objects within their constructors and thrown an exception when validation fails. For example:

class Name
{
    const MIN_LENGTH = 1;

    const MAX_LENGTH = 120;

    private $value;

    public function __construct(string $name)
    {
        if (!$this->isValidNameLength($name)) {
            throw new InvalidArgumentException(
                sprintf('The name must be between %d and %d characters long', self::MIN_LENGTH, self::MAX_LENGTH)
            );
        }
        $this->value = $name;
    }

    public function changeName(string $name)
    {
        return new self($name);
    }

    private function isValidNameLength(string $name)
    {
        return strlen($name) >= self::MIN_LENGTH && strlen($name) <= self::MAX_LENGTH;
    }
}

Whilst I like this approach as my object is responsible for enforcing its consistency and ensuring it is always-valid, I've never been overly enthusiastic about the use of exceptions. Whilst there are people who would argue for and against the use of exceptions as above, it does limit the number of validation messages I can return when performing validation over multiple objects. For example:

class Room
{
    private $name;

    private $description;

    public function __construct(Name $name, Description $description)
    {
        $this->name = $name;
        $this->description = $description;
    }
}

class Name
{
    public function __construct(string $name)
    {
        // do some validation
    }
}

class Description
{
    public function __construct(string $description)
    {
        // do some validation
    }
}

Should both Name and Description fail validation, I want to be able to return the failure messages for both objects, not just a single exception from whichever object failed first.

Having done some reading on the notification pattern I feel this would be a good fit for my scenario. Where I become stuck is how I perform validation and prevent my object from entering an invalid state should validation fail.

class Name
{
    const MIN_LENGTH = 1;

    const MAX_LENGTH = 120;

    private $notification;

    private $value;

    public function __construct(string $name, Notification $notification)
    {
        $this->notification = $notification;
        $this->setName($name);
    }

    private function setName(string $name)
    {
        if ($this->isValidNameLength($name)) {
            $this->value = $name;
        }
    }

    private function isValidNameLength(string $name)
    {
        if (strlen($name) < self::MIN_LENGTH || strlen($name) > self::MAX_LENGTH) {
            $this->notification->addError('NAME_LENGTH_INVALID');
            return false;
        }
        return true;
    }

    public function hasError()
    {
        return $this->notification->hasError();
    }

    public function getError()
    {
        return $this->notification->getError();
    }
}

I have a few concerns about the above:

  1. If validation fails, then the object is still constructed but its $value is null which is not a valid state.
  2. After creating a Name, I must remember to call hasError to determine if a validation error has occured.
  3. I am now pebbling my domain objects with hasError/getError functions which I'm not sure is good practice.

Is there a piece of this puzzle I am missing? How would I got about utilising the notification pattern but ensuring that my object can not enter an invalid state?

Guadalajara answered 8/12, 2017 at 14:19 Comment(0)
L
2

How would I got about utilising the notification pattern but ensuring that my object can not enter an invalid state?

Factory pattern - aka "named constructors"

You leave the constructor validation has is -- you never want to create a value that can't preserve its own invariant.

But you take the constructor out of the public API, instead arranging for client code to invoke a method on a factory. That factory gets to decide how to manage the failure to construct the object - gathering up all of your notifications and then either

  • throwing an exception that includes the notification collection
  • returning a failure type that includes the notification collection

depending on whether you prefer handling control flow with exceptions or discriminated unions.

Lynnell answered 8/12, 2017 at 16:0 Comment(1)
A Factory for which object, the Room? In this RoomFactory(string name, string description) I create my Name & Description objects and check hasError on each to determine their validity? Does that not still allow for invalid invariants?Guadalajara
S
2

I also prefer providing a list of errors (in your case notifications) over throwing exceptions right away. But I still want to make sure my domain model cannot enter an invalid state.

My rule of thumb here is, provide errors if the user could have made a mistake (or a series of mistakes), only throw exceptions if you as the programmer obviously made a mistake or something else bad occurred in the system (which did not happen due to user input).

So I'm following a pattern which is based on the ideas from Martin Fowler (Replacing Throwing Exceptions with Notification in Validations) and Vladimir Khorikov (Validation and DDD)

Whenever an operation or a constructor does necessary validation which could result in an exception provide a corresponding method to ask the entity if it would be OK to perform the operation.

This means, the programmer should use the asking methods (e.g. canPurchaseProduct()) before performing an operation or constructing the domain entity if there is any domain specific validation logic to be checked which only the entities know about.

If the asking method fails you can collect all the errors from the aggregate root and inform the user about the mistake they made.

The corresponding operation of the entity (or the constructor) to do the real work will than call the asking method as well and if there are any errors resulting from calling the asking method it throws an exception. For instance with a summary of all errors.

Each entity of course would have to follow this pattern so that each entity can call the asking method of it's child entity when required to perform it's own operation.

I'm using the good old Order and OrderItem example here.

I am using an abstract base class that provides the error handling functionality for all domain entity classes.

abstract class AbstractDomainEntity
{
    public function addError(string $message)
    {
        //...
    }
    public function mergeErrors(array $errors)
    {
        //...
    }
    public function getErrors(): array
    {
        //...
    }
    public function getErrorSummary(): array
    {
        //...
    }
    public function hasErrors(): bool
    {
        //...
    }
}

The Order class extends the abstract domain class to use it's error handling features.

class Order extends AbstractDomainEntity
{
    public function __construct(OrderId $id, CustomerId $customerId, ShopId $shopId, $orderItems)
    {
        $this->canCreate($id, $customerId, $shopId, $orderItems);
        if ($this->hasErrors()) {
            throw new DomainException($this->getErrorSummary);
        }

        $this->setId($id);
        $this->setCustomerId($customerId);
        $this->setShopId($shopId);
        $this->setOrderItems($orderItems);
    }

    /**
     * @return ErrorNotification[] 
     */
    public static function canCreate(OrderId $id, CustomerId $customerId, ShopId $shopId, $orderItems): array
    {
        // Perform validation
        // add errors if any...
        return $this->getErrors();
    }

    public function acceptGeneralTermsAndConditions()
    {
        //...
    }

    public function ship(ShipmentInformation $shipmentInfo)
    {
        $this->canShip(ShipmentInformation $shipmentInfo);
        if ($this->hasErrors()) {
            throw new DomainException($this->getErrorSummary);
        }

        foreach ($this->orderItems as $orderItem) {
            $orderItem->shipToCountry($shipmentInfo->country);
        }

        $this->recordShipmentInformation($shipmentInfo);
        $this->setOrderState(self::SHIPPED);
    }

    public function canShip(ShipmentInformation $shipmentInfo)
    {
        // Validate order item operations
        foreach ($this->orderItems as $orderItem) {
            $orderItem->canShipToCountry($shipmentInfo->country);
            $this->mergeErrors($orderItem->getErrors());
        }

        if (!$this->generalTermsAndConditionsAccepted()) {
            $this->addError('GTC needs to be agreed on prio to shipment');
        }

        return $this->getErrors();
    }
}

This application service illustrates how this approach is intended to be applied:

class OrderApplicationService
{
    public function startNewOrder(NewOrderCommand $newOrderCommand): Result
    {
        $orderItems = $newOrderCommand->getOrderItemDtos->toOrderItemEntities();

        $errors = this->canCreate(
            $this->orderRepository->getNextId(),
            $this->newOrderCommand->getCustomerId(),
            $this->newOrderCommand->shopId(),
            $orderItems);

        if ($errors > 0) {
            return Result.NOK($errors)
        }

        $order = new Order(
            $this->orderRepository->getNextId(),
            $this->newOrderCommand->getCustomerId(),
            $this->newOrderCommand->shopId(),
            $orderItems);

        $this->orderRepository->save($order);
    }

    public function shipOrder(ShipOrderCommand $shipOrderCommand): Result
    {
        $order = $this->orderRepository->getById($shipOrderCommand->getOrderId());

        $shipmentInformation = $shipOrderCommand
            ->getShipmentInformationDto()
            ->toShipmentInformationEntity();

        if (!$order->canShip($shipmentInformation)) {
            return Result::NOK($order->getErrors());
        }

        $order->ship($shipmentInformation);
    }
}

Collecting the errors in the entities themselves allows to collect and receive all errors rather conveniently. If there are more than one domain operations to be performed on the aggregate root all possible errors can be collected at once.

The factory method has to handle this differently and return the list of errors directly of course. But if constructing an entity would fail further operations for that kind of entity would not be performed anyways.

This pattern gives me the flexibility to collect all error information that concerns business logic for the user without throwing exceptions on the first error.

Also it allows me to make sure that at least an exception will be thrown in case I forgot to call the corresponding asking method (e.g. canShip()) in advance.

The DomainException can be caught at the most upper stack (e.g. API controller layer) as a last resort and the information from the exception can easily be used for logging or similar.

Sulfonate answered 26/3, 2020 at 8:9 Comment(0)
H
0

What about calling the notification.hasError function and throw an exception afterwards?

This way you can handle any errors using your notification handling, and because of the exception it will be guaranteed that you won't have a valid object.

Hanover answered 8/12, 2017 at 15:3 Comment(1)
Calling notification.hasError() from where exactly?Guadalajara
P
0

Well, you pretty much did it already but if you share all your validation in one validate() method, you could just call it before proceeding in your business logic.

So something like

if (model.validate()) 
{
   // You can safely proceed
}

After this you can for example throw an exception so you will know that you have an object in an invalid state.
Plebs answered 8/12, 2017 at 15:5 Comment(3)
I could, but I am looking to see if there is another way of performing the validation in the constructor so as to avoid having to pebble my code with calls to validate() methods.Guadalajara
I'm not sure what do you want then? You said you looked into the notification pattern, and that is not enough. You have tried to throw exceptions in the constructor if the object is given invalid parameters. Having an public validation method to be called from external code is not enough. What is it that you really want?Plebs
It's not that these options are 'not enough', to some people these are perfectly acceptable and I might end up using model.validate(). I am simply looking at potential alternatives (if there are any) and to learn. I don't like accepting something as 'the way' purely because it works, I like to know if there are alternatives.Guadalajara

© 2022 - 2024 — McMap. All rights reserved.