エンティティ — 識別子とライフサイクルを持つドメインオブジェクト
エンティティとは
前章では値オブジェクトを学びました。本章では、もう1つのドメインオブジェクトであるエンティティについて学びます。
エンティティは以下の特徴を持つドメインオブジェクトです。
- 識別子(ID)を持つ:一意に識別できる
- データとビジネスルールを持つ:単なるデータの入れ物ではない
- 識別子で同一性を判断する:属性が変わっても、識別子が同じなら同一
- ライフサイクルがある:作成、更新、削除される
識別子(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)
以下のコードでは、前章で学んだ値オブジェクト(OrderLineId、ProductId、Money)を活用しています。
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不要でドメインロジックをテスト |
状態遷移とビジネスルールの設計ポイント
- 状態はenumで定義 → 型安全性の確保
- 遷移可否判定はenumに →
canBeConfirmed()など - 遷移処理はエンティティに →
confirm()など - ビジネスルールはエンティティが強制 → 不正な遷移を防ぐ
この設計により、ビジネスルールがコード全体に散らばることを防ぎます。
次のチャプターでは、集約について詳しく見ていきます。