Commit 51aaa246 authored by Jan Kuchař's avatar Jan Kuchař

docs: add order state example

parent 69816bb0
......@@ -220,7 +220,7 @@ Now type-system knows that every enum value must have method `nextDay()` with re
This approach is very useful when one wants to implement anything state-machine related (see tests for more examples, they are simple and easy to read).
More use cases:
- order state (new, in progress, delivering, delivered) and relations between them
- order state: [show example](tests/Example/OrderState) - shows 5 styles of implementing this
- day of week
- tracking life-cycle
......
# Order state example
In order state example I would like to demonstrate that there are more then one solution of domain problem of order state which can transition into another states.
## 1. Class constants
[source code](refactoring-1.phpt)
There are public constants on class and you should figure out that you should put them into `canDoTransition()` method. There is nothing on type-level that helps you with that. Please note that all logic is in `OrderService`.
## 2. Dumb type-safe enum
[source code](refactoring-2.phpt)
This test shows usage of explicitly-declared dumb-enum.
I have explicitly declared type for `OrderState`. It is not possible anymore to pass non-sense values into `OrderService`. That is because `OrderState` enum provides no interface for creating non-sense values. So they simply cannot exists.
All logic has been kept in `OrderService`. We still need to handle cas when someone added new value to enum, which we do not count with. (the exception in default case).
## 3. Logic moved into enum
[source code](refactoring-3.phpt)
Here I have moved `OrderService::canDoTransition()` method into enum itself.
Nice thing is that we do not need anymore external service for asking `OrderState`-related questions.
Remaining problem is that there are still lot of ifs and we still need to handle case where someone adds new value into enum which we do not count with.
## 4. Separate instance for each value
[source code](refactoring-4.phpt)
When there is behaviour same for all values of enum, it can be safely placed on enum class. Behaviour can be parametrized by providing necessary information in enum-value constructor.
## 5. Separate class implementation for each value
[source code](refactoring-5.phpt)
Now, new domain requirement:
> I would like to remove person who has been assigned to work on order, when order changes state to cancelled or finished.
1. I have rewritten each value as separate class (as behaviour is different for different values)
2. I have implemented doTransition() on enum parent class as it is only proper way of changing enum value
3. I have added `onActivation(Order $order)` method, which is called whenever state transition occurs.
3. I have overridden `onActivation()` on enum values with desired behaviour.
\ No newline at end of file
<?php declare(strict_types=1);
/**
* This test shows implicit enum declaration.
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_1;
require __DIR__ . '/../../bootstrap.php';
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
class OrderService
{
public const STATE_RECEIVED = 'received';
public const STATE_PREPARING = 'preparing';
public const STATE_FINISHED = 'finished';
public const STATE_CANCELLED = 'cancelled';
public function canDoTransition(string $currentState, string $desiredState): bool
{
if ($currentState === $desiredState) {
return TRUE;
}
switch ($currentState) {
case self::STATE_RECEIVED:
return $desiredState === self::STATE_PREPARING || $desiredState === self::STATE_CANCELLED;
case self::STATE_PREPARING:
return $desiredState === self::STATE_FINISHED;
case self::STATE_FINISHED:
return FALSE;
case self::STATE_CANCELLED:
return FALSE;
default:
throw new \LogicException('Should not happen: Unknown state');
}
}
}
$orderService = new OrderService();
// Standard order flow:
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_PREPARING
)
);
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_PREPARING,
OrderService::STATE_FINISHED
)
);
// Cancellation order flow
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_CANCELLED
)
);
// Reflexivity test
Assert::true(
$orderService->canDoTransition(
OrderService::STATE_CANCELLED,
OrderService::STATE_CANCELLED
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_RECEIVED,
OrderService::STATE_FINISHED
)
);
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_PREPARING,
OrderService::STATE_CANCELLED
)
);
Assert::false(
$orderService->canDoTransition(
OrderService::STATE_FINISHED,
OrderService::STATE_CANCELLED
)
);
// check for invalid completely invalid arguments
Assert::exception(function () use ($orderService) {
$orderService->canDoTransition('invalid', 'non-existing');
}, \LogicException::class);
<?php declare(strict_types=1);
/**
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_2;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PREPARING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
use \Grifart\Enum\AutoInstances;
protected const
RECEIVED = 'received',
PREPARING = 'preparing',
FINISHED = 'finished',
CANCELLED = 'cancelled';
}
class OrderService
{
public function canDoTransition(OrderState $currentState, OrderState $desiredState): bool
{
if ($currentState === OrderState::RECEIVED()) {
return $desiredState === OrderState::PREPARING() || $desiredState === OrderState::CANCELLED();
}
if ($currentState === OrderState::PREPARING()) {
return $desiredState === OrderState::FINISHED();
}
if ($currentState === OrderState::FINISHED()) {
return FALSE;
}
if ($currentState === OrderState::CANCELLED()) {
return FALSE;
}
throw new \LogicException('Should not happen: Unknown state');
}
}
$orderService = new OrderService();
// Standard order flow:
Assert::true(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::PREPARING()
)
);
Assert::true(
$orderService->canDoTransition(
OrderState::PREPARING(),
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::CANCELLED()
)
);
// Reflexivity test
Assert::false(
$orderService->canDoTransition(
OrderState::CANCELLED(),
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
$orderService->canDoTransition(
OrderState::RECEIVED(),
OrderState::FINISHED()
)
);
Assert::false(
$orderService->canDoTransition(
OrderState::PREPARING(),
OrderState::CANCELLED()
)
);
Assert::false(
$orderService->canDoTransition(
OrderState::FINISHED(),
OrderState::CANCELLED()
)
);
<?php declare(strict_types=1);
/**
* Logic moved into enum
* @see README.md
*/
namespace Grifart\Enum\Example\__test_refactoring_3;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PREPARING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
use \Grifart\Enum\AutoInstances;
protected const
RECEIVED = 'received',
PREPARING = 'preparing',
FINISHED = 'finished',
CANCELLED = 'cancelled';
public function canDoTransition(OrderState $desiredState): bool
{
if ($this === self::RECEIVED()) {
return $desiredState === self::PREPARING() || $desiredState === self::CANCELLED();
}
if ($this === self::PREPARING()) {
return $desiredState === self::FINISHED();
}
if ($this === self::FINISHED()) {
return FALSE;
}
if ($this === self::CANCELLED()) {
return FALSE;
}
throw new \LogicException('Should not happen: Unknown state');
}
}
// Standard order flow:
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::PREPARING()
)
);
Assert::true(
OrderState::PREPARING()->canDoTransition(
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::CANCELLED()
)
);
// Non-reflexivity test
Assert::false(
OrderState::CANCELLED()->canDoTransition(
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
OrderState::RECEIVED()->canDoTransition(
OrderState::FINISHED()
)
);
Assert::false(
OrderState::PREPARING()->canDoTransition(
OrderState::CANCELLED()
)
);
Assert::false(
OrderState::FINISHED()->canDoTransition(
OrderState::CANCELLED()
)
);
<?php declare(strict_types=1);
/**
* Separate instance for each value
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_4;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PREPARING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
final class OrderState extends Enum
{
protected const
RECEIVED = 'received',
PREPARING = 'preparing',
FINISHED = 'finished',
CANCELLED = 'cancelled'; // domain logic: can be cancelled before preparation is started
/** @var string[] */
private $nextAllowedStates = [];
/**
* @param string[] $nextAllowedStates
*/
protected function __construct(array $nextAllowedStates)
{
$this->nextAllowedStates = $nextAllowedStates;
}
public function canDoTransition(OrderState $nextState): bool
{
return \in_array($nextState->getScalarValue(), $this->nextAllowedStates, TRUE);
}
/** @return self[] */
final protected static function provideInstances(): array
{
// please not that we cannot reference self::PREPARING()
// as it returns class instance, and this will call provideInstances()
// again and you will get infinite loop.
return [
self::RECEIVED => new self([self::PREPARING, self::CANCELLED]),
self::PREPARING => new self([self::FINISHED]),
self::FINISHED => new self([]),
self::CANCELLED => new self([]),
];
}
}
// Standard order flow:
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::PREPARING()
)
);
Assert::true(
OrderState::PREPARING()->canDoTransition(
OrderState::FINISHED()
)
);
// Cancellation order flow
Assert::true(
OrderState::RECEIVED()->canDoTransition(
OrderState::CANCELLED()
)
);
// Non-reflexivity test
Assert::false(
OrderState::CANCELLED()->canDoTransition(
OrderState::CANCELLED()
)
);
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::false(
OrderState::RECEIVED()->canDoTransition(
OrderState::FINISHED()
)
);
Assert::false(
OrderState::PREPARING()->canDoTransition(
OrderState::CANCELLED()
)
);
Assert::false(
OrderState::FINISHED()->canDoTransition(
OrderState::CANCELLED()
)
);
<?php declare(strict_types=1);
/**
* Separate class implementation for each value
* @see README.md
*/
namespace Grifart\Enum\Example\OrderState\__test_refactoring_5;
require __DIR__ . '/../../bootstrap.php';
use Grifart\Enum\Enum;
use Tester\Assert;
final class InvalidTransitionException extends \RuntimeException {}
class Order
{
/** @var string|null */
private $employee = 'employee';
/** @var OrderState */
private $state;
public function __construct() {
$this->state = OrderState::RECEIVED();
}
public function unassignEmployee(): void {
$this->employee = null;
}
public function getEmployee(): ?string {
return $this->employee;
}
/**
* @throws InvalidTransitionException
*/
public function changeState(OrderState $desiredState): void
{
$this->state =
$this->state->doTransition($this, $desiredState);
}
}
/**
* @method static OrderState RECEIVED()
* @method static OrderState PREPARING()
* @method static OrderState FINISHED()
* @method static OrderState CANCELLED()
*/
abstract class OrderState extends Enum {
protected const
RECEIVED = 'received',
PREPARING = 'preparing',
FINISHED = 'finished',
CANCELLED = 'cancelled'; // domain logic: can be cancelled before preparation is started
/**
* @throws InvalidTransitionException
*/
final public function doTransition(Order $order, OrderState $desiredState): self
{
if ($desiredState !== $this && !$this->canDoTransition($desiredState)) {
throw new InvalidTransitionException();
}
$desiredState->onActivation($order);
return $desiredState;
}
abstract public function canDoTransition(OrderState $nextState): bool;
protected function onActivation(Order $order): void { /* override me */}
/** @return self[] */
final protected static function provideInstances(): array
{
return [
self::RECEIVED => new class extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return $nextState === $this::PREPARING() || $nextState === $this::CANCELLED();
}
},
self::PREPARING => new class extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return $nextState === $this::FINISHED();
}
},
self::FINISHED => new class extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return FALSE;
}
protected function onActivation(Order $order): void
{
$order->unassignEmployee();
}
},
self::CANCELLED => new class extends OrderState {
public function canDoTransition(OrderState $nextState): bool
{
return FALSE;
}
protected function onActivation(Order $order): void
{
$order->unassignEmployee();
}
},
];
}
}
// Standard order flow:
(function() {
$order = new Order();
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::PREPARING());
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::FINISHED());
Assert::null($order->getEmployee());
})();
// Cancellation order flow
(function() {
$order = new Order();
Assert::same('employee', $order->getEmployee());
$order->changeState(OrderState::CANCELLED());
Assert::null($order->getEmployee());
})();
// --- NEGATIVE TESTS ---
// Invalid order flow
Assert::exception(function () {
$order = new Order();
$order->changeState(OrderState::FINISHED()); // not allowed
}, InvalidTransitionException::class);
Assert::exception(function () {
$order = new Order();
$order->changeState(OrderState::PREPARING());
$order->changeState(OrderState::CANCELLED()); // not allowed
}, InvalidTransitionException::class);
Assert::exception(function () {
$order = new Order();
$order->changeState(OrderState::PREPARING());
$order->changeState(OrderState::FINISHED());
$order->changeState(OrderState::CANCELLED()); // not allowed
}, InvalidTransitionException::class);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment