本章の方針
Entity(エンティティ)は、一意な識別子を持ち、時間とともに状態が変化するオブジェクト だ。VOが「値」なら、Entity は「ライフサイクルを持つ個体」である。本章では、Entityの本質と、実務で最も陥りやすい「God Entity(神エンティティ)」問題、そしてFactoryとの関係を扱う。
Entity の本質
VOとEntityの違いは、一行で言える。
VO :値が同じなら同一。生成後に変わらない。
Entity:IDが同じなら同一。生成後に変わり続ける。
SaaS課金管理での具体例を見る。
// VO:通貨が同じで金額が同じなら同じ「お金」
const m1 = Money.of(1000, Currency.JPY)
const m2 = Money.of(1000, Currency.JPY)
m1.equals(m2) // true
// Entity:IDが同じなら「同じ契約」だが、状態は時間で変わる
const sub1 = Subscription.create(customerId, planId)
// 翌日...
sub1.upgrade(newPlanId) // 状態が変わっても「同じ契約」
Entityの核心は 「IDが同じなら同一」 であることだ。状態の変化はEntityの本質を変えない。
Entityの実装(SaaS契約管理)
契約管理コンテキストの主要なEntityを実装する。
// [VO] SubscriptionStatus
type SubscriptionStatus =
| 'trial'
| 'active'
| 'past_due' // 支払い失敗・猶予期間中
| 'canceled'
| 'expired'
// [Entity] Subscription
class Subscription {
private constructor(
readonly id: SubscriptionId,
readonly customerId: CustomerId,
private _planId: PlanId,
private _status: SubscriptionStatus,
private _currentPeriod: BillingPeriod,
private _canceledAt: Date | null,
) {}
// --- Factory method(静的生成メソッド)---
static startTrial(
customerId: CustomerId,
planId: PlanId,
trialPeriod: BillingPeriod,
): Subscription {
return new Subscription(
SubscriptionId.generate(),
customerId,
planId,
'trial',
trialPeriod,
null,
)
}
// --- 振る舞い(ドメインロジック)---
activate(): void {
if (this._status !== 'trial') {
throw new Error(`Cannot activate subscription in ${this._status} state`)
}
this._status = 'active'
}
upgrade(newPlanId: PlanId): void {
if (this._status !== 'active') {
throw new Error('Only active subscription can be upgraded')
}
this._planId = newPlanId
}
cancel(asOf: Date): void {
if (this._status === 'canceled' || this._status === 'expired') {
throw new Error('Already terminated')
}
this._status = 'canceled'
this._canceledAt = asOf
}
// --- 等価性は ID で判定 ---
equals(other: Subscription): boolean {
return this.id.equals(other.id)
}
// --- ゲッタ(必要最小限)---
get status(): SubscriptionStatus { return this._status }
get planId(): PlanId { return this._planId }
}
ここで重要なポイントは3つある。
idはreadonly:Entityの識別子は生成後に変わらない- 状態変更は専用メソッドで:
subscription.status = 'active'のような直接代入を許さない - 等価性は ID で判定:内容ではなくIDで比較する
Entity設計の3つの迷い所
Entityを書き始めると、多くの人が悩む箇所がある。
迷い1:ゲッタを全フィールドに付けるべきか
答えは 「ノー」。ゲッタは外部が本当に必要とする情報だけに絞る。
// ❌ 全フィールド公開
class Subscription {
get id() { ... }
get customerId() { ... }
get planId() { ... }
get status() { ... }
get currentPeriod() { ... }
get canceledAt() { ... }
get createdAt() { ... }
get updatedAt() { ... }
// ... 外部が触る必要のないものまで全公開
}
// ✅ 必要なものだけ
class Subscription {
get id(): SubscriptionId { ... }
get status(): SubscriptionStatus { ... }
get planId(): PlanId { ... }
// それ以外は外部に出さない
}
ゲッタを増やしすぎると、Entity の内部状態に外部が依存 してしまい、Entity のリファクタリングが破壊的になる。
迷い2:セッタを書くべきか
答えは 「原則ノー」。セッタは存在しないべきだ。状態変更は常に 意味のあるメソッド で行う。
// ❌ セッタで状態変更
subscription.status = 'canceled'
subscription.canceledAt = new Date()
// ✅ 意味のあるメソッドで状態変更
subscription.cancel(new Date())
後者は「キャンセルする」というドメイン操作として読める。前者は「状態をいじる」としか読めない。意味のあるメソッドは 不変条件を一箇所に集約 する効果もある(cancel() 内で「既に期限切れなら例外」などのルールを書ける)。
迷い3:Entityを肥大化させるか分割するか
答えは 「不変条件の単位で分割する」。ただし実務では、これが最も難しい。
後述の「God Entity アンチパターン」で詳しく扱う。
Factory:複雑な生成ロジックの居場所
Entity の生成ロジックが複雑になる場合に登場するのが Factory だ。ただし2026年現在、筆者は 「Factoryクラスを明示的に作るべき場面は少ない」 という立場を取る。多くの場合、Entity 内の 静的生成メソッド(static factory method) で十分だ。
静的生成メソッドで十分な例
// Entity内のstatic methodで十分
class Subscription {
static startTrial(customerId, planId, trialPeriod): Subscription {
// 初期化ロジック
}
static restoreFromSnapshot(data: SubscriptionSnapshot): Subscription {
// 永続化層からの復元
}
}
専用のFactoryクラスが必要な例
複数の集約を同時に生成したり、外部サービスに問い合わせる必要がある場合は、専用の Factory を用意する。
// 専用Factoryが妥当な例
class SubscriptionFactory {
constructor(
private readonly planRepository: PlanRepository,
private readonly featureFlagService: FeatureFlagService,
) {}
async createForNewCustomer(
customerId: CustomerId,
planCode: PlanCode,
): Promise<Subscription> {
// Plan集約を取得する必要がある
const plan = await this.planRepository.findByCode(planCode)
if (!plan) throw new Error(`Plan not found: ${planCode}`)
// フィーチャーフラグに応じてトライアル期間を変える
const trialDays = await this.featureFlagService.trialDaysFor(customerId)
const trialPeriod = BillingPeriod.of(
new Date(),
addDays(new Date(), trialDays),
)
return Subscription.startTrial(customerId, plan.id, trialPeriod)
}
}
判断基準:Entity内の情報だけで生成できるなら static method、外部依存(他の集約・外部サービス・フィーチャーフラグ)が必要なら Factory クラス。
God Entityアンチパターン
Entityを使い続けると、必ず出会う病気がある。God Entity(神エンティティ) だ。
症状
// ❌ God Entity の典型
class Subscription {
// 30個以上のフィールド
// 50個以上のメソッド
// トライアル管理・課金・通知送信・フィーチャー制御・使用量計測・監査ログ
// すべてが1つのクラスに詰め込まれている
sendWelcomeEmail() { ... } // ← なぜEntityがメール送信?
calculateUsage() { ... } // ← 使用量は別集約では?
logAuditEvent() { ... } // ← 監査は横断関心事では?
checkFeatureAvailable() { ... } // ← フィーチャーフラグはドメイン?
}
根本原因
3つの原因が複合する。
- ドメインの境界が見えていない:「Subscription に関係しそう」な処理を全て追加してしまう
- 「Serviceに書くと貧弱なドメインモデルになる」という恐れ:Ch.9 で扱うAnemic論争への過剰反応
- モデリングのリファクタリングを怠る:最初に決めた境界を更新しない
脱出法
ステップ1:そのメソッドが「Subscriptionの不変条件を守っている」か問う
守っていないなら Entity から外す。
ステップ2:別のEntity・VO・集約に所属する責務を切り出す
例:calculateUsage() → UsageRecord集約へ
例:sendWelcomeEmail() → Notification側のイベントハンドラへ
ステップ3:純粋な「取得系の情報」はEntityに残さず、Query側に寄せる
CQRS的に、Entity は「状態を変更する操作」の主役に限定する
ステップ4:それでも残る「どこに置けばいいか分からないロジック」は
Domain Service を検討する(Ch.6 で詳述)
Entity の肯定的な体験
体験1:状態遷移バグの撲滅
以前は、契約ステータスを直接代入していた(sub.status = 'canceled')。ある日、「トライアル中の契約をいきなり expired にする」というバグが発生した。Entity化して activate() / cancel() / expire() のような意味のあるメソッドだけを公開した結果、不正な状態遷移がコード上で起こせなくなった。
体験2:レビューでの議論が減った
「この処理はどこに書くべきか」のレビュー議論が、Entity導入後に明確に減った。「Subscriptionの状態を変える処理なら Subscription クラス」というルールが明文化されたためだ。
Entity の否定的な体験
体験1:ORMとの摩擦がVO以上に激しい
Entity は プライベートフィールド + 意味のあるメソッド で構成される。しかし多くのORMは、プリミティブなDTOとして読み書きできる ことを前提に設計されている。TypeORMやPrismaでEntityを直接マッピングしようとすると、
・プライベートフィールドにアクセスできない
・コンストラクタが private なので復元できない
・メソッドが付いているオブジェクトをシリアライズできない
という摩擦が生じた。Repository層で「Entity ↔ 永続化DTO」の変換 を明示的に書く運用に切り替えた。詳細は Ch.6 で扱う。
体験2:Entity名の議論が終わらない
「これは Subscription か Contract か Subscription Agreement か」というユビキタス言語論争に、チームで3日費やしたことがある。名前選びは重要だが、初期は仮の名前で進め、後でリファクタ する柔軟性を持つべきだった。
本章のまとめ
・Entity は「ID が同じなら同一」なオブジェクト
・状態変更は専用メソッドで、直接代入は禁止
・ゲッタ・セッタは最小限。外部に必要な情報だけ
・Factory は外部依存がある場合のみ専用クラスを作る
・God Entity は最大の敵。責務ごとに分割する勇気を持つ
次章では、Entityの集まりである 集約(Aggregate) を扱う。戦術的DDDで最も議論を呼び、最も失敗しやすいパターンだ。