フロントエンドでドメイン駆動設計を使うメリットとは?
ドメイン駆動設計と聞くと「サーバーサイドの設計手法」という印象を持つ方が多いかもしれません。
しかし近年ではフロントエンドの役割が大きく変わり、ドメイン駆動設計的なアプローチが求められます。
1. フロントエンドにも複雑な業務知識が現れる
かつてのフロントエンドは「画面を描画するだけ」でした。
しかし、いまやSPAやリッチなUIがよくあると思います。
たとえばECサイトのフロント側ではこんなロジックが存在します。
- 「在庫がなければ購入ボタンを無効化」
- 「注文後に一定時間だけキャンセル可能」
- 「クーポンは条件を満たした場合のみ適用」
これらは ビジネスルールそのもの であり、UI表示に直結します。
フロント側で同じルールを重複実装すれば「知識の断絶」が生じます。
2. フロントエンドこそ「業務ルールごとに整理」されると強い
バックエンドはAPI設計やDBスキーマである程度ルールが見えます。
しかしフロントエンドは「コンポーネントのprops」や「状態管理の変数」が並ぶだけでは、
どんな業務ルールがあるのか見えづらい。
const product = { id: "P1", quantity: 0 };
if (product.quantity === 0) disableButton();
今回紹介するプロダクトの流れ
今回の記事で登場する要素の関係は下図のようになります。
UI (React Component)
│
▼
ユースケース (Use Case)
├── 値オブジェクト (Value Object)
│ └─ 小さなルールを閉じ込める
│
├── エンティティ (Entity)
│ └─ IDと状態遷移を持つ
│
└── ドメインサービス (Domain Service)
└─ 複数モデルをまたぐ横断ルール
- UI:ユーザー入力や画面表示を扱う
- ユースケース:UIとドメインモデルをつなぐ接着役
- 値オブジェクト:数量などの小さなルールを型に閉じ込める
- エンティティ:同一性や状態遷移を表現する
値オブジェクト (Value Object)
特徴
- 不変 (Immutable)
- 一度生成したら変更されない
- 値の等価性で同一性を判断
- 「参照の同一性」ではなく「値そのもの」で比較
- ルールを内部に閉じ込める
- バリデーションや制約を外に漏らさない
例: 在庫数量 値オブジェクト
type InventoryQuantity = Readonly<{
value: number;
}>;
function createInventoryQuantity(value: number): InventoryQuantity {
if (value < 0) throw new Error("在庫は0以上である必要があります");
return { value };
}
function addQuantity(a: InventoryQuantity, b: InventoryQuantity): InventoryQuantity {
return createInventoryQuantity(a.value + b.value);
}
function subtractQuantity(a: InventoryQuantity, b: InventoryQuantity): InventoryQuantity {
if (a.value < b.value) throw new Error("在庫不足です");
return createInventoryQuantity(a.value - b.value);
}
function equalsQuantity(a: InventoryQuantity, b: InventoryQuantity): boolean {
return a.value === b.value;
}
上記コードの特徴は下記の通りです
-
InventoryQuantity
は必ず0以上 -
addQuantity
/subtractQuantity
によってのみ加減算 -
equalsQuantity
で比較(=== を直接使わない)
フロントエンドで値オブジェクトを使うメリット
フロントエンドは 状態変化が激しく、業務ルールがUIに直結します。
そのため単なる number や string で表現するより安全だと思います。
値オブジェクトを使うと…
- 不変性が保証される
- 一度作った
InventoryQuantity
は書き換えられない
- 一度作った
- 業務ルールが型に閉じ込められる
- 「在庫数は0以上」「在庫不足のときは減算できない」が型で保証される
- 業務ルールごとに整理する
-
InventoryQuantity
と聞けば「在庫数量」であることが明確
-
エンティティ (Entity)
特徴
- IDで識別される
- 属性が変わっても「同じIDなら同じもの」とみなす
- 状態変化を持つ
- 値オブジェクトと違い、可変であることが許される
- ドメインルールを伴った状態遷移を表現できる
例1:在庫エンティティ (Inventory)
- 同一性
- id が同じなら同じ在庫を表す(属性が変わっても同一)
- 状態変化:在庫数は増減するが、必ず値オブジェクト
InventoryQuantity
を経由して行う(生の number を直接触らない)
type Inventory = Readonly<{
id: string;
productCode: string;
quantity: InventoryQuantity;
}>;
function createInventory(args: {
id: string;
productCode: string;
quantity: InventoryQuantity;
}): Inventory {
return { ...args };
}
function isSameInventory(a: Inventory, b: Inventory): boolean {
return a.id === b.id;
}
function withIncreasedQuantity(inv: Inventory, qty: InventoryQuantity): Inventory {
return {
...inv,
quantity: addQuantity(inv.quantity, qty),
};
}
function withDecreasedQuantity(inv: Inventory, qty: InventoryQuantity): Inventory {
return {
...inv,
quantity: subtractQuantity(inv.quantity, qty),
};
}
例2:注文エンティティ (Order)
- 同一性
- id が同じなら同じ注文を表す
- 状態遷移
- 注文は "PLACED" → "CANCELLED" または "FULFILLED" へ遷移する
- 「キャンセルできるのはPLACEDのときだけ」
- 「出荷できるのはPLACEDのときだけ」
- 注文は "PLACED" → "CANCELLED" または "FULFILLED" へ遷移する
type Order = Readonly<{
id: string;
productCode: string;
orderQuantity: InventoryQuantity;
status: "PLACED" | "CANCELLED" | "FULFILLED";
}>;
function createOrder(args: {
id: string;
productCode: string;
orderQuantity: InventoryQuantity;
}): Order {
return { ...args, status: "PLACED" };
}
function isSameOrder(a: Order, b: Order): boolean {
return a.id === b.id;
}
function applyCancel(order: Order): Order {
if (order.status !== "PLACED") throw new Error("キャンセルできません");
return { ...order, status: "CANCELLED" };
}
function applyFulfill(order: Order): Order {
if (order.status !== "PLACED") throw new Error("出荷できません");
return { ...order, status: "FULFILLED" };
}
ドメインサービス (Domain Service)
特徴
- エンティティや値オブジェクトに収まらない横断的な処理を担う
- ステートレス(状態を持たない関数やモジュールとして実装できる)
- 複数のエンティティや値オブジェクトをまたがるルールを表現する
例:注文を在庫で処理できるか?
「注文を処理できるか?」というルールは、
- 注文(Order)単体でも
- 在庫(Inventory)単体でも
表現できません。両方を横断したビジネスルールになります。
そこで ドメインサービス として定義します。
type FulfillmentRule = {
canFulfill: (order: Order, inventories: ReadonlyArray<Inventory>) => boolean;
};
const fulfillmentRule: FulfillmentRule = {
canFulfill: (order, inventories) => {
const total = inventories.reduce((sum, inv) => sum + inv.quantity.value, 0);
return total >= order.orderQuantity.value;
},
};
なぜドメインサービスに分離するのか?
- Order に書くのは不自然
- 注文は「自分がPLACEDかどうか」を知っているが、「在庫全体の合計」を知るのは責務が大きすぎる
- Inventory に書くのも不自然
- 在庫は「自分の数量」を管理するのが責務であり、「複数在庫を合計して注文に対応できるか?」は役割を逸脱する
- 値オブジェクトには合わない
- 値オブジェクトは「数量は0以上」「減算時に不足はエラー」といった単独の値のルールを守るためのもの。複数のオブジェクトを横断するビジネスルールを持たせると責務過多になる
ドメインサービスに置くメリット
- 責務の分離が明確になる
- Order や Inventory がシンプルに保たれる。「横断ルールはここを見る」とチーム内での合意が作りやすい
- テストしやすい
- 関数に
Order
とinventories
を渡すだけで判定できる。状態を持たないのでモックや依存を考えなくてよい
- 関数に
- フロントエンド実装との相性が良い
- 例えば購入画面で「在庫が足りるなら購入ボタンを有効化」というUI判定を行うとき、
fulfillmentRule.canFulfill(order, inventories)
を呼べば業務知識がそのまま使える。UI側で if 文を並べるのではなく、ドメインルールを業務ルールごとに整理した関数として扱える
- 例えば購入画面で「在庫が足りるなら購入ボタンを有効化」というUI判定を行うとき、
ユースケース (Use Case)
ユースケースとは?
ユースケースは「アプリケーションがどうドメインモデルを使うか」を表現する層です。ドメイン駆動設計ではよく「アプリケーションサービス」と呼ばれることもあります。
- ドメインモデル(値オブジェクト / エンティティ / ドメインサービス)を組み合わせて処理を実行する
- しかし自分自身はビジネスルールを持たない
- フロントエンドにおいては Reactコンポーネントから呼ばれる関数 として表現するのが自然です
例1:在庫の数量を更新するユースケース
「ユーザーが在庫数を入力 → 確定ボタンで在庫を更新」というシナリオを考えてみます。
export function updateInventory(
current: Inventory | null,
input: string
): Inventory {
const qty = createInventoryQuantity(Number(input));
return current
? withIncreasedQuantity(current, qty)
: createInventory({ id: "1", productCode: "ABC", quantity: qty });
}
const [quantityInput, setQuantityInput] = useState<string>("");
const [inventory, setInventory] = useState<Inventory | null>(null);
function handleUpdateInventory() {
try {
const updated = updateInventory(inventory, quantityInput);
setInventory(updated);
} catch (e) {
alert("在庫数は0以上で入力してください");
}
}
このときの責務の分離
- ユースケース関数 (
updateInventory
)- 値オブジェクト(
InventoryQuantity
)を生成 - エンティティ(
Inventory
)を生成または更新 - バリデーションエラーがあれば例外を投げる
- 値オブジェクト(
- Reactハンドラ (
handleUpdateInventory
)- ユースケースを呼び出す
- 成功時はstate更新、失敗時はUIにエラーを表示
例2:注文処理のユースケース(ドメインサービス利用)
今度は「注文を処理できるか判定して、可能なら注文を確定する」というケースです。
ここでは ドメインサービス が登場します。
export function purchaseOrder(params: {
order: Order;
inventories: ReadonlyArray<Inventory>;
rule: FulfillmentRule;
}): Order {
const { order, inventories, rule } = params;
if (!rule.canFulfill(order, inventories)) {
throw new Error("在庫不足です");
}
return applyFulfill(order);
}
const [order, setOrder] = useState<Order | null>(null);
const [inventories, setInventories] = useState<Inventory[]>([]);
function handlePurchase() {
try {
const updated = purchaseOrder({
order,
inventories,
rule: fulfillmentRule,
});
setOrder(updated);
} catch (e) {
const msg = e instanceof Error ? e.message : "処理に失敗しました";
alert(msg);
}
}
おわりに
本記事では フロントエンドにおけるドメイン駆動設計の適用方法 を、値オブジェクト・エンティティ・ドメインサービス・ユースケースという4つの観点で紹介しました。
- 値オブジェクト で小さなルールを型に閉じ込める
- エンティティ で同一性や状態遷移を表現する
- ドメインサービス で複数モデルにまたがる横断的ルールを担う
- ユースケース で React のコンポーネントとドメインモデルをつなぐ
フロントエンドは入力やUI制御に近いため、業務ルールを「画面の if 文」で雑に処理してしまいがちです。しかしドメイン駆動設計的なアプローチを取ることで、 UIコードからビジネスロジックを切り離し、業務ルールごとに整理されたモデルとして再利用可能にできる ことが大きなメリットです。
もちろん、すべての画面・すべての入力にドメイン駆動設計を適用する必要はありません。
「業務ルールが複雑」「バックエンドと知識を揃えたい」といった場面から、少しずつ導入するのが現実的です。
フロントエンドにドメイン駆動設計を持ち込むことで、
- UI実装の安全性が高まり
- 業務知識がコードに表れ
- バックエンドとの会話がスムーズになる
という効果が期待できます。
これをきっかけに、みなさんのフロントエンド実装にも「ドメイン駆動設計的な視点」を取り入れていただければ嬉しいです。
最後までご覧頂きありがとうございます!