フロントエンドのValue Objectに複数のformatを生やすのを止めた話 - ドメイン整形と表示整形の境界線

開発

フロントエンドのValue Objectにformat()toLabel()を生やしていいのか。結論から言うと、canonicalな整形が1個に決まるなら生やしていい。でもformatAformatBと複数になり始めたら、それはもうVOの仕事じゃない。presentation層のformatterに出すサイン。

なんでこんな話をするかというと、今のプロジェクトでまさに複数formatが増殖し始めて、止めることにしたから。その判断の過程を残しておく。

formatBを書こうとした手が止まった瞬間の話

筆者

formatA, formatBが増えていく瞬間

きっかけは価格を表すVO。仮にPriceとしておく。最初はシンプルだった。

class Price {
  constructor(private readonly amount: number) {}

  toLabel(): string {
    return${this.amount.toLocaleString()}`;
  }
}

toLabel()が1個。これは問題ない。¥1,000という表現はこのアプリでの価格の自然な姿だった。

問題は数週間後。商品一覧では¥1,000、サマリー画面では1,000円(税込)、CSV出力では1000、と画面ごとに違う表現が要るようになった。そこでこうなりかけた。

class Price {
  constructor(private readonly amount: number) {}

  formatForList(): string { return${this.amount.toLocaleString()}`; }
  formatForSummary(): string { return `${this.amount.toLocaleString()}円(税込)`; }
  formatForCsv(): string { return String(this.amount); }
}

メソッド名にForListForSummaryForCsvが入ってる。この時点で気づいた。VOが画面を知り始めてる。

「フロントだから入れていい」は逆だった

正直、最初は複数formatでもいい気がしてた。理屈はこう。「バックエンドのVOにビジネスロジックを入れるのと違って、フロントのformatはただの表示整形。プレゼンテーショナルロジックだから、VOに持たせても害は少ないでしょ」と。

でもこれ、逆なんだよね。「プレゼンテーショナルロジックだ」と認識できてるなら、それはVOの外に出す根拠になる。表示整形はpresentation層の関心事。フロントエンドでも「ドメイン層/プレゼンテーション層」は分けられる。フロントだから混ぜていい、じゃない。プレゼンテーショナルだからプレゼンテーション層に置く、が筋。

過去にtoLabel一個で成功した記憶があるから誘惑される

筆者

過去にcanonicalなtoLabel()を1個だけ生やしてうまくいった成功体験があると、つい「VOにformat、いいじゃん」と引っ張られる。でも1個と複数は別物だった。

境界線は「画面を知らずに名前をつけられるか」

ここが今回いちばん言いたいところ。VOに整形メソッドを持たせていいかは、この一本の問いで判断できる。

そのメソッドは、どの画面で使うかを知らずに名前をつけられるか?

  • price.toLabel() → 画面を知らない。価格の自然な姿。OK
  • price.formatForSummary() → サマリー画面を知ってる。NG、層を出せ

メソッド名に画面名や用途(ForListForModalShortCompact)が入った瞬間、それはドメインの関心事じゃなくてUIの都合。VOが吸い込むべきじゃない。

これを「ドメイン整形」と「表示整形」の境界線と呼んでる。

ドメイン整形 = 値の意味として正準な形が1つに定まる変換
              例: Email正規化(小文字化・trim)、Score → 等級
表示整形     = 画面・ロケール・文脈によって変わる見せ方
              例: 1000 → "¥1,000" / "1,000円(税込)" / "1000"

ドメイン整形はVOの中。表示整形はpresentation層のformatter。

Martin Fowlerの「沈黙」が論拠になる

おもしろいのが、Value Objectの正準定義であるMartin Fowlerの記事。VOの中核に置いてるのは「値が等しければ等しい」というequalityと、immutability。そして示すコード例はequals()とシンプルなaccessorだけ。

class Point {
  constructor(x, y) { this.x = x; this.y = y; }
  equals(other) { return this.x === other.x && this.y === other.y; }
}

format系、表示メソッドには一言も触れてない。つまり原典のVO像に表示整形は含まれてない。「VOにformatを生やす」のはデフォルトじゃなくて拡張なんだよね。日本語のVO解説はtoStringを生やす前提で書かれがちだけど、この「不在の論拠」はあまり語られてない。

Engine Yardの例で線が引ける

JS文脈でVOにメソッドを持たせる古い良記事(Engine Yard)に、ちょうどいい対比がある。成績を表すGradeVOにこんなメソッドを持たせてる。

// ドメイン内在の正準変換: 0.65 → "D" はGradeの本質
letterGrade: function() { return this.grade.letter; }

// ECMAScript特殊メソッド: 再構築可能なcanonical値を1個返す
valueOf: function() { return this.percentage; }

letterGrade()(0.65→“D”)は画面に関係なくGradeの意味として決まる正準変換。だからVOでいい。一方valueOf/toStringはECMAScriptの特殊メソッドで、本来「canonicalな値を1個返す」のが目的。仕様レベルで「1個・正準」が前提になってる。

ここから言えるのは、canonicalな1個ならVO、画面依存の複数は別、という線にはECMAScript仕様の裏付けがあるってこと。toStringを複数バージョン持てないのは、そういう設計思想だから。

複数formatをVOに生やすと何が壊れるか

VOに表示整形を足すと壊れるのは4つ。依存逆転・i18n破綻・テスト肥大・共有VOの相互破壊。どれも書いてる瞬間は害がなくて、後からじわじわ効いてくる。止めた理由を順番に挙げておく。

ひとつめ、依存逆転。formatForSummary()という名前は「サマリー画面が存在する」という知識をVOに焼き付ける。ドメインモデルがUIを知ってる状態。画面を消したらVOのメソッドも消す、みたいな逆向きの依存が生まれる。

あと、i18n破綻。表示整形をVOに置くと、ロケール対応を足したくなった瞬間にVOがi18nライブラリやロケール状態に依存し始める。ドメインが言語環境に縛られる。price.formatForSummary(locale)とか引数が生えてきて、もう収拾がつかない。

みっつめ、テスト肥大。表示の都合でVOのテストが増殖する。「サマリー表記が円(税込)になってるか」みたいな表示仕様のテストが、本来は値の同一性と不変条件を守るはずのVOにぶら下がる。

最後に、共有VOの相互破壊。Khalil Stemmlerが指摘してるリスク。VOをフロント・バック共有のパッケージに置いてる場合、フロント都合の表示変更がVO経由でバックエンドのテストを壊す。表示整形をVOに足すのは、この事故の入り口になる。

「見た目が同じ」で共通化して後悔する話と構造は同じで、整形メソッドも「同じVOだから」と集めすぎると責務が膨らむ。

shadcn/uiに学ぶReactのprops地獄から抜け出すCompound Componentパターン開発

じゃあどう書くか

答えはシンプル。canonicalな整形はVOに1個、画面依存の整形はpresentation層のformatterに出す。

VO側は薄く保つ。

class Price {
  constructor(private readonly amount: number) {}

  // 値の取り出しとドメイン操作だけ
  get value(): number { return this.amount; }
  add(other: Price): Price { return new Price(this.amount + other.value); }

  // canonicalな表現が1つに決まるならこれだけ持つ
  toString(): string { return${this.amount.toLocaleString()}`; }
}

画面ごとの表現はformatter関数に切り出す。presentation層、コンポーネントの近くに置く。

// presentation/formatters/price.ts
export const formatPriceForSummary = (price: Price): string =>
  `${price.value.toLocaleString()}円(税込)`;

export const formatPriceForCsv = (price: Price): string =>
  String(price.value);

こうすると、ロケール対応も画面追加もpresentation層の中で閉じる。VOはずっと薄いまま。テストもドメインはドメイン、表示は表示で分かれる。

命名で意図を示したいなら、VO側のcanonicalメソッドをtoLabel()toDisplayLabel()にするのもあり。ただし「Labelが1個に収束する」のが条件。複数のtoLabel系が要るなら、それはもうformatterの仕事。

このあたりの「Repository/Domainは正規化まで、フォーマットはPresentation層」という責務分離は、フロントエンドのレイヤー設計の話とつながってる。

フロントエンドにクリーンアーキテクチャは必要か?判断基準とWeb/モバイルの違い開発

バリデーションのエラーメッセージを表示層に置くのと同じ理屈で、formatも表示層に寄せる、と考えると一貫する。

Zodでフロントエンド/バックエンドのバリデーションを一元管理する方法開発

ちなみに、薄く保ったVO自体をどのディレクトリに置くか、も別で迷うポイント。これはBounded Contextの話とつながるので、気になる人はこっちも。

ドメイン層をfeatureディレクトリに置くな - Bounded Contextで整理すべき理由開発

「フロントは高級なJSON」論の落とし穴

判断に効いた視点をもうひとつ。Stemmlerはフロントのドメインモデルを、DTOから「hydrateする」ものと表現してる。hydrateは乾燥わかめを水で戻すみたいに、サーバーから来たただのJSONをメソッド付きのオブジェクトに膨らませること。フロントのVOはバックにある真実のコピーで、存在理由が最初から描画寄り、という見方。

知り合いが「フロントは高級なJSONだ」と言ってたけど、近い話。ビジネスロジックの真実はバックにある、と。CRUDだけの画面なら、これでだいたい合ってる。

ただこれを「フロントにロジックはない」まで進めると行き過ぎ。フロントにもロジックはある。リアルタイムに価格を再計算して出したい、入力に応じて検証したい、みたいな要件は普通にある。そういうのはdomain serviceやVOに寄せる。Stemmler自身も後から「バックでやってる設計はフロントでもやるべき。value objectもuse caseも」と言い直してる。

ここで混同しやすいのが、フロントのロジックには2種類あること。

  • ドメインロジック: 価格の再計算、検証、状態遷移。値の意味やビジネスルールに属する。domain service / VOへ
  • プレゼンテーショナルロジック: 「3日前」表記、通貨フォーマット、画面ごとの見せ方。表示の都合に属する。presentation層へ

見分け方はシンプルで、「画面が変わっても答えが同じか」。カート合計の計算はどの画面でも同じ答えになるからドメインロジック。formatForSummaryは画面が変わると答えが変わるからプレゼンテーショナルロジック。

リアルタイム計算をdomain serviceに寄せるのは正しい。でもそれは「プレゼンテーショナルロジックだから」じゃなくて「ドメインロジックだから」なんだよね。逆に複数のtoLabelは、計算してるように見えても中身は「この画面でどう見せるか」。だからVOじゃなくpresentation層。

フロントは描画寄りの器。だからこそ、その器に何でも盛らずに、ドメインロジックとプレゼンテーショナルロジックを分けて置く。複数のtoLabelが必要になったら、それは後者が紛れ込んだサイン。

需要は小さいニッチな論点だけど、現場で実際に手が止まる場面ではある。formatBを書こうとして「あれ、これVOでいいんだっけ」と思ったら、メソッド名に画面名が入ってないか見てみて。

まとめ

  • canonicalな整形が1個に決まるならVOに置いていい(toString/toLabel
  • formatForXxxと画面依存・複数になったらpresentation層のformatterへ
  • 判断軸は「どの画面で使うか知らずにメソッド名をつけられるか」
  • 複数formatをVOに足すと、依存逆転・i18n破綻・テスト肥大・共有VOの相互破壊が起きる
  • Fowlerの正準VO像に表示整形は含まれない。ECMAScriptのtoString/valueOfも「canonicalな1個」が前提

フロントだからプレゼンテーショナルロジックを混ぜていい、じゃない。プレゼンテーショナルだと気づけたなら、それは層を分ける合図。

Thanks for reading!
value-objectdomain-driven-designfrontendtypescriptarchitecture