Practice of using the "State" pattern

Practice of using the "State" pattern

The State pattern is one of the best way to add business logic to a model and define ways to manage its state without violating the principles of clean code.

The State pattern is one of the best way to add business logic to a model and define ways to manage its state without violating the principles of clean code.

In this article I will give an example of using the approach to managing the state of models in one of my projects for developing a CRM system on Laravel. As a result, you will find another way to separate business logic into separate classes. Because me, like many other developers, take an approach to keep controllers and models as lightweight and independent as possible.

But when the question of moving the business logic into separate classes arises, then what to do with the state of the models in this case?

If we take a CRM system as an example, then the key module here is "Transactions". A deal can have different statuses: "Proposal", "Negotiation", "Awaiting response", etc. Depending on the status of the deal, the model can behave in different ways - the rating, the predicted cost of the deal, the date of the next contact, etc. are considered. How can we associate the business logic with the model?

The "State" pattern, which has very strong functionality, will help us with this. Let's look at an example with deals, which has three stages - "On approval", "Negitiation", "Successfully closed".

If we take a way of implementation in which all logic is stored in models, our class will look like this:

 

class Potential extends Model
{
public function getNextContactDate(): ?Carbon
{
if ($this->stage->value == PotentialStage::NEGOTIATION) {
return $this->last_contact_date->addDays(3);
// Here logic for date calculation
}
if ($this->stage->value == PotentialStage::PROPOSAL) {
return $this->last_contact_date->addDays(5);
}
return null;
}
}

 

Note that here we are using the ENUM class to store data on the states of the object. Those. as one of the simplest options, we can transfer the function for calculating the date of the next contacts to this class:

 

<?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;
}
}

 

And then model will be following in our case:

 

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

 

Those. The simplest approach is as follows - we check by conditions whether each of the stages we are checking corresponds to the current one, and if they match, we make the calculations we need. This is a very large block with if/else or switch/case statement. As a result, readability drops, the code turns into a large unreadable function.

Let's try to abstract and move the logic into separate classes. First, let's create the PotentialStage class, which will describe all the functionality that each specific stage can provide. In our case, we will be calculating the date for now, but nothing prevents us from adding functionality to calculate rating, the projected amount of the transaction and so on.

 

abstract class PotentialStage
{
protected Potential $potential;

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

abstract public function getNextContactDate(): ?Carbon
}

 

Now let's create 3 classes, every class will be responsible for model state:

 

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;
}
}

 

First thing I want to note - these classes it very easy to test. Let's look at the example:

 

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());
}
}

 

Secondly, example with the following contact date is the simplest way to explain these pattern. You can create functions and methods of any complexity and include any business logic in them. Let's look at this example: do you need to attach additional contacts to the deal? The number of contacts and their types depends on the stage of the transaction, amount and type. Suppose that if the deal is in the "Negotiation" stage, then the number of linked contacts must be more than two. At the stage of "Proposal" - more than three, but if the source of the transaction is "Tender", then more than five. At the "Closed" stage, the transaction must have contact with the "Director" position. All the logic described above can be isolated in these classes describing the state of the transaction.

Note that we pass the deal itself with associated contacts to the constructor, now we just have to write a new method - are there enough contacts in the deal:

 

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

abstract public function isNeedMoreContacts(): bool;
}

 

And as the next step we will need to write logic for adding method isNeedMoreContacts for every stage:

 

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;
}
}

 

And again we can write simple unit tests for every state and our model will be very simple:

 

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

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

 

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

 

State transition pattern

Implementing the state pattern above is only half of the success. Now we need to manage the process of changing the stages of the transaction, and at the same time be sure that the stage changes sequentially. You must admit that it will be much calmer if we are confident that the deal after the "Negotiation" will not be transferred to the "Closed successfully" stage, bypassing the "Proposal", "Approved" stages and so on.

Remember, I wrote earlier about isolating business logic from models and controllers. Business logic should be isolated and only use the data it needs. We apply similar rules to state transitions.

We should avoid the side effects when we use states: things like changing the database, sending messages ... All that state management classes have to do is process and provide data. The classes responsible for the state transition do not provide anything. All they do is check if the model can change state from one state to another without breaking business logic.

Separating these two concepts into different classes gives us the same benefits that I described earlier: better testing procedures and reduced cognitive load. Also, this approach is consistent with the principle of single responsibility.

So, the state transition pattern in our example accepts a deal, changes its stage, when it is permissible, to the next one. In some cases, side effects can be tolerated - sending messages to the administrator or saving messages to logs.

One of the ways to implement this pattern is as follows (consider the example of a transfer from the "Agreed" to "Closed successfully" status):

 

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

 

Again, using this pattern, you can do a lot of things:

  • register all possible state transitions in the model,
  • automatically determine which state should be assigned to the model from the transmitted parameters,
  • directly transfer the model to the next stage, using the state transition class as a basis.

Thus, using the State and State transition pattern makes the code cleaner, minimize the useage of if/else statements. In my opinion, this pattern is very easy to use and test.