メインコンテンツまでスキップ

エンティティ — 識別子とライフサイクルを持つドメインオブジェクト

エンティティとは

前章では値オブジェクトを学びました。本章では、もう1つのドメインオブジェクトであるエンティティについて学びます。

エンティティは以下の特徴を持つドメインオブジェクトです。

  1. 識別子(ID)を持つ:一意に識別できる
  2. データとビジネスルールを持つ:単なるデータの入れ物ではない
  3. 識別子で同一性を判断する:属性が変わっても、識別子が同じなら同一
  4. ライフサイクルがある:作成、更新、削除される

識別子(ID)とは

識別子(ID)は、オブジェクトを一意に識別するための値です。現実世界の例で考えてみましょう。

現実世界の識別子の例

対象識別子説明
人間マイナンバー、パスポート番号名前や住所が変わっても、同一人物を識別できる
書籍ISBN同じタイトルの本でも版が違えば別のISBN
注文注文番号注文内容が変わっても注文番号は変わらない
銀行口座口座番号残高が変わっても口座番号は同じ

識別子があることで、属性(データ)が変化しても「同じもの」として追跡できるのです。

識別子の重要性

// 識別子がない場合(値オブジェクト)
$address1 = new Address('東京都', '渋谷区', '1-1-1');
$address2 = new Address('東京都', '渋谷区', '1-1-1');
$address1->equals($address2); // true(全属性が同じなら同じ)

// 識別子がある場合(エンティティ)
$user1 = new User(id: 1, name: 'Yuki', email: 'yuki@example.com');
$user2 = new User(id: 1, name: 'Yuki Igarashi', email: 'new@example.com');
$user3 = new User(id: 2, name: 'Yuki', email: 'yuki@example.com');

$user1->equals($user2); // true ← IDが同じなら同一(名前やメールが変わっても)
$user1->equals($user3); // false ← IDが違えば別人(名前とメールが同じでも)
エンティティと値オブジェクトの使い分け
  • 「それが何か」が重要 → 値オブジェクト(例:1000円、東京都渋谷区)
  • 「どれか」が重要 → エンティティ(例:田中さん、注文#123)

エンティティと値オブジェクトの違い

具体例で比較してみましょう。

例1:住所

// 住所は値オブジェクト
$address1 = new Address('東京都', '渋谷区', '1-1-1');
$address2 = new Address('東京都', '渋谷区', '1-1-1');

// 全属性が同じなら「同じ住所」
$address1->equals($address2); // true

// 「東京都渋谷区1-1-1」という住所は世界中で1つ
// 田中さんの住所も山田さんの住所も「同じ住所」として扱える

例2:ユーザー

// ユーザーはエンティティ
$user1 = new User(id: 1, name: '田中太郎', address: $address1);
$user2 = new User(id: 2, name: '山田花子', address: $address1);

// IDが違えば別のユーザー(同じ住所に住んでいても)
$user1->equals($user2); // false

// 田中さんが引っ越しても
$user1->changeAddress(new Address('大阪府', '梅田', '2-2-2'));
// 田中さんは田中さん(IDは変わらない)

比較表

観点値オブジェクトエンティティ
識別子なしあり(必須)
同一性の判断全属性の値識別子のみ
変更可能性不変(変更不可)可変(状態が変化する)
ライフサイクルなしある(作成→更新→削除)
金額、住所、メールアドレスユーザー、注文、商品
  • confirm(): 明細が空なら確定できない
  • cancel(): 発送済みならキャンセル不可
  • addItem(): 確定後は商品追加不可

エンティティ ≠ テーブル

ここで重要なのは、エンティティはテーブルと同じではないということです。

テーブルは単なるデータの入れ物であり、ビジネスルールを持ちません。エンティティはビジネスルールを持つドメインオブジェクトです。

識別子による同一性判断

エンティティは識別子で同一性を判断します。

$user1 = new User(id: 1, name: 'Yuki', email: 'yuki@example.com');
$user2 = new User(id: 1, name: 'Yuki Igarashi', email: 'new@example.com');
$user3 = new User(id: 2, name: 'Yuki', email: 'yuki@example.com');

$user1->equals($user2); // true ← 識別子が同じなら同一(属性が変わっても)
$user1->equals($user3); // false ← 属性が同じでも識別子が違えば別物

人間で例えると、田中さんが引っ越して住所が変わっても、田中さんは田中さんです。これがエンティティの同一性です。

エンティティの実装例

注文明細エンティティ(OrderLine)

以下のコードでは、前章で学んだ値オブジェクト(OrderLineIdProductIdMoney)を活用しています。

final class OrderLine
{
public function __construct(
private readonly OrderLineId $id, // 識別子(値オブジェクト)
private readonly ProductId $productId, // 識別子(値オブジェクト)
private readonly int $quantity,
private readonly Money $unitPrice, // 金額(値オブジェクト)
) {
if ($quantity <= 0) {
throw new InvalidArgumentException('数量は1以上である必要があります');
}
}

public function subtotal(): Money
{
return $this->unitPrice->multiply($this->quantity);
}

public function equals(OrderLine $other): bool
{
return $this->id->equals($other->id); // 識別子で同一性判断
}

// ゲッター
public function id(): OrderLineId { return $this->id; }
public function productId(): ProductId { return $this->productId; }
public function quantity(): int { return $this->quantity; }
public function unitPrice(): Money { return $this->unitPrice; }
}

注文エンティティ(Order)- 集約ルート

注記

集約ルートとは、関連するオブジェクトをまとめた「集約」の入口となるエンティティです。詳しくは次章で説明します。

// Orderエンティティ:集約ルート(外部からのアクセス起点)
final class Order
{
/** @var OrderLine[] PHPDoc型アノテーション:配列の中身の型を明示 */
private array $orderLines;

// privateコンストラクタ:直接newできない
// 代わりにcreate()やreconstruct()ファクトリメソッドを使用
private function __construct(
private readonly OrderId $id, // 識別子は不変(readonly)
private OrderStatus $status, // 状態は変化するのでreadonlyなし
private readonly ShippingAddress $shippingAddress, // 配送先は不変
array $orderLines,
) {
$this->orderLines = $orderLines;
}

/**
* 新規注文を作成するファクトリメソッド
* 初期状態(DRAFT、明細空)を設定して生成
* self: このクラス自身の型を返すことを示す
*/
public static function create(OrderId $id, ShippingAddress $shippingAddress): self
{
// 新規作成時は必ずDRAFT状態、明細は空
return new self($id, OrderStatus::DRAFT, $shippingAddress, []);
}

/**
* 永続化されたデータから復元するファクトリメソッド
* リポジトリがDBから読み込んだデータをエンティティに変換する際に使用
* create()と違い、保存された状態をそのまま復元する
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
): self {
return new self($id, $status, $shippingAddress, $orderLines);
}

/**
* 商品を追加する
* ビジネスルール:下書き状態でのみ追加可能(確定後は不可)
* void: 戻り値なし。状態を変更する操作
*/
public function addItem(
OrderLineId $lineId,
ProductId $productId,
int $quantity,
Money $unitPrice
): void {
// ビジネスルールの判定はエンティティ内で行う
// UseCaseではなくドメインオブジェクトがルールを持つのがDDDの特徴
if (!$this->status->isDraft()) {
throw new DomainException('下書き状態でのみ商品を追加できます');
}
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}

/**
* 注文を確定する
* ビジネスルール:下書き状態かつ明細が1つ以上必要
*/
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new DomainException('この注文は確定できません');
}
// ビジネスルール:明細が空なら確定不可
if (empty($this->orderLines)) {
throw new DomainException('注文明細が空の状態では確定できません');
}
// 状態を変更(readonlyではないので変更可能)
$this->status = OrderStatus::CONFIRMED;
}

/**
* 注文をキャンセルする
* ビジネスルール:下書きまたは確定済みの場合のみキャンセル可能(発送済みは不可)
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new DomainException('この注文はキャンセルできません');
}
$this->status = OrderStatus::CANCELLED;
}

// ゲッター:内部状態を外部に公開するためのメソッド
public function id(): OrderId { return $this->id; }
public function status(): OrderStatus { return $this->status; }
public function shippingAddress(): ShippingAddress { return $this->shippingAddress; }
public function orderLines(): array { return $this->orderLines; }
}

ファクトリメソッドのパターン

エンティティには通常、2つのファクトリメソッドを用意します。

  • create() — 新規作成用。初期状態を設定する(例: Order::create() → status = DRAFT)
  • reconstruct() — DB からの復元用。保存された状態をそのまま復元する(例: Order::reconstruct() → status = 保存された値)
// 新規作成
$order = Order::create($orderId, $shippingAddress);
// → status = DRAFT

// DBから復元(リポジトリ内で使用)
$order = Order::reconstruct($id, $status, $address, $lines);
// → status = DBに保存された値

なぜ2つのファクトリメソッドが必要なのか

この設計パターンには重要な意図があります。

問題:コンストラクタ1つだけの場合

// アンチパターン:コンストラクタで全て対応しようとする
class Order
{
public function __construct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
) {
$this->id = $id;
$this->status = $status;
$this->shippingAddress = $shippingAddress;
$this->orderLines = $orderLines;
}
}

// 新規作成時の問題
$order = new Order(
$orderId,
OrderStatus::DRAFT, // 毎回DRAFTを指定しないといけない
$address,
[] // 毎回空配列を指定しないといけない
);
// → 初期状態の設定を呼び出し側に強制(忘れるリスク)

// DB復元時の問題
$order = new Order($id, $status, $address, $lines);
// → 新規作成と区別がつかない(意図が不明確)

解決:2つのファクトリメソッドで意図を明確に

class Order
{
// privateコンストラクタ:外部から直接newできない
private function __construct(...) {}

// 新規作成:初期状態を自動設定
public static function create(OrderId $id, ShippingAddress $address): self
{
return new self(
$id,
OrderStatus::DRAFT, // 常にDRAFTで開始
$address,
[] // 常に空の明細で開始
);
}

// DB復元:保存された状態をそのまま復元
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $address,
array $lines
): self {
return new self($id, $status, $address, $lines);
}
}

// メリット1:初期状態の設定を強制
$order = Order::create($orderId, $address);
// → 初期状態(DRAFT、空明細)が保証される

// メリット2:意図が明確
$order = Order::reconstruct($id, $status, $address, $lines);
// → DB復元だとひと目でわかる

ファクトリメソッドのメリット

メリット説明
初期状態の強制create()が正しい初期状態を保証常にDRAFT状態で開始
バリデーションの一元化生成ロジックが1箇所に集約空明細のチェックなど
意図の明確化メソッド名で用途が分かるcreate vs reconstruct
変更の局所化初期化ロジックの変更が容易create()だけ修正すればOK
ファクトリメソッドパターンの本質
  • コンストラクタをprivateにする → 直接newさせない
  • static factoryメソッドを提供 → 生成ロジックを制御
  • 用途ごとにメソッドを分ける → 意図を明確に

これにより、オブジェクトの生成を制御し、不正な状態のオブジェクトが作られることを防ぎます。

状態遷移とビジネスルール

エンティティの状態遷移は、列挙型(enum)で表現すると明確になります。

状態とビジネスルールの関係

エンティティの状態は、どのような操作が許可されるかを決定します。これがビジネスルールです。

[注文の状態と許可される操作]

DRAFT(下書き)状態
✓ 商品を追加できる
✓ 確定できる
✓ キャンセルできる
✗ 発送できない

CONFIRMED(確定済み)状態
✗ 商品を追加できない ← 確定後は変更不可
✗ 確定できない(既に確定済み)
✓ キャンセルできる
✓ 発送できる

SHIPPED(発送済み)状態
✗ すべての操作が不可 ← 発送後は何もできない

CANCELLED(キャンセル済み)状態
✗ すべての操作が不可 ← キャンセル後は変更不可

enumで状態遷移ルールを表現

// PHP 8.1+: Backed Enum(値を持つ列挙型)
// string型の値を持つことで、DBに保存する際にその値が使われる
enum OrderStatus: string
{
// case: 列挙型の各値を定義
// = 'draft': DBに保存される実際の文字列値
case DRAFT = 'draft';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';

// enumにもメソッドを定義できる
// 状態判定ロジックをenumに集約することで、コードの可読性が向上
public function isDraft(): bool { return $this === self::DRAFT; }

// 状態遷移の可否を判定するメソッド
// 「どの状態からどの状態に遷移できるか」というビジネスルールを表現
public function canBeConfirmed(): bool { return $this === self::DRAFT; }
public function canBeCancelled(): bool { return $this === self::DRAFT || $this === self::CONFIRMED; }
public function canBeShipped(): bool { return $this === self::CONFIRMED; }
}

エンティティで状態遷移を制御

class Order
{
private OrderStatus $status;

public function confirm(): void
{
// ビジネスルール1:状態遷移チェック
if (!$this->status->canBeConfirmed()) {
throw new DomainException(
"この注文は確定できません(現在の状態: {$this->status->value})"
);
}

// ビジネスルール2:明細チェック
if (empty($this->orderLines)) {
throw new DomainException('注文明細が空の状態では確定できません');
}

// 状態遷移
$this->status = OrderStatus::CONFIRMED;
}

public function cancel(): void
{
// ビジネスルール:発送済みはキャンセル不可
if (!$this->status->canBeCancelled()) {
throw new DomainException(
"この注文はキャンセルできません(現在の状態: {$this->status->value})"
);
}

$this->status = OrderStatus::CANCELLED;
}
}

状態遷移図

  • cancel() は DRAFT または CONFIRMED 状態から可能
  • SHIPPED と CANCELLED は終端状態(これ以上遷移しない)

ビジネスルールをエンティティに集約するメリット

メリット説明
ルールの一元管理ビジネスルールが1箇所に集約「確定後は追加不可」がOrderクラスに
不正な状態の防止エンティティが自身の整合性を保証発送済みの注文は変更不可
変更の局所化ルール変更時の影響範囲が明確Orderクラスだけ修正すればOK
テストしやすいエンティティ単体でテスト可能UseCase不要でドメインロジックをテスト
状態遷移とビジネスルールの設計ポイント
  1. 状態はenumで定義 → 型安全性の確保
  2. 遷移可否判定はenumにcanBeConfirmed()など
  3. 遷移処理はエンティティにconfirm()など
  4. ビジネスルールはエンティティが強制 → 不正な遷移を防ぐ

この設計により、ビジネスルールがコード全体に散らばることを防ぎます。

次のチャプターでは、集約について詳しく見ていきます。