Александр Карпов
388 subscribers
26 photos
1 video
39 links
Блог PHP разработчика для шаринга личного опыта с друзьями, коллегами и всеми желающими.

Не стесняйтесь задавать вопросы в комментариях к постам, или в личку: @slayervc (UTC +8).
Download Telegram
Границы агрегата в DDD: теория и практика (часть 1)

Этот пост перерос в статью на Хабре, если тема интересна, лучше сразу переходите к статье.)

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

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

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

Давайте рассмотрим на примере программы лояльности (ПЛ). Есть корневой класс LoyaltyProgram, который держит в себе настройки ПЛ для конкретной организации, и различную бизнес-логику.

Следующий класс, который нам понадобится - это LoyaltyCard, инкапсулирующий баланс, связку с клиентом и бизнес-логику работы с начислениями и списаниями. В идеале класс LoyaltyCard должен быть частью агрегата LoyaltyProgram, то есть LoyaltyProgram должен держать в себе (читай, в памяти PHP процесса) все карты.

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

Давайте взглянем на код:

class LoyaltyCard
{
public function accrue(int $amount): void
{
$this->balance += $amount;
}
}


Это все, на что способен класс LoyaltyCard, и проблема здесь в том, что этот код не реализует все бизнес-требования, а именно:

🍀 Сумма начисления должна вычисляться исходя из настроек ПЛ.

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

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

class LoyaltyProgram
{
private LoyaltyCardCollection $cards;

public function accrue(Order $order): void
{
$clientId = $order->getClientId();
$card = $this->cards->getByClientId($clientId);
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
$newLevel = $this->levels->getLevelByAmount($card->getBalance());
if (null !== $newLevel) {
$card->tryToUpLevel($newLevel);
}
}
}


Такой код обладает максимальным cohesion и полностью реализует всю необходимую бизнес-логику. Все проверки, такие как статус ПЛ, карты и заказа, наличие карты по $clientId и т. д., в примере опущены.

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

Как я уже говорил, при таком подходе писать (а позднее и дорабатывать) бизнес-логику становится очень легко, а сам код становится компактным и читаемым. Размещение всей логики внутри агрегата делает код максимально сбитым (высокий cohesion) и не позволяет обойти бизнес-правила в клиентском коде.

Однако, применение такого подхода порождает проблемы цены и определения границ агрегата. Но об этом я расскажу в следующем посте. 🙂
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥7👍41
Границы агрегата в DDD: теория и практика (часть 2)

Этот пост перерос в статью на Хабре, если тема интересна, лучше сразу переходите к статье.)

В предыдущем посте ⬆️ я привел пример доменного агрегата и кратко описал плюсы от их применения.

Теперь пришло время поговорить о проблемах, связанных с написанием агрегатов. Напомню предыдущий пример:

class LoyaltyProgram
{
private LoyaltyCardCollection $cards;

public function accrue(Order $order): void
{
$clientId = $order->getClientId();
$card = $this->cards->getByClientId($clientId);
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
$newLevel = $this->levels->getLevelByAmount($card->getBalance());
if (null !== $newLevel) {
$card->tryToUpLevel($newLevel);
}
}
}

class LoyaltyCard
{
public function accrue(int $amount): void
{
$this->balance += $amount;
}
}


Программа лояльности (ПЛ) держит в себе (то есть, в памяти PHP процесса) все карты, каковых могут быть десятки тысяч. Уже накладно. А теперь давайте представим, что карта лояльности держит в себе историю всех событий: списаний, начислений, активации и т. д.

class LoyaltyCardChangeLog
{
private string $cardId;

/** @var LoyaltyCardChangeLogEntry[] */
private array $entries = [];

public function accrue(/*...*/): void
{
$this->entries[] = new LoyaltyCardChangeLogEntry(/*...*/);
}
}

class LoyaltyCard
{
private LoyaltyCardChangeLog $changeLog;

public function accrue(int $amount): void
{
$this->balance += $amount;
$this->changeLog->accrue(/*...*/);
}
}


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

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

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

С точки зрения удобства в коде нам конечно было бы лучше всего сделать так:

class LoyaltyCard
{
private ?Discount $discount

public function getDiscount(): ?Discount
{
if (!$this->isActive()) {
//Инвариант: нельзя получить скидку по неактивной карте
return null;
}

return $this->discount;
}
}


Но тут возникает закономерный вопрос: как не дойти до того, что весь проект окажется в одном агрегате?

Итого, две самые часто встречающиеся проблемы при написании доменных агрегатов - это проблема цены (загрузка из хранилища, гидрация, удержание в памяти) и проблема границ. В следующем посте (или постах, как пойдет) поговорим об их решении. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥4👍31
Пытаясь осветить проблему границ доменного агрегата, я все-таки устал биться в границах размера телеграм-поста и решил дописать эту тему в статье на Хабре.

Кто читал предыдущие два поста, можете сразу скроллить к разделу "Решение проблемы границ".

https://habr.com/ru/articles/954688/
👍8🔥42
Паттерн спецификация: практический пример применения

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

🧾 Бизнес-правило

В проекте с программой лояльности нужно реализовать такое правило:

Скидочная карта может быть выдана только клиенту, чей профиль заполнен.


🧨 Проблема

Знание о том, какой профиль клиента считается заполненным, растеклось по коду. До применения спецификации это выглядело так:

class LoyaltyCard
{
//Условие из этого метода также фигурирует в
//DoctrineInitialCardIssuingStatisticsRepository::guestProfileFilledCondition()
public function checkGuestProfileFilled(Guest $guest): void
{
//...

$this->guestProfileFilled = !empty($guest->getPhone())
&& !empty($guest->getFirstname()) && !empty($guest->getLastname());

//...
}
}


А вот код репозитория, про который говорится в комментарии к предыдущему методу.

class DoctrineInitialCardIssuingStatisticsRepository
{
private function guestProfileFilledCondition(string $alias): string
{
$sql = <<<SQL
(TRIM(%1\$s.phone) <> '' AND %1\$s.phone IS NOT NULL
AND TRIM(%1\$s.firstname_plain) <> '' AND %1\$s.firstname_plain IS NOT NULL
AND TRIM(%1\$s.lastname_plain) <> '' AND %1\$s.lastname_plain IS NOT NULL)
SQL;

return sprintf($sql, $alias);
}
}


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

Продолжение в следующем посте. ⬇️
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥4👍21
Паттерн спецификация: практический пример применения. Часть 2.

Продолжаем предыдущий пост. ⬆️

🛠 Решение: используем Спецификацию


Для начала создадим Спецификацию для выражения требования к гостю:

class GuestWithFilledProfileSpecification
{
private array $notEmptyFields;

public function isSatisfiedBy(Guest $guest): bool
{
foreach ($this->notEmptyFields as $field) {
$getter = 'get' . ucfirst($field);
$ref = new \ReflectionClass($guest);
if (method_exists($guest, $getter)) {
$value = $guest->$getter();
} elseif ($ref->hasProperty($field) && $ref->getProperty($field)->isPublic()) {
$value = $guest->$field;
} else {
throw new \LogicException();
}

if (empty($value)) {
return false;
}
}

return true;
}

//getters and setters
}


Знание о том, какие именно требования предъявляются к заполнению профиля гостя, оставим в LoyaltyCard:

class LoyaltyCard
{
public function checkGuestProfileFilled(Guest $guest): void
{
//...

$this->guestProfileFilled = self::getGuestProfileFilledSpecification()->isSatisfiedBy($guest);

//...
}

public static function getGuestProfileFilledSpecification(): GuestWithFilledProfileSpecification
{
return new GuestWithFilledProfileSpecification(['phone', 'firstname', 'lastname']);
}
}


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

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

trait GuestProfileFilledSpecificationTrait
{
protected readonly EntityManagerInterface $em;

private function guestProfileFilledCondition(string $alias, GuestWithFilledProfileSpecification $spec): string
{
$meta = $this->em->getClassMetadata(Guest::class);
$conditions = [];
foreach ($spec->getNotEmptyFields() as $field) {
if (!$meta->hasField($field)) {
throw new \LogicException(sprintf("Field %s not found in %s entity mapping", $field, Guest::class));
}

$column = $meta->getColumnName($field);
$conditions[] = sprintf(
"TRIM(%s.%s) <> '' AND %s.%s IS NOT NULL",
$alias,
$column,
$alias,
$column
);
}

return '(' . implode(' AND ', $conditions) . ')';
}
}


Обратите внимание, что трейт обращается к доктриновскому мапингу, чтобы получить знание о том, на какие столбцы БД мапятся поля класса Guest. Это позволит нам просто добавлять новые поля в методе LoyaltyCard::getGuestProfileFilledSpecification(), не думая о том, как отработает SQL запрос. Он отработает как надо. 💪

Теперь мы можем использовать этот трейт в нужных нам репозиториях для построения запросов, которые требуют выборки гостей, удовлетворяющих требованиям для выдачи карты. Если требования изменятся, мы просто изменим метод LoyaltyCard::getGuestProfileFilledSpecification(), а весь остальной код останется без изменений.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥6👍32🤔1
К чему приводит замалчивание ошибок

На ревью у коллег часто вижу решения вот такого плана:

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use src\Common\Domain\Model\Discount\DiscountType as DiscountTypeEnum;

class DiscountKindType extends Type
{
public function convertToPHPValue($value, AbstractPlatform $platform): ?string
{
/** @var int|null $value */
if (is_null($value)) {
return null;
}

try {
return DiscountTypeEnum::codeToValue($value);
} catch (\Throwable) {
return null;
}
}
}


Делать так ни в коем случае нельзя сразу по многим причинам. Давайте сначала разберемся, что здесь происходит:

⚙️ Из БД извлечено значение, которое Doctrine должна поместить в поле PHP объекта, вызвав метод convertToPHPValue() кастомного типа DiscountKindType.

⚙️ В БД данные хранятся в виде числовых констант (легаси), в коде - в виде строковых констант.

⚙️ Если из БД пришла константа, про которую наш код не знает, к какой строке ее привести, из DiscountTypeEnum::codeToValue() вылетит Exception.

⚙️ Этот Exception в нашем коде замалчивается и выполняется возврат null в качестве значения.

Теперь давайте посмотрим, к каким последствиям приводят такие решения:

☠️ Заглушенный эксцепшен не осядет в логах, мы никогда не узнаем, что он вообще был.

☠️ Тем самым факт присутствия в БД данных, которые наш код не может обработать, скрыт. То есть, у нас либо в базе лежат невалидные данные, либо код написан так, что одна часть приложения не знает, что пишет другая. А мы эту ситуацию скрываем.

☠️ Когда происходила запись в базу неизвестной нашему коду константы, в это действие вкладывался смысл "сохранение скидки с таким-то типом". Когда мы из базы достаем этот неизвестный коду тип и приводим его к null, мы пускаем по коду "гулять" сущность "скидки без типа". Ведь эта сущность после гидрации куда-то пойдет, с ней будут выполняться какие-то действия, и результат этих действий, скорее всего, снова будет сохранен в базу. И все это в полной тишине (т. е. без логирования).

Хотим ли мы, чтобы наше приложение вело себя таким образом? Позже или раньше последствия таких решений накапливаются и становятся заметны. Только вот дебажить их и исправлять последствия (т. е., исправлять данные) в разы дольше и неприятнее, чем если бы наше приложение сразу упало с ошибкой.
👍6🔥21🙏1
Хочу понять, что вам сейчас интереснее всего читать. Канал потихоньку растёт, варианты тем есть разные, поделитесь, пожалуйста, своими предпочтениями. Можно выбрать несколько вариантов.

На какие темы вам было бы интереснее всего читать посты?
Final Results
84%
Архитектура, DDD, паттерны, ООП.
33%
Фреймворки (повседневная практика): Как пользоваться компонентами, ходовые кейсы.
33%
Фреймворки (внутрянка): резолверы, компайлер-пассы, тонкая настройка, как и зачем этим пользоваться.
45%
Общий PHP: эксцепшены (как правильно), логирование, итераторы-генераторы (зачем и где) и т. п.
50%
Кейсы: как решается та или иная бизнес-задача.
18%
Карьера (Middle): собеседования, роадмапы, советы.
22%
Карьера (Senior): аналогично предыдущему.
25%
Работа в команде: ревью, согласование контрактов, командные соглашения и т. п.
15%
Взаимодействие с менеджментом и лидами: кейсы из опыта, советы.
18%
Самоорганизация на удаленке: тайм-менеджмент, work-life balance, как работать эффективнее.
Не нужно бояться исключений

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

Многие разработчики напрасно воспринимают исключения как что-то негативное.


К оператору throw следует относиться также, как и к оператору return. Бросая исключение, вы сообщаете клиентскому коду (не пользователю ни в коем случае), что выполнение операции невозможно.

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

throw new \RuntimeException('EntityNotFound');


и

class EntityNotFoundException extends \Exception
{
public function __construct(
string $entityClass,
string $criteriaValue,
string $criteriaName = 'id',
?\Throwable $previous = null
) {
$parts = explode('\\', $entityClass);
$message = sprintf('%s with %s %s not found', $parts[array_key_last($parts)], $criteriaName, $criteriaValue);
parent::__construct($message, 0, $previous);
}
}

public function getByUserId($userId): Profile
{
throw new EntityNotFoundException(Profile::class, $userId, 'userId');
}


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

Таким образом, когда вы пишите код, ваша обязанность - при невозможности продолжать выполнение сообщить об этом в клиентский код и точно указать причину. И правильно делать это через throw, а не через return null или return false.
👍12🔥3
О строгости кода

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

class LoyaltyCard
{
public function setLevel(LoyaltyLevel $level): void
{
if (!$this->isGuestBound()) {
throw new \DomainException('Невозможно установить уровень карты при отсутствии привязанного гостя');
}

if ($level->getProgramId() !== $this->programId) {
throw new \DomainException('Невозможно установить уровень карты от другой программы лояльности');
}

if (!$this->guestProfileFilled) {
throw new \DomainException(
'Невозможно изменить уровень карты лояльности при незаполненном профиле гостя',
);
}

if (null !== $this->level && $this->level->getRequiredAmount() > $level->getRequiredAmount()) {
throw new \DomainException('Уровень карты не может быть понижен');
}

if (null !== $this->level && $this->level->getId() === $level->getId()) {
throw new \DomainException('Карте лояльности уже присвоен этот уровень');
}

//...
}
}


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

На каждый такой вопрос я отвечаю отрицательно и поясняю, что:

🍀 Каждая конструкция throw - это выражение средствами языка PHP бизнес-требований, продиктованных заказчиком.

🍀 Демонтаж throw - это не только демонтаж бизнес-логики, но и ослабление строгости кода в угоду сиюминутной потребности.

🍀 Если метод не выполняется так, как вы ожидаете, и плюется исключениями, вы должны решать эту проблему в клиентском коде (т. е. в том, который вы пишите).

Давайте разберемся чуть подробнее. Класс \DomainException является прямым наследником \LogicException, в описании которого сказано:

This kind of exception should lead directly to a fix in your code.


То есть:

⚙️ Если вы написали код, который приводит к выбросу \LogicException и его наследников, вы исправляете свой код (а не тот, который бросает exception).

⚙️ Тот, кто написал класс или метод, который вы используете, не предполагает его использование тем способом, который вы выбрали.

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

В следующем посте расскажу о том, почему строгий код лучше, чем нестрогий.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥9👍42
Почему строгий код лучше, чем нестрогий

Строгий код дает два значимых преимущества: надежность и разделение ответственности.

Взгляните на пример кода в предыдущем посте ⬆️. Метод LoyaltyCard::setLevel() является самым глубоким и низкоуровневым с точки зрения слоев приложения. Все, что вы будете писать про изменение уровня карты лояльности, вы будете писать, опираясь на этот метод.

При этом, кейсы, которые вам придется реализовывать, будут самыми разными: ответ на запрос из UI, консольные команды, вызываемые по крону, и т. д.

Как разработчика класса LoyaltyCard, меня волнует, чтобы бизнес-правила, продиктованные заказчиком, выполнялись в любой ситуации, и чтобы в базу данных ни при каких обстоятельствах не попали неконсистентные данные. И меня не волнует, что в пользовательском интерфейсе кнопка "изменить уровень" может быть нажата 10 раз: это ответственность другого слоя.

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


Таким образом, в доменном методе setLevel() я сосредоточил ответственность за бизнес-логику и за надежность данных. А более высоким слоям я оставил ответственность реализовывать частные кейсы использования своего метода.

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

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

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

Строгость - это про то, чтобы exception вылетел как можно раньше и отражал суть проблемы как можно четче.


Избегая строгости, вы не избегаете проблем. Вы их откладываете, смещая точку их возникновения. Проблема в том, что при таком смещении проблемы имеют свойство множиться и становиться неявными, затрудняя дебаг.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍62🔥2
Чем сложнее логика, тем выше требования к cohesion

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

Сейчас, глядя на получившийся код как на завершенную систему, могу сказать, что спроектировал и реализовал бы этот функционал немного по-другому. Причина тому - непривычно большой объем мелконюансированной логики.

🪶 Пример

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

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

На самом деле, у меня была выделена пара стратегий:

abstract class AccumulationStrategy
{
abstract public function calculate(Order $order): int
}


abstract class InitialCardIssuing
{
abstract public function createCardSpecifications(): InitialCardIssuingSpecificationSet;
}



AccumulationStrategy агрегирует в себе всю логику начислений по программе лояльности, а InitialCardIssuing - логику определения, кому из клиентов следует выдать карту сразу при запуске программы лояльности (здесь я интуитивно "попал в боль" большого количества мелких правил).

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

⚙️ Решение проблемы

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

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

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

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

А вот с доработками-то как раз и возникали проблемы. Несмотря на то, что все правила были сосредоточены внутри доменного агрегата, они были написаны в разных местах этого агрегата и влияли друг на друга. В результате этого в классах LoyaltyProgram и LoyaltyCard возникло много мест, где определялась дата начала накоплений или, например, стартовый уровень лояльности карты.

🤨 Следствием этого стали такие проблемы

💀 Усложнился дебаг.

💀 Код классов в целом читался легко, но при попытке сосредоточиться именно на логике определения даты старта накоплений приходилось прыгать по разным методам. А это вызывало сильные затруднения в понимании логики.

💀 Отсутствовала возможность охватить "единым взглядом" логику именно вычисления даты старта накоплений.

💀 Тестировщики нашли кейсы, когда правило срабатывало дважды. Например, для карты, у которой дата должна была сдвигаться на один год, она сдвигалась на два.

Вывод

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

Такой подход повысит cohesion до предела и очень сильно упростит вам жизнь.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍32
Media is too big
VIEW IN TELEGRAM
Поговорили о методологии разработки, архитектуре приложения и том, каково здесь место фреймворка.
🔥7
Идемпотентность: где уместно, а где - нет

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

Первое, что нужно помнить: идемпотентность - это свойство операции. Это важно, потому что, когда мы проектируем систему или смотрим на код, мы можем смотреть через разные призмы.

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

🍀 Доменными сущностями с бизнес-логикой,
🍀 Сервисами с технической логикой,
🍀 Application-командами (каждая команда, по сути, является операцией),
🍀 Репозиториями,
🍀 API эндпоинты, которые мы предоставляем и вызываем - это тоже операции.

Список, разумеется, далеко не полный.

Если операции - один из основных способов думать о системе и коде, то идемпотентность - один из способов мыслить об операциях.


🗒 Идемпотентная операция дает один и тот же результат (не меняет состояние системы) при повторных вызовах с теми же аргументами.

Давайте рассмотрим три варианта одной и той же операции.

public function giveBonus(Order $order, Account $account): void
{
$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}

public function giveBonus(Order $order, Account $account): void
{
if ($account->orderRewarded($order)) {
throw new \DomainException('Бонусы за этот заказ начислены');
}

$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}

public function giveBonus(Order $order, Account $account): void
{
if ($account->orderRewarded($order)) {
return;
}

$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}


Идемпотентным является только последний пример. При повторных вызовах с одинаковыми order и $account состояние системы не изменится. Бонусы начислятся только однажды.

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

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

Так вот, правило, которого придерживаюсь лично я, звучит так:

Чем на более глубоком слое предоставляется операция, тем менее желательна идемпотентность.


А именно:

🍀 В доменном слое - никогда.
🍀 В Application слое - крайне редко и строго осознанно.
🍀 В контроллерах - сколько угодно по потребности, но тоже осознанно.

Доменный слой - не место для замалчивания ошибок. Я недавно написал целую серию постов о том, почему важна строгость кода (первый пост).

В Application слое каждая команда является по сути отдельной операцией. И вот здесь часто кроется соблазн сделать такую операцию идемпотентной в угоду сиюминутной потребности.

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

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

Например, команду RewardForOrder вы вызываете по нажатию кнопки в интерфейсе. А команду RewardForOrderIdempotent вы вызываете при массовой автоматической раздаче бонусов по крону. Очевидно, что в первом случае вы хотите заявить об ошибке, а во втором - промолчать.

Таким образом, идемпотентность должна использоваться вами осознанно и только для того, чтобы сделать работу системы предсказуемой и безопасной, и никогда для того, чтобы сделать разработку удобнее и быстрее.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍10🔥51
Оптимизация агрегатов без ленивой загрузки

Мы привыкли к тому, что содержимое коллекций вложенных объектов одинаково в БД и памяти PHP процесса. Например:

class User
{
/** @var Tag[] */
private array $tags;
}


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

Максимальное отклонение от этой ситуации, которое мы обычно готовы принять, это ленивая загрузка. При ленивой загрузке поле $tags держит специальный объект коллекции, который в памяти PHP хранит только id вложенных объектов. Все поля конкретных объектов Tag загружаются из базы по потребности.

Но ленивая загрузка не ломает того факта, что состав объектов Tag, связанных с User, одинаков как в памяти PHP, так и в БД.

💡 Однако, при проектировании доменных агрегатов бывает полезным перешагнуть через этот шаблон.

Давайте посмотрим пару примеров.

class LoyaltyCard
{
private ChangeLog $changeLog;

public function accrue(int $amount): void
{
//...
$this->changeLog->accrue();
}
}

class ChangeLog
{
/** @var Entry[] */
private array $entries = [];

public function accrue(): void
{
$this->entries[] = new Entry();
}
}


Согласно DDD, чендж лог карты является неотъемлемой ее частью. Код написан так, чтобы было невозможно совершить действия с картой, минуя запись в историю. LoyaltyCard является доменным агрегатом, а значит при сохранении его в базу, сохраняются и все вложенные в него объекты Entry.

Аналогично, при загрузке LoyaltyCard из базы мы, по идее, должны выгрузить в память все связанные Entry.

Но вот вопрос: зачем?

Если на чендж лог карты не опирается никакая логика, то зачем нам таскать в памяти всю коллекцию Entry? В данном случае можно вообще не загружать Entry из базы.

🍀 Загруженная из БД карта всегда будет с пустым логом, но по бизнес-логике нам это неважно.

🍀 Все новые события, произошедшие в рамках PHP процесса, сохраняются в базу вместе с картой.

🍀 Лог в базе растет инкрементно.

🍀 Лог карты в памяти хранит только последние события и только с целью сохранения их в БД.

🍀 Это не мешает, например, выводить историю карты в интерфейсе с навигацией и поиском.

🧠 Другой пример.

class BonusAccount
{
/** @var BonusGrant[] */
private array $grants = [];

public function accrue(int $amount, ?\DateInterval $burnPeriod): void
{
$this->grants[] = new BonusGrant($amount, $burnPeriod);
}

public function writeOff(int $amount): void
{
//Идем циклом по грантам и списываем с них, пока не наберется $amount
}
}


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

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

🍀 БД будет содержать всю историю грантов с первоначальными суммами и датами сгорания.

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

🍀 Память содержит только ненулевые гранты, которые можно потратить. Этого достаточно для методов getBalance() и writeOff($amount).

Такое мышление частично заимствовано из архитектурного подхода Event Sourcing, где каждая сущность представлена не набором полей, а набором событий. Сам Event Sourcing в чистом виде довольно экзотичен. Но иногда экзотичные подходы помогают сломать шаблоны для достижения практических результатов.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍2😴2🗿1
Отключили с утра интернет. Смотрю свои задачи в работе по найму: вся бизнес-логика описана, на ближайшие дни одни Application команды да контроллеры предстоит штамповать. Думаю, не буду это без AI вручную делать: долго и нудно. Неэффективно.

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

Решил, что нет смысла без доступа к AI заниматься разработкой и ушел писать статью. Мир изменился.
💯4🔥3😢2🤔1🙉1
Архитектурный подход - это как думать, а не как делать

На днях у меня состоялась переписка с одним из студентов моего курса по Symfony. Она показалась мне интересной, и я решил опубликовать ее фрагмент.

Есть библиотека webmozart/assert. Видел примеры ее использования для валидации Value Object (например) при их создании (чтобы объект мог находиться только в валидном состоянии). Эту библиотеку допустимо использовать на слое домена или нужно писать свой аналог? В случае толстой модели объект должен сам обеспечивать свое валидное состояние. это предполагает наличие каких-то типовых проверок. Мы их пишем сами или используем готовые библиотеки. Разве нельзя использовать такие проверки внутри объекта доменной области?


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

А ассерты вы используете для валидации пользовательского ввода.

Посмотрите на вот эти два правила:

1️⃣ Номер дома в адресе не может быть отрицательным.
2️⃣ Пользователь не может иметь больше одного адреса проживания.

Задайте себе такие вопросы:

1️⃣ Нарушение какого из этих двух правил может приводить к непредсказуемому поведению приложения?

2️⃣ Какое из этих двух правил будет удобно описать через ассерты, а какое - нет.

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

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

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

Первое вы легко опишите ассертом Positive, под второе вам пришлось бы писать кастомный валидатор (а зачем?)

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

Это дает вам структуру, порядок и понятный путь. Это НЕ дает вам истины в последней инстанции или единственно верного решения.

Можно еще так на это посмотреть.

Правильно: "Я использую стейт-машину на проекте X способом Y для решения задачи Z. Это решит мне проблемы 1, 2 и 3, но породит проблемы 4, 5 и 6. Я проанализировал предполагаемый вектор развития проекта, поговорив с бизнес-заказчиком и счел такое решение оправданным, потому что А и Б".

Неправильно: "Я супер-буквально понимаю принцип DRY и моей основной целью является недопущение появления двух внешне похожих кусков кода на проекте. Для этого я стремлюсь использовать вендорный код как можно чаще и больше".

Ну а вывод из этой переписки следует такой: любая связка архитектура + методология - это не более, чем набор оптики, через которую вы смотрите на свои объекты, их свойства и отношения между ними. Нет единственно верного взгляда. Есть накопленный опыт, который позволяет нам осознанно принимать решения, понимая цену и выгоду.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍101
Как думать о перформансе на небольших и средних проектах

Большая часть разговоров о перформансе на не-highload проектах базируется на мифах, а не реальных расчетах. Это касается как совсем абсурдных заявлений типа "рефлексия медленная", так и типичной боязни вытащить в память PHP процесса несколько тысяч объектов и что-то с ними поделать.

Давайте разберемся, всегда ли эти страхи оправданы.

🐢 Про медленную рефлексию

Нужно понимать, что рефлексия везде: в любом фреймворке, в Doctrine, в вендорном коде. Вы используете рефлексию гораздо больше, чем вам может показаться, каждый день. И если разработчик не хочет применить в своем коде рефлексию однократно, потому что "она медленная", то ему стоит в первую очередь отказаться от фреймворков.

Я много раз слышал про медленную рефлексию, но ни разу о том - кто и как это замерил.

🤪 Про избыточную оптимизацию

Иногда разработчики пытаются усложнить свое решение, объясняя это "перформансом". Часто речь идет об отказе использовать перебор тысяч объектов в цикле и намерении сгородить вместо этого 8-этажный SQL с получением сырых данных в 8-этажный же массив и последующие пляски с этим по всей кодовой базе.

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

А как же с реальными замедлениями на небольших и средних проектах?

Да, на не-highload проектах тоже случаются слишком медленные HTTP запросы или очень долгие cron-задачи. Это не удивительно. Удивительно то, что, сталкиваясь с подобным, разработчики почти всегда начинают рассуждать о логе медленных запросов SQL сервера или, еще круче, "переходе на golang". 🌈

На самом деле, частая причина таких затыков - что-нибудь вроде ActiveRecord::findOne, выполняющегося десятки тысяч раз внутри цикла. И, как правило, в подавляющем большинстве случаев достаточно найти медленный код профайлером xdebug и убрать цикл, заменив дополнительно при необходимости тяжелые ActiveRecord объекты простыми массивами.

Так нужно ли заморачиваться перформансом на не-highload проектах?

По дефолту - никогда. По факту возникновения проблемы - сначала смотрим код профайлером xdebug, а потом умничаем про golang.

😱 "А вдруг у нас будет highload?"

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

Гуглим "сколько людей берут микрозаймы" и видим грубо 15 млн. чел. Предположим, что наш проект "захватит мир" и каждый второй в России начнет брать займ именно у нас. Получаем 7-8 миллионов юзеров. Отсюда можно увидеть максимальные объемы таблиц в БД: пользователи, займы, транзакции. И понять, что никаких больших объемов и больших нагрузок на проекте не будет никогда: в нашем проекте юзер ходит на сайт нечасто и не генерит много запросов.

А значит, все разговоры о нагрузках для этого проекта лишены оснований. Если в процессе разработки возникнет какой-то медленный кусок, с ним нужно будет разбираться отдельно.

Конечно, не нужно впадать и в обратную крайность и быть расточительным без причины. Если вам нужно обработать два поля для десятков тысяч записей, и написание нативного SQL вам ничего не стоит, то напишите его и работайте с массивом. Здесь, как и везде: требуется адекватный взгляд на задачу и понимание дальнейшего развития проекта, а не догматизм.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍9💯4🔥3
Лог, метрика или профайлер?

Почти любую проблему в проде пытаются решать одним из трёх инструментов:

⚙️ логирование,
⚙️ профилирование,
⚙️ сбор метрик.

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

📝 Логирование

☘️ Как правило, собирает информацию о событиях.

☘️ Логи хранятся в файлах, их агрегация ресурсоемка (как, впрочем и результатов профилирования).

☘️ Хорошо подходит для ретроспективного расследования инцидентов.

☘️ Изначально не предназначены для агрегации таких метрик, как время выполнения запроса или подсчет количества запросов.

Любое приложение генерирует огромное количество логов. Другое дело, насколько они полезны? Самое частое их использование - это выяснение причин ошибки на проде, ведь дебажить прод дебаггером не получится. В остальном, в большом количестве проектов основная масса логов никогда не анализируется.

🔬 Профилирование

☘️ Позволяет построить дерево вызовов с указанием ресурсоемкости каждого элемента дерева.

☘️ Возможна агрегация узлов дерева и показателей затрат ресурсов.

☘️ Некоторые профайлеры позволяют агрегировать статистику.

☘️ Позволяет исследовать только отдельные ветки выполнения программы.

☘️ Замедляет работу приложения, что затрудняет применение на проде.

☘️ Профайлер пишет в файл или в реляционную БД, объем сохраняемых данных может быть огромным.

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

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

Если логи используются повсеместно, то профилирование, к моему огромному сожалению, не используется почти никем. А зря. Регулярное использование профайлера для наблюдения за тем, как строится дерево вызовов в вашем приложении, меняет ваше мышление, как разработчика.

📊 Сбор метрик

☘️ Метрики приложения – это измерители, представляющие уже сжатую информацию.

☘️ Метрики не дают возможности «провалиться в детали».

☘️ Хорошо подходят для обнаружения изменений и сдвигов в работе приложения.

☘️ Подходят для замера бизнесовых показателей (количество заказов, средний чек, конверсия).

☘️ Хорошо сочетаются с алертами.

☘️ Занимают мало места в хранилище приложения.

Основное отличие метрик состоит в том, что они сохраняются в базе данных временных рядов. Тот, кто работал на мало-мальски крупных проектах, наверняка сталкивался с Prometheus. Для тех, кто не сталкивался, опишу очень коротко и упрощенно, что такое метрика в базе данных временных рядов:

⚙️ Ваше приложение держит счетчик. Он может быть в памяти, key-value хранилище или в РБД.

⚙️ При наступлении определенного события (обращение к роуту, определенный вид эксцепшена, создание заказа клиентом) счетчик увеличивается.

⚙️ Раз в период, называемый scrape interval, Prometheus обращается к вашему приложению, чтобы получить значение счетчика.

⚙️ Значение сохраняется в базе данных временных рядов с текущей меткой времени.

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

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

☘️ логи - для расследования ("что произошло?");

☘️ метрики - для наблюдения ("насколько массово?", "где отклонения?");

☘️ профайлер - для поиска узких мест ("почему это медленно?") и трассировки.
👍8🔥3
Хочу поделиться новостями о жизни и планах канала

1️⃣ На платформе LevelUp Club появился каталог постов этого канала.

Давно хотел систематизировать материалы по темам: архитектура, паттерны, PHP, Фреймворки и т. д. Теперь можно быстро посмотреть список всех публикаций и найти посты по интересующему направлению.

2️⃣ В ближайшее время выйдут новые лонгриды на Хабре.

Планирую серию материалов про:

🍀 Symfony Messenger в слоистой архитектуре и DDD.
🍀 ID и первичные ключи (казалось бы все просто, но нет).
🍀 Работу с таймзонами в коде и БД (источник многих болей).
🍀 О «медленной рефлексии» с реальными замерами (которые по замыслу мы сделаем вместе с участниками LevelUp Club).

3️⃣ Помимо постов и статей постепенно добавляется ещё один формат: видео-разборы с примерами кода реальных проектов и практикой.

В целом стараюсь собрать всё в более системную структуру, чтобы материалы не терялись в ленте и не существовали отдельно друг от друга.

Во втором посте расскажу подробнее про видео-формат и как он теперь устроен. ⬇️
Please open Telegram to view this post
VIEW IN TELEGRAM
👍10
Видео-формат, о котором я писал выше, уже некоторое время существует в закрытом виде и называется LevelUp Club. 😌

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

Сейчас это отдельная платформа с двумя форматами участия:

🏛 Библиотека - лёгкий формат. Регулярные занятия и разборы, которые можно смотреть в своём темпе.

⚒️ Кузница - более интенсивная работа с практикой.

Есть игровые механики, опыт, игровая валюта, но это скорее способ поддерживать ритм, а не “геймификация ради геймификации”.

Занятия платные, но после регистрации можно бесплатно открыть два занятия Библиотеки и посмотреть, подходит ли вам это.

Сегодня стартует голосование за тему ближайшего занятия, а само занятие выйдет на выходных.

Если интересно поучаствовать, можно зайти посмотреть, как всё устроено:

https://level-up-club.ru/
Please open Telegram to view this post
VIEW IN TELEGRAM
👍2👎1🔥1