В интернете довольно много разрозненного материала по 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.: Исходный код проекта. Актуальное состояние репозитория может отличаться от того, что было описано в статье. Тем не менее вы всегда можете посмотреть, как «Москва не сразу строилась» по отдельным коммитам хоть с самого начала :)

Комментарии (28)


  1. oxidmod
    23.08.2017 10:32
    +4

    1. У вас довольно странная реализация VO для идентификаторов.

    $depositId = DepositId::fromString('550e8400-e29b-41d4-a716-446655440000');
    $wishId = WishId::fromString('550e8400-e29b-41d4-a716-446655440000');
    
    var_dump($depositId->equalTo($wishId)); //???
    

    Кроме того статический метод next тоже не очень. Лучше вынести такое в сервис-генератор, который можно будет мокнуть в тестах или застабать в функциональных тестах на АПИ (Вы будете знать какой идентификатор будет сгенерирован для сущности).

    2. createdAt можно тестировать и без аргумента, указав дельту в 1 секунду к примеру, или использовав Carbon

    3. Вы только что жестко привязали свою доменную модель к доктрине
    $this->deposits = new ArrayCollection();

    Тоже касается ваших манипуляций в методе getDepositById. Теперь без доктрины ваша БЛ не работает.

    4. Агрегат не должен отдавать наружу сущности. getDeposits должен возвращать массив идентификаторов, но не сами депозиты (Но это уже мое ИМХО)


    1. VolCh
      23.08.2017 13:56

      Вы только что жестко привязали свою доменную модель к доктрине

      Вполне допустима привязка домена к библиотекам общего назначения. Вы же не замечаете привязок к стандартной библиотеки, к Uuid, к Money и т. п. Это всё "чистые" библиотеки, не производящих значимых сайд-эффектов.


      Агрегат не должен отдавать наружу сущности.

      Корень агрегата должен обеспечивать контроль за жизненным циклом сущностей, входящих в него, отдавать он их может, главное, чтобы ссылки на них постоянно негде больше не хранились и состояние не менялось без контроля агрегата. К сожалению, в PHP нет технических способов предоставить доступ к методам только некоторым классам вне иерархии наследования — нет ни дружественных модификаторов, ни области модуля/неймспэйса. Соображения практичности часто заставляют не городить параллельную сущностям иерархию DTO/массивов, кучу прокси-геттеров и/или вовсю использовать Reflection, когда можно обойтись соглашениями не хранить ссылки на части агрегата и не изменить их состояния кроме как через методы корня.


      getDeposits должен возвращать массив идентификаторов, но не сами депозиты

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


      1. oxidmod
        23.08.2017 17:25

        К Uuid как раз нет привязки, ибо он завернут в собственные VO.
        Вот Money провтыкал. На рабочем проекте мы тоже эту либу юзаем, но она точно также завернута в свой VO и в домене нигде не светится.

        Чуть ниже я потом писал, что по моему мнению, желание как раз не является агрегатом, так что этому методу совсем не место в классе Wish


        1. VolCh
          24.08.2017 12:18
          +2

          Какая разница во что завернут? Такие VO — неотъемлемая часть домена. По сути мы делаем классы типа Collection, Uuid, Money и т. п. частью домена так же как DateTime. То, что некоторые из них часть стандартной библиотеки, часть реализована в нестандартных расширениях, а часть — обычные PHP-классы — техническая деталь. То же и с функциями типа strlen, count и т. п. стандартной библиотеки — если можем использовать их, то можем использовать и сторонние библиотеки. С другой стороны, не можем использовать ни функции стандартных библиотеки типа работы с ФС, сетью или БД, ни подобные функции сторонних. То есть допустимость использования в домене зависит от того, что функция/класс делает, а не от того, откуда мы их подключаем.


  1. franzose Автор
    23.08.2017 10:43

    1. Запилил хотфикс! Тут действительно баг.
    2. Надо подумать.
    3. Я привязал бизнес-логику только к Doctrine\Common, т.к. ArrayCollection именно оттуда.
    4. А как в таком случае мне получить все вклады в желание?


    1. oxidmod
      23.08.2017 10:59

      3. Да, но все-же. Я бы предпочел отказаться от этой зависимости, но не отрицаю что я могу быть не прав.
      4. Через findByWish у репо депозитов.


      1. VolCh
        23.08.2017 14:01
        +1

        Через findByWish у репо депозитов.

        Депозит тут является частью желания, не имеющим смысла вне его, частью агрегата, корнем которого является желание. Отдельный репо для депозита тут не просто не нужен, а запрещен определением агрегата — доступ к депозиту можно получить только через желание.


  1. aprusov
    23.08.2017 10:48
    +6

    Название статьи не соответствует действительности. Тут скорее лишь попытка применения теоретических знаний о DDD на простейшем сферическом примере в вакууме, делеком от реальных требований бизнеса.
    Ожидал, наконец, увидеть применение на реальном проекте с историей о том как введенные на начальном этапе адовы слои абстракции DDD помогали (или же мешали) развитию проекта в будущем.


    1. franzose Автор
      23.08.2017 10:52

      Тут скорее лишь попытка применения теоретических знаний о DDD на простейшем сферическом примере в вакууме, делеком от реальных требований бизнеса.

      По большому счёту, так и есть. И я это обозначил в самом начале статьи. И по поводу реальных проектов тоже соглашусь, мне бы тоже хотелось видеть подобные статьи. Но их почему-то нет.


      1. VolCh
        23.08.2017 14:03
        +3

        Но их почему-то нет.

        1. Очень большой объём материала, минимум на книгу
        2. NDA


  1. oxidmod
    23.08.2017 10:53

    5. Зачем вы возвращаете строку, а не сам VO WishName?
    6. Написал и подумал. Wish не является агрегатом для Deposit. Это просто 2 сущности. Потому метод deposit не должен находится там, ИМХО.


    1. franzose Автор
      23.08.2017 10:59

      Wish не является агрегатом для Deposit. Это просто 2 сущности. Потому метод deposit не должен находится там, ИМХО.

      Почему вы так считаете?


      1. oxidmod
        23.08.2017 11:12

        Боюсь что я не смогу этого объяснить. Потому и добавил в конце ИМХО. Возможно кто-то более опытный приведет аргументы за или против по этому вопросу


  1. maghamed
    23.08.2017 11:10
    +4

    у вас даже начало статьи принципиально не DDD-шное, и показывает, что вы мыслите CRUD подходом.
    Потому, что статью вы начинаете с того, как создаете проект и базу данных для него.
    Для Domain Drive Design — это абсолютно не важно. Детали реализации, которые должны быть вынесены за рамки статьи. Так как один из главных принципов DDD, который отражается в проектировании это persistence ignorance.

    Дальше вы выбираете набор атрибутов, которые нужно хранить в классе. Не рассмотрев домен и как действуют сущности в нем.
    Отталкиваясь от того какие ключи в Базу повесить.
    Это не DDD, это CRUD c его подходом — Forms over Data.
    Процесс проектирования в DDD начинается с чего-то вроде Event Stroming, где основная идея это выделить основные доменные события (Domain Events) и то как посредством их взаимодействуют доменные сущности, а также определиться с именованием этих сущностей (Ubiquitous Language)


  1. maghamed
    23.08.2017 11:23
    +1

    Ну и судя по статье вы сделали классический God object из своего объекта Wish. В нем зашили вообще всю логику домена, нарушив при этом Single Responsibility принцип множество раз.
    Само количество публичных методов у этого объекта (а их там 20) толжно было дать вам почувствовать «smell» в вашем коде.


    1. franzose Автор
      23.08.2017 11:53

      А каким образом эту логику можно было бы разбить на части?


      1. oxidmod
        23.08.2017 12:17
        +1

        Это вот одна из причин почему я считаю, что желание не агрегат и стоит разделить процесс создания и модификации желаний от процесса с балансом (депозит \ виздрав)


        1. VolCh
          23.08.2017 14:19
          +2

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


  1. VolCh
    23.08.2017 14:14
    +3

    Непонятно абслютно почему Deposit — это сущность, а не объект-значение? Инициализируется при создании и потом не изменяется.


  1. ghost404
    25.08.2017 11:52
    +2

    У вас тут целая куча проблем.


    1. Уберите раздел Предыстория из статьи.
      Вы в начале конфигурируете проект под Symfony, Docker, VueJs и прочее, но в статье они ни как не фигурируют больше.
      Ваща статья исключительно про DDD. VueJs только пару раз упомянулся, но на практике не использовался.
      Возможно вы будете их использовать в следующих статьях.
      Вот тогда и напишете про Docker и VueJs.


    2. Сходу проблемы с DDD.


      у каждого желания есть стоимость, начальный фонд и накопленные средства — фонд

      Вы Фонд потеряли в своем проекте. Все финансовые транзакции делаются через Фонд накопления средств, а не через сущность Желание.
      Я бы их вообще разделил на 2 отдельных контекста (Bounded Context).


    3. Как уже сказали, AbstractId::next() лучше вынести в сервис генерации id.


      interface WishIdGenerator
      {
         public function next(): WishId;
      }

    4. Я бы не привязывался так явно к UUID.
      Вдруг захотите сменить генератор id.
      Я сейчас готовлю статью по использованию более оптимального id чем UUID.


    5. Статические фабричные методы AbstractId::fromString() и Expense::fromCurrencyAndScalars вам вообще не нужны.
      Вы все должны деалть через конструктор и передавать в него явные значения, а не генерировать VO внутри.


    6. Использование getter-ов и setter-ов это известный DDD антипаттерн.
      Лучше переименовать методы:


      • AbstractId::getId() в AbstractId::id()
      • WishName::getValue() в WishName::name()
      • Expense::getCurrency() в Expense::сurrency()
      • Wish::getFund() в Wish::fund()
        и т.д.
        Кто-то может со мной не согласится, но по мне так префикс get тут лишний.

    7. publish/unpublish


      например, вы можете отложить его до лучших времен

      Вы явно описали действие: отложить
      Тоесть действия у вас будут:


      • отложить до лучших времен — postpone
      • возобновить накопление — resume

      В некоторых местах вы говорите:


      Публиковать и убирать в черновики

      Что немного противоречит. Черновики это отдельная история и тоже делается не через unpublish.


    8. Вангуем дату исполнения.
      Логика расчитана на то, что вы вклад делаете каждый день, но этого нет в условии.
      В нашей стране распространеное двух этапная выплата зарплаты — аванс и зарплата.
      Соответсвенно, делать вклады в таком случае чаще 2 раз в месяц затруднительно.
      Вообще это все сильно зависит от возможостей делать вклады.
      Я бы закладывал ежемесечные вклады, но это сильно зависит от бизнеса.


    9. С вычетами и удалениями депозитов у вас тоже не все впорядке.


      Ну, или, например, если вы откладывали на желание достаточно большие суммы, а потом просто «промахнулись», не уследив за количеством уже имеющихся средств.

      Вклад это фиксированная величина. Вы не можете удалить вклад после внесения его в фонд, так как он растворяется в нем.
      В фонде у вас хранится общая сумма и история вкладов (если она вам нужна).
      Внесение вклада это внесение средств. Вы делаете вклад в копилку и вносите в нее деньги и теперь денег в копилке стало больше.
      Вклада в ней нет. В ней только деньги. По сути вклад обертка над Money.
      Если вы по ошибке сделаи вклад не на то желание, то вы можете сделать транзакцию по переводу средств из одного фонда в другой на размер последнего вклада или любую другую величину.
      Если вы внесли в фонд больше денег чем хотели, то вы можете изьять сумму из конкретного фонда.
      Вы не обязаны извлекать из фонда сумму равную какому-то вкладу.
      Например, вы внесли 50 рублей, а потом поняли что 24 рубля 74 копейки из них были лишними (я утрирую но мысль я думаю вы поняли) и хотите извлеч из вклада конкретную сумму денег.
      Да и вообще, вам по жизни может потребоваться извлечь произвольную суммму из вклада.


    10. Вы уверены что накопленные средства = исполнению желания?


      По мере накопления достаточного количества средств желание становится исполненным.

      Я бы не ставил между ними равно.
      Исполнение желания это одно, а накопление достаточной суммы для исполнения желания это савсем другое.
      И не ограничивайте явно потолок накопления суммы. Вспоминаем Kickstarter.


    11. Вклад не должен знать о Желании.
      Вы сделали рекурсивную ссылку, а это плохо.
      Правильней так:
      • Есть Желание и Фонд накопления средств на конкретное Желание.
      • Если Фонд выносить в отдельный контекст, то лучше делать связь от Фонда к Желанию и тогда:
      • У Желания есть цена.
      • Желание не знает о Фонде.
      • Фонд знает о Желании и как следствие о его цену.
      • Фонд накапливает сумму на исполнение Желания.
      • Вклад не знает ничего ни о Фонде, ни о Желании. Это просто деньги.
      • Мы сами определяем в какой Фонд внести Вклад.
      • Вклад это VO.
      • После внесения Вклада в Фонд он превращается (если это нам нужно) в Транзакцию в Истории транзакций фонда.
      • Транзакции нельзя удалять или изменять. Это уже история.

    Там еще целая гора мелких недочетов с реализацией финансовой части. Я не буду тут вдаваться в подробности.


    1. franzose Автор
      25.08.2017 12:08
      +1

      Большое спасибо за развернутый комментарий! Вы знаете, по поводу Транзакций вместо вкладов я уже думал в процессе написания статьи, т.к. посмотрел еще раз на код. Действительно, при необходимости мы можем любую сумму из одного желания «переложить» в другое. Тогда это уже не Вклад, а Транзакция. Так как относительно одного желания это будет «минус», относительно другого — «плюс». Не стал об писать, т.к. не было полной уверенности относительно этого, но теперь вы меня убедили. В принципе, можно отрефакторить это дело.


      1. ghost404
        25.08.2017 13:15
        +2

        Советую переосмыслить и хорошенечко подумать над всей схемой. И не пару часов, а лучше несколько дней/недель.
        Тогда вы возможно сможете лучше понять вашу предметную область и написать статью — работу над ошибками. В ней можно больше сконцентрировать на описании и формулировании предметной области, а не на конкретной реализации. В статью можно будет добавить схемы взаимодействия, схемы транзакций и все прочее. Вот это точно будет взрывня статью.


        DDD это не про реализацию, а про проектирование. Качественно продуманная и сформулированная предметная область легко реализуется на любом языке программирования. А вот проработать эту самую предметную область и есть основная проблема.


        И не подумайте что я вас критикую. Ваше стремление похвально.


        PS: Рекомендую к прочтению книгу Вон Вернона — Implementing Domain-Driven Design.


        1. VolCh
          25.08.2017 13:37
          +3

          Всё же DDD предполагает, по-моему, многоитерационный (условно бесконечный) цикл "уточнение знаний"->"проектирование"->"реализация"->"уточнение знаний", причём этапы вовсе не обязаны чётко разделяться в "водопадно-гостовском" стиле как внутри цила, так и по итерациям.


          Я слабо представляю себе успешную реализацию серьезного проекта по DDD без постоянного переделывания сделанного по получению фидбэка от экспертов, пробующих очередной билд системы.


          Я к тому, что выражение "работа над ошибками" в отношении именно моделирования домена плохо подходит, лучше что-то вроде "переработка модели в связи с получением новой информации".


          1. ghost404
            25.08.2017 14:48
            +1

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


            Интересно былоб почитать именно про эволюцию проекта. Например разобрать штук 5 итераций, чтоб было понятней как это вообще происходит и почему выбираются те или иные решения, а почему какие-то решения отклоняются. Почитать про архитектурные ошибки и способы их решения. И я не говорю о конечных итерациях переработки кода. Многие решения не уходят дальше головы или бумаги. Их просто отбрасывают за ненадобностью или проскакивают, но для обучения и понимания они важны ИМХО.


      1. ghost404
        25.08.2017 21:02
        +1

        Небольшое пояснение на счёт транзакций.
        Если вы изучаете DDD, то, я думаю, вы это знать, но я все же поясню. Вдруг кто не вкурсе.


        В примере который описал я под ваши задачи, Транзакция существует только в рамках Истории транзакций. Перевод средств из Фонда одного Желания в Фонд другого это бизнес транзакция. Не объект транзакция. В результате перевода средств будут созданы 2 объекта Транзакции в Истории транзакций одного и второго Фондов. Тоесть История транзакций напоминает Event Sourcing. В коде, перевод средств может иметь вид:


        $fund1->trasfer($money, $fund2);

        Всё остальное делается внутри.
        Вклад тоже является Транзакцией только в Истории транзакций.
        Если у нас существует сущность Кошелёк или Счёт, то мы можем делать вклад используя их. Что-то тип:


        $fund->invest($money, $account);

        Здесь кстати не очень понятно. Возможно лучше делать вклад через кошелек:


        $account->invest($money, $fund);

        Если Вклады появляются из неоткуда, как у вас, то лучше делать VO на мой взгляд.


        $fund->invest($deposit);

        Но это все чисто рассуждения на тему.


        1. VolCh
          26.08.2017 15:51

          В результате перевода средств будут созданы 2 объекта Транзакции в Истории транзакций одного и второго Фондов.

          Сильно зависит от выбранной модели учёта транзакций. Транзакция может иметь поля типа "фонд-источник" и "фонд-получатель" и тогда перевод требует только одной транзакции. А внесение в фонд или вывод из него могут маркироваться какими-то специальными случаями, в простейшем варианте — null.


  1. glagola
    26.08.2017 18:21
    +1

    Делал я похожий сайт для личных нужд, с одним лишь отличием, что это были не «хотелки», а в полне реальное планирование, у каждой цели были сроки (с, по) и сумма: ДР родственников, оплата интернета (плачу раз в год), продление доменов, серверов, покупка машины, взносы в собственный пенсионный фонд и т.д. Вообщем все что выше 1000 руб попадало в эту систему. Система сама рассчитывала, какие цели активны, какие просрочены, сколько нужно вносить в день/неделю/месяц (оч. хорошо мотивирует по жизни ;) и т.д. Изначально реализовал так что у каждой цели был отдельный банк/фонд (как хотите) и с каждого взноса деньги распределялись на все цели в зависимости от текущего отставания по ней (накоплено меньше, чем должно к моменту взноса). Потом почти год пользовался ей — в целом сносно, но разделение обезличенных денег между целями было ошибкой. Достаточно иметь общую кубышку с историей пополнения/списания, а цели нужно рассматривать как отдельный bounding context, так как они лишь вас стимулируют, не более. В случае нехватки денег за пределами системы вы всеравно залезете в кубышку, главное чтобы в истории была зафиксирована дата и сумма списания.


    1. glagola
      26.08.2017 18:27
      +1

      P.S. Если ваша система считает сколько нужно вложить в месяц/неделю/день — рекомендую, эти показатели только увеличивать автоматически, а не уменьшать (только в ручную). Хитрость: раз вы жили без этих денег какое-то время и выжили, значит они вам не так уж и нужны — лучше им будет в кубышке, быстрее цели закроете ;)