3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンドを業務ルールごとに整理する ― ドメイン駆動設計の入り口

Posted at

フロントエンドでドメイン駆動設計を使うメリットとは?

ドメイン駆動設計と聞くと「サーバーサイドの設計手法」という印象を持つ方が多いかもしれません。
しかし近年ではフロントエンドの役割が大きく変わり、ドメイン駆動設計的なアプローチが求められます。

1. フロントエンドにも複雑な業務知識が現れる

かつてのフロントエンドは「画面を描画するだけ」でした。
しかし、いまやSPAやリッチなUIがよくあると思います。
たとえばECサイトのフロント側ではこんなロジックが存在します。

  • 「在庫がなければ購入ボタンを無効化」
  • 「注文後に一定時間だけキャンセル可能」
  • 「クーポンは条件を満たした場合のみ適用」

これらは ビジネスルールそのもの であり、UI表示に直結します。
フロント側で同じルールを重複実装すれば「知識の断絶」が生じます。

2. フロントエンドこそ「業務ルールごとに整理」されると強い

バックエンドはAPI設計やDBスキーマである程度ルールが見えます。
しかしフロントエンドは「コンポーネントのprops」や「状態管理の変数」が並ぶだけでは、
どんな業務ルールがあるのか見えづらい。

❌ 無口なコード.ts
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)
    • 一度生成したら変更されない
  • 値の等価性で同一性を判断
    • 「参照の同一性」ではなく「値そのもの」で比較
  • ルールを内部に閉じ込める
    • バリデーションや制約を外に漏らさない

例: 在庫数量 値オブジェクト

在庫数量.ts
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 を直接触らない)
在庫エンティティ.ts
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のときだけ」
注文エンティティ.ts
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 がシンプルに保たれる。「横断ルールはここを見る」とチーム内での合意が作りやすい
  • テストしやすい
    • 関数にOrderinventoriesを渡すだけで判定できる。状態を持たないのでモックや依存を考えなくてよい
  • フロントエンド実装との相性が良い
    • 例えば購入画面で「在庫が足りるなら購入ボタンを有効化」というUI判定を行うとき、fulfillmentRule.canFulfill(order, inventories) を呼べば業務知識がそのまま使える。UI側で if 文を並べるのではなく、ドメインルールを業務ルールごとに整理した関数として扱える

ユースケース (Use Case)

ユースケースとは?

ユースケースは「アプリケーションがどうドメインモデルを使うか」を表現する層です。ドメイン駆動設計ではよく「アプリケーションサービス」と呼ばれることもあります。

  • ドメインモデル(値オブジェクト / エンティティ / ドメインサービス)を組み合わせて処理を実行する
  • しかし自分自身はビジネスルールを持たない
  • フロントエンドにおいては Reactコンポーネントから呼ばれる関数 として表現するのが自然です

例1:在庫の数量を更新するユースケース

「ユーザーが在庫数を入力 → 確定ボタンで在庫を更新」というシナリオを考えてみます。

ユースケース.ts
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 });
}
Reactコンポーネント.tsx
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:注文処理のユースケース(ドメインサービス利用)

今度は「注文を処理できるか判定して、可能なら注文を確定する」というケースです。
ここでは ドメインサービス が登場します。

ユースケース.ts
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);
}
Reactコンポーネント.tsx
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実装の安全性が高まり
  • 業務知識がコードに表れ
  • バックエンドとの会話がスムーズになる

という効果が期待できます。
これをきっかけに、みなさんのフロントエンド実装にも「ドメイン駆動設計的な視点」を取り入れていただければ嬉しいです。

最後までご覧頂きありがとうございます!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?