Агрегатное мышление

Вправляем бизнес-логику

Дмитрий Елисеев

elisdn.ru

Могут ли программисты делать клиентов счастливыми?

Могут. Было бы желание.

От чего клиент у программиста бывает несчастным?

  • Не работает вообще
  • Не работает по ТЗ
  • Ломается
  • Программисты матерятся
  • Отсутствие понимания текущего состояния
  • Страшно дорабатывать
  • Страшно обновлять
  • Нужен гуру-сисадмин

Хоть в разработке есть тренд на скорость...

...всегда есть пласт для лучшего качества.

  • Есть задание
  • По нему пишем требования и/или тесты
  • По ним пишем код

Как пишем код? Здесь уже есть варианты

RAD, DDD, TDD, BDD, Task Based UI, SOLID, GRASP, CQRS...

Все знают ООП, но по назначению его редко используют

Не будем пересказывать эти книги:

или их же в переводе:

Нужно ли?

Дмитрий Елисеев

Когда сидите за компом и не ходите на работу, ваши знакомые считают вас идиотом. Но вас понимают другие программисты. А когда пишете по DDD вас считают идиотом даже программисты.

Дмитрий Елисеев

Книги пересказывать не будем...

Пофилософствуем...

Попрактикуемся...

Обычно данные в таблицах мы храним так:


{
    id: 15,
    email: 'mail@elisdn.ru',
    password: 'dfXwer2eq5ha3',
    name_first: 'Vasiliy',
    name_middle: 'Ivanovich',
    name_last: 'Pupkin',
    phone_country: '7',
    phone_code: '234',
    phone_number: '5678901',
    address_country: 'Russia',
    address_city: 'Moscow',
    address_street: 'Req Square',
    address_house: '1'
}

И подключаем ActiveRecord:


class User extends ActiveRecord
{
    public static function tableName(): string
    {
        return 'users';
    }
}

Или генерируем:


class User
{
    public $id;
    public $email;
    public $password;
    public $name_first;
    public $name_middle;
    public $name_last;
    public $phone_country;
    public $phone_code;
    public $phone_number;
    public $address_country;
    public $address_city;
    public $address_street;
    public $address_house;
}

И заполняем поля поштучно:


$user = new User();

$user->name_first = 'Vasiliy';
$user->name_middle = 'Ivanovich';
$user->name_last = 'Pupkin';
$user->phone_country = 7;
$user->phone_code = '234';
$user->phone_number = '5678901';

$user->save();

echo $user->name_first . ' ' . $user->name_last;

Почему так?

  • Таблицы плоские
  • Автогенерация
  • Свобода
  • Это проще всего

По-быстрому (RAD):

  • сочинили таблицы из нужных полей;
  • прописали им ActiveRecord;
  • нагенерировали CRUD-ов;
  • phpMyAdmin-like админка готова.

Но не всегда такой самый быстрый вариант удобен вдальнейшем и понятен с первого взгляда

Если отойти от просто сгенерированного кода и немного подумать, то его можно делать удобнее

Дано:


{
    id: 15,
    email: 'mail@elisdn.ru',
    password: 'dfXwer2eq5ha3',
    name_first: 'Vasiliy',
    name_middle: 'Ivanovich',
    name_last: 'Pupkin',
    phone_country: '7',
    phone_code: '234',
    phone_number: '5678901',
    address_country: 'Russia',
    address_city: 'Moscow',
    address_street: 'Req Square',
    address_house: '1',
}

Группируем по смыслу:


{
    id: 15,
    email: 'mail@elisdn.ru',
    password: 'dfXwer2eq5ha3',

    name_first: 'Vasiliy',
    name_middle: 'Ivanovich',
    name_last: 'Pupkin',

    phone_country: '7',
    phone_code: '234',
    phone_number: '5678901',

    address_country: 'Russia',
    address_city: 'Moscow',
    address_street: 'Req Square',
    address_house: '1'
}

Вкладываем:


{
    id: 15,
    email: 'mail@elisdn.ru',
    password: 'dfXwer2eq5ha3',

    name: {
        first: 'Vasiliy',
        middle: 'Ivanovich',
        last: 'Pupkin'
    },

    phone: {
        country: '7',
        code: '234',
        number: '5678901'
    },

    address: {
        country: 'Russia',
        city: 'Moscow',
        street: 'Req Square',
        house: '1'
    }
}

Теперь:

  • понятнее заказчику
  • понятнее клиенту API
  • «неудобнее» программисту

Псевдонеудобства:

  • Надо думать
  • Не автоматизируется
  • Не подходит ActiveRecord
  • Надо перегонять в БД

В коде:


class User
{
    public $id;
    public $email;
    public $password;
    public $name_first;
    public $name_middle;
    public $name_last;
    public $phone_country;
    public $phone_code;
    public $phone_number;
    public $address_country;
    public $address_city;
    public $address_street;
    public $address_house;
}

Вынесем в объекты-значения:


class User
{
    public $id;
    public $email;
    public $password;
    public $name;           class Name {
                                public $first;
                                public $middle;
                                public $last;
                            }
    public $phone;          class Phone {
                                public $country;
                                public $code;
                                public $number;
                            }
    public $address;        class Address {
                                public $country;
                                public $city;
                                public $street;
                                public $house;
                            }
}

Name у нас теперь объект

Можно добавить поведение (методы)

Добавим конструктор:


class Name
{
    public $first;
    public $middle;
    public $last;

    public function __construct($first, $middle, $last)
    {
        $this->first = $first;
        $this->middle = $middle;
        $this->last = $last;
    }
}

$user = new User();

$user->name_first = 'Vasiliy';
$user->name_middle = 'Ivanovich';
$user->name_last = 'Pupkin';

echo $user->name_first . ' ' . $user->name_last;

vs


$user = new User();

$user->name = new Name('Vasiliy', 'Ivanovich', 'Pupkin');

echo $user->name->first . ' ' . $user->name->last;

Добавим бизнес-правила:


class Name
{
    public $first;
    public $middle;
    public $last;

    public function __construct($first, $middle, $last)
    {
        if (empty($first)) {
            throw new \InvalidArgumentException('Empty first name.');
        }

        if (empty($last)) {
            throw new \InvalidArgumentException('Empty last name.');
        }

        $this->first = $first;
        $this->middle = $middle;
        $this->last = $last;
    }
}

Можем упростить:


use Webmozart\Assert\Assert;

class Name
{
    public $first;
    public $middle;
    public $last;

    public function __construct($first, $middle, $last)
    {
        Assert::notEmpty($first);
        Assert::notEmpty($last);

        $this->first = $first;
        $this->middle = $middle;
        $this->last = $last;
    }
}

Сделаем неизменяемым:


class Name
{
    private $first;
    private $middle;
    private $last;

    public function __construct($first, $middle, $last) { ... }

    public function getFirst(): string
    {
        return $this->first;
    }

    public function getMiddle(): string
    {
        return $this->middle;
    }

    public function getLast(): string
    {
        return $this->last;
    }
}

Заполняем и выводим так


$user = new User();

$user->name = new Name('Vasiliy', 'Ivanovich', 'Pupkin');

echo $user->name->getLast() . ' ' . $user->name->getFirst();

Добавим getFullFio():


class Name
{
    private $first;
    private $middle;
    private $last;

    public function __construct($first, $middle, $last) { ... }

    ...

    public function getFullFio(): string
    {
        return $this->last
            . (!empty($this->middle) ? ' ' . $this->middle : '')
            . ' ' . $this->first;
    }
}

echo $user->getName()->getFullFio();

Добавим getShortFio():


class Name
{
    private $first;
    private $middle;
    private $last;

    ...

    public function getFullFio(): string
    {
        return $this->last
            . (!empty($this->middle) ? ' ' . $this->middle : '')
            . ' ' . $this->first;
    }

    public function getShortFio(): string
    {
        return $this->last
            . (!empty($this->middle) ? ' ' . $this->middle[0] . '.' : '')
            . ' ' . $this->first[0] . '.';
    }
}

echo $user->getName()->getFullFio();
echo $user->getName()->getShortFio();

Но дабы не тянуться снаружи по всей цепочке...


echo $user->getName()->getShortFio();

...можно делегировать:


class User
{
    /**
     * @var Name
     */
    private $name;

    ...

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

// echo $user->getName()->getShortFio();
echo $user->getShortFio();

Заполняем через конструкторы:


class User
{
    __construct($id, $email, $hash, Name $name, Phone $phone, Address $address)
    {
        $this->id = $id;
        ...
    }
}

$user = new User(
    $id,
    $email,
    $passwordHash,
    new Name('Vasiliy', 'Ivanovich', 'Pupkin'),
    new Phone(7, '920', '1231231'),
    new Address('Russia', 'Moscow', 'Req Square', '1')
);

Или с Profile:


class User
{
    function __construct($id, $email, $hash, Profile $profile) {
        ...
    }
}

class Profile
{
    function __construct(Name $name, Phone $phone, Address $address) {
        ...
    }
}

$user = new User(
    $id,
    $email,
    $passwordHash,
    new Profile(
        new Name('Vasiliy', 'Ivanovich', 'Pupkin'),
        new Phone(7, '920', '1231231'),
        new Address('Russia', 'Moscow', 'Req Square', '1')
    )
);

Можно всё сделать обязательным:


class User
{
    __construct($id, $email, $hash, Name $name, Phone $phone, Address $address)
    {
        $this->id = $id;
        ...
    }
}

$user = new User(
    $id,
    $email,
    $passwordHash,
    new Name('Vasiliy', 'Ivanovich', 'Pupkin'),
    new Phone(7, '920', '1231231'),
    new Address('Russia', 'Moscow', 'Req Square', '1')
);

Или отделить опциональное:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function setPhone(Phone $phone)
    {
        $this->phone = $phone;
    }

    public function changeEmail(string $email)
    {
        $this->email = $email;
    }
}

$user = new User($id, $email, $hash);

$user->setPhone(new Phone(7, '920', '1231231'));

Забыли валидацию:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        Assert::notEmpty($email);
        Assert::regex($email, '#^[\w\.-]+@[\w\.-]+$#s');

        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        Assert::notEmpty($email);
        Assert::regex($email, '#^[\w\.-]+@[\w\.-]+$#s');

        $this->email = $email;
    }
}

Забыли валидацию:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        Assert::notEmpty($email);
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Incorrect email.');
        }

        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        Assert::notEmpty($email);
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Incorrect email.');
        }

        $this->email = $email;
    }
}

Забыли валидацию:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        Assert::notEmpty($email);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        Assert::notEmpty($email);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->email = $email;
    }
}

Забыли валидацию:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        Assert::notEmpty($email);                                    //
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL)); // Repeat

        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        Assert::notEmpty($email);                                    //
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL)); // Repeat

        $this->email = $email;
    }
}

Инкапсулируем в метод:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        $this->id = $id;
        $this->setEmail($email);
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        $this->setEmail($email);
    }

    private function setEmail(string $email)
    {
        Assert::notEmpty($email);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->email = $email;
    }
}

Инкапсулируем в класс:


class Email
{
    private $value;

    public function __construct(string $value)
    {
        Assert::notEmpty($value);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->value = $value;
    }

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

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

И вместо валидации:


class User
{
    public function __construct($id, string $email, string $hash)
    {
        Assert::notEmpty($email);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {
        Assert::notEmpty($email);
        Assert::notEmpty(filter_var($email, FILTER_VALIDATE_EMAIL));

        $this->email = $email;
    }
}

И вместо валидации:


class User
{
    public function __construct($id, string $email, string $hash)
    {



        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(string $email)
    {



        $this->email = $email;
    }
}

будем принимать Email:


class User
{
    public function __construct($id, Email $email, string $hash)
    {



        $this->id = $id;
        $this->email = $email;
        $this->password = $hash;
    }

    public function changeEmail(Email $email)
    {



        $this->email = $email;
    }
}

Общий Email:


$user = new User(
    $id,
    new Email($command->email),
    $this->hasher->hash($command->password)
);

$company = new Company(
    $id,
    new Email($command->email),
    $command->name
);

К чему мы пришли?

Вместо рассмотрения данных просто как набров полей...

...мы пришли к смысловому разделению ответственностей

Куда поместить бизнес-логику?

Когда вместо простых примитивов string или int имеем разгруппированные объекты...

...всю связанную с ними бизнес-логику можем помещать прямо в них


class House
{
    public $id;
    public $name;
    public $owner_name_last;
    public $owner_name_middle;
    public $owner_name_first;
    public $owner_email;
    public $owner_phone;
    public $address_country;
    public $address_city;
    public $address_street;
    public $address_house;

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner_name_last) &&
            !empty($this->owner_name_first) &&
            (!empty($this->owner_email) || !empty($this->owner_phone)) &&
            !empty($this->address_city) &&
            !empty($this->address_street) &&
            !empty($this->address_house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner_name_last) &&
            !empty($this->owner_name_first) &&
            (!empty($this->owner_email) || !empty($this->owner_phone)) &&
            !empty($this->address_city) &&
            !empty($this->address_street) &&
            !empty($this->address_house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner->getName()->getLast()) &&
            !empty($this->owner->getName()->getFirst()) &&
            (!empty($this->owner->getEmail()) || !empty($this->owner->getPhone())) &&
            !empty($this->address->getCity()) &&
            !empty($this->address->getStreet()) &&
            !empty($this->address->getHouse());
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner->name->last) &&
            !empty($this->owner->name->first) &&
            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner->name->last) &&   // Owner contains
            !empty($this->owner->name->first) &&  // first and last name
            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                           isFilled() {}
                                                 }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner->name->last) &&   // Owner contains
            !empty($this->owner->name->first) &&  // first and last name
            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                           isFilled() {}
                                                 }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->getName()->isFilled() &&

            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                         }                           isFilled() {}
                                                 }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->getName()->isFilled() && // Can we write it shorter?

            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                             hasFilledName() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->getName()->isFilled() && // Can we write it shorter?

            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                             hasFilledName() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&

            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             private $email;         private $middle;
                             private $phone;         private $last;
                             hasFilledName() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            // Owner has any contact
            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             ...                     private $middle;
                             hasFilledName() {}      private $last;
                             hasAnyContact() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            // Owner has any contact
            (!empty($this->owner->email) || !empty($this->owner->phone)) &&
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             ...                     private $middle;
                             hasFilledName() {}      private $last;
                             hasAnyContact() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&

            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             ...                     private $middle;
                             hasFilledName() {}      private $last;
                             hasAnyContact() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             ...
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&
            // Address is completely filled
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             ...                     private $middle;
                             hasFilledName() {}      private $last;
                             hasAnyContact() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             isCompleteFilled() {}
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&
            // Address is completely filled
            !empty($this->address->city) &&
            !empty($this->address->street) &&
            !empty($this->address->house);
    }
}


class House
{
    private $id;
    private $name;
    private $owner;      class Owner {           class Name {
                             private $name;          private $first;
                             ...                     private $middle;
                             hasFilledName() {}      private $last;
                             hasAnyContact() {}      isFilled() {}
                         }                       }
    private $address;    class Address {
                             private $country;
                             isCompleteFilled() {}
                         }
    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&
            $this->address->isCompleteFilled();



    }
}

Вместо одного объекта с грудой полей появились куча объектов с методами

До (сложно и страшно):


class House
{
    public $id;
    public $name;
    public $owner_name_last;
    public $owner_name_middle;
    public $owner_name_first;
    public $owner_email;
    public $owner_phone;
    public $address_country;
    public $address_city;
    public $address_street;
    public $address_house;

    public function isAvailableForSelling(): bool {
        return
            !empty($this->owner_name_last) &&
            !empty($this->owner_name_first) &&
            (!empty($this->owner_email) || !empty($this->owner_phone)) &&
            !empty($this->address_city) &&
            !empty($this->address_street) &&
            !empty($this->address_house);
    }
}

После (проще и понятнее):


class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->owner = $owner;
        $this->address = $address;
    }

    public function isAvailableForSelling(): bool
    {
        return
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&
            $this->address->isCompleteFilled();
    }
}

После (проще и понятнее):


class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->owner = $owner;
        $this->address = $address;
    }

    public function isAvailableForSelling(): bool
    {
        return
            // Owner has contacts for selling
            $this->owner->hasFilledName() &&
            $this->owner->hasAnyContact() &&
            $this->address->isCompleteFilled();
    }
}

После (проще и понятнее):


class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->owner = $owner;
        $this->address = $address;
    }

    public function isAvailableForSelling(): bool
    {
        return
            $this->owner->hasContactsForSelling() &&


            $this->address->isCompleteFilled();
    }
}

После (проще и понятнее):


class Owner
{
    private $name;
    private $phone;
    private $email;

    ...

    public function hasContactsForSelling(): bool
    {
        return $this->name->isFilled() && $this->hasAnyContact();
    }

    private function hasAnyContact(): bool
    {
        return !empty($this->email) || !empty($this->phone);
    }
}

class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->owner = $owner;
        $this->address = $address;
    }

    public function isAvailableForSelling(): bool
    {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }

    ...
}

class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        ...
    }

    public function isAvailableForSelling(): bool
    {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }

    public function getId(): Id { return $this->id; }
    public function getName(): string { return $this->name; }
    public function getOwner(): Owner { return $this->owner; }
    public function getAddress(): Address { return $this->address; }
}

class House
{
    private $id;
    private $name;
    private $owner;
    private $address;

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        $this->name = $name;
        $this->owner = $owner;
        $this->address = $address;
    }

    public function isAvailableForSelling(): bool
    {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }

    ...
}

class House
{
    const STATUS_DRAFT = 'draft';
    const STATUS_PUBLISHED = 'published';

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        ...
        $this->status = self::STATUS_DRAFT;
    }

    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }

    public function isDraft(): bool {
        return $this->status === self::STATUS_DRAFT;
    }
}


{% if house.isDraft() %}
    {% if house.isAvailableForSelling() %}

        <a href="{{ path('houses.publish', {'id': house.id}) }}">
            Publish for Selling
        </a>

    {% else %}

        <div class="alert alert-danger">
            Specify owner's contacts and house's address for selling!
        </div>

    {% endif %}
{% endif %}

Вместо одного класса с грудой полей появились куча классов с методами

Какие плюсы?

1

Логику требования
«дом доступен для продажи, если у него заполнен адрес, а у собственника заполнены полное имя и один из контактов»
можно понять прямо из кода

2

Заказчик может посмотреть в код и прочитать что там происходит

3

Если раньше мы не знали, для чего нам нужны тесты, то теперь знаем.

Когда у нас появляются методы, то мы теперь можем тестировать эти методы.


class AvailabilityTest extends TestCase
{
    public function testAvailableWithEmailAndPhone()
    {
        $owner = new Owner(
            new Email('owner@app.test'),
            new Phone(...)
        );

        self::assertTrue($owner->hasContactsForSelling());
    }

    public function testNotAvailableWithoutEmailAndPhone()
    {
        $owner = new Owner(null, null);

        self::assertFalse($owner->hasContactsForSelling());
    }

    ...
}


class AvailabilityTest extends TestCase
{
    /**
     * @dataProvider getVariants
     */
    public function testAvailability($email, $phone, $result)
    {
        $owner = new Owner($email, $phone);
        self::assertEquals($result, $owner->hasContactsForSelling());
    }

    public function getVariants()
    {
        $email = new Email('owner@app.test');
        $phone = new Phone(...);

        return [
            [$email, $phone, true],
            [$email, null, true],
            [null, $phone, true],
            [null, null, false],
        ];
    }
}

4

Если прибежит заказчик и скажет «давай и с емэйлом, и с телефоном», то мы сразу знаем, что и где нужно поменять

Есть ковейер для всех.

Есть индивидуальная сборка на заказ.

А это уже супериндивидуальная сборка.

Какие минусы?

1

Нужно сильно думать. Досконально изучать задание, рисовать структуру объектов и методов. Кто что будет хранить и кого будет дёргать.

2

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

Вручную:


class UserService
{
    public function changeName($id, UserNameChangeRequest $request): void
    {
        $user = $this->repository->get($id);

        $user->changeName(
            new Name(
                $request->input('first'),
                $request->input('middle'),
                $request->input('last')
            )
        );

        $this->repository->save($user);
    }
}

Нужно ли вместо одного класса программировать пять?

Дмитрий Елисеев

Архитектура придумана для упрощения сложного кода, а не для усложнения лёгкого.

Дмитрий Елисеев

Если это действительно упрощает код, то делайте пять

Если же бизнес-логики практически нет и методы не нужны, то оставляйте простой CRUD с полями

Добавим статус


class House
{
    const STATUS_DRAFT = 'draft';
    const STATUS_PUBLISHED = 'published';

    function __construct(Id $id, string $name, Owner $owner, Address $address)
    {
        $this->id = $id;
        ...
        $this->status = self::STATUS_DRAFT;
    }

    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }

    public function isDraft(): bool {
        return $this->status === self::STATUS_DRAFT;
    }
}

Нужно выставить дом на продажу в какую-то дату с произвольной заметкой?

Добавим метод $house->publish($date, $comment)


class PublishHandler
{
    private $houses;

    public function __construct(HouseRepository $houses)
    {
        $this->houses = $houses;
    }

    public function __invoke(PublishCommand $command): void
    {
        $house = $this->houses->get($command->id);

        $house->publish(
            $command->date,
            $command->comment,
        );

        $this->houses->save($house);
    }
}

class HouseController extends Controller
{
    private $logger;

    function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * @Route("/houses/create", name="houses.create")
     */
    function create(Request $request, CreateHandler $handler) {
        ...
    }

    /**
     * @Route("/houses/{id}/publish", name="houses.publish")
     */
    function publish(House $house, Request $request, PublishHandler $handler) {
        ...
    }
}

function publish(House $house, Request $request, PublishHandler $handler)
{
    $command = new PublishCommand($id = $house->getId());

    $form = $this->createForm(PublishForm::class, $command);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        try {
            $handler($command);
            return $this->redirectToRoute('houses.show', ['id' => $id]);
        } catch (\DomainException $e) {
            $this->logger->error($e->getMessage(), ['exception' => $e]);
            $this->addFlash('error', $e->getMessage());
        }
    }

    return $this->render('house/publish.html.twig', [
        'house' => $house,
        'form' => $form->createView(),
    ]);
}

class House
{
    public function publish(\DateTimeImmutable $date, string $comment): void
    {
        if (!$this->isDraft()) {
            throw new \DomainException('House is not a draft.');
        }
        if (!$this->isAvailableForSelling()) {
            throw new \DomainException('Specify all information for selling.');
        }
        $this->publishDate = $date;
        $this->publishComment = $comment;
        $this->status = self::STATUS_PUBLISHED;
    }

    public function isAvailableForSelling(): bool {
        return
            $this->owner->hasContactsForSelling() &&
            $this->address->isCompleteFilled();
    }
    public function isDraft(): bool {
        return $this->status === self::STATUS_DRAFT;
    }
}

Нужно пометить дом проданным в какую-то дату?

Добавим метод $house->sell($date)


class House
{
    ...

    public function sell(\DateTimeImmutable $date): void
    {
        if (!$this->isPublished()) {
            throw new \DomainException('House is not published.');
        }
        $this->sellDate = $date;
        $this->status = self::STATUS_SOLD;
    }

    public function isPublished(): bool {
        return $this->status === self::STATUS_PUBLISHED;
    }

    ...
}

Какой план действий в обычном CRUD с ActiveRecord?

  • Сущности - это просто набор полей из БД
  • Поля изменяются напрямую снаружи

Как теперь устроена работа?

  • Сущности представляют собой полноценные объекты
  • Всё состояние приватно и полностью скрыто от прямых изменений
  • Состояние меняется только через имеющиеся публичные методы
  • В методах содержатся все проверки (бизнес-правила)

Plain PHP


$order = new Order($id, $user->getId(), $date);

$order->addItem($product, $quantity);
$order->removeItem($product->getId());
$order->activate();
$order->delivery($date);
$order->pay($date);

$order->isActive();
$order->isDraft();
$order->isSent();
$order->isPaid();

$order->getId();
$order->getCreateDate();
$order->getItems();
$order->...

Doctrine


$order = new Order($id, $user, $date);

$order->addItem($product, $quantity);
$order->removeItem($product->getId());
$order->activate();
$order->delivery($date);
$order->pay($date);

$order->isActive();
$order->isDraft();
$order->isSent();
$order->isPaid();

$order->getId();
$order->getCreateDate();
$order->getItems();
$order->...

class Product
{
    /**
     * @ORM\OneToMany(
     *     targetEntity="Review", mappedBy="product",
     *     orphanRemoval=true, cascade={"persist"}
     * )
     */
    private $reviews;
    private $rating;

    public function addReview(User $user, int $vote, string $content): void
    {
        foreach ($this->reviews as $review) {
            if ($review->isByUser($user->getId())) {
                throw new \DomainException('Review already exists.');
            }
        }
        $this->reviews->add(new Review($this, $user, $vote, $content));
        $this->recalculateRating();
    }

    ...
}

class Product
{
    public function addReview(User $user, int $vote, string $content): void
    {
        ...
        $this->reviews->add(new Review($this, $user, $vote, $content));
        $this->recalculateRating();
    }

    private function recalculateRating(): void
    {
        if (!$this->reviews->count()) {
            $this->rating = null;
            return;
        }
        $sum = 0;
        foreach ($this->reviews as $review) {
            if ($review->isPublished()) {
                $sum += $review->getVote();
            }
        }
        $this->rating = $sum / $this->reviews->count();
    }
}

class Product
{
    public function addReview(User $user, int $vote, string $content): void
    {
        ...
        $this->reviews->add(new Review($this, $user, $vote, $content));
        $this->recalculateRating();
    }

    public function editReview(Id $id, int $vote, string $content): void
    {
        foreach ($this->reviews as $review) {
            if ($review->getId()->isEqualTo($id)) {
                $review->edit($vote, $content);
                $this->recalculateRating();
                return;
            }
        }
        throw new \DomainException('Review is not found.');
    }
}

class Product
{
    public function addReview(User $user, int $vote, string $content): void
    {
        ...
    }

    public function editReview(Id $id, int $vote, string $content): void
    {
        ...
    }

    public function publishReview(Id $id): void
    {
        foreach ($this->reviews as $review) {
            if ($review->getId()->isEqualTo($id)) {
                $$review->publish();
                $this->recalculateRating();
                return;
            }
        }
        throw new \DomainException('Review is not found.');
    }
}

class Product
{
    public function addReview(User $user, int $vote, string $content): void { ... }

    public function editReview(Id $id, int $vote, string $content): void { ...  }

    public function publishReview(Id $id): void { ... }

    public function draftReview(Id $id): void
    {
        foreach ($this->reviews as $review) {
            if ($review->getId()->isEqualTo($id)) {
                $$review->draft();
                $this->recalculateRating();
                return;
            }
        }
        throw new \DomainException('Review is not found.');
    }
}

class Product
{
    public function addReview(User $user, int $vote, string $content): void { ... }

    public function editReview(Id $id, int $vote, string $content): void { ... }

    public function publishReview(Id $id): void { ... }

    public function draftReview(Id $id): void { ... }

    public function removeReview(Id $id): void
    {
        foreach ($this->reviews as $review) {
            if ($review->getId()->isEqualTo($id)) {
                $this->reviews->removeElement($review);
                $this->recalculateRating();
                return;
            }
        }
        throw new \DomainException('Review is not found.');
    }
}

class Review
{
    /**
     * @ORM\ManyToOne(targetEntity="Product")
     * @ORM\JoinColumn(
     *     name="product_id", referencedColumnName="id",
     *     nullable=false, onDelete="CASCADE"
     * )
     */
    private $product;
    /** ... */
    private $user;
    private $vote;
    private $content;
    private $status;

    function __construct(Product $product, User $user, int $vote, string $content)
    {
        $this->product = $product;
        $this->user = $user;
        $this->vote = $vote;
        $this->content = $content;
    }
}

$product = new Product(...);
$product->addReview($user, $vote, $content);

$em->persist($product);
$em->flush(); // Сохранится весь агрегат со всеми внутренностями

Получаем полноценный объект,

представляющий из себя сложный агрегат,

живущий своей жизнью


class UserCreateHandler
{
    private $users;
    private $hasher;

    function __construct(UserRepository $users, PasswordHasher $hasher) {
        $this->users = $users;
        $this->hasher = $hasher;
    }

    function __invoke(UserCreateCommand $command): void
    {
        if ($this->users->existsByEmail($email = new Email($command->email)) {
            throw new \DomainException('User with this email already exists.');
        }

        $user = new User(
            $this->users->nextId(),
            new Email($command->email),
            $this->hasher->hash($command->password)
        );

        $this->users->add($user);
    }
}

class SignupHandler
{
    private $users;
    private $hasher;

    function __construct(UserRepository $users, PasswordHasher $hasher) {
        $this->users = $users;
        $this->hasher = $hasher;
    }

    function __invoke(SignupCommand $command): void
    {
        if ($this->users->existsByEmail($email = new Email($command->email)) {
            throw new \DomainException('User with this email already exists.');
        }

        $user = new User($this->users->nextId());
        $user->signupByEmail(
            new Email($command->email),
            $this->hasher->hash($command->password)
        );

        $this->users->add($user);
    }
}

class User
{
    private $id;
    private $email;
    private $hash;
    private $name;

    public function __construct(Id $id, Email $email, string $hash)
    {
        $this->id = $id;
        $this->email = $email;
        $this->hash = $hash;
    }

    public function setName(Name $name): void
    {
        $this->name = $name;
    }

    public function getId(): Id { return $this->id; }
    public function getEmail(): Email { return $this->email; }
    public function getHash(): string { return $this->hash; }
    public function getName(): ?Name { return $this->name; }
}

Как зарегистрировать Id, Email и Name в Doctrine?


use Webmozart\Assert\Assert;

class Id
{
    private $id;

    public function __construct($id) {
        Assert::notEmpty($id);
        $this->id = $id;
    }

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

    public function isEqualTo(self $other): bool {
        return $this->getId() === $other->getId();
    }

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

namespace App\Doctrine;

use App\Entity\Id;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\GuidType;

class IdType extends GuidType
{
    public const NAME = 'Id';

    public function convertToDatabaseValue($value, AbstractPlatform $platform) {
        return $value instanceof Id ? $value->getId() : $value;
    }

    public function convertToPHPValue($value, AbstractPlatform $platform) {
        return new Id($value);
    }

    public function getName(): string {
        return self::NAME;
    }
}

namespace App\Doctrine;

use App\Entity\Id;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\StringType;

class EmailType extends StringType
{
    public const NAME = 'Email';

    public function convertToDatabaseValue($value, AbstractPlatform $platform) {
        return $value instanceof Email ? $value->getValue() : $value;
    }

    public function convertToPHPValue($value, AbstractPlatform $platform) {
        return !empty($value) ? new Email($value) : null;
    }

    public function getName(): string {
        return self::NAME;
    }
}

doctrine:
    dbal:
        ...
        url: '%env(resolve:DATABASE_URL)%'
        types:
            Id: 'App\Doctrine\IdType'
            Email: 'App\Doctrine\EmailType'

use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity
 * @ORM\Table(name="users")
 */
class User
{
    /**
     * @ORM\Column(type="Id")
     * @ORM\Id
     */
    private $id;
    /**
     * @ORM\Column(type="Email")
     */
    private $email;
    /**
     * @ORM\Column(type="string", length=64)
     */
    private $hash;
    /**
     * @ORM\Embedded(class="Name")
     */
    private $name;
}

/**
 * @ORM\Embeddable
 */
class Name
{
    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $first;
    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $middle;
    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $last;

    ...
}

Контроль пустот:


use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 * @ORM\Table(name="users")
 */
class User
{
    /**
     * @ORM\Embedded(class="Name")
     */
    private $name;

    /**
     * @ORM\PostLoad()
     */
    public function checkEmbedded(): void
    {
        if (!$this->name->getFirst()) {
            $this->name = null;
        }
    }
}

Путём нехитрых манипуляций мы подключили Doctrine

А как же ActiveRecord?


class User extends \yii\db\ActiveRecord
{
    private $id;
    private $name;

    public function afterFind(): void
    {
        $this->id = new Id($this->getAttribute('user_id'));

        if (!empty($this->getAttribute('name_first'))) {
            $this->name = new Name(
                $this->getAttribute('name_first'),
                $this->getAttribute('name_middle'),
                $this->getAttribute('name_last'),
            );
        }

        parent::afterFind();
    }

    ...
}

class User extends \yii\db\ActiveRecord
{
    private $id;
    private $name;

    ...

    public function beforeSave($insert): bool
    {
        $this->setAttribute('user_id', $this->id->getId());

        if ($this->name) {
            $this->setAttribute('name_first', $this->name->getFirst()),
            $this->setAttribute('name_middle', $this->name->getMiddle()),
            $this->setAttribute('name_last', $this->name->getLast()),
        } else {
            $this->setAttribute('name_first', null),
            $this->setAttribute('name_middle', null),
            $this->setAttribute('name_last', null),
        }

        return parent::beforeSave($insert);
    }
}

Совместные покупки:


/**
 * @property integer $status
 * @property integer $deliveryDate
 *
 * @property Member[] $members
 */
class Order extends ActiveRecord
{
    public function getMembers()
    {
        return $this->hasMany(Member::class, ['order_id', 'id']);
    }
}

Много участников

с долями по оплате:


/**
 * @property integer $order_id
 * @property integer $user_id
 * @property integer $amount
 * @property integer $status
 */
class Member extends ActiveRecord
{
    const STATUS_WAITING = 1;
    const STATUS_PAID = 2;

    public function getUser()
    {
        return $this->hasOne(User::class, ['id', 'user_id']);
    }
}

Может ли участвовать:


class Order extends ActiveRecord
{
    ...

    public function canParticipate($userId)
    {
        // Если заказ уже ушёл
        if (!$this->isWaitingForMembers()) {
            return false;
        }

        // Если является участником, то ещё раз не может:
        foreach ($this->members as $member) {
            if ($member->isForUser($userId)) {
                return false;
            }
        }

        // Иначе может
        return true;
    }
}

Ссылка участия:


<?php if ($order->canParticipate(Yii::$app->user->id)): ?>

    <?= Html::a('Участвовать', ['participate', 'id' => $order->id]) ?>

<?php endif; ?>

Добавляем участника:


class Order extends ActiveRecord
{
    ...

    public function participate($userId, $amount)
    {
        if (!$this->isWaitingForMembers()) {
            throw new \DomainException('Приём участников закрыт.');
        }

        foreach ($this->members as $member) {
            if ($member->isForUser($userId)) {
                throw new \DomainException('Вы уже участвуете.');
            }
        }

        $this->link('members', Member::create($userId, $amount));
    }
}

И наш Member:


class Member extends ActiveRecord
{
    const STATUS_WAITING = 1;
    const STATUS_PAID = 2;

    public static function create($userId, $amount)
    {
        $member = new self();
        $member->user_id = $userId;
        $member->amount = $amount;
        $member->status = self::STATUS_WAITING;
        return $member;
    }
}

Может ли оплатить:


class Order extends ActiveRecord
{
    ...

    public function canBePaidByUser($userId)
    {
        // Если заказ не в стадии оплаты, то не может:
        if (!$this->isWaitingForPayment()) {
            return false;
        }

        // Если является участником, но ещё не платил, то может:
        foreach ($this->members as $member) {
            if ($member->isForUser($userId) && $member->isWaiting()) {
                return true;
            }
        }

        // Иначе не может:
        return false;
    }
}

И наш Member:


class Member extends ActiveRecord
{
    const STATUS_WAITING = 1;
    const STATUS_PAID = 2;

    public static function create($userId, $amount)
    {
        $member = new self();
        ...
        return $member;
    }

    public function isForUser($id)
    {
        return $this->user_id === $id;
    }

    public function isWaiting()
    {
        return $this->status === self::STATUS_WAITING;
    }
}

Ссылка оплаты:


<?php if ($order->canBePaidByUser(Yii::$app->user->id)): ?>

    <?= Html::a('Оплатить', ['pay', 'id' => $order->id]) ?>

<?php endif; ?>

Внесение денег участником:


class Order extends ActiveRecord
{
    ...

    public function payByUser($userId)
    {
        if (!$this->isWaitingForPayment()) {
            throw new \DomainException('Оплата заказа недоступна.');
        }
        foreach ($this->members as $member) {
            if ($member->isForUser($userId)) {
                $member->pay();
                return;
            }
        }
        throw new \DomainException('Заявка не найдена.');
    }
}

на его долю:


class Member extends ActiveRecord
{
    ...

    public function pay()
    {
        if ($this->isPaid()) {
            throw new \DomainException('Заявка уже оплачена.');
        }
        $this->status = self::STATUS_PAID;
        $this->update();
    }
}


src
├── Doctrine
│       Type
│       ├── IdType.php
│       └── EmailType.php
└── Entity
    ├── Product
    │   ├── Product.php
    │   ├── Photo.php
    │   └── Review.php
    ├── Order
    │   ├── Order.php
    │   ├── Item.php
    │   └── Member.php
    ├── User
    │   ├── User.php
    │   ├── Name.php
    │   └── Address.php
    ├── Id.php
    └── Email.php

Наборы равноправных сущностей мы сгруппировали в агрегаты

Что получили?

  • Полноценные объекты вместо голых структур данных
  • Все данные надёжно скрыты в объектах
  • С агрегатом можно работать только через его методы
  • Методы управляют всеми внутренностями агрегата
  • Все проверки лежат именно там, к чему они относятся
  • Можно строить системы любой сложности
  • Легко тестировать любое поведение

Время вопросов!