Laravel: DDD и Объект-Значение
Что такое DDD или Предметно-ориентированное программирование
Предметно-ориентированное программирование — подход к разработке программного обеспечения пытающийся максимально приблизить бизнес-логику к исходному коду, настолько близко, на сколько это возможно.
Я думаю, что это самое просто, но правильное определение DDD. Чтобы быть немного более конкретным, оно достигает этого, делая каждую важную вещь
 первоклассным гражданином. Если Изменить статус счёт-фактуры/Change invoice status
 или Увеличить количество позиций в заказе/Increase order item quantity
 — это два предложения которые часто произносят ваши бизнес-партнёры, то у вас должны быть
- ChangeInvoiceStatus
- IncreaseOrderItemQuantity
классы где-то в вашем приложении. Это один из примеров первоклассного гражданина.
Теперь предположим вы работаете над приложением для управления/мониторинга сервера. Может быть, коммерческий или какой-то внутренний инструмент для собственных проектов. Что самое важное в этом приложении? На мой взгляд — статус сервера. Если оно сообщает Healthy
, все счастливы, но если говорит Down
 — вы знаете, что у вас будет тяжёлый день. Мы знаем, что это важно, но всё же это просто строка в таблице базы данных. Это 15 атрибут в Модели Server. И мы меняем его с Healthy
 на Deploying
 в 837 строке какой-то случайной Команды или Задачи, или где-то в Модели, или, что ещё хуже, в Контроллере. Вместо того чтобы иметь строковые значения и изменять их, у нас могут быть классы:
- ServerStatus/Healthy
- ServerStatus/Down
- ServerStatus/Deploying
У нас могут быть такие переходы, как:
- HealthyToDeploying
Так вот, что я имею в виду, говоря о создании вещей, первоклассных граждан. Это то, что касается Предметно-ориентированного программирования (и немного больше, но сейчас это самое главное).
Что такое Объект-Значение в мире Предметно-ориентированного программирования
Что больше всего раздражает в работе с унаследованным кодом? Да, есть много раздражающих вещей, поэтому я покажу свою любимую:
public function doSomething($data)
{
    // 954 строк кода
}
public function getList($data)
{
    // 673 строк кода
}Конечно здесь нет подсказок типов, но замечательные имена подсказывают нам, что $data в обоих случаях является массивом. Мой следующий вопрос: что внутри $data? Конечно мы этого не знаем. Мне нужно прочитать 673 строки, чтобы получить ответы. Ох, и через 412 строк выясняется, что мы что-то помещаем в $data. Великолепно!
Изначально PHP массивами пытались решить сразу все проблемы: очереди, стек, списки, хэш-карты, деревья. Всё. Но с его слабой системой типов очень сложно поддерживать такие методы.
Ответ Предметно-ориентированного программирования на эти проблемы: Объект-Значение.
Объект-Значение — очень простой класс содержащий в основном (но не только) скалярные данные. Итак, это класс-оболочка объединяющий связанную информацию. Давайте посмотрим пример:
class Percent
{
    public function __construct(private readonly ?float $value)
    {
    }
    public static function from(?float $value): self
    {
        return new static($value);
    }
    public function format(string $defaultValue = ''): string
    {
        if ($this->value === null) {
            return $defaultValue;
        }
        return number_format($this->value * 100, 2) . '%';
    }
}Это Объект-Значение, представляющий проценты. Как вы можете увидеть, он также применяет некоторые правила
, например:
- Всегда используем два знака после запятой (или X знаков после запятой из конфигурации).
- По умолчания представляем значение NULLкак пустую строку.
Второе правило произвольное и его можно переопределить, но первое правило, в данном примере, обязательное. Я использую этот класс в основном (но не только) в HTTP ресурсах:
class HoldingResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'ticker' => $this->stock->ticker,
            'averageCost' => Money::from($this->average_cost)->format(),
            'quantity' => Decimal::from($this->quantity)->format(),
            'investedCapital' => Money::from($this->invested_capital)->format(),
            'marketValue' => Money::from($this->market_value)->format(),
            'yield' => Percent::from($this->yield)->format(),
            'yieldOnCost' => Percent::from($this->yield_on_cost)->format(),
        ];
    }
}Взглянув на этот массив, вы сразу узнаете тип каждого ключа.
Хорошо, это простой пример. Давайте посмотрим на что-то более похожее на getList($data) приведённое выше. Нам нужно принять фильтр даты от FE и отфильтровать результаты на основе этого. Я уверен, что у вас есть проекты в которых много фильтров даты, но в каждом месте разработчик назвал фильтры по-разному, например:
// В Модели Product
public function getProducts(array $filters)
{
    // startDate и endDate строки
    // Но с неверной timezone и форматом
    $filters['startDate'];
    $filters['endDate'];
}
// В Модели Order
public function getOrders(array $filters)
{
    // start и end объекты Carbon
    // Но время установлено в 00:00:00 и на самом деле они нужны для фильтрации
    $filters['start'];
    $filters['end'];
}
//В Модели Invoice
public function getInvoices(array $filters)
{
    // from_date и to_date объекты DateTime
    // Но from_time и end_time относительные timestamps от
    // start_date и end_date которые содержат время в секундах
    $filters['from_date'];
    $filters['from_time'];
    $filters['to_date'];
    $filters['to_time'];
}
// В модели Customers
public function getCustomers(array $filters)
{
    // Вы знаете, что это будет не CarbonInterval
    // Но это другой массив с timestamp
    $filters['interval'];
}Итак, разные ключи массивов, разные форматы даты и времени, иногда вложенные массивы, иногда different_casing и так далее. Кругом бардак. Бьюсь об заклад, вы работали с подобным кодом.
Сделаем фильтры даты снова великолепными! Сначала мы сможем определить наши собственные даты начала и окончания:
class StartDate
{
    public Carbon $date;
    public function __construct(Carbon $date)
    {
        $this->date = $date->startOfDay();
    }
    public static function fromString(string $date): self
    {
        return new static(Carbon::parse($date));
    }
    public function __toString(): string
    {
        return $this->date->format('Y-m-d H:i:s');
    }
}В этом примере не нужно беспокоится о времени, только о дате. Так что в этом случае я могу обеспечить, чтобы дата начала означала начало дня. У меня есть __toString(), потому что я хочу использовать этот класс в выражениях where Eloquent. Как видите, здесь я также могу применить format. У меня есть точно такой же класс EndDate:
class EndDate
{
    public Carbon $date;
    public function __construct(Carbon $date)
    {
        $this->date = $date->endOfDay();
    }
    public static function fromString(string $date): self
    {
        return new static(Carbon::parse($date));
    }
    public function __toString(): string
    {
        return $this->date->format('Y-m-d H:i:s');
    }
}Теперь мы можем создать новый класс DateFilter из этих двух классов:
class DateFilter
{
    public function __construct(public StartDate $startDate, public EndDate $endDate)
    {
    }
    public static function fromCarbons(Carbon $startDate, Carbon $endDate): self
    {
        return new static(
            StartDate::fromString($startDate->toString()),
            EndDate::fromString($endDate->toString())
        );
    }
}Как видите, мне нравится идея фабричных методов. В этом случае у меня есть тот, кто создаёт класс из объектов Carbon.
Далее необходимо создать Объект-Значение DateFilter в запросе:
class GetHoldingDividendsRequest extends FormRequest
{
    public function authorize()
    {
        return $this->getHolding()->user()->is($this->user());
    }
    public function getHolding(): Holding
    {
        return $this->route('holding');
    }
    public function getDateFilter(): ?DateFilter
    {
        if ($this->input('filter.startDate')) {
            return new DateFilter(
                StartDate::fromString($this->input('filter.startDate')),
                EndDate::fromString($this->input('filter.endDate')),
            );
        }
        return null;
    }
    public function rules()
    {
        return [
            'filter' => 'nullable|sometimes|array',
            'filter.startDate' => 'date|required_with:endDate',
            'filter.endDate' => 'date|required_with:startDate',
        ];
    }
}Теперь я могу использовать класс DateFilter в любом запросе или области видимости:
public function wherePayDateBetween(?DateFilter $dates): self
{
    if ($dates) {
        return $this->whereBetween(
            'pay_date',
            [$dates->startDate, $dates->endDate]
        );
    }
    return $this;
}Вот зачем StartDate и EndDate перезаписывают метод __toString(). Его можно использовать в выражении where.
Мы также можем использовать фабрику fromCarbons() следующим образом:
public function thisWeek(User $user): float
{
    $dates = DateFilter::fromCarbons(now()->startOfWeek(), now()->endOfWeek());
    return $this->sumByDate($dates, $user);
}
public function thisMonth(User $user): float
{
    $dates = DateFilter::fromCarbons(now()->startOfMonth(), now()->endOfMonth());
    return $this->sumByDate($dates, $user);
}Я думаю, что использование Объект-Значение в качестве контейнера или обёртки для значений — отличный способ сделать код более удобным для сопровождения.
Преимущества Объект-Значение
- Всё типизировано. Больше нет getProduct($data)где вы не знаете, что происходит.
- Авто-завершение. Ваше IDE знает, что в классе DateFilterесть свойствоstartDateтипаStartDate.
- Высокоуровневый код. Я думаю, что гораздо лучше взглянуть на эти методы.
- Применяет некоторые основные правила относительно этих значений и форматов.
- Первоклассные граждане. Теперь у вас есть простые и видимые классы в вашем каталоге ValeObjects, поэтому каждый разработчик должен их использовать и знать, например, как ваше приложение представляет проценты.