0
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?

バイブコーディングの質を決めるのは、結局モデリング力だった

0
Posted at

AIの出力を良くする方法は、プロンプト小手先ではなく“先にモデルを作る”ことだった。

はじめに

最近、AIエージェントを使った開発(いわゆるバイブコーディング)をよくやっています。
Claude Code、GitHub Copilotなど、ツールは色々ありますが、どれを使っても感じることがあります。

「丸投げすると、動くけど微妙なものが出てくる」

文脈を伝えないと的確なアウトプットは出てきません。
これはAIエージェントが登場する前から変わらない話です。

私はもともと、業務理解やドメイン知識を整理する「モデリング」を重視してきました。
バイブコーディングを始めてから気づいたのは、手で書こうがAIに書かせようが、結局モデリングの質が重要ということでした。

AIエージェント = 優秀だけど文脈を知らない新メンバー

考えてみれば当たり前の話で、AIエージェントに丸投げするということは、プロジェクトに入ったばかりの優秀なエンジニアに「よろしく!」と言って仕事を投げるようなものです。

  • 何を作りたいのか曖昧だと、曖昧なものが返ってくる
  • 背景を伝えないと、的外れな実装になる
  • 細かい要件を言わないと、抜け漏れが起きる

結局、相手の能力ではなく、こちらの伝え方がボトルネックになっています。

これは人間相手でも同じです。
新メンバーに丸投げしたら微妙なものが出てくるのは当たり前で、ちゃんと背景や意図を伝えないといけない。

美容院予約システムで比較してみた

実際にどれくらい違いが出るのか、美容院の予約システムを題材に試してみます。

丸投げプロンプト

美容院の予約機能を作ってください。
顧客がスタイリストを指名して、メニューを選んで、日時を選んで予約できるようにしてください。

これだけ投げて、Laravel プロジェクトで実装してもらいました。

※記事の趣旨上、極端に雑なプロンプトにしています。

Claude Codeの出力

⏺ 美容院の予約機能を作成しました。

  作成したファイル

  マイグレーション
  - database/migrations/2024_01_01_000001_create_stylists_table.php
  - database/migrations/2024_01_01_000002_create_menus_table.php
  - database/migrations/2024_01_01_000003_create_reservations_table.php

  モデル
  - app/Models/Stylist.php - スタイリスト
  - app/Models/Menu.php - メニュー
  - app/Models/Reservation.php - 予約

  コントローラー
  - app/Http/Controllers/Api/StylistController.php
  - app/Http/Controllers/Api/MenuController.php
  - app/Http/Controllers/Api/ReservationController.php

  ルート
  - routes/api.php

  API エンドポイント
  | メソッド | パス                                 | 説明                 |
  |----------|--------------------------------------|----------------------|
  | GET      | /api/v1/stylists                     | スタイリスト一覧     |
  | GET      | /api/v1/stylists/{id}                | スタイリスト詳細     |
  | GET      | /api/v1/menus                        | メニュー一覧         |
  | GET      | /api/v1/menus/{id}                   | メニュー詳細         |
  | GET      | /api/v1/reservations/available-slots | 空き時間スロット取得 |
  | POST     | /api/v1/reservations                 | 予約作成             |

  使用方法
  # マイグレーション実行
  php artisan migrate:fresh --seed

  # サーバー起動
  php artisan serve

  予約の流れ:
  1. GET /api/v1/stylists でスタイリストを選ぶ
  2. GET /api/v1/menus でメニューを選ぶ
  3. GET /api/v1/reservations/available-slots?stylist_id=1&menu_id=1&date=2025-12-28 で空き時間を確認
  4. POST /api/v1/reservations で予約を作成

一見良さそうに見えますね。でも中身を見ると...

出てきたコード

ReservationController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Menu;
use App\Models\Reservation;
use App\Models\Stylist;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ReservationController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'customer_name' => 'required|string|max:255',
            'customer_email' => 'required|email|max:255',
            'customer_phone' => 'required|string|max:20',
            'stylist_id' => 'required|exists:stylists,id',
            'menu_id' => 'required|exists:menus,id',
            'reserved_at' => 'required|date|after:now',
            'notes' => 'nullable|string|max:1000',
        ]);

        $stylist = Stylist::findOrFail($validated['stylist_id']);
        $menu = Menu::findOrFail($validated['menu_id']);

        $reservedAt = Carbon::parse($validated['reserved_at']);

        if ($this->hasConflict($stylist->id, $reservedAt, $menu->duration_minutes)) {
            return response()->json([
                'message' => 'この時間帯は既に予約が入っています。',
            ], 422);
        }

        $reservation = Reservation::create([
            'customer_name' => $validated['customer_name'],
            'customer_email' => $validated['customer_email'],
            'customer_phone' => $validated['customer_phone'],
            'stylist_id' => $validated['stylist_id'],
            'menu_id' => $validated['menu_id'],
            'reserved_at' => $reservedAt,
            'status' => 'pending',
            'notes' => $validated['notes'] ?? null,
        ]);

        $reservation->load(['stylist', 'menu']);

        return response()->json([
            'message' => '予約を受け付けました。',
            'data' => $reservation,
        ], 201);
    }

    public function availableSlots(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'stylist_id' => 'required|exists:stylists,id',
            'menu_id' => 'required|exists:menus,id',
            'date' => 'required|date|after_or_equal:today',
        ]);

        $stylist = Stylist::findOrFail($validated['stylist_id']);
        $menu = Menu::findOrFail($validated['menu_id']);
        $date = Carbon::parse($validated['date']);

        $slots = $this->generateAvailableSlots($stylist, $menu, $date);

        return response()->json([
            'data' => $slots,
        ]);
    }

    private function hasConflict(int $stylistId, Carbon $reservedAt, int $durationMinutes): bool
    {
        $newEnd = $reservedAt->copy()->addMinutes($durationMinutes);

        $existingReservations = Reservation::where('stylist_id', $stylistId)
            ->whereIn('status', ['pending', 'confirmed'])
            ->whereDate('reserved_at', $reservedAt->toDateString())
            ->with('menu')
            ->get();

        foreach ($existingReservations as $reservation) {
            $existingStart = $reservation->reserved_at;
            $existingEnd = $existingStart->copy()->addMinutes($reservation->menu->duration_minutes);

            if ($reservedAt < $existingEnd && $newEnd > $existingStart) {
                return true;
            }
        }

        return false;
    }

    private function generateAvailableSlots(Stylist $stylist, Menu $menu, Carbon $date): array
    {
        $openingTime = $date->copy()->setTime(9, 0);
        $closingTime = $date->copy()->setTime(19, 0);
        $slotInterval = 30;

        $existingReservations = Reservation::where('stylist_id', $stylist->id)
            ->whereIn('status', ['pending', 'confirmed'])
            ->whereDate('reserved_at', $date)
            ->with('menu')
            ->get();

        $slots = [];
        $currentSlot = $openingTime->copy();
        $now = Carbon::now();

        while ($currentSlot->copy()->addMinutes($menu->duration_minutes) <= $closingTime) {
            if ($currentSlot > $now) {
                $isAvailable = true;
                $slotEnd = $currentSlot->copy()->addMinutes($menu->duration_minutes);

                foreach ($existingReservations as $reservation) {
                    $reservationEnd = $reservation->reserved_at->copy()
                        ->addMinutes($reservation->menu->duration_minutes);

                    if ($currentSlot < $reservationEnd && $slotEnd > $reservation->reserved_at) {
                        $isAvailable = false;
                        break;
                    }
                }

                $slots[] = [
                    'time' => $currentSlot->format('H:i'),
                    'datetime' => $currentSlot->toIso8601String(),
                    'available' => $isAvailable,
                ];
            }

            $currentSlot->addMinutes($slotInterval);
        }

        return $slots;
    }
}

Reservation.php(モデル)

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Reservation extends Model
{
    use HasFactory;

    protected $fillable = [
        'customer_name',
        'customer_email',
        'customer_phone',
        'stylist_id',
        'menu_id',
        'reserved_at',
        'status',
        'notes',
    ];

    protected $casts = [
        'reserved_at' => 'datetime',
    ];

    public function stylist(): BelongsTo
    {
        return $this->belongsTo(Stylist::class);
    }

    public function menu(): BelongsTo
    {
        return $this->belongsTo(Menu::class);
    }
}

問題点

いわゆる Fat Controller ですね。そして問題はそれだけではなく、

  1. メニューが1つしか選べないmenu_id が単一。カット + カラーみたいな組み合わせができない
  2. 指名料がない — スタイリストのランクによる指名料という概念が抜けている
  3. 顧客が Entity になっていない — 毎回名前・電話番号を入力する設計
  4. ロジックが Controller に集中 — 重複チェック、空き枠生成が全部ここに
  5. ステータスがただの文字列'status' => 'pending' typo しても気づかない

動くけど、実際の美容院予約としては使い物にならないですね。
要件が全然伝わっていません。

モデリングしてから依頼する

次に、事前にドメインモデリングをしてから依頼してみました。

Step 1: ドメインモデル図を作る

まずはクラス図を作成します。

image.png

登場人物を整理します。

  • 集約ルート: 予約
  • Entity: スタイリスト、メニュー、顧客
  • Value Object: 金額、所要時間、時間枠、予約メニュー
  • Enum: 予約ステータス、スタイリストランク、メニューカテゴリ

Step 2: 自然言語化する

図を見ながら、自然言語でドメインモデルを説明します。
バイブコーディングという概念が登場するまでは自然とやっていた方も多いのではないでしょうか。

【集約ルート: 予約】
- 顧客ID、スタイリストID、予約メニュー(1件以上)、時間枠、指名料、予約ステータスを持つ
- 合計所要時間を算出できる(予約メニューの所要時間を合算)
- 合計金額を算出できる(予約メニューの価格合計 + 指名料)
- 確定する / キャンセルする という振る舞いを持つ

【VO: 予約メニュー】
- 予約時点のメニュー情報スナップショット
- メニューID、メニュー名、価格、所要時間を持つ

【VO: 時間枠】
- 開始時刻と所要時間を持ち、終了時刻を算出できる
- 他の時間枠との重複判定ができる

【Enum: スタイリストランク】
- ジュニア / レギュラー / トップ
- 各ランクは指名料を返す(0円 / 550円 / 1,100円)

【ドメインルール】
1. 予約には1件以上の予約メニューが必須
2. スタイリストは対応可能カテゴリのメニューのみ受け付け可能
3. 予約の時間枠はスタイリストの既存予約と重複不可
4. 指名料はスタイリストのランクから導出
5. 予約メニューの価格・所要時間は予約時点でスナップショット保存

Step 3: アーキテクチャガイドラインを定義する

レイヤードアーキテクチャで実装してください。

- Domain層: ビジネスロジックの中心。Entity, VO, Enumを配置。Eloquentに依存しない。
- UseCase層: ユースケースを実装。ドメイン層を組み合わせてフローを組み立てる。
- Infrastructure層: リポジトリの実装。Eloquentはここで使う。
- Presentation層: コントローラー。HTTPの入出力のみ。

実装ルール:
- VOは不変(イミュータブル)
- Enumは振る舞いを持てる(PHP8.1+のEnum)
- 集約をまたぐ参照はIDで行う

Step 4: ドメイン層を依頼する

まずドメイン層を作ってもらいます。

Domain層を実装してください。
リポジトリはインターフェースのみ定義してください。

Claude Codeの出力

⏺ ドメイン層の実装が完了しました。以下の構造で作成しました:

  app/Domain/
  ├── Aggregate/
  │   └── Reservation.php          # 集約ルート
  ├── Entity/
  │   ├── Customer.php             # 顧客
  │   ├── Menu.php                 # メニュー
  │   └── Stylist.php              # スタイリスト
  ├── Enum/
  │   ├── MenuCategory.php         # メニューカテゴリ
  │   ├── ReservationStatus.php    # 予約ステータス
  │   └── StylistRank.php          # スタイリストランク(指名料取得可能)
  ├── Exception/
  │   └── InvalidReservationException.php
  ├── Repository/
  │   ├── CustomerRepositoryInterface.php
  │   ├── MenuRepositoryInterface.php
  │   ├── ReservationRepositoryInterface.php
  │   └── StylistRepositoryInterface.php
  └── ValueObject/
      ├── Duration.php             # 所要時間(分単位、加算可能)
      ├── Money.php                # 金額(不変、加算可能)
      ├── ReservedMenu.php         # 予約メニュー(スナップショット)
      └── TimeSlot.php             # 時間枠(重複判定可能)

  実装のポイント:
  - VO: すべて readonly class でイミュータブル
  - Enum: PHP 8.1+ の Backed Enum を使用、振る舞い(nominationFee() など)を持つ
  - Entity: create() と reconstruct() を分離(新規生成と永続化からの復元を区別)
  - 集約ルート(Reservation):
    - 1件以上のメニュー必須を検証
    - スタイリストの対応可能カテゴリを検証
    - ステータス遷移を制御(canTransitionTo())
    - 合計金額・所要時間の算出
  - リポジトリ: インターフェースのみ定義(Infrastructure層で実装予定)

いい感じですね!出てきたコードを見てみましょう。

Reservation.php(集約ルート)

<?php

declare(strict_types=1);

namespace App\Domain\Aggregate;

use App\Domain\Entity\Menu;
use App\Domain\Entity\Stylist;
use App\Domain\Enum\ReservationStatus;
use App\Domain\Exception\InvalidReservationException;
use App\Domain\ValueObject\Duration;
use App\Domain\ValueObject\Money;
use App\Domain\ValueObject\ReservedMenu;
use App\Domain\ValueObject\TimeSlot;

class Reservation
{
    /**
     * @param array $reservedMenus
     */
    private function __construct(
        private readonly string $id,
        private readonly string $customerId,
        private readonly string $stylistId,
        private array $reservedMenus,
        private TimeSlot $timeSlot,
        private Money $nominationFee,
        private ReservationStatus $status,
    ) {
        if (count($reservedMenus) === 0) {
            throw new InvalidReservationException('予約には1件以上のメニューが必要です');
        }
    }

    /**
     * 新規予約を作成(仮予約状態で作成)
     *
     * @param array $menus
     */
    public static function create(
        string $id,
        string $customerId,
        Stylist $stylist,
        array $menus,
        TimeSlot $timeSlot,
    ): self {
        if (count($menus) === 0) {
            throw new InvalidReservationException('予約には1件以上のメニューが必要です');
        }

        // スタイリストが対応可能か検証
        foreach ($menus as $menu) {
            if (!$stylist->canHandleMenu($menu)) {
                throw new InvalidReservationException(
                    "スタイリスト「{$stylist->name()}」はメニュー「{$menu->name()}」に対応できません"
                );
            }
        }

        // メニューからスナップショットを生成
        $reservedMenus = array_map(
            fn(Menu $menu) => ReservedMenu::fromMenu($menu),
            $menus
        );

        return new self(
            id: $id,
            customerId: $customerId,
            stylistId: $stylist->id(),
            reservedMenus: $reservedMenus,
            timeSlot: $timeSlot,
            nominationFee: $stylist->nominationFee(),
            status: ReservationStatus::Provisional,
        );
    }

    /**
     * 永続化からの再構築
     */
    public static function reconstruct(
        string $id,
        string $customerId,
        string $stylistId,
        array $reservedMenus,
        TimeSlot $timeSlot,
        Money $nominationFee,
        ReservationStatus $status,
    ): self {
        return new self(
            id: $id,
            customerId: $customerId,
            stylistId: $stylistId,
            reservedMenus: $reservedMenus,
            timeSlot: $timeSlot,
            nominationFee: $nominationFee,
            status: $status,
        );
    }

    public function id(): string
    {
        return $this->id;
    }

    public function customerId(): string
    {
        return $this->customerId;
    }

    public function stylistId(): string
    {
        return $this->stylistId;
    }

    /**
     * @return array
     */
    public function reservedMenus(): array
    {
        return $this->reservedMenus;
    }

    public function timeSlot(): TimeSlot
    {
        return $this->timeSlot;
    }

    public function nominationFee(): Money
    {
        return $this->nominationFee;
    }

    public function status(): ReservationStatus
    {
        return $this->status;
    }

    /**
     * 合計所要時間を算出
     */
    public function totalDuration(): Duration
    {
        return array_reduce(
            $this->reservedMenus,
            fn(Duration $carry, ReservedMenu $menu) => $carry->add($menu->duration()),
            Duration::zero()
        );
    }

    /**
     * 合計金額を算出(予約メニューの価格合計 + 指名料)
     */
    public function totalPrice(): Money
    {
        $menuTotal = array_reduce(
            $this->reservedMenus,
            fn(Money $carry, ReservedMenu $menu) => $carry->add($menu->price()),
            Money::zero()
        );

        return $menuTotal->add($this->nominationFee);
    }

    /**
     * 予約を確定する
     */
    public function confirm(): void
    {
        if (!$this->status->canTransitionTo(ReservationStatus::Confirmed)) {
            throw new InvalidReservationException(
                "ステータス「{$this->status->label()}」から確定への変更はできません"
            );
        }

        $this->status = ReservationStatus::Confirmed;
    }

    /**
     * 予約をキャンセルする
     */
    public function cancel(): void
    {
        if (!$this->status->canTransitionTo(ReservationStatus::Cancelled)) {
            throw new InvalidReservationException(
                "ステータス「{$this->status->label()}」からキャンセルへの変更はできません"
            );
        }

        $this->status = ReservationStatus::Cancelled;
    }

    /**
     * 他の時間枠と重複するか判定
     */
    public function overlapsWithTimeSlot(TimeSlot $other): bool
    {
        return $this->timeSlot->overlapsWith($other);
    }
}

StylistRank.php(Enum)

<?php

declare(strict_types=1);

namespace App\Domain\Enum;

use App\Domain\ValueObject\Money;

enum StylistRank: string
{
    case Junior = 'junior';
    case Regular = 'regular';
    case Top = 'top';

    public function label(): string
    {
        return match ($this) {
            self::Junior => 'ジュニア',
            self::Regular => 'レギュラー',
            self::Top => 'トップ',
        };
    }

    /**
     * 指名料を取得
     */
    public function nominationFee(): Money
    {
        return match ($this) {
            self::Junior => Money::fromInt(0),
            self::Regular => Money::fromInt(550),
            self::Top => Money::fromInt(1100),
        };
    }
}

Enum が振る舞いを持っています。指名料のロジックがここに閉じているので、「トップスタイリストの指名料はいくら?」という質問にコードが答えてくれます。

TimeSlot.php(VO)

<?php

declare(strict_types=1);

namespace App\Domain\ValueObject;

use DateTimeImmutable;

final readonly class TimeSlot
{
    private function __construct(
        private DateTimeImmutable $startAt,
        private Duration $duration,
    ) {
    }

    public static function create(DateTimeImmutable $startAt, Duration $duration): self
    {
        return new self($startAt, $duration);
    }

    public function startAt(): DateTimeImmutable
    {
        return $this->startAt;
    }

    public function duration(): Duration
    {
        return $this->duration;
    }

    public function endAt(): DateTimeImmutable
    {
        return $this->startAt->modify("+{$this->duration->minutes()} minutes");
    }

    /**
     * 他の時間枠と重複するか判定
     */
    public function overlapsWith(self $other): bool
    {
        return $this->startAt < $other->endAt()
            && $this->endAt() > $other->startAt;
    }

    public function equals(self $other): bool
    {
        return $this->startAt == $other->startAt
            && $this->duration->equals($other->duration);
    }
}

重複判定ロジックが VO に閉じ込められています。
naive版では Controller に 30 行くらいあったロジックが、たった 4 行になりました。

Step 5: ユースケースを依頼する

次にユースケースを依頼します。

「予約を作成する」ユースケースを実装してください。

処理フロー:
1. 顧客ID、スタイリストID、メニューIDリスト、開始時刻を受け取る
2. スタイリストの対応可能カテゴリをチェック
3. メニューから予約メニュー(スナップショット)を生成
4. 所要時間を合算して時間枠を生成
5. スタイリストの既存予約と重複チェック
6. スタイリストのランクから指名料を取得
7. 予約を生成(ステータス: 仮予約)
8. 保存して予約IDを返す

Claude Codeの出力

⏺ 「予約を作成する」ユースケースの実装が完了しました。

  作成ファイル一覧

  UseCase層
  app/UseCase/
  ├── Exception/
  │   └── UseCaseException.php
  └── Reservation/Create/
      ├── CreateReservationInput.php   # 入力DTO
      ├── CreateReservationOutput.php  # 出力DTO
      └── CreateReservationUseCase.php # ユースケース本体

  Infrastructure層
  app/Infrastructure/Eloquent/
  ├── Model/
  │   ├── CustomerModel.php
  │   ├── StylistModel.php
  │   ├── MenuModel.php
  │   ├── ReservationModel.php
  │   └── ReservedMenuModel.php
  └── Repository/
      ├── EloquentCustomerRepository.php
      ├── EloquentStylistRepository.php
      ├── EloquentMenuRepository.php
      └── EloquentReservationRepository.php

  Presentation層
  app/Http/
  ├── Controllers/Api/
  │   └── ReservationController.php
  └── Requests/Reservation/
      └── CreateReservationRequest.php

  マイグレーション
  database/migrations/
  ├── 2024_01_01_000001_create_customers_table.php
  ├── 2024_01_01_000002_create_stylists_table.php
  ├── 2024_01_01_000003_create_menus_table.php
  ├── 2024_01_01_000004_create_reservations_table.php
  └── 2024_01_01_000005_create_reserved_menus_table.php

  API エンドポイント
  POST /api/reservations

  リクエスト例:
  {
    "customer_id": "01HXY...",
    "stylist_id": "01HXY...",
    "menu_ids": ["01HXY...", "01HXY..."],
    "start_at": "2024-12-28T10:00:00+09:00"
  }

  レスポンス例 (201):
  {
    "reservation_id": "01HXY..."
  }

  処理フロー
  1. CreateReservationRequest でバリデーション
  2. CreateReservationUseCase で以下を実行:
    - 顧客・スタイリスト・メニューの存在確認
    - 所要時間合算 → 時間枠生成
    - スタイリストの既存予約との重複チェック
    - Reservation::create() 内でスタイリストの対応可能カテゴリ検証
    - 仮予約ステータスで保存

CreateReservationUseCase.php

<?php

declare(strict_types=1);

namespace App\UseCase\Reservation\Create;

use App\Domain\Aggregate\Reservation;
use App\Domain\Exception\InvalidReservationException;
use App\Domain\Repository\CustomerRepositoryInterface;
use App\Domain\Repository\MenuRepositoryInterface;
use App\Domain\Repository\ReservationRepositoryInterface;
use App\Domain\Repository\StylistRepositoryInterface;
use App\Domain\ValueObject\Duration;
use App\Domain\ValueObject\TimeSlot;
use App\UseCase\Exception\UseCaseException;

final class CreateReservationUseCase
{
    public function __construct(
        private readonly ReservationRepositoryInterface $reservationRepository,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly StylistRepositoryInterface $stylistRepository,
        private readonly MenuRepositoryInterface $menuRepository,
    ) {
    }

    public function execute(CreateReservationInput $input): CreateReservationOutput
    {
        // 1. 顧客の存在確認
        $customer = $this->customerRepository->findById($input->customerId);
        if ($customer === null) {
            throw UseCaseException::entityNotFound('顧客', $input->customerId);
        }

        // 2. スタイリストの取得
        $stylist = $this->stylistRepository->findById($input->stylistId);
        if ($stylist === null) {
            throw UseCaseException::entityNotFound('スタイリスト', $input->stylistId);
        }

        // 3. メニューの取得
        if (count($input->menuIds) === 0) {
            throw UseCaseException::validationError('メニューを1件以上選択してください');
        }

        $menus = $this->menuRepository->findByIds($input->menuIds);
        if (count($menus) !== count($input->menuIds)) {
            throw UseCaseException::validationError('指定されたメニューの一部が見つかりません');
        }

        // 4. 所要時間を合算して時間枠を生成
        $totalDuration = array_reduce(
            $menus,
            fn(Duration $carry, $menu) => $carry->add($menu->duration()),
            Duration::zero()
        );
        $timeSlot = TimeSlot::create($input->startAt, $totalDuration);

        // 5. スタイリストの既存予約と重複チェック
        if ($this->reservationRepository->existsOverlapping($stylist->id(), $timeSlot)) {
            throw UseCaseException::validationError(
                '指定された時間帯は既に予約が入っています'
            );
        }

        // 6-7. 予約を生成(ステータス: 仮予約)
        try {
            $reservation = Reservation::create(
                id: $this->reservationRepository->nextId(),
                customerId: $customer->id(),
                stylist: $stylist,
                menus: $menus,
                timeSlot: $timeSlot,
            );
        } catch (InvalidReservationException $e) {
            throw UseCaseException::validationError($e->getMessage());
        }

        // 8. 保存
        $this->reservationRepository->save($reservation);

        return new CreateReservationOutput(
            reservationId: $reservation->id(),
        );
    }
}

ReservationController.php

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Reservation\CreateReservationRequest;
use App\UseCase\Exception\UseCaseException;
use App\UseCase\Reservation\Create\CreateReservationInput;
use App\UseCase\Reservation\Create\CreateReservationUseCase;
use DateTimeImmutable;
use Illuminate\Http\JsonResponse;

class ReservationController extends Controller
{
    public function __construct(
        private readonly CreateReservationUseCase $createReservationUseCase,
    ) {
    }

    public function store(CreateReservationRequest $request): JsonResponse
    {
        try {
            $input = new CreateReservationInput(
                customerId: $request->validated('customer_id'),
                stylistId: $request->validated('stylist_id'),
                menuIds: $request->validated('menu_ids'),
                startAt: new DateTimeImmutable($request->validated('start_at')),
            );

            $output = $this->createReservationUseCase->execute($input);

            return response()->json([
                'reservation_id' => $output->reservationId,
            ], 201);
        } catch (UseCaseException $e) {
            return response()->json([
                'error' => $e->getMessage(),
            ], 422);
        }
    }
}

Controller は HTTP の入出力だけ。めちゃくちゃ薄くなりました。


比較まとめ

観点 丸投げ モデリング後
メニュー選択 1つだけ 複数OK
指名料 なし ランクから自動算出
重複チェック Controllerにベタ書き TimeSlot(VO)に閉じ込め
ステータス管理 文字列 Enum + 遷移ルール
Controller 120行(Fat) 30行(薄い)
要件の網羅性 漏れ多数 ほぼ網羅

同じ「美容院予約を作って」でも、ここまで差が出ます。

なぜモデリングが効くのか

モデリングとは「作りたいものを言語化する作業」というより、「世界観を言語化する作業」だと思っています。

  • どんな概念があるか(Entity、VO、Enum)
  • それらがどう関連するか
  • どんなルールがあるか

これらを整理できていれば、誰に対しても世界観を的確に伝えられるようになります。
人間に対しても、AIに対しても。

モデリング力 = マネジメント力 = 指示力 = プロンプト力

全てがつながっています。

アーキテクチャはAIとの共通言語

もう一つ重要なのが、アーキテクチャのガイドラインです。

「レイヤードアーキテクチャで」「VOは不変で」「Enumは振る舞いを持てる」といったルールを事前に伝えておくと、AIも人間も同じ枠組みで話せます。結果としてブレない実装になります。

これは人間のチームでも同じですよね。
アーキテクチャが共有されていないチームは、各自がバラバラな実装をしてカオスになります。AIも同じです。

終わりに

バイブコーディング時代になっても、結局大事なことは変わりません。

ドメインを理解し、モデリングし、言語化する。

AIが書くか人間が書くかの違いだけで、良いアウトプットを出すために必要なことは同じでした。

むしろ、AIがコードを書いてくれるようになった分、「何を作るか」「どういう世界観か」を言語化できる人の価値は上がっているのかもしれません。

バイブコーディング時代こそ、モデリング力を磨いていきましょう!🚀

0
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
0
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?