Result/Row/ViewModel三段構成がしっくりきた話

2025.10.28 09:00
2025.10.21 23:23
Result/Row/ViewModel三段構成がしっくりきた話

一覧画面を作るたびに、「どの層で何を持つか」がぼやけがちでした。
Eloquentのまま渡すと表示ロジックが散るし、DTOに寄せるとVMっぽく太る。
そこで今回、Result / Row / ViewModel をきっちり分けてみたら、想像以上に見通しが良くなりました。
最終的に落ち着いたのがこの構成です。

まずは全体の構成

流れをざっくりと。ファイルは膨らみましたが、
責務がはっきりして混乱が減り、改善運用もやりやすくなりました。

Controller
   ↓
UseCase
   ↓
Result (Row[] + ページ情報)
   ↓
ViewModelFactory
   ↓
ItemViewModel[]  ← Formatter(日付・価格・Tel)
   ↓
ListViewModel(paginator/toArray など)
   ↓
Blade(出すだけ)

Row:1件分の“生データ”を固定する(SpotListItemRow

SpotListItemRowreadonly & 非null のDTO。
検索結果1件分をありのまま運ぶ役で、表示の整形は一切しない。

  • 住所・電話・辞書名など、一覧に必要な最小限を持つ
  • 価格系は monthlyPriceMin/Max状態フラグforCheckMonthly, isPricingConsul)で明示
  • 画像や状態アイコンもそのままstatusIcons{type_id,status} の配列)

良かった点:
「非null+フラグ」で状態が明示されるので、上位でissetを書かなくて済む。

Result:束ねて運ぶ“台車”(SpotListResult

SpotListResultRow[] + ページ情報 のまとまり。
UseCaseからController/VMへ渡す“契約”になりました。

  • itemsSpotListItemRow[]
  • total / perPage / currentPage をセットで保持
  • ここでも整形はしない。あくまで運搬役

良かった点:
ページング情報の責務が固定されて、「どこから取る?」迷いが消えた

ViewModel:見せ方の最終形(SpotListItemViewModel

SpotListItemViewModel::fromRow()唯一の変換入口
ここで Formatter に委譲しながら表示用に整えます。

  • SpotFormatter:住所結合 / 価格レンジ / 画像URL
  • TelFormatter:表示用・数字のみ の両方を生成
  • statusIcons辞書解決済みの最小構造を渡して、そのままループ可能に

ねらい:
Bladeは $vm->monthlyPrice$vm->fullAddress出すだけ
“どこで整形するか”を固定できたので、画面ごとのブレがなくなりました。

一覧全体のVM:道具が揃ってる(SpotListViewModel

SpotListViewModel一覧全体のVM。
fromResult()Row[] → ItemVM[] に変換して、下記の“便利関数”でViewを支えます。

  • totalPages / hasNextPage / hasPreviousPage / from / to
  • paginator()LengthAwarePaginator に変換(クエリ付与対応)
  • toArray():API/Blade共有用の軽量シリアライズ

良かった点:
ページング周りの微妙な計算をここに隠せるので、BladeもControllerも静か。

Factoryで“辞書解決”を外出し(SpotListViewModelFactory

SpotListItemViewModel::fromRow()整形の入口に特化させて、
アイコンの辞書解決だけは SpotIconsViewModelFactory に分離しました。

  • statusIcons{type_id,status} 生配列 → 表示可能なVM配列
  • その後に SpotListItemViewModel::fromRow($row, $statusIconViewModels) で組み立て

良かった点:
辞書解決の責務がVMから抜けたことで、VMが“見せ方”だけに集中できた。

使ってみてわかった三段構成の効きどころ

責務の棚卸しが進む
 Row=生, Result=束ねる, VM=見せる の三語だけで合意できる。

レビュー基準が明快
 「それはRowに入れる?VMでやる?」の会話が早い。

テストの層が分かれる
 Row/Resultは型の保証、VMは表示の期待値、Factoryは差し替えを検証。

ページングの匂いを封じ込める
 paginator()totalPages() 系をVMに寄せたら、Controller/Bladeが一気に静かに。

まとめ

Resultが運んで、Rowが持って、ViewModelが見せる

この三段で並べ直したら、
データの旅路がはっきり見えるようになりました。

  • Row:検索結果1行の事実
  • Result:その束とページの文脈
  • ViewModel:画面での語り方

ファイルは増えるけど、迷いが減る
最終的に、BladeもControllerもスッキリしましたね。

今回は以上です!