TypeScriptでクロージャを使った真のカプセル化を実現するEntity設計
Published on 2026年1月28日
オブジェクト指向はじめに
「オブジェクト指向」と「オブジェクト指向言語」は別物である。
この視点を持つことが、良い設計への第一歩だと考えています。
オブジェクト指向(OOP)の核心は「カプセル化・継承・ポリモーフィズム」という概念です。これは特定の言語や構文に依存するものではありません。一方、JavaやC++、TypeScriptといった「オブジェクト指向言語」は、この概念を実現するための手段を提供しているに過ぎません。
ここで重要なのは、各言語が提供する手段(class 構文など)が、必ずしもOOPの概念を最も効果的に実現する方法とは限らないということです。
TypeScriptでドメイン駆動設計(DDD)を実践する際、多くのプロジェクトでは当然のように class を使ってEntityを実装します。しかし、JavaScript/TypeScriptにおける class の実態を見ると:
- プロトタイプベースの糖衣構文: JSのクラスは、内部的にはプロトタイプベースで動作しており、JavaやC#のような静的型付け言語の「クラス」とは本質的に異なります。
- 不完全なカプセル化:
private修飾子はコンパイル時のチェックに過ぎず、実行時にはプロパティが露出します。
つまり、TypeScriptで class を使うことは、OOPの概念を実現する唯一の方法でも、最良の方法でもないのです。
では、TypeScriptという言語の特性を活かして、OOPの概念をどう実現すべきか?
今回は、JavaScriptの言語特性であるクロージャを最大限に活用し、ランタイムでも堅牢なカプセル化を実現する設計パターンを紹介します。
カプセル化の本質的な目的は、オブジェクト内部の詳細を隠蔽し、「そのオブジェクトが何ができるのか(振る舞い)」だけを外部に公開することです。この目的を達成するために、言語の特性に合った実装方法を選ぶべきだと考えています。
言語仕様を理解する
「OOP言語におけるclass」の実装は、実は言語の系譜によって大きく2つの方式に分かれます。
TypeScript(JavaScript)が採用しているプロトタイプベースと、JavaやC++などが採用している**クラスベース(仮想関数テーブル方式)**です。
1. クラスベース言語(Java, C++, C# など)
これらの言語では、クラスは「設計図」としてコンパイル時にメモリ上の構造が決定されます。
内部構造:仮想関数テーブル(vtable)
インスタンスを生成する際、データ(フィールド)はインスタンスごとに確保されますが、メソッド(関数)は全インスタンスで共有されます。このとき、どのメソッドを呼び出すかを管理するのが**仮想関数テーブル(vtable)**です。
- インスタンスメモリ: 実際のデータ(
id,nameなど)と、vtableへのポインタを保持します。 - vtable: そのクラスが持つ全メソッドのアドレスが並んだリストです。
特徴:
- メモリ効率が良い(メソッドの実体は1つだけ)
- 実行時のメソッド呼び出しが非常に高速(ポインタを辿るだけ)
- 実行中にメソッドを動的に追加することは基本的にできません
2. プロトタイプベース言語(JavaScript / TypeScript)
JavaScriptには、厳密な意味での「クラスの設計図」という実体は存在しません。class 構文を使っても、内部ではプロトタイプチェーンという仕組みが動いています。
内部構造:プロトタイプリンク
インスタンス(オブジェクト)を作成すると、そのオブジェクトは隠しプロパティ(__proto__)を持ち、親となる「プロトタイプオブジェクト」を指し示します。
- インスタンス: 自分自身のプロパティのみを保持
- プロトタイプ: メソッドを保持
- 探索:
user.toJSON()を呼ぶと、まずインスタンス内を探し、なければプロトタイプへ、さらになければその親へ…と鎖(チェーン)を辿ります
特徴:
- 動的で柔軟(実行中にメソッドを差し替えることが可能)
- 探索コストがクラスベースよりわずかに高い(鎖を辿るため)
3. クロージャによる実装
今回紹介する手法は、上記のどちらとも異なる**「実行コンテキストのキャプチャ」**を利用したものです。
内部構造:レキシカル環境
関数が定義されたときの変数スコープ(環境)を、関数がそのまま保持し続ける仕組みです。
- メモリ上の挙動:
createUserを実行するたびに、新しい「変数の箱(UserState)」と「それを見る関数群」がセットで生成されます - カプセル化: プロトタイプやvtableは(仕組みを知っていれば)外部から覗けますが、クロージャ内の変数は言語エンジンレベルで外部から隔離されているため、物理的にアクセスする手段がありません
まとめ:なぜ「真のカプセル化」と言えるのか
| 実装方式 | 隠蔽の仕組み | 外部からの干渉 |
|---|---|---|
| クラスベース | private 修飾子 |
リフレクション等を使えばアクセス可能 |
| プロトタイプ | 慣習(_)や # |
プロトタイプを辿れば解析可能 |
| クロージャ | スコープの局所性 | アクセス手段が完全に断たれる |
「OOPの考え方をTSの言語特性(クロージャ)に落とし込む」というアプローチは、JS/TSの実行環境において、最も厳格にカプセル化を定義できる手法と言えます。
提案するアーキテクチャ
Entityを「データ構造(State)」「振る舞い(Behaviors)」「生成(Factory)」の3つに分離し、クロージャによってそれらを結合します。
domain/users/
├── entities/ ← 型定義(Stateの型 + 外部公開インターフェース)
├── behaviors/ ← 振る舞いの実装(クロージャによる状態の隠蔽)
├── factories/ ← Entityの生成・再構築
└── valueObjects/ ← 値オブジェクト
1. entities - 契約の定義
内部状態(UserState)と、外部に公開するインターフェース(User)を明確に分けます。
// entities/index.ts
export type UserState = {
readonly accountId: string;
readonly sub: Sub;
readonly email: Email;
readonly name: Name;
readonly createdAt: Date;
readonly updatedAt: Date;
};
// User Entityはどの様な振る舞いができるのかを定義
// 外部から触れるのは「メソッド」のみ。プロパティは一切公開しない。
export type User = {
toJSON: () => {
accountId: string;
sub: string;
email: string;
name: string;
createdAt: string;
updatedAt: string;
};
};
2. behaviors - 状態を閉じ込める
state を引数に取り、メソッドを返す関数です。ここで定義されたメソッドのみが、クロージャとして state にアクセスできます。
// behaviors/index.ts
import type { User, UserState } from "../entities";
export const createUserBehaviors = (state: UserState): User => ({
toJSON: () => ({
accountId: state.accountId,
sub: state.sub.value,
email: state.email.value,
name: state.name.value,
createdAt: state.createdAt.toISOString(),
updatedAt: state.updatedAt.toISOString(),
}),
});
3. factories - 生成とカプセル化の完結
Factory関数が実行されると、state はそのスコープ内に閉じ込められ、二度と外部から直接書き換えることはできなくなります。
// factories/index.ts
export const createUser = (params: CreateUserParams): User => {
const now = new Date();
const state: UserState = {
accountId: params.accountId,
sub: createSub(params.sub),
email: createEmail(params.email),
name: createName(params.name),
createdAt: now,
updatedAt: now,
};
return createUserBehaviors(state);
};
これらをどの様に使用しているか
// src/usecases/users/index.ts
import { createUser } from "../../domain/users/factories";
import { IUserRepository } from "../../domain/users/repositories";
type CreateUserInput = {
accountId: string;
sub: string;
email: string;
name: string;
};
type CreateUserOutput = {
userId: string;
};
export const createUserUseCase = async (
input: CreateUserInput,
userRepository: IUserRepository
): Promise<CreateUserOutput> => {
const existingUserId = await userRepository.findUserIdBySub(input.sub);
if (existingUserId) {
return { userId: existingUserId };
}
const user = createUser({
accountId: input.accountId,
sub: input.sub,
email: input.email,
name: input.name,
});
const userId = await userRepository.save(user);
return { userId };
};
このパターンの優位性
1. ランタイムでの完全な隠蔽
Object.keys(user) を実行しても、取得できるのはメソッド名のみです。内部の state には、定義されたメソッドを通さなければ物理的にアクセス不可能な状態になります。
2. 「不変性(Immutability)」の自然な強制
class ではプロパティの書き換え(副作用)を抑制するために多くの工夫が必要ですが、このパターンではそもそもプロパティが露出していないため、意図しない書き換えが起こり得ません。
3. 関心の分離(Separation of Concerns)
- entities: ドメインの「形」を決める
- behaviors: ドメインの「論理」を記述する
- factories: ドメインの「誕生」を司る
このように責務が分かれているため、振る舞いが増えてもFactoryが肥大化せず、コードの可読性が高く保たれます。
比較まとめ
| 特徴 | class による実装 | クロージャによる実装 |
|---|---|---|
| カプセル化 | コンパイル時のみ(private) |
実行時も含め完全(真の隠蔽) |
| 不変性 | readonly やゲッターが必要 |
スコープにより構造的に保護される |
| this の扱い | 常に this のバインドを意識 |
不要(関数のため安全) |
| 拡張性 | 継承(extends) |
合成(Composition) |
おわりに
JavaScriptという柔軟すぎる言語でDDDを実践するには、時に言語の「制約」を自ら作り出す必要があります。クロージャを用いたこのパターンは、class という枠組みを超え、よりドメインモデルを純粋かつ堅牢に保つための強力な武器になります。