三つ目の画面をRorkに追加してもらった直後、前の週に手で書き込んだ課金状態の判定ロジックが跡形もなく消えていました。生成された新しい画面はきれいでした。ただ、その裏で動いていたはずの「購入済みユーザーには広告を出さない」分岐が、標準的な実装に戻ってしまっていたのです。
追加指示のたびにこれが起きます。プロンプトに「既存のロジックは変えないで」と書き添える対処法は広く知られていますし、私も長く頼ってきました。けれど、指示文で守れる範囲には限界があります。AIは毎回コードベース全体を読み、最適と判断した形へ画面を作り直すからです。守りたいものを指示文の外側に、つまり設計の側に逃がしておく。この記事で扱うのはその考え方と、具体的な実装です。
プロンプトで守るのをやめ、境界で守る
Rorkへの追加指示でコードが戻ってしまう仕組みは、差分ではなく全体を再生成するという生成AIの性質そのものです。詳しくは「Rork で修正が上書きされる問題の解決策」で扱っていますが、そこでの結論は「プロンプトで保護範囲を明示する」でした。日々の開発ではこれで十分に効きます。
ただ、アプリが育つほど守りたいものは増えます。課金判定、データの取得と整形、通信のリトライ、ローカル保存の整合。これらを毎回プロンプトで列挙するのは現実的ではありませんし、一度でも書き漏らせば消えます。
発想を変えます。AIが再生成するのは画面です。ならば、守りたいロジックを画面の外に出してしまえばよい。画面からは呼び出すだけにしておき、実体を別のファイルに置く。AIが画面を何度作り直しても、呼び出しの一行さえ残っていれば、実体は無傷で残ります。
境界を設計で引くとは、この「呼び出す側」と「呼び出される側」を物理的に別のファイル、別のディレクトリに分けることです。指示文の丁寧さに依存しない、構造による保護です。
何をAIに所有させ、何を自分が持つか
まず、コードを二つの層に分けて考えます。
| 層 | 所有者 | 中身 | 再生成の扱い |
| プレゼンテーション層 | AI(Rork) | 画面・レイアウト・見た目・遷移 | 何度でも作り直してよい |
| ドメイン層 | 自分 | 状態管理・データ取得・課金判定・業務ルール | AIに触らせない |
線引きの基準はシンプルです。作り直されても痛くないものはAIに、作り直されたら困るものは自分に。ボタンの位置や色は前者です。購入済み判定のように、間違うとお金や信頼に直結するものは後者です。
この区別は、コードの善し悪しの話ではありません。変更の頻度と、間違えたときの痛みの話です。見た目は頻繁に変わってよく、多少崩れてもすぐ直せます。業務ルールは滅多に変わらず、崩れると気づかないまま損失を出します。だから守り方を変える。ここに設計の起点があります。
境界を実装する — service層とstoreを画面の外へ
具体的なファイル構成に落とし込みます。React Native(Expo)で生成されたプロジェクトを前提にします。Rork Maxが生成するSwiftでも、層を分ける原則はそのまま通用します。
守りたいロジックを、次の三種類のファイルに分けて画面の外へ置きます。
| ファイル | 役割 | 例 |
| 型定義(契約) | 層をまたぐデータの形を固定する | src/domain/types.ts |
| service | データ取得・外部通信・整形 | src/domain/purchaseService.ts |
| store | 状態の保持と更新 | src/domain/usePurchaseStore.ts |
まず契約となる型を置きます。これが層と層をつなぐ約束です。
// src/domain/types.ts — 自分が所有。AIには触らせない。
export interface PurchaseState {
isPremium: boolean;
purchasedAt: string | null;
}
export interface PurchaseService {
restore(): Promise<PurchaseState>;
buy(productId: string): Promise<PurchaseState>;
}
次にservice。課金の実体はここに閉じ込めます。通信のリトライや、失敗時に権限を与えない判断(deny by default)も、この一枚の中で完結させます。
// src/domain/purchaseService.ts — 自分が所有。
import type { PurchaseState, PurchaseService } from './types';
const FALLBACK: PurchaseState = { isPremium: false, purchasedAt: null };
export const purchaseService: PurchaseService = {
async restore() {
try {
const res = await fetch('https://api.example.com/purchase/restore');
if (!res.ok) return FALLBACK; // 不明なら非課金として扱う
return (await res.json()) as PurchaseState;
} catch {
return FALLBACK; // 通信失敗時も権限を広げない
}
},
async buy(productId) {
const res = await fetch('https://api.example.com/purchase/buy', {
method: 'POST',
body: JSON.stringify({ productId }),
});
if (!res.ok) throw new Error('purchase_failed');
return (await res.json()) as PurchaseState;
},
};
そして状態を持つstore。ここではZustand v5を使います。画面はこのフックを呼ぶだけになります。
// src/domain/usePurchaseStore.ts — 自分が所有。
import { create } from 'zustand';
import type { PurchaseState } from './types';
import { purchaseService } from './purchaseService';
interface PurchaseStore extends PurchaseState {
restore: () => Promise<void>;
buy: (productId: string) => Promise<void>;
}
export const usePurchaseStore = create<PurchaseStore>((set) => ({
isPremium: false,
purchasedAt: null,
restore: async () => set(await purchaseService.restore()),
buy: async (productId) => set(await purchaseService.buy(productId)),
}));
ここまでが自分の所有物です。src/domain/ の三ファイルには、アプリの判断がすべて詰まっています。そしてこの三つは、画面をどれだけ作り直しても影響を受けません。
画面は「呼ぶだけ」にしておく
AIに所有させる画面側は、驚くほど薄くなります。
// app/home.tsx — AIが再生成してよい領域。
import { View, Text, Button } from 'react-native';
import { usePurchaseStore } from '../src/domain/usePurchaseStore';
export default function HomeScreen() {
const { isPremium, buy } = usePurchaseStore();
return (
<View>
<Text>{isPremium ? 'プレミアム会員' : '無料プラン'}</Text>
{!isPremium && (
<Button title="購入する" onPress={() => buy('premium_lifetime')} />
)}
</View>
);
}
この画面が丸ごと作り直されても、失われるのはレイアウトだけです。usePurchaseStore を呼ぶ一行と、isPremium による分岐の意図さえ残れば、課金判定の中身は src/domain/ で無事です。仮にAIが分岐を書き落としても、影響は「表示が一つ変わる」に留まり、判定ロジックそのものは壊れません。
Before と After を並べると、守りの差がはっきりします。
| Before(画面に直書き) | After(境界あり) |
| 課金判定の置き場所 | 画面コンポーネント内 | src/domain/ |
| 画面再生成の影響 | 判定ごと消える | レイアウトのみ |
| 守り方 | 毎回プロンプトで明示 | 構造で自動的に保護 |
| 失敗の痛み | 広告露出・誤課金 | 表示のずれ(すぐ直せる) |
「この画面だけ作り直して」を安全に言う
境界があると、Rorkへの指示も具体的で安全になります。あいまいな「直して」ではなく、作り直してよい範囲を名指しできます。
app/home.tsx のレイアウトだけを二カラムに作り直してください。
src/domain 配下のファイルは読み取り専用として、一切変更しないでください。
usePurchaseStore の呼び出しと isPremium による分岐は必ず残してください。
ポイントは、守ってほしい対象がディレクトリという明確な単位になっていることです。「あの色は変えないで」のような、AIが解釈に迷う指示ではありません。src/domain という境界を指すだけで、意図が一意に伝わります。
さらに型契約が安全網として働きます。もしAIが PurchaseState の形を無視した画面を書けば、TypeScriptが即座に型エラーを出します。境界を越えた変更は、コンパイルの段階で自分に知らされる。プロンプトの丁寧さではなく、型システムが検知してくれる状態を作れます。
長く運用して分かった、境界の引きすぎという落とし穴
この設計を六つのアプリに入れて運用してきて、効き目と同じくらい、やりすぎの危うさも見えてきました。
一つは、境界を細かく刻みすぎることです。画面ごとにservice層とstoreを作ると、ファイルばかり増えて全体が見通せなくなります。守るべきは「作り直されたら困るもの」だけです。単なる表示用の一時状態まで src/domain に上げる必要はありません。迷ったら、間違えたときにお金・信頼・データの整合が損なわれるかを基準にします。
もう一つは、境界を越える誘惑です。急いでいるとき、つい画面の中に直接fetchを書きたくなります。一度それをやると、次のAI再生成でそのロジックは消えます。境界は、自分が守り続けて初めて意味を持ちます。私は app/ 配下でのfetch呼び出しを禁じる簡単なlintルールを一本入れて、自分の弱さを機械に見張ってもらうことにしました。
そして、境界は最初から完璧である必要はありません。個人開発では手を広げすぎると回らなくなるので、次の順に少しずつ移すのが現実的だと考えています。
- 一番痛いロジック、多くの場合は課金判定を画面の外へ出します。それだけで、再生成のたびに失われていた最悪の一つが消えます。
- 効果を確かめたら、データ取得と外部通信を service へ移します。ここが二番目に壊れて痛い場所です。
- 最後にローカル保存の整合を store へ集約します。ここまで来れば、画面は安心して作り直させられます。
おわりに — 今日、外へ出す一つを決める
プロンプトで守る開発から、構造で守る開発へ。境界を引くという発想の転換は、Rorkのように何度も再生成を重ねる道具と長く付き合ううえで、静かに効いてきます。
今日できることを一つだけ挙げるなら、いま一番「作り直されたら困る」ロジックを見つけて、src/domain/ という一つのフォルダへ移すことです。画面からは呼ぶだけにする。次にRorkへ画面の変更を頼んだとき、そのロジックが無事に残っているのを確かめられたら、この設計はあなたのアプリに根づき始めています。
生成された画面の一つ深いところに、作り直されない芯を持たせておく。私自身まだ試行錯誤の途中ですが、この芯があるだけで、AIに任せる部分を安心して広げられるようになりました。お読みいただきありがとうございました。