webdevilopers / php-ddd

PHP Symfony Doctrine Domain-driven Design
200 stars 11 forks source link

How to use factory methods on aggregates in CQRS - WRITE vs. READ model #55

Open webdevilopers opened 3 years ago

webdevilopers commented 3 years ago

Came from:

In their book Patterns Principles and Practices of Domain-Driven Design @elbandit and @NTCoding show the following examples to create an aggregate through another aggregate by using a factory method.

Factories don’t always need to be standalone static classes. A factory method can exist on an aggregate to hide the complexities of object creation from clients. In Listing 20‐5, the Basketclass, which you looked at previously, now exposes the ability to create a WishListItem from a BasketItem. The resulting WishListItem object is an entity for the WishList aggregate. The Basket has nothing to do with the WishListItem after its construction, but it does contain the data required for it. The factory method removes the need for the client, the application service, to know how to extract a BasketItem and turn it into a WishListItem, by moving this control into the Basket where it naturally fits.

namespace DomainModel
{
    public class Basket    
    {
       // .....        
    public WishListItem MoveToWishList(Product product)        
    {
            if (BasketContainsAnItemFor(product_snapshot))            
            {
                 var wishListItem = WishListItemFactory.CreateFrom(GetItemFor(product));             

                RemoveItemFor(product);

                return wishListItem;
            }        
        }   
    }
}

factory method can also create an aggregate itself. Listing 20‐6 shows that you can use an Account to create an order if there is enough credit within the account.

namespace DomainModel
{
    public class Account
        // .....
        public Order CreateOrder()
        {
            if (HasEnoughCreditToOrder())
               return new Order(this.Id, this.PaymentMethod, this.Address);
            else
               throw new InsufficentCreditToCreateAnOrder();
        }
    }
}

A great post on this topic by Udi Dahan:

A follow-up by @gedgei:

I wonder where these factory methods would live in a CQRS application with not a single Domain Model but a WRITE (Domain?!) Model and a READ (Domain also?) Model.

Let's take the last example where the Account holds the required credit.

(1) WRITE MODEL (possibly event-sourced)

final class Account extends AggregateRoot
{
    private AccountId $accountId;

    private Credit $credit;

    private PaymentMethod $method;

    private Address $address;

    private function __construct();

    public static function signUp(AccountId $accountId): Account
    {
        $self = new self();
        $self->recordThat(SignedUpForAccount::with(accountId);

        return $self;
    }

    protected function apply(AggregateChanged $event): void
    {
        switch (get_class($event)) {
            case SignedUpForAccount::class:
                /** @var SignedUpForAccount $event */
                $this->accountId = $event->accountId();
                break;
        }
    }

    public function createOrder(OrderId $orderId): Order
    {
        if (!$this->hasEnoughCreditToOrder()) {
            throw new InsufficentCreditToCreateAnOrder();
        }

        return new Order($this->id, $orderId, $this->paymentMethod, $this->address);
    }
}
final class CreateOrderHandler
{
    private $accountWriteModelRepository;

    private $orderWriteModelRepository;

    public function __invoke(CreateOrder $command): void
    {
        $accountWriteModel = $this->accountWriteModelRepository->ofAccountId($command->accountId());

        $order = $accountWriteModel->createOrder($command->orderId());

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

(2) READ MODEL) (rich model w/ e.g. Domain Value Objects, NOT a VIEW model with primitives only)

final class AccountReadModel
{
    private AccountId $accountId;

    private Credit $credit;

    private PaymentMethod $method;

    private Address $address;

    private function __construct();

    public static function fromDto($dto): AccountReadModel
    {
        $self = new self();
        $self->credit = $dto['credit'];
        $self->method = $dto['method'];
        $self->address = $dto['address'];

        return $self;
    }

    public function createOrder(OrderId $orderId): Order
    {
        if (!$this->hasEnoughCreditToOrder()) {
            throw new InsufficentCreditToCreateAnOrder();
        }

        return new Order($this->accountId, $orderId, $this->paymentMethod, $this->address);
    }
}
final class CreateOrderHandler
{
    private $accountReadModelRepository;

    private $orderWriteModelRepository;

    public function __invoke(CreateOrder $command): void
    {
        $accountReadModel = $this->accountReadModelRepository->ofAccountId($command->accountId());

        $order = $accountReadModel->createOrder($command->orderId());

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

(3) Pass the READ model instead

final class Order extends AggregateRoot
{
    private OderId $orderId;

    private function __construct();

    public static function create(AccountReadModel $account, OrderId $orderId): Order
    {
        if (!$this->hasEnoughCreditToOrder($account->credit())) {
            throw new InsufficentCreditToCreateAnOrder();
        }

        $self = new self();
        $self->recordThat(OrderCreated::with($account->accountId(), $orderId, $account->paymentMethod(), $account->address());

        return $self;
    }
}
final class CreateOrderHandler
{
    private $accountReadModelRepository;

    private $orderWriteModelRepository;

    public function __invoke(CreateOrder $command): void
    {
        $accountReadModel = $this->accountReadModelRepository->ofAccountId($command->accountId());

        $order = Order::create($accountReadModel);

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

Possibly related:

webdevilopers commented 3 years ago

Of course there is Option #4 and go full event-driven. An Event could create the Order and then a Saga / Process Manager fires a "checkCredit" Event etc. - but in this case I just wanted to focus on the "through another aggregate"-solution.