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

Не стесняйтесь задавать вопросы в комментариях к постам, или в личку: @slayervc (UTC +8).
Download Telegram
Почему я не люблю DRY

Отвечаю на вопрос подписчика к посту о CQRS "а как же DRY?"

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

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


Принципы, паттерны, архитектурные подходы – это больше про мышление, чем про дотошное исполнение в любой ситуации.

🔎 Давайте сначала немножечко о DRY

Этот принцип сформулирован предельно универсально, а значит может быть натянут на любую ситуацию.

Везде ли надо его натягивать?

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

Самый часто мне встречавшийся случай гипертрофированного применения DRY – это использование одного класса в качестве риквеста, сущности и DTO. При таком подходе связанность кода становится максимальной, как и число болей по сопровождению проекта.

Да, когда вы выделяете три класса: request, entity и DTO, значительная часть полей в этих классах может совпадать, и изменение entity потянет за собой изменение request и DTO.

Является ли это нарушением DRY?

Мое мнение: вопрос не имеет смысла.

Можно до бесконечности спорить на тему, а что в этом кейсе та самая единица знания, которая не должна повторяться.

Я скажу: request, entity и DTO – три разные единицы, они про разное. И буду прав. Вы скажете: request и DTO – производные от entity, и первоисточник знаний – entity. И будете правы. А потом придет третий чувак и спросит: а как же Single responsibility?

Качество кода в подходе с выделением request, entity и DTO значительно выше, чем при соединении всего в одном классе. И для меня это – ключевой критерий.

📌 Теперь про вопрос с legacy Order и его проекциями

Вопрос подписчика звучал так:

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


Отвечаю по порядку с учетом написанного выше.

🧠 Вы сами решаете, что для вас больнее: продолжать работать с огромным legacy классом или нарушить DRY. И это не ирония и не колкость. Вы принимаете решение, как инженер, с учетом условий вашего проекта.

В заскорузлом legacy идеальных решений нет. Только боль и компромиссы.


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

Дотошное следование DRY почти всегда ведет к переусложнению.


🧠 Надо понимать, что ваш код – это воплощение вашего образа мышления и вашего видения. «Изменять количество можно только пока заказ не оплачен». Это один инвариант, или два? А если завтра бузинес скажет вам: мы хотим иметь возможность сделать частичный возврат средств покупателю если при сборке оплаченного заказа товара на складе оказалось недостаточно. В этой ситуации вам будет удобнее с одним инвариантом или с двумя?

Мысль, которую я пытаюсь донести, следующая:

DRY слишком универсален, чтобы применять его дотошно к любой ситуации. Только вы решаете, где у вас нарушение, а где – нет.


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

#DRY
Please open Telegram to view this post
VIEW IN TELEGRAM
👍6🔥51
🤔 Мысль за обедом

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

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

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

Прошло 20 лет, и наш воображаемый дядя Вася ни то, что не помер, но, возможно, даже и на пенсию еще не вышел. Жизнь уже научила его, что некоторые мЕнеджеры за год зарабатывают больше, чем он за всю жизнь, но он все равно мыслит с привычной инерцией и верит, что обладание навыком загуглить – уже неплохое достижение в мире Интернета.

А вот теперь представьте, как к этому же дяде подходит внук и объявляет, что хочет стать… Оркестратором смыслов. Потому что в мире AI традиционные профессии просто вымрут. Как вы думаете, дядя Вася застрелится, или просто напьется?

А мысли все эти пошли у меня оттуда, что раньше нам в школе говорили: «мы не учим вас будущей профессии, мы учим вас учиться». Похоже, что современная школа моего ребенка не научит даже этому.
👍61
🔒 Набор завершен.

📯 Объявляется донабор в наш LevelUp Club

Двери открыты для первых пяти желающих.) В Клубе проводятся занятия в двух пространствах: Библиотеки и Кузницы.

📜 В Библиотеке мы работаем над созданием большого дерева навыков PHP-разработчика, по которому видно весь ландшафт: что нужно уметь, куда прокачиваться и как оценивать свои компетенции. Участники сами выбирают навык из дерева, а я провожу по этому навыку занятие.

⚒️ В Кузнице мы планируем совместную разработку платформы Клуба в обучающих целях. На занятиях я планирую делиться своим подходом к разработке.

Первое занятие в Библиотеке уже в это воскресенье.

Подробнее можно почитать в правилах Клуба.

Чтобы присоединиться, просто напишите мне в личку @slayervc со словами "хочу вступить в Клуб". 🙂

Участие бесплатное.)

#LevelUp
Please open Telegram to view this post
VIEW IN TELEGRAM
🕊4👍1👏1
Принципы построения хорошего репозитория

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

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

Это не коннект к базе, не запрос, не QueryBuilder и т. п.


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

🔁 Всегда следуйте принципу инвертирования зависимостей

Какой бы репозиторий или Storage вы не писали, всегда прокладывайте интерфейс между вашим репозиторием и клиентским кодом. Это позволит вам:

☘️ Спокойно писать бизнес-логику, откладывая детали выборки данных из базы на последний момент.

☘️ Выделить реализацию репы в отдельную задачу и отдать ее коллеге. 😄

☘️ В будущем легко добавлять новые реализации при переходе на другое хранилище (редко, но бывает), фреймворк (актуально в легаси проектах) или микросервисы (вместо базы репа дергает API).

🗃 Используйте классы коллекций вместо plural types

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

🚫 Не держите даже примитивную логику в репозитории

Вот пример нарушения этого принципа:

class UserRepository implements UserRepositoryInterface
{
public function deleteWhoFinishedCourse():void
{
//Удалить всех пользователей, кто привязан к курсу со статусом FINISHED

$userIds = $this->em->createQueryBuilder()->... //Делаем сложный запрос для выборки id
foreach ($userIds as $id) {
$this->em->createQueryBuilder->()... //Делаем запрос на удаление по id
}
}
}


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

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

✂️ Репозитории можно делить не только по сущностям, но и на Read и Write

Эта идея позаимствована из CQRS. Если ваш репозиторий слишком большой, или если вы явно следуете CQRS, то выделение UserReadRepository и UserWriteRepository будет вам полезно.

🪶 Переход на абстракции не обязывает вас отказываться от ORM и даже ActiveRecord

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

☘️ Сначала вы можете написать InMemoryRepository для тестов.

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

☘️ Позднее вы можете решить притащить Doctrine и написать DoctrineRepository, где спокойно можете использовать EntityManager.

☘️ И, наконец, столкнувшись с высокой нагрузкой вы можете захотеть написать PDORepository или DBALRepository с запросами на нативном SQL и ручной гидрацией.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥9👍3🤩21
Паттерн Стратегия. Пример применения.

Я уже писал как-то о превратном понимании Фабрики. Стратегия, наверное, второй паттерн по исковерканности понимания его разработчиками. Что только не объявляли при мне реализацией Стратегии: и обычный if-оператор и какие-то инфраструктурные моменты, типа возможности десериализовать в различные классы входящий JSON.

Так что же такое Стратегия?

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

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

Помимо логики расчета начислений, реализация ПЛ имеет и много другой логики. Возникает вопрос:

Как реализовать различные варианты расчета бонусов?


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

2️⃣ Второй вариант: использовать наследование. Мы могли бы сделать несколько классов ПЛ. Общую логику, не касающуюся расчета, можно поместить в базовый класс, а уникальную логику - в потомков. У нас могли бы быть такие классы:

☠️ ПЛ с рублями от всей стоимости заказа.

☠️ ПЛ с рублями только за основные услуги.

☠️ ПЛ с бонусами от всей стоимости заказа.

☠️ И так далее.

Чем больше комбинаций "валюта" <-> способ расчета, тем больше классов. А теперь представьте, что следующей задачей вам нужно добавить в ПЛ функционал массовой раздачи карт клиентам. Раздача карт может быть в зависимости от статуса клиента, или от того, сколько товаров он купил за последний год. То есть, похожая с начислением бонусов задача. Что делать? Плодить еще комбинации наследников?

3️⃣ Третий вариант: пойти по принципу "Предпочитайте композицию наследованию". Вынесем логику расчета бонусов и стартовой раздачи карт в отдельные классы. И вот тут-то и будем плодить наследников. А класс ПЛ будет в себя агрегировать конкретные реализации алгоритмов.

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

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

class LoyaltyProgram
{
private string $id;
//enum {KOPECKS, MILES, BONUSES}
private LoyaltyCurrency $currency;
private AccumulationStrategy $accumulationStrategy;
private InitialCardIssuing $initialCardIssuing;

//Начислить бонусы на карту
public function accrue(Order $order, LoyaltyCard $card): void
{
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
//...
}
}

abstract class AccumulationStrategy
{
public const ?LoyaltyCurrency CURRENCY = null;

public function __construct()
{
if (null === static::CURRENCY) {
throw new \LogicException();
}
}

/**
* @return int сумма начислений на карту ПЛ в валюте ПЛ
*/
abstract public function calculate(Order $order): int;
}

class BaseServicesKopecksAccumulationStrategy extends AccumulationStrategy
{
public const ?LoyaltyCurrency CURRENCY = LoyaltyCurrency::KOPECKS;

public function calculate(Order $order): int
{
//...
}
}

class MilesAccumulationStrategy extends AccumulationStrategy
{
//...
}
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥6👍4
Паттерн Стратегия. Продолжение.

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

class LoyaltyProgram
{
//...

public function changeAccumulationStrategy(AccumulationStrategy $strategy): void
{
$this->accumulationStrategy = $strategy;
$this->currency = $strategy::CURRENCY;
//...
}
}


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

Что дает нам такое решение:

🍀 Можно писать неограниченное количество стратегий, не меняя код LoyaltyProgram.

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

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

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

Вообще Стратегия - один из самых простых и понятных способов создать точку расширения в вашем коде, избегая кучи if-ов и запутывания логики.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍51
Давно хотел поделиться своим неоднозначным опытом применения паттерна Спецификация. Вообще, все, что касается сложных выборок данных, неоднозначно. 😄

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

https://habr.com/ru/articles/929848/
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4🔥3
Сколько лет с этим сталкиваюсь, а все не могу привыкнуть. Ты такой весь в задаче, напряжен и собран. И тут тебе шторм выделяет русское слово красным. Ты перечитываешь его трижды и не видишь ошибки.

Раздраженный тем, что уже выронил из головы контекст задачи и переключился на орфографию, ты наводишь мышь на слово: "ну давай, научи меня русскому языку!" И видишь вот это. В лесу родилась елочка. Даже музыка заиграла в голове.
😁10
Про оптимистичную конкуренцию на простом примере

Недавно у нас в команде разработчик решал такую задачу:

💳 Реализовать генератор номеров пластиковых карточек. Номер состоит из двух частей: id компании и порядковый номер карты внутри компании.

1️⃣ В качестве первого решения разработчик подключил в проект Redis. Мотивация такая: редис умеет делать атомарный инкремент с отдачей нового значения, что снимает проблему конкуренции за один и тот же номер карты. То есть, исключается ситуация, когда два PHP процесса попробуют сохранить в базу карту с одинаковым номером.

☠️ Услышав о необходимости поднять на проде Redis, техлид нервно подпрыгнул и в удовольствии разработчику отказал. Оно и понятно: добавлять в систему ради такой мелкой задачи новый компонент, который будет являться еще одной потенциальной точкой отказа, не согласится ни один человек, отвечающий за стабильность прода, будь то техлид, девопс или СТО.

2️⃣ Тогда разработчик реализовал второе решение: хранение последнего порядкового номера карты last_sequence_id в MySQL с накладыванием блокировок через SELECT FOR UPDATE. То есть, первый PHP процесс, создающий новую карту, берет из базы последний порядковый номер. И пока этот PHP процесс не завершил создание карты, сохранение ее в базу и не инкрементнул last_sequence_id, остальные процессы будут дожидаться снятия блокировки.

Лично у меня на этом моменте возникло два вопроса:

1. Насколько удачно такое решение, и
2. а нужны ли здесь вообще блокировки?

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

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

Давайте посмотрим: у каждой компании свой last_sequence_id. При генерации карты он инкрементится на 1. Карта создается либо при регистрации нового клиента, либо сотрудником компании вручную. Компании имеют тысячи клиентов. Итого: вероятность ситуации, что в один и тот же момент два PHP процесса попытаются одновременно создать карту, крайне мала. Это и есть пример оптимистичной конкуренции.

А дальше возникает вопрос: а что с этим делать? При оптимистичной конкуренции применяются семантические блокировки, но это больше для распределенных систем (микросервисы). Здесь не требуется и этого. Достаточно создать уникальный индекс в таблице пластиковых карт по полю number. Это гарантирует нас от сохранения двух карт с одинаковым номером.

Следующий вопрос: а что будет, если конфликт все-таки возникнет. Ответ прост: один из PHP процессов упадет с SQL ошибкой Integrity constraint violation. И здесь вы уже сами решаете, устраивает вас это, или нет. Если ваше приложение юзер-френдли, то на фронте пользователь увидит что-то вроде "Что-то пошло не так, попробуйте еще раз."

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

В общем, не в том сила, чтобы чуть что, тащить в проект редис, рабита и монгу, а в том, чтобы адекватно оценивать как риски, так и стоимость разрабатываемого решения. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
👍123🔥1
🐸 Дождь бывает не только из лягушек

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

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

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

Кто-то прыгал с тех самых стартовых вышек прямо через мою голову, кто-то с борта, кто-то чинно спускался по лестнице. Но их было реально МНОГО. В мгновение ока идиллическая картина тихого полупустого бассейна с приятными размеренно покачивающимися волнами, превратилась в бурлящий водопой из какого-нибудь "Мадагаскара" или "Ледникового периода". 🤪

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

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

Дальше по курсу шли жирные, обрюзгшие мужики. Опершись на борт и разделительный канат как на барную стойку, они, перекрывая дорожку почти полностью, громко обсуждали свои похождения по бабам. Причем, послушаешь их, так у каждого не только член 30 сантиметров, но и такой список отполированных девок, что Джейсон Стэйтем сдох бы от зависти. 💪

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

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

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

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

Ну а сокрушаться о том, что народ у нас что на парковке, что в очереди, что в бассейне вести себя не умеет, не буду, пост и так вышел язвительным. Зато жизненным. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
😁14🌚2🤬1💯1
Параметрирование через конструктор vs параметрирование через аргументы метода

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


class CreateLoyaltyCard
{
public function __construct(
private readonly GuestRepositoryInterface $guestRepository,
private readonly LoyaltyProgramRepositoryInterface $programRepository,
//...
) {
}

public function execute(CreateLoyaltyCardParams $params): void
{
$guest = $this->guestRepository->getById($params->guestId);
$lp = $this->programRepository->getByHotelId($guest->getHotelId());

//выдача карты гостю
}


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

Решение состоит в том, чтобы добавить параметр bool $strictLoyaltyProgramExists и через него управлять поведением команды. Я предложил добавить этот параметр в конструктор, а коллега - в аргумент метода execute(), в CreateLoyaltyCardParams.

Понятно, что работать будет и так и так, но давайте посмотрим, в чем разница, и почему это важно.

1️⃣ Первое отличие: аргументы конструктора (и они же поля класса в данном случае) - это состояние класса, влияющее на его поведение на всем сроке жизни. Аргумент метода - это входные параметры для конкретной операции, выполняемой разово.

2️⃣ Второе отличие: Аргумент метода execute() - это часть интерфейса класса, контракта, показывающего, какие есть операции в классе, и что они делают. Аргумент конструктора - это настройка на уровне самого класса.

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

Хотим ли мы для каждого гостя вместе с его id указывать, должно поведение команды быть строгим или мягким? Или нам удобнее будет один раз проинстанцировать команду, указать ей параметр строгости и дальше уже пользоваться сконфигурированной командой?

Другой пример: экземпляр CreateLoyaltyCardParams, передаваемый аргументом в метод execute(), как это почти всегда бывает с Application командами, будет создаваться из HTTP риквеста. Что-то вроде:

class CreateLoyaltyCardRequest
{
#[Assert\NotBlank]
public int $guestId;

public function toParams(): CreateLoyaltyCardParams
{
//...
}
}


Насколько уместно в таком случае будет смотреться флаг $strictLoyaltyProgramExists в CreateLoyaltyCardParams?

Конечно, для данного конкретного случая разница не столь принципиальна. Но мне этот случай показался хорошим примером для демонстрации того, как вообще можно мыслить о классах, которые мы пишем. И что вообще-то, когда мы пишем классы, нужно мыслить. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥72👍2🦄2
Границы агрегата в 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