DTOとViewModelについて考えてみた

2025.10.21 09:00
2025.10.21 20:57
DTOとViewModelについて考えてみた

Laravelで開発していると、「どこで整形すべきか」がわからなくなることがよくあります。
Eloquentのまま渡すのか、DTOを挟むのか、それともViewModelまで作るのか。
自分のプロジェクトでもまさにそこが曖昧で、

  • DTOにformatメソッドが増えていく
  • BladeでCarbonやnumber_formatを直書き
  • nullチェックがあちこちに散る

…という状態になっていました。
最初に結論を言うと、「責務を分ける」ことを最優先にしたら、すべてが整理されたんです。
nullをどう扱うかは、そのあと自然に整っていきました。一旦リセットしました。

きっかけ:DTOが太ってきた

はじめは「EloquentモデルをそのままViewに渡すのはよくない」と思って、
DTOを作り始めました。

でも使っているうちに、DTOにどんどん整形処理が増えていきます。
日付や価格、住所の合成、ラベルの変換……。

そのうち、DTOがほぼViewModel化してしまい、
「これ何の層なんだっけ?」と混乱するようになりました。

DTOが責務を越えていたんです。

決めたこと:変換ポイントを1か所に。流れは一方向

そこでルールを一度リセットしました。
いろんな層の整形をひとまとめにして、
変換ポイントを1か所に決めることにしました。

まず責務の分離だけにフォーカスしてルールを決めました。

Infrastructure(DB/外部I/F)
  → DTO(*Row/*Result)   // 整形しない、生データの運搬役
    → ViewModel::fromRow/Result() // 表示用に整形する唯一の入口
      → Blade            // 値を出すだけ
  • DTO:生データを安全に運ぶ箱。整形・依存は持たない。
  • ViewModel:画面に最適化された最終形。見た目の整形はすべてここで完結。
  • Blade:単なる出力。フォーマット処理は禁止。

この構成にしてから、レビューやテストで「どこを見ればいいか」が一気に明確になりました。
“どこで整形するか”の迷いが消えたんです。

null非許容は「あとから効いてくる」ルールだった

この分離を運用しているうちに、
「nullを許すとまた曖昧になる」と感じるようになりました。

なので、DTO側でnullを使わない(欠損は既定値+フラグで表す)運用ルールを追加。
すると今度はViewModelの分岐がさらに薄くなり、
テストも安定して動くようになりました。

つまり順番としては、この流れ。

① 責務を分ける → ② nullを減らす

nullをなくすのは前提じゃなくて、設計を補強する仕上げの一手という位置づけです。

実装例(簡略)

DTO(整形しない・構造固定)

final readonly class SpotListItemRow {
    public function __construct(
        public int    $id,
        public string $name,
        public int    $monthlyPriceMin,   // 欠損時は0
        public int    $monthlyPriceMax,   // 同上
        public bool   $hasPrice,          // 価格情報が有効かどうか
        public string $prefectureName,    // 欠損時は空文字
        public string $cityName,
        public string $createdAtIso,      // ISO8601文字列
    ) {}
}

ViewModel(表示用の最終形)

final readonly class SpotListItemViewModel {
    public function __construct(
        public int    $id,
        public string $title,
        public string $priceRangeLabel,  // "120,000〜150,000円" / "—"
        public string $locationLabel,    // "東京都"
        public string $publishedLabel,   // "2025/10/21(火)"
    ) {}

    public static function fromRow(SpotListItemRow $r): self {
        $date = DateFormatter::ymdWithJpWeekday(CarbonImmutable::parse($r->createdAtIso));
        $loc  = $r->prefectureName . $r->cityName;
        $price = self::formatPrice($r->hasPrice, $r->monthlyPriceMin, $r->monthlyPriceMax);

        return new self(
            id: $r->id,
            title: $r->name,
            priceRangeLabel: $price,
            locationLabel: $loc,
            publishedLabel: $date,
        );
    }

    private static function formatPrice(bool $has, int $min, int $max): string {
        if (!$has) return '—';
        if ($min > 0 && $max > 0) return number_format($min).'〜'.number_format($max).'円';
        if ($min > 0) return number_format($min).'円〜';
        if ($max > 0) return '〜'.number_format($max).'円';
        return '—';
    }
}

Formatter(状態を持たない道具箱)

final class DateFormatter {
    public static function ymdWithJpWeekday(CarbonImmutable $dt): string {
        $w = ['日','月','火','水','木','金','土'][$dt->dayOfWeek];
        return $dt->format('Y/m/d')."($w)";
    }
}

Blade(表示するだけ)

@foreach ($vm->items as $it)
  <article class="c-card">
    <h3>{{ $it->title }}</h3>
    <p>{{ $it->locationLabel }} / {{ $it->publishedLabel }} / {{ $it->priceRangeLabel }}</p>
  </article>
@endforeach

nullを使わないメリット

null禁止にすると、コードの見通しが劇的に変わりました。

  • if ($dto->name !== null) がなくなり、VMが読みやすい
  • ViewModel::fromRow() が「必ず成立する」のでテストが軽い
  • hasXxx フラグがあることで意図が明確

「nullを排除する」こと自体が目的じゃなく、
結果的に責務分離を強く支えるルールになった感覚です。

チェックリスト(自分へのメモ)

  • DTOに整形ロジックを書いていないか?
  • 変換は ViewModelの静的ファクトリだけか?
  • Bladeに Carbonnumber_format が出てないか?
  • nullがDTO内に紛れ込んでないか?(既定値+フラグで済む?)
  • VMが外部依存(DBやサービス呼び出し)をしていないか?

まとめ

今回の整理で学んだのは、
責務を分けてシステムの見通しを良くするために
「DTOとViewModelを分ける」こと自体が目的であり、
null非許容はその設計を支える運用ルールにすぎないということ。

先に分ける。
そして、余白が見えてきたら null を整える。

順番を間違えなければ、Laravelの設計はずっとシンプルで、
テストも壊れにくくなりますね。

今回は以上です!