型とエンティティをバランス良く使う
状態毎にValueを全く別の型のValueに変える事でプログラムを安全にする手法と、エンティティに状態を持たせてその状態を監視する事で安全にする手法をバランス良く使う事について。
お店視点で商品の受注から発送及び返品までの処理をエンティティのみで表現する場合と型とエンティティを併用して表現する場合を示す。
お店視点と言いつつ発注者視点の言葉も混ざっているのとコード上のメソッドやら何やらの命名が雑だが、これは例なので良い感じに読み替えてもらいたい。
サンプルコード
エンティティのみで表現する場合
ざっくり以下のような要素で成り立つものとする
- 注文エンティティ
- 受注サービス
- 発送サービス
- 返品・返金サービス
OrderEntity
<?php
declare(strict_types=1);
namespace EntityRelay\NoRelay;
/**
* 可変:注文者の情報、発送先、商品情報、小計、合計
* 注文の状態:未発注 / 注文済み
*/
final class OrderEntity
{
private const UNORDER = 1;
private const ORDER = 2;
private const SHIPPED = 3;
private const RETURNED = 4; // 返品済み
private const REFUNDED = 5;
public function __construct(
readonly public string $orderer,
readonly public string $destination,
readonly public string $product, // 商品情報だが仮でstring型
readonly public int $subtotal,
readonly public int $total,
private int $status = self::UNORDER
) {
}
public function isOrderd(): bool
{
return $this->status === self::ORDER;
}
public function order(): void
{
$this->status = self::ORDER;
}
public function cancel(): void
{
$this->status = self::UNORDER;
}
public function shipped(): void
{
$this->status = self::SHIPPED;
}
public function returned(): void
{
$this->status = self::RETURNED;
}
public function refund(): void
{
$this->status = self::REFUNDED;
}
}
Shipping
<?php
declare(strict_types=1);
namespace EntityRelay;
use EntityRelay\NoRelay\OrderEntity as NoRelayOrderEntity;
use EntityRelay\Relay\OrderEntity;
use EntityRelay\Relay\ShippedEntity;
use RuntimeException;
/**
* 発送サービス OrderEnityを使って発送する 発送後はShippedEntityを返す
*/
final class Shipping
{
public static function execForNoRelay(NoRelayOrderEntity $order): NoRelayOrderEntity
{
if (!$order->isOrderd()) {
throw new RuntimeException('注文済みでないと発送できません');
}
$order->shipped();
return $order;
}
}
これらを用いて発送までの流れコードで表現すると
final class Entry
{
public function runNoRelay(): void
{
$order = new NoRelayOrderEntity(
orderer: 'orderer',
destination: 'destination',
product: 'product',
subtotal: 100,
total: 108);
$order->order(); // 発注済み
$order = Shipping::execForNoRelay($order);
//人的ミスにより返品済みの注文を発注済みにしてしまうかもしれない
$order->order(); // 発注済み
}
}
となる。
コード中のコメントにある通り、発送済みや返金済みのエンティティのステータスを発注済み(注文済み)に変更する事が出来てしまう。
型とエンティティを併用して表現する場合
OrderEntity
<?php
declare(strict_types=1);
namespace EntityRelay\Relay;
/**
* 可変:注文者の情報、発送先、商品情報、小計、合計
* 注文の状態:未発注 / 注文済み
*/
final class OrderEntity
{
private const UNORDER = 1;
private const ORDER = 2;
public function __construct(
readonly public string $orderer,
readonly public string $destination,
readonly public string $product, // 商品情報だが仮でstring型
readonly public int $subtotal,
readonly public int $total,
private int $status = self::UNORDER
) {
}
public function isOrderd(): bool
{
return $this->status === self::ORDER;
}
public function order(): void
{
$this->status = self::ORDER;
}
public function cancel(): void
{
$this->status = self::UNORDER;
}
}
ShippedEntity
<?php
declare(strict_types=1);
namespace EntityRelay\Relay;
/**
* 可変:注文者の情報、発送先、商品情報、小計、合計
* 注文の状態:発送済み / 返品済み / 返金済み
*/
final class ShippedEntity
{
private const SHIPPED = 1;
private const CANCEL = 2;
private const REFUNDED = 3;
public static function create(OrderEntity $orderEntity): self
{
return new self(
orderer: $orderEntity->orderer,
destination: $orderEntity->destination,
product: $orderEntity->product,
subtotal: $orderEntity->subtotal,
total: $orderEntity->total
);
}
private function __construct(
readonly public string $orderer,
readonly public string $destination,
readonly public string $product, // 商品情報だが仮でstring型
readonly public int $subtotal,
readonly public int $total,
private int $status = self::SHIPPED
) {
}
public function getStatus(): int
{
return $this->status;
}
public function cancel(): void
{
$this->status = self::CANCEL;
}
public function refund(): void
{
$this->status = self::REFUNDED;
}
}
Shipping
<?php
declare(strict_types=1);
namespace EntityRelay;
use EntityRelay\NoRelay\OrderEntity as NoRelayOrderEntity;
use EntityRelay\Relay\OrderEntity;
use EntityRelay\Relay\ShippedEntity;
use RuntimeException;
/**
* 発送サービス OrderEnityを使って発送する 発送後はShippedEntityを返す
*/
final class Shipping
{
public static function execForRelay(OrderEntity $order): ShippedEntity
{
if (!$order->isOrderd()) {
throw new RuntimeException('注文済みでないと発送できません');
}
return ShippedEntity::create($order);
}
}
上記を用いて発送までの流れを表現する場合
final class Entry
{
public function runRelay()
{
$order = new OrderEntity(
orderer: 'orderer',
destination: 'destination',
product: 'product',
subtotal: 100,
total: 108);
$order->order(); // 発注済み
$shipped = Shipping::execForRelay($order);
//$shippedは返品・返金状態にはなり得るが、発注済み・未発注にはなれない
}
}
発送済みのエンティティについては、受注状態になる事はできない。
これは現実で言うと「発送しました」となった注文については「注文を受け付けました」に戻れない事を表現している。
ちょっと雑に書いているが、ある状態になったらそれより前の状態には戻れない、というのを型レベルで表現する事の良さが伝わると嬉しい。
なぜこの記事を書いたか
過去にフリーランスとして働いた現場で 「状態の変化をValueObjectで表せば良い、振る舞いは別で定義すれば良い、状態の変化はServiceを通る事で別のValueObjectにすれば良い」 という自分の意見と、「振る舞いを分ける必要が無い、エンティティに集約されている事に意味がある」 という先方と意見が分かれた時に、別に自分の意見は全く間違っていないが相手の意見も間違っていない、このレベルの設計でぶつかる事に価値は無い(どちらでも高品質と言う意味で)、それなら外注である自分は先方に合わせるか、という考えで相手に合わせた事があったが、セキュアバイデザインという書籍を読んで自分の中でベストな答えが出たのでぜひお披露目したく記事にした。
とは言えその頃はF#の表現にハマっていたので、若干型寄った意見だった可能性は否めない。
Discussion