Практика использования паттерна "Состояние" (State)

Практика использования паттерна "Состояние" (State)

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

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

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

Но когда встаёт вопрос выноса бизнес-логики в отдельные классы, то что в этом случае делать с состоянием моделей?

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

В этом нам поможет паттерн "Состояние", который обладает очень сильной функциональностью. Давайте посмотрим на пример со сделками, у которой есть три стадии - "На согласовании", "Рассмотрение КП", "Закрыта удачно".

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

 

class Potential extends Model
{
public function getNextContactDate(): ?Carbon
{
if ($this->stage->value == PotentialStage::NEGOTIATION) {
return $this->last_contact_date->addDays(3);
// Здесь возможна дополнительная логика вычисления даты
}
if ($this->stage->value == PotentialStage::PROPOSAL) {
return $this->last_contact_date->addDays(5);
}
return null;
}
}

 

Заметьте, что здесь мы используем класс ENUM для хранения данных по состояниям объекта. Т.е. как один из самых простых вариантов, мы можем перенести функцию по вычислению даты следующего контактв в этот класс:

 

<?php

class PotentialStage extends Enum
{
public const NEGOTIATION = 'negotiation';

public const PROPOSAL = 'proposal';

public function getNextContactDate(Carbon $last_contact_date): ?Carbon
{
if ($this->value == self::NEGOTIATION) {
return $last_contact_date->addDays(3);
}
if ($this->value == self::PROPOSAL) {
return $last_contact_date->addDays(5);
}
return null;
}
}

 

И далее модель у нас будет выглядеть следующим образом:

 

class Potential extends Model
{
public function getNextContactDate(): ?Carbon
{
return $this->state->getNextContactDate($this->last_contact_date);
}
}

 

Т.е. самый простой подход выглядит следующим образом - мы проверяем по условиям, соответствует ли каждая из проверямых нами стадий текущей и при совпадении мы производим необходимые нам вычисления. Это очень большой блок с if/else или switch/case. В итоге читаемость падает, код превращается в большую нечитаемую функцию.

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

 

abstract class PotentialStage
{
protected Potential $potential;

public function __construct(Potential $potential)
{
$this->potential = $potential;
}

abstract public function getNextContactDate(): ?Carbon
}

 

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

 

class NegotiationPotentialState extends PotentialStage
{
public function getNextContactDate(): ?Carbon
{
return $this->potential->last_contact_date->addDays(3);
}
}

class ProposalPotentialStage extends PotentialStage
{
public function getNextContactDate(): ?Carbon
{
return $this->potential->last_contact_date->addDays(5);
}
}

class ClosedPotentialStage extends PotentialStage
{
public function getNextContactDate(): ?Carbon
{
return null;
}
}

 

Первое на что стоит обратить внимание, это классы очень удобно тестировать. Достаточно посмотреть на этот пример:

 

class PotentialStateTest extends TestCase
{
public function test_null_last_contact_date_in_closed_stage(): void
{
$potential = Potential::factory()->createOne(['stage' => 'Closed']);
$state = new ClosedPotentialStage($potential);
$this->assertNull($state->getNextContactDate());
}
}

 

Во-вторых, следует отметить, что пример со следующей датой контакта - это наиболее простой способ объяснения сути паттерна. Вы можете создать функции и методы любой сложности и включить в них любую бизнес-логику. Давайте рассмотрим такой пример: нужно ли к сделке прикрепить дополнительные контакты? Количество контактов и их типы зависит от стадии сделки, её суммы, вида. Предположим, что если сделка находится в стадии "Предложение", то количество привязанных контактов должно быть более двух. На стадии "Рассмотрение КП" - более трёх, но если источник сделки "Тендер", то более пяти. На стадии "Закрыта", у сделки должен быть контакт с должностью "Директор". Всю описанную выше логику можно изолировать в этих классах, описывающих состояние сделки.

Заметим, что в конструктор мы передаём саму сделку со связаннми контактами, теперь нам остаётся написать новый метод - достаточно ли контактов в сделке:

 

abstract class PotentialState
{
public function __construct(protected Potential $potential)
{}

abstract public function isNeedMoreContacts(): bool;
}

 

И в качестве следующего шага нам понадобится написать логику для метода isNeedMoreContacts в каждой стадии:

 

class NegotiationPotentialState extends PotentialStage
{
public function isNeedMoreContacts(): bool
{
return $this->potential->contacts->count() < 3;
}
}

class ProposalPotentialState extends PotentialStage
{
public function isNeedMoreContacts(): bool
{
if($this->potential->type == 'Tender') {
return $this->potential->contacts->count() < 4;
}
return $this->potential->contacts->count() < 5;
}
}

class ClosedPotentialState extends PotentialStage
{
public function isNeedMoreContacts(): bool
{
return $this->potential->contacts->firstWhere('position', 'Director') == null;
}
}

 

И снова, мы можем написать простейшие Unit-тесты для каждого состояния и наша модель сделки примет следующий вид:

 

class Potential extends Model
{
public function getStageAttribute(): PotentialState
{
return new $this->stage_class($this);
}

public function isNeedMoreContacts(): bool
{
return $this->state->isNeedMoreContacts();
}
}

 

В базе данных мы можем хранить конкретную имплементацию класса состояния в поле state_class и всё готово.

 

Паттерн перехода состояний

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

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

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

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

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

Один из способов реализации данного паттерна следующий (рассмотрим на примере перевода из статуса "Согласовано" в "Закрыта удачно"):

 

class ApprovalToCloseTransition
{
public function __invoke(Potential $potential): Potential
{
if(!$potential->isNeedMoreContacts()) {
throw new InvalidTransitionException(self::class, $potential);
}

$potential->stage_class = ClosedPotentialState::class;
$potential->save();

info($potential, 'Closed successfully');
}
}

 

И снова, используя данный паттерн, вы можете делать множество вещей:

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

Таким образом, использование паттерна "Состояние" и "Переход состояния" делает код чище, сводит к нулю использование if/else. На мой взгляд, этот паттерн очень полезен в использовании и в тестировании.