PHP
Symfony
Designing and refactoring
Doctrine ORM
ООP
23 August 2017

DDD на практике. Проектирование списка желаний

Tutorial

В интернете довольно много разрозненного материала по DDD. Если не считать синей книги, то в основном это короткие статьи с теорией, надёрганной из этой же книги, и которая мало пересекается с практикой. Возможно, конечно, что я просто плохо искал, но мне давно хотелось найти какой-нибудь цельный пример, что называется, «от и до». И я решил такой пример создать на Symfony 3 и VueJS. Сразу хочу сказать, что я изучаю DDD недавно, поэтому взял довольно простую предметную область — список желаний (wish list).


Список желаний


Любой человек когда-нибудь да что-нибудь покупает. Будь то новый телефон, подарок, поездка за границу или даже квартира. Список желаний, как дополнение к «хардварной» копилке, призван помочь отслеживать накопленные средства по каждому из желаний и заставлять эти средства постоянно увеличивать. Допустим, сегодня я решил начать копить на новый ноутбук: добавлю желание и начну откладывать деньги. А завтра я захочу посчитать, какое количество денег нужно будет откладывать ежедневно, чтобы через полгода я смог купить хороший подарок жене.


Желание


Желания, которые мы рассматриваем, можно удовлетворить, купив нечто за деньги. Из этого следует, что у каждого желания есть стоимость, начальный фонд (если вы начали копить деньги до того, как решили внести желание в список) и накопленные средства — фонд, который выражается суммой всех вкладов. Вклад — это единовременно отложенная сумма денег на конкретное желание. Поскольку желания требуют регулярных вложений, неплохо было бы определить и базовую ставку, ниже которой сумма вклада быть не может. К тому же мы должны иметь возможность отслеживать вклады в любое из желаний, чтобы при необходимости их изымать. По мере накопления достаточного количества средств желание становится исполненным. Если же есть избыток денежных средств, то его можно перераспределить на другие свои желания (об этом в одной из следующих статей).


Проектируем сущности


Исходя из вышеперечисленных требований, мы можем закодировать две сущности: Wish (желание) и Deposit (вклад).


Желание: конструктор сущности


Давайте начнём с желания и подумаем, какие поля нам понадобятся и как мы оформим конструктор сущности. Первое, что приходит в голову, это примерно вот такой код:


<?php

namespace Wishlist\Domain;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;

class Wish
{
    private $id;
    private $name;
    private $price;
    private $fee;
    private $deposits;
    private $initialFund;
    private $createdAt;
    private $updatedAt;

    public function __construct(
        string $name,
        int $price,
        int $fee,
        int $initialFund
    ) {
        $this->name = $name;
        $this->price = $price;
        $this->fee = $fee;
        $this->initialFund = $initialFund;
        $this->deposits = new ArrayCollection();
        $this->createdAt = $createdAt ?? new DateTimeImmutable();
        $this->updatedAt = $createdAt ?? new DateTimeImmutable();
    }
}

Однако здесь есть целый ряд проблем:


  1. Используется суррогатный ключ
  2. Отсутствует валидация полей
  3. Если валидацию закодировать в конструктор, он станет еще монструознее
  4. Отсутствует информация о валюте, в которой ведутся расчёты
  5. Конструктор перегружен аргументами

Что же будем делать? Есть решение — использовать объекты-значения. Тогда конструктор нашей сущности преобразится следующим образом:


<?php

namespace Wishlist\Domain;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;

class Wish
{
    private $id;
    private $name;
    private $expense; 
    private $deposits;
    private $published = false;
    private $createdAt;
    private $updatedAt;

    public function __construct(
        WishId $id,
        WishName $name,
        Expense $expense,
        DateTimeImmutable $createdAt = null
    ) {
        $this->id = $id;
        $this->name = $name;
        $this->expense = $expense;
        $this->deposits = new ArrayCollection();
        $this->createdAt = $createdAt ?? new DateTimeImmutable();
        $this->updatedAt = $createdAt ?? new DateTimeImmutable();
    }
}

Мы использовали три объекта-значения:


  1. WishId, который представляет из себя UUID, генерируемый с помощью библиотеки ramsey/uuid
  2. WishName — название желания
  3. Expense, который представляет «траты» на желание: стоимость, базовую ставку и начальный фонд (возможно, не самое удачное название, но другого я не придумал)

Вы спросите: почему в конструктор сущности попала дата её создания? Я вам отвечу: это сделано, чтобы облегчить написание тестов и нигде кроме тестов не используется. Возможно, не самое лучшее решение, конечно.


Что ж, раз мы использовали объекты-значения, неплохо было бы посмотреть и на их реализацию. Для начала подумаем, как реализовать идентификаторы (забегая вперёд, скажу, что кроме WishId у нас будет еще и DepositId). Для этого мы напишем простенький тест на примере одного из них (суть-то одна и та же, поэтому два разных теста писать смысла нет):


<?php

namespace Wishlist\Tests\Domain;

use Wishlist\Domain\WishId;
use PHPUnit\Framework\TestCase;

class IdentityTest extends TestCase
{
    public function testFromValidString()
    {
        $string = '550e8400-e29b-41d4-a716-446655440000';
        $wishId = WishId::fromString($string);

        static::assertInstanceOf(WishId::class, $wishId);
        static::assertEquals($string, $wishId->getId());
        static::assertEquals($string, (string) $wishId);
    }

    public function testEquality()
    {
        $string = '550e8400-e29b-41d4-a716-446655440000';
        $wishIdOne = WishId::fromString($string);
        $wishIdTwo = WishId::fromString($string);
        $wishIdThree = WishId::next();

        static::assertTrue($wishIdOne->equalTo($wishIdTwo));
        static::assertFalse($wishIdTwo->equalTo($wishIdThree));
    }
}

Исходя из данных тестов, мы можем сделать базовый класс идентификаторов, который содержит общую логику:


<?php

namespace Wishlist\Domain;

use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Wishlist\Domain\Exception\InvalidIdentityException;

abstract class AbstractId
{
    protected $id;

    private function __construct(UuidInterface $id)
    {
        $this->id = $id;
    }

    public static function fromString(string $id)
    {
        try {
            return new static(Uuid::fromString($id));
        } catch (InvalidUuidStringException $exception) {
            throw new InvalidIdentityException($id);
        }
    }

    public static function next()
    {
        return new static(Uuid::uuid4());
    }

    public function getId(): string
    {
        return $this->id->toString();
    }

    public function equalTo(AbstractId $id): bool
    {
        return $this->getId() === $id->getId();
    }

    public function __toString(): string
    {
        return $this->getId();
    }
}

А «настоящие» идентификаторы просто от него «отпачковать»:


<?php

namespace Wishlist\Domain;

final class WishId extends AbstractId
{
    //
}

И то же самое с DepositId:


<?php

namespace Wishlist\Domain;

final class DepositId extends AbstractId
{
    //
}

Теперь рассмотрим WishName. Это самый простой объект-значение, и нам нужно лишь чтобы название не было пустым. Давайте для начала напишем тесты:


<?php

namespace Wishlist\Tests\Domain;

use Wishlist\Domain\WishName;
use PHPUnit\Framework\TestCase;

class WishNameTest extends TestCase
{
    /**
     * @expectedException \InvalidArgumentException
     */
    public function testShouldNotCreateWithEmptyString()
    {
        new WishName('');
    }

    public function testGetValueShouldReturnTheName()
    {
        $expected = 'A bucket of candies';
        $name = new WishName($expected);

        static::assertEquals($expected, $name->getValue());
        static::assertEquals($expected, (string) $name);
    }
}

Теперь давайте, собственно, закодируем WishName. Кстати, для проверок здесь и далее будем использовать очень удобную библиотеку webmozart/assert:


<?php

namespace Wishlist\Domain;

use Webmozart\Assert\Assert;

final class WishName
{
    private $name;

    public function __construct(string $name)
    {
        Assert::notEmpty($name, 'Name must not be empty.');

        $this->name = $name;
    }

    public function getValue(): string
    {
        return $this->name;
    }

    public function __toString(): string
    {
        return $this->getValue();
    }
}

Теперь перейдем к более интересному объекту-значению — Expense. Он призван контролировать корректные значения стоимости, базовой ставки и начального фонда. Поможем ему в этом, определив требования:


  1. Стоимость может быть только положительным числом
  2. То же относится к базовой ставке
  3. Начальный фонд не может быть отрицательным числом, если указан

Помимо этого, на свойства накладываются следующие ограничения:


  1. Базовая ставка должна быть меньше, чем стоимость
  2. Начальный фонд также должен быть меньше, чем стоимость

Поскольку нам нужна еще и валюта, то для работы с деньгами мы не будем использовать «голые» int’ы, а воспользуемся библиотекой moneyphp/money. Учитывая всё вышесказанное об Expense, напишем следующие тесты:


<?php

namespace Wishlist\Tests\Domain;

use Money\Currency;
use Money\Money;
use Wishlist\Domain\Expense;
use PHPUnit\Framework\TestCase;

class ExpenseTest extends TestCase
{
    /**
     * @expectedException \InvalidArgumentException
     * @dataProvider nonsensePriceDataProvider
     */
    public function testPriceAndFeeMustBePositiveNumber($price, $fee, $initialFund)
    {
        Expense::fromCurrencyAndScalars(new Currency('USD'), $price, $fee, $initialFund);
    }

    public function nonsensePriceDataProvider()
    {
        return [
            'Price must be greater than zero' => [0, 0, 0],
            'Fee must be greater than zero' => [1, 0, 0],
            'Price must be positive' => [-1, -1, 0],
            'Fee must be positive' => [1, -1, 0],
            'Initial fund must be positive' => [2, 1, -1],
        ];
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testFeeMustBeLessThanPrice()
    {
        Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 150);
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testInitialFundMustBeLessThanPrice()
    {
        Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 50, 150);
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testNewPriceMustBeOfTheSameCurrency()
    {
        $expense = Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 50, 25);

        $expense->changePrice(new Money(200, new Currency('RUB')));
    }

    public function testChangePriceMustReturnANewInstance()
    {
        $expense = Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 50, 25);

        $actual = $expense->changePrice(new Money(200, new Currency('USD')));

        static::assertNotSame($expense, $actual);
        static::assertEquals(200, $actual->getPrice()->getAmount());
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testNewFeeMustBeOfTheSameCurrency()
    {
        $expense = Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 50, 25);

        $expense->changeFee(new Money(200, new Currency('RUB')));
    }

    public function testChangeFeeMustReturnANewInstance()
    {
        $expense = Expense::fromCurrencyAndScalars(new Currency('USD'), 100, 10, 25);

        $actual = $expense->changeFee(new Money(20, new Currency('USD')));

        static::assertNotSame($expense, $actual);
        static::assertEquals(20, $actual->getFee()->getAmount());
    }
}

В них, помимо всего прочего, заложена возможность смены стоимости и базовой ставки желания. Поэтому класс содержит два дополнительных теста на соответствие валют.


Теперь можем закодировать Expense:


<?php

namespace Wishlist\Domain;

use Money\Currency;
use Money\Money;
use Webmozart\Assert\Assert;

final class Expense
{
    private $price;
    private $fee;
    private $initialFund;

    private function __construct(Money $price, Money $fee, Money $initialFund)
    {
        $this->price = $price;
        $this->fee = $fee;
        $this->initialFund = $initialFund;
    }

    public static function fromCurrencyAndScalars(
        Currency $currency,
        int $price,
        int $fee,
        int $initialFund = null
    ) {
        foreach ([$price, $fee] as $argument) {
            Assert::notEmpty($argument);
            Assert::greaterThan($argument, 0);
        }

        Assert::lessThan($fee, $price, 'Fee must be less than price.');

        if (null !== $initialFund) {
            Assert::greaterThanEq($initialFund, 0);
            Assert::lessThan($initialFund, $price, 'Initial fund must be less than price.');
        }

        return new static(
            new Money($price, $currency),
            new Money($fee, $currency),
            new Money($initialFund ?? 0, $currency)
        );
    }

    public function getCurrency(): Currency
    {
        return $this->price->getCurrency();
    }

    public function getPrice(): Money
    {
        return $this->price;
    }

    public function changePrice(Money $amount): Expense
    {
        Assert::true($amount->getCurrency()->equals($this->getCurrency()));

        return new static($amount, $this->fee, $this->initialFund);
    }

    public function getFee(): Money
    {
        return $this->fee;
    }

    public function changeFee(Money $amount): Expense
    {
        Assert::true($amount->getCurrency()->equals($this->getCurrency()));

        return new static($this->price, $amount, $this->initialFund);
    }

    public function getInitialFund(): Money
    {
        return $this->initialFund;
    }
}

Итак, мы рассмотрели все объекты-значения, которые используются сущностью Wish, с её конструктором определились, так что теперь пора перейти непосредственно к бизнес-логике.


Желание: копим денежки


Представьте себе обычную копилку. Туда кладут монетки или бумажки определенного номинала и валюты. Т.е. делается вклад в копилку. Как только копилка заполняется доверху, её разбивают. Так и у нас с нашими желаниями: мы вкладываем некоторую сумму денег в них, а когда набирается достаточная сумма, считаем, что желание исполнено (можно идти в магазин :) и потому далее делать вклады в него уже бессмысленно. Есть еще одно небольшое ограничение: вклады можно делать, только если желание опубликовано (например, вы можете отложить его до лучших времен).


Пора снова писать тесты.


<?php

namespace Wishlist\Tests\Domain;

use DateInterval;
use DateTimeImmutable;
use Money\Currency;
use Money\Money;
use Symfony\Bundle\FrameworkBundle\Tests\TestCase;
use Wishlist\Domain\DepositId;
use Wishlist\Domain\Expense;
use Wishlist\Domain\Wish;
use Wishlist\Domain\WishId;
use Wishlist\Domain\WishName;

class WishTest extends TestCase
{
    /**
     * @expectedException \Wishlist\Domain\Exception\DepositIsTooSmallException
     */
    public function testMustDeclineDepositIfItIsLessThanFee()
    {
        $wish = $this->createWishWithPriceAndFee(1000, 100);
        $wish->publish();

        $wish->deposit(new Money(50, new Currency('USD')));
    }

    public function testExtraDepositMustFulfillTheWish()
    {
        $wish = $this->createWishWithPriceAndFund(1000, 900);
        $wish->publish();

        $wish->deposit(new Money(150, new Currency('USD')));

        static::assertTrue($wish->isFulfilled());
    }

    /**
     * @expectedException \Wishlist\Domain\Exception\WishIsUnpublishedException
     */
    public function testMustNotDepositWhenUnpublished()
    {
        $wish = $this->createWishWithEmptyFund();
        $wish->deposit(new Money(100, new Currency('USD')));
    }

    /**
     * @expectedException \Wishlist\Domain\Exception\WishIsFulfilledException
     */
    public function testMustNotDepositWhenFulfilled()
    {
        $fulfilled = $this->createWishWithPriceAndFund(500, 450);
        $fulfilled->publish();

        $fulfilled->deposit(new Money(100, new Currency('USD')));
        $fulfilled->deposit(new Money(100, new Currency('USD')));
    }

    public function testDepositShouldAddDepositToInternalCollection()
    {
        $wish = $this->createWishWithEmptyFund();
        $wish->publish();
        $depositMoney = new Money(150, new Currency('USD'));

        $wish->deposit($depositMoney);

        $deposits = $wish->getDeposits();
        static::assertCount(1, $deposits);
        static::assertArrayHasKey(0, $deposits);

        $deposit = $deposits[0];
        static::assertTrue($deposit->getMoney()->equals($depositMoney));
        static::assertSame($wish, $deposit->getWish());
    }

    /**
     * @expectedException \InvalidArgumentException
     */
    public function testDepositAndPriceCurrenciesMustMatch()
    {
        $wish = $this->createWishWithEmptyFund();
        $wish->publish();

        $wish->deposit(new Money(125, new Currency('RUB')));
    }

    private function createWishWithEmptyFund(): Wish
    {
        return new Wish(
            WishId::next(),
            new WishName('Bicycle'),
            Expense::fromCurrencyAndScalars(
                new Currency('USD'),
                1000,
                100
           )   
        );
    }

    private function createWishWithPriceAndFund(int $price, int $fund): Wish
    {
        return new Wish(
            WishId::next(),
            new WishName('Bicycle'),
            Expense::fromCurrencyAndScalars(
                new Currency('USD'),
                $price,
                10,
                $fund
            )
        );
    }
}

Чтобы тесты заработали, добавим несколько методов в сущность Wish:


<?php

namespace Wishlist\Domain;

// ...
// добавим в блок use несколько исключений
use Wishlist\Domain\Exception\DepositIsTooSmallException;
use Wishlist\Domain\Exception\WishIsFulfilledException;
use Wishlist\Domain\Exception\WishIsUnpublishedException;
// ...

public function deposit(Money $amount): Deposit
{
    $this->assertCanDeposit($amount);

    $deposit = new Deposit(DepositId::next(), $this, $amount);
    $this->deposits->add($deposit);

    return $deposit;
}

private function assertCanDeposit(Money $amount)
{
    if (!$this->published) {
        throw new WishIsUnpublishedException($this->getId());
    }

    if ($this->isFulfilled()) {
        throw new WishIsFulfilledException($this->getId());
    }

    if ($amount->lessThan($this->getFee())) {
        throw new DepositIsTooSmallException($amount, $this->getFee());
    }

    Assert::true(
        $amount->isSameCurrency($this->expense->getPrice()),
        'Deposit currency must match the price\'s one.'
    );
}

public function isFulfilled(): bool
{
    return $this->getFund()->greaterThanOrEqual($this->expense->getPrice());
}

public function publish()
{
    $this->published = true;
    $this->updatedAt = new DateTimeImmutable();
}

public function unpublish()
{
    $this->published = false;
    $this->updatedAt = new DateTimeImmutable();
}

public function getFund(): Money
{
    return array_reduce($this->deposits->toArray(), function (Money $fund, Deposit $deposit) {
        return $fund->add($deposit->getMoney());
    }, $this->expense->getInitialFund());
}

Рассмотрим все эти методы поочередно.


  1. deposit — проверяет, может ли быть сделан вклад, и если может, то совершает вклад в желание указанной суммы денег. Для этого создается объект сущности Deposit и сохраняется во внутреннюю коллекцию вкладов.
  2. isFulfilled — указывает, исполнено ли желание. Ну, а мы ранее определились, что желание считается исполненным, если его накопления больше или равняются стоимости.
  3. publish/unpublish — публикует или убирает в черновики соответственно.
  4. getFund — возвращает фонд, т.е. накопленные средства.

Вы, должно быть, обратили внимание на то, что в методе Wish::deposit используется одноименная сущность. Теперь, чтобы продолжать развивать бизнес-логику желания дальше, нам необходимо запрограммировать сущность Deposit. Давайте этим и займемся, благо, что она гораздо проще и много времени это не займёт.


Deposit: конструктор


Вклад будет иметь всего четыре свойства:


  1. Идентификатор, поскольку это сущность, а также необходимо иметь возможность управлять вкладами
  2. Желание, к которому относится этот вклад
  3. Сумма вклада
  4. Дата создания вклада

Также необходимо учесть, что вклад не может быть нулевым, поскольку это бессмысленно, как если бы мы в копилку клали воображаемые деньги, а в руках у нас даже не было бы игрушечного эквивалента денег :).


Как всегда, начнём с тестов:


<?php

namespace Wishlist\Tests\Domain;

use Mockery;
use Money\Currency;
use Money\Money;
use PHPUnit\Framework\TestCase;
use Wishlist\Domain\Deposit;
use Wishlist\Domain\DepositId;
use Wishlist\Domain\Wish;

class DepositTest extends TestCase
{
    /**
     * @expectedException \InvalidArgumentException
     */
    public function testDepositAmountMustNotBeZero()
    {
        $wish = Mockery::mock(Wish::class);
        $amount = new Money(0, new Currency('USD'));

        new Deposit(DepositId::next(), $wish, $amount);
    }
}

В этом тесте мы использовали библиотеку mockery/mockery, чтобы полностью не описывать желание, т.к. нас интересует логика самого вклада. Здесь есть повод для дискуссии относительного того, надо ли делать в конструкторе Deposit проверку желания, аналогичную той, что делается в методе Wish::deposit. Я этого делать не стал, поскольку напрямую сущность Deposit нигде не используется, все операции с вкладами, которые будут рассмотрены в статье, осуществляются только в сущности Wish.


Получилась вот такая простая сущность:


<?php

namespace Wishlist\Domain;

use DateTimeImmutable;
use DateTimeInterface;
use Money\Money;
use Webmozart\Assert\Assert;

class Deposit
{
    private $id;
    private $wish;
    private $amount;
    private $createdAt;

    public function __construct(DepositId $id, Wish $wish, Money $amount)
    {
        Assert::false($amount->isZero(), 'Deposit must not be empty.');

        $this->id = $id;
        $this->wish = $wish;
        $this->amount = $amount;
        $this->createdAt = new DateTimeImmutable();
    }

    public function getId(): DepositId
    {
        return $this->id;
    }

    public function getWish(): Wish
    {
        return $this->wish;
    }

    public function getMoney(): Money
    {
        return $this->amount;
    }

    public function getDate(): DateTimeInterface
    {
        return $this->createdAt;
    }
}

Желание: изымаем вклад


С сущностью Deposit разобрались, теперь можно вернуться к программированию желания. По условию задачи мы можем не только накапливать деньги на желание, но и изымать уже сделанные вклады. Например, если какой-то из них был сделан по ошибке.


Естественно, сначала добавим несколько тестов в класс WishTest:


/**
 * @expectedException \Wishlist\Domain\Exception\WishIsUnpublishedException
 */
public function testMustNotWithdrawIfUnpublished()
{
    $wish = $this->createWishWithPriceAndFund(500, 0);
    $wish->publish();
    $deposit = $wish->deposit(new Money(100, new Currency('USD')));
    $wish->unpublish();

    $wish->withdraw($deposit->getId());
}

/**
 * @expectedException \Wishlist\Domain\Exception\WishIsFulfilledException
 */
public function testMustNotWithdrawIfFulfilled()
{
    $wish = $this->createWishWithPriceAndFund(500, 450);
    $wish->publish();
    $deposit = $wish->deposit(new Money(100, new Currency('USD')));

    $wish->withdraw($deposit->getId());
}

/**
 * @expectedException \Wishlist\Domain\Exception\DepositDoesNotExistException
 */
public function testWithdrawMustThrowOnNonExistentId()
{
    $wish = $this->createWishWithEmptyFund();
    $wish->publish();

    $wish->withdraw(DepositId::next());
}

public function testWithdrawShouldRemoveDepositFromInternalCollection()
{
    $wish = $this->createWishWithEmptyFund();
    $wish->publish();
    $wish->deposit(new Money(150, new Currency('USD')));

    $wish->withdraw($wish->getDeposits()[0]->getId());

    static::assertCount(0, $wish->getDeposits());
}

Как видим, ограничения на изъятие вкладов похожи на те, что мы писали для их внесения. Теперь добавим необходимую логику в класс желания:


<?php

namespace Wishlish\Domain;

// <...>

public function withdraw(DepositId $depositId)
{
    $this->assertCanWithdraw();

    $deposit = $this->getDepositById($depositId);
    $this->deposits->removeElement($deposit);
}

private function assertCanWithdraw()
{
    if (!$this->published) {
        throw new WishIsUnpublishedException($this->getId());
    }

    if ($this->isFulfilled()) {
        throw new WishIsFulfilledException($this->getId());
    }
}

private function getDepositById(DepositId $depositId): Deposit
{
    $deposit = $this->deposits->filter(
        function (Deposit $deposit) use ($depositId) {
            return $deposit->getId()->equalTo($depositId);
        }
    )->first();

    if (!$deposit) {
        throw new DepositDoesNotExistException($depositId);
    }

    return $deposit;
}

Как говорится, в любой непонятной ситуации кидай эксепшн! Метод withdraw получился довольно простым, тем не менее мы учли все условия задачи:


  1. Не получится изъять вклад, которого нет
  2. Мы не сможем этого сделать, если желание в черновиках или уже исполнено

Желание: рассчитываем излишки накоплений


Функция не самая важная, на самом деле, но она сделана на тот случай, когда в один прекрасный момент окажется, что под рукой нет нужной суммы для пополнения запасов, но есть большая. Ну, или, например, если вы откладывали на желание достаточно большие суммы, а потом просто «промахнулись», не уследив за количеством уже имеющихся средств. Посчитать излишки, на самом деле, просто: из стоимости желания вычтем его фонд и возьмём абсолютное значение. Если разница была положительной, излишки можно считать равными нулю.


Дополним класс WishTest новыми тестами:


public function testSurplusFundsMustBe100()
{
    $wish = $this->createWishWithPriceAndFund(500, 300);
    $wish->publish();

    $wish->deposit(new Money(100, new Currency('USD')));
    $wish->deposit(new Money(200, new Currency('USD')));

    $expected = new Money(100, new Currency('USD'));
    static::assertTrue($wish->calculateSurplusFunds()->equals($expected));
}

public function testSurplusFundsMustBeZero()
{
    $wish = $this->createWishWithPriceAndFund(500, 250);
    $wish->publish();

    $wish->deposit(new Money(100, new Currency('USD')));

    $expected = new Money(0, new Currency('USD'));
    static::assertTrue($wish->calculateSurplusFunds()->equals($expected));
}

На основе вышесказанного и написанных тестов мы можем написать вот такой метод в сущности Wish:


<?php

namespace Wishlist\Domain;

// <...>

public function calculateSurplusFunds(): Money
{
    $difference = $this->getPrice()->subtract($this->getFund());

    return $difference->isNegative()
        ? $difference->absolute()
        : new Money(0, $this->getCurrency());
}

Желание: вангуем дату исполнения


Предсказать дату исполнения желания можно двумя способами:


  1. На основе стоимости и базовой ставки
  2. На основе стоимости и накопленных средств

Первый способ простой: делим стоимость на базовую ставку, как результат получаем количество дней, которые уйдут на накопление средств. К текущей дате прибавляем полученные дни, и вот нам дата исполнения желания.


Второй способ немногим сложнее. Из стоимости вычитаем накопленные средства, полученную разность разделим на базовую ставку, это даст нам количество дней до исполнения желания, их прибавим к текущей дате и получим дату исполнения желания.


Алгоритмы есть, напишем тесты:


public function testFulfillmentDatePredictionBasedOnFee()
{
    $price = 1500;
    $fee = 20;
    $wish = $this->createWishWithPriceAndFee($price, $fee);
    $daysToGo = ceil($price / $fee);

    $expected = (new DateTimeImmutable())->add(new DateInterval("P{$daysToGo}D"));

    static::assertEquals(
        $expected->getTimestamp(),
        $wish->predictFulfillmentDateBasedOnFee()->getTimestamp()
    );
}

public function testFulfillmentDatePredictionBasedOnFund()
{
    $price = 1500;
    $fund = 250;
    $fee = 25;
    $wish = $this->createWish($price, $fee, $fund);
    $daysToGo = ceil(($price - $fund) / $fee);

    $expected = (new DateTimeImmutable())->add(new DateInterval("P{$daysToGo}D"));

    static::assertEquals(
        $expected->getTimestamp(),
        $wish->predictFulfillmentDateBasedOnFund()->getTimestamp()
    );
}

Чтобы тесты стали зелеными, запрограммируем вычисление даты исполнения желания по заданным алгоритмам:


public function predictFulfillmentDateBasedOnFee(): DateTimeInterface
{
    $daysToGo = ceil(
        $this->getPrice()
        ->divide($this->getFee()->getAmount())
        ->getAmount()
    );

    return $this->createFutureDate($daysToGo);
}

public function predictFulfillmentDateBasedOnFund(): DateTimeInterface
{
    $daysToGo = ceil(
        $this->getPrice()
        ->subtract($this->getFund())
        ->divide($this->getFee()->getAmount())
        ->getAmount()
    );

    return $this->createFutureDate($daysToGo);
}

private function createFutureDate($daysToGo): DateTimeInterface
{
    return (new DateTimeImmutable())->add(new DateInterval("P{$daysToGo}D"));
}

Желание: меняем состояние


Всё, что нам осталось закодировать на данном этапе, — несколько несложных методов, которые меняют состояние желания:


  1. Публикация и отправка в «черновики»
  2. Изменение стоимости
  3. Изменение базовой ставки вкладов

Думаю, вы и без меня догадались, что сначала мы под всё это дело напишем тесты в класс WishTest. Сперва для публикации:


public function testPublishShouldPublishTheWish()
{
    $wish = $this->createWishWithEmptyFund();
    $updatedAt = $wish->getUpdatedAt();

    $wish->publish();

    static::assertTrue($wish->isPublished());
    static::assertNotSame($updatedAt, $wish->getUpdatedAt());
}

public function testUnpublishShouldUnpublishTheWish()
{
    $wish = $this->createWishWithEmptyFund();
    $updatedAt = $wish->getUpdatedAt();

    $wish->unpublish();

    static::assertFalse($wish->isPublished());
    static::assertNotSame($updatedAt, $wish->getUpdatedAt());
}

Методы сущности будут столь же просты, сколь и тесты:


<?php

namespace Wishlist\Domain;

// <...>

class Wish
{
    // <...>

    public function publish()
    {
        $this->published = true;
        $this->updatedAt = new DateTimeImmutable();
    }

    public function unpublish()
    {
        $this->published = false;
        $this->updatedAt = new DateTimeImmutable();
    }

    public function isPublished(): bool
    {
         return $this->published;
    }

    // <...>
}

Теперь то, что касается изменения стоимости и базовой ставки. Тесты:


public function testChangePrice()
{
    $wish = $this->createWishWithPriceAndFee(1000, 10);
    $expected = new Money(1500, new Currency('USD'));
    $updatedAt = $wish->getUpdatedAt();

    static::assertSame($updatedAt, $wish->getUpdatedAt());

    $wish->changePrice($expected);

    static::assertTrue($wish->getPrice()->equals($expected));
    static::assertNotSame($updatedAt, $wish->getUpdatedAt());
}

public function testChangeFee()
{
    $wish = $this->createWishWithPriceAndFee(1000, 10);
    $expected = new Money(50, new Currency('USD'));
    $updatedAt = $wish->getUpdatedAt();

    static::assertSame($updatedAt, $wish->getUpdatedAt());

    $wish->changeFee($expected);

    static::assertTrue($wish->getFee()->equals($expected));
    static::assertNotSame($updatedAt, $wish->getUpdatedAt());
}

И соответствующие им методы в сущности:


<?php

namespace Wishlist\Domain;

// <...>

class Wish
{
    // <...>

   public function changePrice(Money $amount)
   {
        $this->expense = $this->expense->changePrice($amount);
        $this->updatedAt = new DateTimeImmutable();
   }

   public function changeFee(Money $amount)
   {
        $this->expense = $this->expense->changeFee($amount);
        $this->updatedAt = new DateTimeImmutable();
   }

   // <...>
}

Столько кода, столько тестов написано и всё впустую для того, чтобы наши желания исполнялись. Вот что в итоге мы можем делать с желанием:


  1. Накапливать денежные средства — Wish::deposit(Money $amount)
  2. Избавляться от них — Wish::withdraw(DepositId $depositId)
  3. Рассчитывать дату исполнения двумя способами — Wish::predictFulfillmentDateBasedOnFee() и Wish::predictFulfillmentDateBasedOnFund()
  4. Публиковать и убирать в черновики — Wish::publish()и Wish::unpublish()
  5. Менять стоимость и базову ставку — Wish::changePrice(Money $amount) и Wish::changeFee(Money $amount)

И всё же статья когда-то должна закончиться, так что вишенкой на торте станет интерфейс репозитория желаний, который мы положим рядом с сущностью Wish:


<?php

namespace Wishlist\Domain;

interface WishRepositoryInterface
{
    public function get(WishId $wishId): Wish;
    public function put(Wish $wish);
    public function slice(int $offset, int $limit): array;
    public function contains(Wish $wish): bool;
    public function containsId(WishId $wishId): bool;
    public function count(): int;
    public function getNextWishId(): WishId;
}

Его реализацию, а также много других интересных вещей, мы рассмотрим в следующих статьях на тему списка желаний. Удачи!


P.S.: Исходный код проекта. Актуальное состояние репозитория может отличаться от того, что было описано в статье. Тем не менее вы всегда можете посмотреть, как «Москва не сразу строилась» по отдельным коммитам хоть с самого начала :)


+11
30k 126
Comments 55