
既存開発への途中参画に悩んだので、AIと壁打ちして整理した
はじめに
私は前職ではプロジェクトの立ち上げから参加することが多く、新規案件をベースから構築する経験が中心でした。
クラスメソッドに転職してからは、既存案件に途中から参画する機会が増えました。
しかし、既存のコードを読み解きながら新機能を実装していく作業には苦労する場面がありました。
「既存案件にどういう思考で臨めばいいのか」をAIに壁打ちしながら整理したところ、個人的にとても参考になったので、ブログ記事として共有したいと思います。
ベテランエンジニアのみなさんからのご意見もお待ちしています!
前提条件
- 筆者はフロントエンドエンジニアです
- 今回の記事の元になったプロジェクトはNext.jsを使用しています
- 既存案件への途中参画は今回が初めての経験でした
サンプルシナリオ
以下のようなNext.js(App Router + Server Actions)のTODOアプリを考えます。
既に「TODOの削除(DeleteTodo)」が実装されており、新たに「TODOの編集(EditTodo)」ダイアログを追加したいとします。
既存のディレクトリ構造
src/
├── app/
│ └── todos/
│ └── page.tsx
├── features/
│ └── todos/
│ ├── forms/
│ │ ├── DeleteTodoForm/
│ │ │ ├── types.ts
│ │ │ └── index.tsx
│ │ └── (ここにEditTodoFormを追加)
│ ├── dialogs/
│ │ ├── DeleteTodoDialog/
│ │ │ ├── types.ts
│ │ │ └── index.tsx
│ │ └── (ここにEditTodoDialogを追加)
│ ├── actions/
│ │ ├── schema.ts
│ │ └── server.ts
│ └── templates/
│ ├── types.ts
│ └── TodoListTemplate.tsx
├── components/
│ └── ui/
│ ├── Dialog.tsx
│ └── TextField.tsx
└── lib/
└── api.ts
既存の実装(DeleteTodo)の全体像
既に動いているDeleteTodo機能のコードを確認しましょう。
// features/todos/forms/DeleteTodoForm/types.ts
export type DeleteTodoFormState =
| { status: 'idle' }
| { status: 'success' }
| { status: 'error'; error: string };
export type DeleteTodoFormProps = {
todoId: string;
todoTitle: string;
onSuccess?: () => void;
};
// features/todos/forms/DeleteTodoForm/index.tsx
"use client";
import { useActionState, useEffect } from "react";
import { deleteTodoAction } from "../../actions/server";
import type { DeleteTodoFormProps, DeleteTodoFormState } from "./types";
const initialState: DeleteTodoFormState = { status: 'idle' };
export function DeleteTodoForm({ todoId, todoTitle, onSuccess }: DeleteTodoFormProps) {
const [state, formAction, isPending] = useActionState(
deleteTodoAction,
initialState
);
useEffect(() => {
if (state.status === "success") {
onSuccess?.();
}
}, [state.status, onSuccess]);
return (
<form action={formAction}>
<input type="hidden" name="todoId" value={todoId} />
<p>「{todoTitle}」を削除しますか?この操作は取り消せません。</p>
{state.status === 'error' && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "削除中..." : "削除する"}
</button>
</form>
);
}
// features/todos/dialogs/DeleteTodoDialog/types.ts
import type { DeleteTodoFormProps } from "../../forms/DeleteTodoForm/types";
export type DeleteTodoDialogProps = Omit<DeleteTodoFormProps, 'onSuccess'> & {
onClose: () => void;
};
// features/todos/dialogs/DeleteTodoDialog/index.tsx
"use client";
import { Dialog } from "@/components/ui/Dialog";
import { DeleteTodoForm } from "../../forms/DeleteTodoForm";
import type { DeleteTodoDialogProps } from "./types";
export function DeleteTodoDialog({
onClose,
todoId,
todoTitle,
}: DeleteTodoDialogProps) {
return (
<Dialog open onClose={onClose} title="TODOを削除">
<DeleteTodoForm todoId={todoId} todoTitle={todoTitle} onSuccess={onClose} />
</Dialog>
);
}
// features/todos/actions/schema.ts
import { z } from "zod";
export const deleteTodoSchema = z.object({
todoId: z.string().min(1, "IDは必須です"),
});
// ここにeditTodoSchemaを追加予定
// features/todos/actions/server.ts
"use server";
import { revalidatePath } from "next/cache";
import { deleteTodoSchema } from "./schema";
import { apiClient } from "@/lib/api";
import type { DeleteTodoFormState } from "../forms/DeleteTodoForm/types";
export async function deleteTodoAction(
_prevState: DeleteTodoFormState,
formData: FormData
): Promise<DeleteTodoFormState> {
const parsed = deleteTodoSchema.safeParse({
todoId: formData.get("todoId")?.toString() ?? "",
});
if (!parsed.success) {
return { status: 'error', error: "入力内容に誤りがあります" };
}
try {
await apiClient.deleteTodo(parsed.data.todoId);
revalidatePath("/todos");
return { status: 'success' };
} catch {
return { status: 'error', error: "削除に失敗しました" };
}
}
ステップ1: 最も近い既存実装を1つ決める
新機能を追加するとき、まずテンプレートにする既存実装を1つ決めることから始めます。
ここで重要なのは、「内容の類似性」ではなく「構造の類似性」を優先することです。
判断の例
今回のEditTodo(TODOのタイトル・説明を編集するダイアログ)に対して、以下の候補があるとします:
| 候補 | 類似点 | 相違点 |
|---|---|---|
| DeleteTodo | 同じダイアログ構造、同じServer Actionパターン、同じファイル構成 | 入力フィールドがない |
| EditProfile(設定画面) | 同じ入力項目 | ダイアログではなくページ内フォーム、クライアントサイドでAPIを呼び出す方式 |
EditProfileの方がフィールドは似ていますが、構造が異なります(ダイアログではない、Server Actionではない)。
→ DeleteTodoを骨格のテンプレートとして採用し、フィールド部分だけEditProfileを参考にする
なぜ構造を優先するのか
- 構造をテンプレートにすれば、ファイル構成・import関係・状態管理パターンがそのまま使える
- 内容(フィールド)は後から足せるが、構造を途中で変えると全体の整合性が崩れる
- TypeScriptの型システムが構造の整合性を保証してくれる
ステップ2: レイヤー構造を把握する
テンプレートを決めたら、そのレイヤー構造を書き出します。
forms/types.ts ← フォームのProps型・State型
forms/index.tsx ← フォームUI (useActionState)
dialogs/types.ts ← ダイアログ固有のProps型(onCloseを追加)
dialogs/index.tsx ← ダイアログUI(FormをDialogで包む)
actions/schema.ts ← Zodバリデーション定義
actions/server.ts ← Server Action本体
templates/types.ts ← ページに渡すデータ型
templates/TodoListTemplate.tsx ← ダイアログの呼び出し元
この順番を把握していれば、あとは1ファイルずつ順に作るだけです。
ステップ3: 「型からUIへ」作業する
レイヤー構造がわかったら、依存される側から依存する側(型 → ロジック → UI)に向かって1ファイルずつ作ります。
3-1. forms/EditTodoForm/types.ts を作成
DeleteTodoFormのtypes.tsをコピーし、フィールドを変更します。
// features/todos/forms/EditTodoForm/types.ts
export type EditTodoFormState =
| { status: 'idle' }
| { status: 'success' }
| { status: 'error'; error: string };
export type EditTodoFormProps = {
todoId: string;
defaultTitle: string;
defaultDescription?: string;
onSuccess?: () => void;
};
補足: 実務ではフィールドごとのバリデーションエラーを表示するために、
fieldErrors?: { title?: string[]; description?: string[] }のようなプロパティを追加することが多いです。今回は構造の説明に集中するため省略しています。
3-2. actions/schema.ts に追記
// features/todos/actions/schema.ts
import { z } from "zod";
export const deleteTodoSchema = z.object({
todoId: z.string().min(1, "IDは必須です"),
});
// 追加
export const editTodoSchema = z.object({
todoId: z.string().min(1, "IDは必須です"),
title: z.string().min(1, "タイトルは必須です").max(100, "100文字以内で入力してください"),
description: z.preprocess(
(val) => (val === "" ? undefined : val),
z.string().max(500, "500文字以内で入力してください").optional()
),
});
3-3. actions/server.ts に追記
DeleteTodoのactionをコピーし、スキーマとAPI呼び出しを変更します。
// features/todos/actions/server.ts(editTodoAction追加後の全体)
"use server";
import { revalidatePath } from "next/cache";
import { deleteTodoSchema, editTodoSchema } from "./schema";
import { apiClient } from "@/lib/api";
import type { DeleteTodoFormState } from "../forms/DeleteTodoForm/types";
import type { EditTodoFormState } from "../forms/EditTodoForm/types";
export async function deleteTodoAction(
_prevState: DeleteTodoFormState,
formData: FormData
): Promise<DeleteTodoFormState> {
const parsed = deleteTodoSchema.safeParse({
todoId: formData.get("todoId")?.toString() ?? "",
});
if (!parsed.success) {
return { status: 'error', error: "入力内容に誤りがあります" };
}
try {
await apiClient.deleteTodo(parsed.data.todoId);
revalidatePath("/todos");
return { status: 'success' };
} catch {
return { status: 'error', error: "削除に失敗しました" };
}
}
// 追加
export async function editTodoAction(
_prevState: EditTodoFormState,
formData: FormData
): Promise<EditTodoFormState> {
const parsed = editTodoSchema.safeParse({
todoId: formData.get("todoId")?.toString() ?? "",
title: formData.get("title")?.toString() ?? "",
description: formData.get("description")?.toString() ?? "",
});
if (!parsed.success) {
return { status: 'error', error: "入力内容に誤りがあります" };
}
try {
await apiClient.updateTodo(parsed.data.todoId, {
title: parsed.data.title,
description: parsed.data.description ?? null,
});
revalidatePath("/todos");
return { status: 'success' };
} catch {
return { status: 'error', error: "更新に失敗しました" };
}
}
3-4. forms/EditTodoForm/index.tsx を作成
DeleteTodoFormをコピーし、入力フィールドを追加します。ここで初めてEditProfile等の「フィールドが似た実装」を参考にします。
// features/todos/forms/EditTodoForm/index.tsx
"use client";
import { useActionState, useEffect } from "react";
import { TextField } from "@/components/ui/TextField";
import { editTodoAction } from "../../actions/server";
import type { EditTodoFormProps, EditTodoFormState } from "./types";
const initialState: EditTodoFormState = { status: 'idle' };
export function EditTodoForm({
todoId,
defaultTitle,
defaultDescription,
onSuccess,
}: EditTodoFormProps) {
const [state, formAction, isPending] = useActionState(
editTodoAction,
initialState
);
useEffect(() => {
if (state.status === "success") {
onSuccess?.();
}
}, [state.status, onSuccess]);
return (
<form action={formAction}>
<input type="hidden" name="todoId" value={todoId} />
<TextField
name="title"
label="タイトル"
defaultValue={defaultTitle}
required
/>
<TextField
name="description"
label="説明"
defaultValue={defaultDescription ?? ""}
multiline
/>
{state.status === 'error' && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "保存中..." : "保存する"}
</button>
</form>
);
}
3-5. dialogs/EditTodoDialog/ を作成
DeleteTodoDialogをほぼそのままコピーし、名前を変更します。
// features/todos/dialogs/EditTodoDialog/types.ts
import type { EditTodoFormProps } from "../../forms/EditTodoForm/types";
export type EditTodoDialogProps = Omit<EditTodoFormProps, 'onSuccess'> & {
onClose: () => void;
};
// features/todos/dialogs/EditTodoDialog/index.tsx
"use client";
import { Dialog } from "@/components/ui/Dialog";
import { EditTodoForm } from "../../forms/EditTodoForm";
import type { EditTodoDialogProps } from "./types";
export function EditTodoDialog({
onClose,
todoId,
defaultTitle,
defaultDescription,
}: EditTodoDialogProps) {
return (
<Dialog open onClose={onClose} title="TODOを編集">
<EditTodoForm
todoId={todoId}
defaultTitle={defaultTitle}
defaultDescription={defaultDescription}
onSuccess={onClose}
/>
</Dialog>
);
}
3-6. templates/ から呼び出す
// features/todos/templates/types.ts
export type Todo = {
id: string;
title: string;
description?: string;
};
// features/todos/templates/TodoListTemplate.tsx に追記
"use client";
import { useState } from "react";
import { DeleteTodoDialog } from "../dialogs/DeleteTodoDialog";
import { EditTodoDialog } from "../dialogs/EditTodoDialog"; // 追加
import type { Todo } from "./types";
export function TodoListTemplate({ todos }: { todos: Todo[] }) {
const [deleteTarget, setDeleteTarget] = useState<Todo | null>(null);
const [editTarget, setEditTarget] = useState<Todo | null>(null); // 追加
return (
<div>
{todos.map((todo) => (
<div key={todo.id}>
<span>{todo.title}</span>
{/* 追加 */}
<button onClick={() => setEditTarget(todo)}>編集</button>
<button onClick={() => setDeleteTarget(todo)}>削除</button>
</div>
))}
{/* 既存 */}
{deleteTarget && (
<DeleteTodoDialog
key={deleteTarget.id}
onClose={() => setDeleteTarget(null)}
todoId={deleteTarget.id}
todoTitle={deleteTarget.title}
/>
)}
{/* 追加 */}
{editTarget && (
<EditTodoDialog
key={editTarget.id}
onClose={() => setEditTarget(null)}
todoId={editTarget.id}
defaultTitle={editTarget.title}
defaultDescription={editTarget.description}
/>
)}
</div>
);
}
ステップ4: コピー元コードの3分類
コピー元のコードを見るとき、以下の3つに分類すると判断がブレません。
| 分類 | 具体例 | 対処 |
|---|---|---|
| 構造(そのまま) | ファイル配置、import関係、useActionStateの使い方、Dialog→Form の包含関係 | コピーして名前だけ変える |
| 中身(変える) | Action関数名、FormStateのフィールド、Zodスキーマの中身、API呼び出し | 要件に合わせて書き換える |
| コンテキスト(別を参照) | TextFieldのname属性、バリデーションルール、defaultValueの渡し方 | 同じ文脈の既存実装を参考にする |
この分類を意識すると、「ここはコピーでいいのか、変えるべきか」の判断が即座にできます。
よくある落とし穴
1. 「似ている」と「同じ構造」を混同する
フィールドが同じだからといって構造が異なるコンポーネントをテンプレートにすると、ファイル構成・状態管理・データフローが噛み合わなくなります。構造の一致を最優先にしてください。
2. 全レイヤーを一気に作ろうとする
types.tsを書きながらserver.tsのことを考えると混乱します。1ファイルに集中し、型チェックが通ることだけを目標にする。 次のファイルのことは今考えなくていいのです。
慣れてきたら複数ファイルを同時に進めても問題ありませんが、迷ったときは「1ファイルずつ」に戻ることで混乱を避けられます。
3. データの流れを追わない
「このフォームに表示するデータはどこから来ているのか」を確認せずに実装すると、間違ったデータソースを使ってしまいます。
例えば、編集対象のデータを取得する方法として:
- Server Componentからpropsとして渡す(正解)
- Client側でAPIを再取得する(過剰)
- グローバルStateから取得する(別の用途のデータかもしれない)
テンプレートのデータフローを追跡することで、適切な方法が判断できます。
4. 理解せずにコピーする
このアプローチは「構造を理解した上でコピーベースで効率的に作業する」手法です。レイヤー構造もデータフローも理解せずに機械的にコピーすると、意図しない動作やデバッグ困難なバグの温床になります。
ステップ2(レイヤー構造を把握する)を省略しないでください。
5. 既存のコードが必ずしも正しいとは限らない
既存コードをテンプレートにする際、そのコード自体にアンチパターンや技術的負債が含まれている可能性があります。「なぜこう書いているのか」を考え、疑問があればチームに確認することも大切です。
特に以下のような場合は注意が必要です:
- 一時的な回避策がそのまま残っている
- 別の文脈では適切だが、今回のケースには合わない実装
「既存コードに合わせる」ことと「既存コードを盲信する」ことは違います。構造を参考にしつつも、批判的な視点を持つことが重要です。
まとめ
- テンプレートは「構造が同じもの」を選ぶ — フィールドの類似性ではなく、ファイル構成・型定義・状態管理パターンの一致を見る
- レイヤー構造を書き出す — 作業の地図があれば迷わない
- 依存される側から依存する側へ1ファイルずつ作る — スコープを絞ることで認知負荷を下げる
- コピー元を3分類する — 構造(そのまま)、中身(変える)、コンテキスト(別を参照)








