実装指針ファーストの実践例 ― Laravel への移行案件で作ったロードマップ
■この記事でわかること
- ゼロスタートでない複雑な案件を AI にいきなり任せると、何が起きやすいのか
- 実装を開始する前にプロジェクトの制約を AI に効率よく渡すための方法
- お手本となるコードを1本書くことがもたらすメリット
はじめに
ここのところ「AI とどう書くか」という話を書いてきました。AI 開発時代のフレームワークとの付き合い方、AI 開発時代の車輪の再発明と DRY 原則、人にも AI にも読みやすいコードの保ち方。多くは、AI が生成したコードをどう扱うかという話でした。
今回は少し手前の話をします。
まず、小規模なものであれば、最初から AI に任せてしまって十分なレベルかと思います。こちらの記事で具体例を紹介しています。
一方、複雑なものを AI にいきなり任せることは推奨しません。 AI は指示が無ければ一般的な問題解決のために働くので、案件特有の制約をくみ取ることが難しいためです。
そこで、実装を AI に任せるために「プロジェクトのコードをどう部品化して、どう書くか」を決めて、その形で実際に1つの機能を書いておきます。 こう書けば良い、というお手本をあらかじめ用意しておくわけです。 指示の文章だけで制約を伝えるより、動くコードで示す方が誤解なく意図が伝わります。
ここではこの進め方を「実装指針ファースト」と呼ぶことにします。 以下では、実際の業務でこの方式で開発を行った具体例を紹介します。 題材は、旧式の PHP で組まれたサイトを Laravel へ移行した案件です。実際のソースコードをもとに、固有の名称などを置き換えた疑似コードで説明します。
案件の前提と制約
案件自体はウェブサイトのリニューアルでしたが、よくある案件に見えて、このプロジェクトには扱いの難しいいくつかの制約がありました。
1. DB の中に「生の HTML」が入っている
通常、CMS のデータベースには本文だけが入っていて、見た目はテンプレート側が持ちます。ところがこの旧システムでは、<html> から始まる完全な HTML 文書がまるごとレコードとして保存されている部分がありました。<head> も <style> も含まれています。
しかも、その書き方が一様ではありません。長年の運用の中で、構造の異なる HTML が大量に積み上がっていました。これらを移行後も継続して利用する必要があります。
2. 独自のカスタムタグがある
本文中に <cms_param name=”…”> のような独自タグが埋め込まれており、表示時にシステム側のデータで置き換える必要があります。条件分岐や比較を行うタグもあり、単純な文字列置換では済みません。
3. 独自のキャッシュ機構がある
大量アクセスをさばくため、旧システムは独自のキャッシュを持っていました。しかも単一のキャッシュではなく、ユーザーの属性ごとに内容が変わるページがあります。移行後も同等の性能と、この出し分けが必要です。
4. デザイン / HTML を管理画面からも修正したい
技術的な制約に加えて、運用側からの要望もありました。管理画面から HTML を直接触って CSS や全体のデザインを変更する、という運用をこれまで通り続けたいというものです。
一般的な Laravel プロジェクトであれば、Blade テンプレートを Git で管理します。ですが、素直にそう実装するだけではこの要望を満たせません。何らかの対応が必要になります。
データフォーマットを「移行しない」と決めた
まず考えたのは、DB 内のデータをマイグレーションして、より一般的なフォーマットに直すかどうかです。生の HTML を解析して本文だけを抜き出し、独自タグを Blade のディレクティブに変換し、きれいなデータとして入れ直す。そうすれば、あとは Laravel の標準的な作り方に乗せられます。
これを検討して、やめました。理由は3つあります。
ひとつは、パターンが多様すぎて、変換スクリプトで網羅しきれる見込みがなかったためです。生の HTML と書きましたが、その中身も一様ではありません。内部で部品的に利用されているものがあったり、カスタムタグが結果的に入れ子になっているパターンがあったりと、単純な走査では正しく解釈できません。取りこぼしは公開後に表示崩れとして現れますが、どこが崩れているかは全ページを目視するまで分かりません。
ふたつめは、データ件数の多さです。変換処理そのものが、本番移行時のメンテナンス期間内に終わるかどうかが読めませんでした。
そしてもうひとつが、先ほどの運用要件です。仮に既存データをすべてきれいに変換できたとしても、運用側は今後も生の HTML を投入します。それを表示できるようにする仕組みはどのみち必要で、それならマイグレーションを行う必要性そのものが薄いのでは、と考えました。
これらの理由から、データはそのままの形で移行し、表示するときにアプリケーション側で解析・吸収する方針を採用しました。
この判断が、以降の設計を決めています。マイグレーションを諦めた瞬間に、「実行時に生 HTML を分解して組み立て直す仕組み」が必要になったからです。そしてこの仕組みは、過去データのためだけでなく、これから入ってくるデータのためにも動き続けることになります。
何も前提を与えずに AI に書かせるとどうなるか
ここで、この要件を AI にそのまま投げるとどうなるかを考えてみます。「DB に入っている HTML を取り出して、独自タグを置換して、共通テンプレートに流し込んで表示して」と頼めば、おそらくコントローラの中に一連の処理がベタ書きされたコードが出てきます。
そして、それは動きます。1ページ分なら何も問題はありません。
問題は、そのコードが目の前のデータに合わせて書かれていることです。実際には様々なパターンに対応する必要がありますが、AI が見ているのは指示された1ページ分だけなので、そこに現れなかったパターンは考慮されません。
さらに、機能ごとにデータが別のテーブルに格納されているため、ページを追加するたびに取得と加工の方法が少しずつ変わります。2ページ目以降は最初に生成されたコードを下敷きにすることになりますが、ページ固有の要件も混ざってきます。
ここで効いてくるのが、何を共通化して何を個別のままにするかの判断です。前提を与えなければ、この判断も AI に委ねることになります。ですが AI に見えているのは目の前のコードの類似性だけで、その差分が本質的なものか、たまたま今そう見えているだけなのかまでは分かりません。結果として、括ってはいけないものが括られたり、逆に共通化すべきものが散らばったりしやすくなります。
もちろん、その都度指示を出していけば収束はするはずです。ですがノイズが増えるぶん、収束までに時間がかかります。最初に型を決めておいた方が速い、というのがここでの判断でした。
最初に示した小規模な例はゼロから AI に作ってもらっています。ですが、この案件ではデータとその構造は既に存在するものを使う必要があり、スタート地点が違います。このギャップを埋めるのは人間の仕事です。ここを先に埋めておくほど、AI はスムーズに動きます。
呼び出し側から決める
そこで、実装から考えるのをやめて、「実際に Controller はどう書かれているべきか」から先に決めました。ここが「実装指針ファースト」の中身です。
テストコードを先に書くわけではありませんが、やっていることは TDD で得られる設計上の効果と同じです。テストを先に書くと自分が最初の利用者になるので、使いにくい形が先に露見します。今回はテストの代わりに「こう書けたら楽だ」という呼び出しコードを先に想像しました。
結果として完成したコントローラはこのようなものです。
public function index(Request $request)
{
$cacheKey = LocalCacheHelper::key($request, 'top');
// 制約3: キャッシュの利用
$html = LocalCacheHelper::get()->remember($cacheKey, LocalCacheHelper::ttl(),
function () use ($request) {
// 制約4: 共通テンプレートも DB から取得する
$templateHtml = HtmlData::get('template')?->content() ?? '';
// 制約1: DB 上の生 HTML を取得
$contentHtml = HtmlData::get('top')?->content() ?? '';
// 制約2: カスタムタグの展開
// この時点の <style> などを共通テンプレートに埋め込む
$expandedHtml = CustomTagProcessor::run($request, $contentHtml);
// 本文側の整形(URL 変換、head へ回す要素の除去、main 要素の抽出)
// ただし、ここはページとデータ次第で変わる
$contentHtml = UrlHelper::replace($expandedHtml);
$contentHtml = HtmlExtractHelper::dropAll($contentHtml);
$contentHtml = HtmlExtractHelper::extractMain($contentHtml) ?? $contentHtml;
// 共通テンプレートへ流し込む
return TemplateRenderer::render(
$templateHtml,
(new PageDataBuilder())
->extracts($expandedHtml) // head へ回す要素(style / meta など)
->styles('/assets/css/top.min.css')
->scripts('/assets/js/pages/top.js')
->content($contentHtml) // 本文として body へ入れる HTML
->build()
);
});
return view('layouts.app', ['html' => $html]);
}
上から順に読むと、この案件の制約がそのまま処理の順番として並んでいるのが分かると思います。
①生のHTML取得 ⇒ ②表示用HTMLを生成(タグ展開/整形/取捨選択など) ⇒ ③生成した文字列をキャッシュとして保持。
一方で、コントローラ自体は旧システムの事情をほとんど知りません。生 HTML がどう入り組んでいるかも、カスタムタグにどんな種類があるかも、キャッシュがどう効いているかも、コントローラ上には直接出てきていません。すべて部品の側に押し込んであります。
では、その部品の境界をどう決めたのか。ここからが本題です。
なぜこの形になったのか
意識しなくても安全にキャッシュが使える仕組み
remember() の中に、組み立て処理の全体が入っています。返るのは完成済みの HTML 文字列です。
制約3のとおりページごとに適切なキャッシュを持たせて高速化したい一方で、実装のたびに「ここはキャッシュ、ここは違う」と意識しなくて済むようにしたかったためです。
最外周で一度だけキャッシュすると決めれば、内側は純粋な変換処理だけになります。デバッグのときはキャッシュを切れば、そのまま処理の流れが見えます。
ユーザーごとの出し分け
一方、ページの内容はユーザーの属性(たとえば未ログインと有料会員)によって変わります。キャッシュを使うとしても、全員に同じものを返すわけにはいきません。
ここで LocalCacheHelper::key() に $request を渡していることが効いてきます。$request には Middleware でログイン中の会員情報などが設定されているので、キーの生成側がリクエストを見て、ユーザー種別ごとに異なるキーを発行するようにしました。属性が同じユーザーは同じキャッシュを共有し、違えば別のエントリになります。呼び出し側のコードは変わりません。
そのうえで、属性ではなく個人単位で変わる情報は、そもそもキャッシュに載せない方針にしました。ここを属性と同じ扱いにすると、キーが人数分に増えてキャッシュとして機能しなくなるためです。
個人向けの部分は、初期表示に必要かどうかで扱いを分けています。必要なければ Ajax で後から取得して埋め込み、必要ならキャッシュの外で都度生成します。
間違いを実行時に止める
問題は、この方針が守られているかどうかを確認しづらいことです。個人向けの情報を出している箇所が意図せずキャッシュしてしまう可能性もあり、間違いを防ぐために何らかの仕組みがあった方が安全です。
そこで、PHP のアトリビュートとして #[NoCache] を用意しました。個人向けの出力を担うクラスや関数にこれを付けておくと、その内側で定型どおりにキャッシュを使おうとした時点で例外になります。
具体的には LocalCacheHelper::key() を呼んだタイミングで条件を判断して例外を投げています。 キャッシュの取得そのもの(cache())で落とさないのは、#[NoCache] の領域でも固定キーでのキャッシュは使うことがあるからです。危ないのはキャッシュを使うこと自体ではなく、リクエストに依存したキーを作ろうとすることなので、そこを例外の検知点にしました。
想定通りにアトリビュートがついていれば、間違った組み合わせは動いた瞬間に落ちます。本番で他人の情報が見えるより、開発中に例外で止まる方がはるかに安全です。
ここまでの整理は、キャッシュを最外周に置いたからこそ成立しています。部品ごとにキャッシュが散らばっていたら、「どのレイヤーでユーザーを区別するのか」を部品の数だけ考えることになっていました。
そして重要なのは、この先コードを書く人(AI含む)が、キャッシュの正しさを覚えておく必要がないことです。人間が書いても AI が書いても、間違っていれば同じように落ちます。
生 HTML を加工する流れを型として決めた
旧データは完全な HTML 文書(もしくはその一部)なので、そのまま本文として <body> に入れると <style> などが混ざり、デザインが崩れます。かといって捨ててしまうと、そのページ固有のスタイルが失われます。
ここで大事なのは、どのような手順で加工するかを個々のページで考えさせないことです。そこで、どの Controller でも次の流れを踏むように見本を作成しました。
- DB からデータを取り出す
- 必要な部分を抽出・変換する
- Builder に流し込み、テンプレートをレンダリングして返す
抽出と変換はヘルパークラスに寄せ、組み立ては Builder に渡す。この形を1本書き切って、見本として提示しました。
2 の抽出処理そのものは、ページごとに様々なパターンがあります。DB 上に保有しているデータフォーマットに合わせて、適切な加工が必要なためです。
また 3 の Builder に何を渡すかもページごとに違います。読み込む CSS や JS が違い、渡す項目も増減します。ここを固定の引数にしてしまうと、ページが増えるたびに引数が増えるか、連想配列のキーを覚えることになります。Builder にしたのは、この差分を吸収するためです。
つまり、共通なのは手順の並びだけで、2 も 3 も中身はページごとに変わります。だからこそ、先に型を示しておく価値があります。何をどの順番でやるかが決まっていれば、あとはページごとの差分を埋めていくだけの作業になるからです
カスタムタグの共通化
カスタムタグについては、処理を行う Processor、タグを実装するための Tag インタフェース、そして具体的な TagImpl をタグの個数分用意しました。Processor の中で複数回の走査を行っているため、タグが入れ子になっているパターンにも対応できます。
その後、タグの挙動が文脈に依存するケースが出てきました。同じタグでも、ページによって参照するパラメータが変わるものです。
これに対しては、既定の実装を呼び出し側から差し替えられるようにしました。
// $eqParams や $ifParams は別途 DB などから得られる値
$contentHtml = CustomTagProcessor::run($request, $contentHtml, [], [
'cms_eq' => new CmsEq($eqParams),
'cms_if' => new CmsIf($ifParams),
]);
デフォルト引数の指定が増えただけで、既存の呼び出しは1行も書き換えていません。渡さないページはこれまで通り動きます。処理をタグ単位に分解してあったので、差し替え口を後から付けるだけで済みました。
なお、パラメータを static なプロパティに置く選択肢もありました。今回、それを実施しなかったのはキャッシュを多用する構成と相性が悪いからです。同一プロセスで複数のリクエストを処理していると、前のリクエストの文脈が残ったまま次の処理が動く可能性があるため、事故の元となるような static 変数の仕組みの利用は避けました。
実装指針ファーストの成果
ここまで、業務を分析し、必要となる部品をメインロジックから分離して用意し、実際の書き方を1ケース分整えてきました。 この基準を用意しておくことで、AI に生成を任せても基本的にはこの流れをなぞるようになります。
特に今回は Claude Code を利用しています。Claude Code は指示しなくても近い実装を探して読みに行くので、1本ちゃんと書いてあればそれがそのまま参照先になります。指針を用意しなかった場合と比較したわけではありませんが、生成されたコードが既存の形をなぞってくることは繰り返し確認できました。
なお、一発でこの形に決まったわけではありません。何度か書き直しています。使いにくい形は TDD のように書いて実際に使う立場になると分かるので、そのたびに部品の境界を引き直しました。最終的に「気持ちよく書ける」と感じたところがこの形であり、決定としました。
そしてこれは、AI に例を示すためだけのものではありませんでした。新しいメンバーに「このように書いてください」とプロジェクトの思想を伝える材料としても、そのまま使うことができました。
結果としては、人が入ってきたときの受け入れ準備を、コードを読むのが得意な AI というメンバーに対して行った。 これが実装指針ファーストだ。 というのが実際のところかもしれません。
まとめ
AI に実装を任せる前に、その前提となる書き方をあらかじめ用意しておく。この進め方を「実装指針ファースト」と呼び、実際の案件を例に紹介しました。 ただし、これは手法として新しいものではありません。TDD で得られる設計上の効果と、例を見て倣うという学習効果の組み合わせにすぎません。新しいのは、その例を読む相手に AI が加わったことです。
実装指針ファーストの実践の流れは次のようになります。
- プロジェクトの前提と制約を分析する
- それをソースコード上でどう解決するか、形を試行錯誤する
- 代表的な書き方を1つ決め、これをベースに開発する
2つめの試行錯誤では、単独の要素についてのアドバイスを AI に求めるなど、自問自答の相手として AI を使っても構いません。決めるのは自分ですが、選択肢を出させるのは得意な相手です。
この流れは例を見て学ぶという点で、AI に限らず新しく参加する開発者にとっても優しい形になります。独特な制約を抱えたプロジェクトであれば、最初にやっておく価値があると考えています。
一方で、どんな案件にも当てはまる万能の型はありません。そして「実装指針ファースト」を実践することは独自のフレームワークを構築するのに近い発想が必要になるので、実践の難易度は高めの話でもあります。
あなたの業務で、今回と全く同じ制約がそのまま出てくることはまずないと思います。ですが、自分たちの業務に当てはめて「こういう制約があるから、こう書かせると見通しがよくなる」と事前に分析することはできます。このような能力が AI 時代の技術者にとっての、新しい価値の置きどころになるのではないかと考えています。

















