Laravel: DDD и Объект-Значение

Источник: «Domain-Driven Design with Laravel - Value Objects»
Давайте разберёмся, что такое DDD? Что делает Объект-Значение и какие у него преимущества? Как и зачем их использовать в Laravel приложении.

Что такое DDD или Предметно-ориентированное программирование

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

Я думаю, что это самое просто, но правильное определение DDD. Чтобы быть немного более конкретным, оно достигает этого, делая каждую важную вещь первоклассным гражданином. Если Изменить статус счёт-фактуры/Change invoice status или Увеличить количество позиций в заказе/Increase order item quantity — это два предложения которые часто произносят ваши бизнес-партнёры, то у вас должны быть

классы где-то в вашем приложении. Это один из примеров первоклассного гражданина.

Теперь предположим вы работаете над приложением для управления/мониторинга сервера. Может быть, коммерческий или какой-то внутренний инструмент для собственных проектов. Что самое важное в этом приложении? На мой взгляд — статус сервера. Если оно сообщает Healthy, все счастливы, но если говорит Down — вы знаете, что у вас будет тяжёлый день. Мы знаем, что это важно, но всё же это просто строка в таблице базы данных. Это 15 атрибут в Модели Server. И мы меняем его с Healthy на Deploying в 837 строке какой-то случайной Команды или Задачи, или где-то в Модели, или, что ещё хуже, в Контроллере. Вместо того чтобы иметь строковые значения и изменять их, у нас могут быть классы:

У нас могут быть такие переходы, как:

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

Что такое Объект-Значение в мире Предметно-ориентированного программирования

Что больше всего раздражает в работе с унаследованным кодом? Да, есть много раздражающих вещей, поэтому я покажу свою любимую:

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) . '%';
}
}

Это Объект-Значение, представляющий проценты. Как вы можете увидеть, он также применяет некоторые правила, например:

Второе правило произвольное и его можно переопределить, но первое правило, в данном примере, обязательное. Я использую этот класс в основном (но не только) в 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);
}

Я думаю, что использование Объект-Значение в качестве контейнера или обёртки для значений — отличный способ сделать код более удобным для сопровождения.

Преимущества Объект-Значение

Дополнительные материалы

Предыдущая Статья

Laravel: Всё о контейнере внедрения зависимостей

Следующая Статья

Laravel API: Создание API