はじめに
個人開発(開発は私一人で、友人がセールスしている)で細々と運営しているSaaSがある。ローンチから半年が経ち、ありがたいことに少しずつユーザーが増え、月に数千円程度の収益が上がるようになってきた。
今後の発展も見据え、このプロダクションコードのリファクタリングをAIを利用して行ってみた。
結果として、2万行程度の差分が発生したが、破綻なくリファクタリングを完了することができた。
どのような方針で、どのようにタスクを分割して実施したのか、内容を記しておく。
利用ツールと技術構成
まずは今回のリファクタリングで利用したツールと、対象となるSaaSの技術スタック、そして目指したアーキテクチャについて簡単に紹介する。
ツール
- Cline
- AIと対話しながら開発を進めるためのコーディングエージェント
- Gemini 2.5 Pro
- 最大1M tokenの巨大なコンテキストウィンドウと高度な論理的思考・推論能力を備えたLLM
モデルに特に強いこだわりはなかったのだが、使い所のないまま余らせていたGCP (Google Cloud Platform) の $300 クレジットがあったので、Cline経由でGeminiのAPIを呼び出し、実質無料でコーディングすることにした。
対象SaaSの技術スタック
- アプリケーション:
- Webフレームワーク: React Router v7 (Remix)
- CSS: TailwindCSS
- 言語: TypeScript
- その他: neverthrow (Result型)
- データベース:
- 決済基盤:
As-Is と To-Be : アーキテクチャの課題と目標
現在アプリケーションは Fly.io の単一コンテナとして動かしている。負荷の低い現時点ではこれで問題ないが、今後ユーザーが増えた場合にスケールしない構成だ。
データベース更新処理などは Loader/Action から切り離したWorkflowとしてはいるものの、Loader/Action のパラメータをそのまま渡してしまっているような、苦しい作りになっていたりする。
これでは React Router (Remix) の Loader/Action とバックエンドが密結合しており、リソースを引き剥がすことができない。
現時点ではまだ単一コンテナとして動かすとしても、今後アクセスが増えてスケールさせる必要性が出てきた時にスムーズに対処できるよう、フロントエンドとバックエンドの責務を明確に分離し、疎結合化しておきたい。
graph TD
%% To-Be
subgraph To-Be
A2[Remix Loader/Action] -->|変換| B2[DTO(Input型)]
C2[Workflow (フレームワーク非依存)] -->|依存| B2
D2[Infrastructure (Repository)] --> C2
class A2 remix;
class B2 dto;
class C2 backend;
class D2 infra;
classDef dto fill:#ddf,stroke:#99f;
classDef infra fill:#ffd,stroke:#fc9;
end
%% As-Is
subgraph As-Is
A1[Remix Loader/Action] --> B1[request/context/params]
A1 --> C1[Workflow (フレームワーク依存)]
B1 --> C1
C1 --> D1[Infrastructure (Repository)]
classDef remix fill:#fdd,stroke:#f99;
classDef backend fill:#dfd,stroke:#9f9;
classDef coupled fill:#fcc,stroke:#c00;
classDef infra fill:#ffd,stroke:#fc9;
class A1 remix;
class B1 remix;
class C1 coupled;
class D1 infra;
end
今回のリファクタリングでは、「ヘキサゴナルアーキテクチャ(ポートアンドアダプター)」の形に持っていくことを目指した。
Loader/Action と Workflow それぞれがDTOの型という抽象に依存することで、柔軟性が高まる。副次的な効果として、テストも書きやすくなる。
リファクタリング実践
E2Eテスト
まずは最低限の動作を担保できるよう、Happy Path のみのライトなE2Eテストを Playwright で書いておいた。
リファクタリングを実施しながら、要所要所でテストを走らせ、大枠の動きに影響がないことを確かめながら前に進む。
テストケースとしては以下を検証するようにした:
- ログイン関連
- サインアップ・ログイン・ログアウト・退会までの各操作ができるか
- 決済関連
- サブスクリプションの開始や停止、プランのアップグレードができるか
- ※Stripeなど外部サービスはHonoでMockサーバーを作成しており、local環境ではそちらに接続することでE2Eテストしやすくしている
- 各種CRUD
Cline × Gemini でリファクタリング
対象のコードを絞り、最初の小さなリファクタリングをやってみる。
なるべくシンプルなRouteを選択し、関連する処理を書き換えてゆく。
Clineで以下を指示した:
1
2
3
4
5
6
7
8
|
WorkflowとInfrastructureのリファクタリングを行う。
WorkflowはDTOを利用し、Remix(現在はReact Router v7に更新済)のloaderやactionとの結合度を下げる。
更に、InfrastructureのSupabaseからの依存度を下げるために、リポジトリ層でDBクライアントを直接生成するのではなく、DIするようにする。
そして、Workflowのテストを記述する。
まずは、小さく実装の実例をつくり、横展開してゆきたい。
apps/frontend/app/routes/sample/$itemId.tsx のloaderとactionを起点とした処理(つまり、loadItemWorkflowとupdateItemWorkflow)からリファクタリングを実施せよ。
また、リファクタリングが完了したら、横展開のために必要な指示を出すための指示書を作成せよ。
|
LLMのカットオフはそれなりに過去のため、最新のライブラリ変更に追従できないことも多い。
最近、WebフレームワークをRemix v2 からReact Router v7に更新したが、LLMは何も指示しないと古いRemix v2 のつもりでコードを書いてしまう。
このあたりはLLMとやりとりしながら、うまくいかない箇所は Few shot で例示し期待通りに出力されるように調整していく必要がある。
指示書によるリファクタリングの半自動化
最初のリファクタリングが完了し、 REFACTORING_GUIDE.md
と REFACTORING_TASK.md
が作成された。以下のトグルを展開すると各Markdownが閲覧できる。
REFACTORING_GUIDE.md (長いので折りたたみ表示)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
|
# Workflow と Infrastructure リファクタリングガイド
このドキュメントは、アプリケーション内の Workflow 層と Infrastructure 層(特に Repository)のリファクタリング手順をまとめたものです。
主な目的は、以下の通りです。
* Workflow と Route 層(Action/Loader)の結合度を下げ、フレームワークへの依存を低減する。
* Infrastructure 層(Repository)の外部サービス(例: Supabase)への依存度を下げ、テスト容易性や将来的な変更への対応力を向上させる。
* Workflow 層のテストを記述しやすくする。
## 対象範囲
このガイドは、主に以下のコンポーネント間のやり取りに焦点を当てています。
* Route (Action/Loader)
* Workflow
* Repository
* DBクライアント (例: Supabase Client)
## リファクタリング手順
以下の手順に従って、各 Workflow とそれに関連するコンポーネントをリファクタリングします。
### フェーズ1: 準備とDTO定義
1. **対象特定**:
* リファクタリング対象の Route (Action/Loader) を特定します。
* その Action/Loader が呼び出している Workflow を特定します。
* その Workflow が呼び出している Repository の関数を特定します。
2. **DTO (Data Transfer Object) の定義**:
* 特定した Workflow ごとに、入力DTOと出力DTOを定義します。
* **入力DTO**: Workflow が処理を実行するために必要な最小限のデータを含めます。Route 層の `request` や `params` オブジェクト全体を渡すのではなく、必要な値を抽出してプレーンなオブジェクトとして渡します。`userId` など、認証やロギングに必要な情報もDTOに含めます。
* **出力DTO**: Workflow の処理結果を表すオブジェクトを定義します。成功時には処理結果のデータを、失敗時にはエラー情報(メッセージ、元のエラーオブジェクト、ステータスコードなど)を含めます。バリデーションエラーがある場合は、それも出力DTOに含めることを検討します。
* 例:
```typescript
// apps/frontend/app/workflows/{domain}/types.ts
export type SampleWorkflowInput = {
itemId: string;
newData: string;
userId: string;
};
export type SampleWorkflowSuccessOutput = {
updatedItemId: string;
status: string;
};
export type WorkflowError = { // 共通エラー型
message: string;
originalError?: unknown;
statusCode?: number;
validationErrors?: Record<string, string[]>;
};
export type SampleWorkflowOutput =
| { success: true; data: SampleWorkflowSuccessOutput }
| { success: false; error: WorkflowError };
```
* DTOの型定義は、関連する Workflow が存在するディレクトリ(例: `apps/frontend/app/workflows/{domain}/types.ts`)に配置することを推奨します。共通のエラー型は、より汎用的な場所に定義しても構いません。
### フェーズ2: Infrastructure層のリファクタリング (RepositoryへのDI対応)
1. **DBクライアント型の確認**:
* 使用しているDBクライアント(例: `SupabaseClient`)の型を確認します。通常、`apps/frontend/app/infrastructure/supabase/supabase-client.ts` や `apps/frontend/app/schema.ts` で定義・エクスポートされています。
2. **Repository 関数の変更**:
* 対象 Workflow が使用している Repository の各関数について、第一引数などでDBクライアントのインスタンスを受け取るようにシグネチャを変更します。
* 関数内で直接DBクライアントを生成していた箇所(例: `supabaseServerClientWithSession(auth)`)を削除し、引数で渡されたクライアントを使用するように変更します。
* `auth` のような認証情報を Repository 関数が直接受け取るのではなく、DBクライアントの生成時に解決されていることを前提とします。
* 例 (変更前):
```typescript
// apps/frontend/app/infrastructure/repositories/sample-repository.ts
export const getItemById = async ({ auth, id }: { auth: SupabaseAuthInput; id: string }) => {
const supabase = await supabaseServerClientWithSession(auth);
// ... supabaseを使った処理 ...
};
```
* 例 (変更後):
```typescript
// apps/frontend/app/infrastructure/repositories/sample-repository.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "~/schema";
export const getItemById = async (dbClient: SupabaseClient<Database>, { id }: { id: string }) => {
// ... dbClientを使った処理 ...
};
```
* **戻り値の型変更 (neverthrow)**: Repository関数の戻り値を `ResultAsync<SuccessType, AppError>` に変更します。
* `SuccessType` は成功時に返されるデータの型です。
* `AppError` は `~/domain/error.ts` で定義されたカスタムエラー型(またはその基底クラス)です。
* `fromPromise` (neverthrow) を使用して非同期処理をラップし、エラー発生時には適切な `AppError` インスタンス(例: `InternalServerError`, `NotFoundError`)を返すようにします。
* 必要に応じて、Repository関数に `logger` を引数として追加し、エラー発生時のログ記録に使用します。
* 例 (変更後 - ResultAsync対応):
```typescript
// apps/frontend/app/infrastructure/repositories/sample-repository.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import { fromPromise, type ResultAsync } from "neverthrow";
import { AppError, InternalServerError, NotFoundError } from "~/domain/error";
import type { Database } from "~/schema";
import type { Logger } from "~/utils/logger";
export type GetItemSuccessOutput = { id: string; name: string; /* ... */ };
export const getItemById = (
dbClient: SupabaseClient<Database>,
{ id }: { id: string },
logger?: Logger,
): ResultAsync<GetItemSuccessOutput, AppError> => {
return fromPromise(
dbClient.from("items").select("*").eq("id", id).single(),
(error) => {
logger?.error({ error, id }, "DB query failed: getItemById");
return new InternalServerError("DBからのデータ取得に失敗しました。");
}
).andThen((result) => {
if (result.error) {
logger?.error({ error: result.error, id }, "DB query returned error: getItemById");
if (result.error.code === "PGRST116") { // Example: Not found
return err(new NotFoundError(`Item with id ${id} not found.`));
}
return err(new InternalServerError(result.error.message));
}
if (!result.data) {
return err(new NotFoundError(`Item with id ${id} not found (no data).`));
}
// result.data を GetItemSuccessOutput にマッピング
return ok({ id: result.data.id, name: result.data.name, /* ... */ });
});
};
```
### フェーズ3: Workflow層のリファクタリング
1. **Workflow 関数のシグネチャ変更**:
* 対象 Workflow 関数の引数に、DBクライアントのインスタンス(例: `SupabaseClient`)、外部サービスクライアントのインスタンス(例: `Stripe` SDK)、フェーズ1で定義した入力DTO、ロガーインスタンスを受け取るように変更します。引数の順番は、クライアントインスタンス群、入力DTO、ロガーの順を推奨します。
* 戻り値の型を、フェーズ1で定義した出力DTOの型を `Promise` でラップしたもの(例: `Promise<SampleWorkflowOutput>`)に変更します。
2. **Workflow 関数の内部実装変更 (neverthrow対応)**:
* `request`, `context`, `params` などのフレームワーク固有オブジェクトへの直接参照を削除します。必要なデータはすべて入力DTOから取得します。
* Workflow内で外部サービスクライアント(例: Stripe SDK)を直接インスタンス化するのではなく、引数で渡されたクライアントインスタンスを使用します。
* Repository 関数 (ResultAsyncを返す) を呼び出す際には、引数で受け取ったDBクライアントインスタンスや外部サービスクライアントインスタンス(および必要であればロガー)を渡します。
* Repository や他の `ResultAsync` を返す処理をチェインさせます。
* 処理の最後に、`ResultAsync` オブジェクトの `match` メソッドを使用して、成功時 (`Ok` の場合) と失敗時 (`Err` の場合) の処理を分岐させます。
* `match` の各コールバック関数内で、最終的な出力DTO(例: `{ success: true, data: ... }` または `{ success: false, error: ... }`)を返します。これにより、Workflow関数の呼び出し元は `neverthrow` に依存せず、プレーンなオブジェクトとして結果を受け取れます。
* 例 (変更後 - ResultAsync対応、戻り値はPromise<OutputDTO>):
```typescript
// apps/frontend/app/workflows/sample/get-item-workflow.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import { ResultAsync } from "neverthrow"; // 必要に応じて err, ok もインポート
import type { Database } from "~/schema";
import type { Logger } from "~/utils/logger";
import type { GetItemInput, GetItemOutput, GetItemSuccessPayload } from "./types";
import { getItemByIdFromRepo, type GetItemSuccessOutput as RepoSuccessOutput } from "~/infrastructure/repositories/sample-repository";
import { AppError, InternalServerError } from "~/domain/error"; // 適切なエラー型をインポート
export const getItemWorkflow = async (
dbClient: SupabaseClient<Database>,
input: GetItemInput,
logger: Logger,
): Promise<GetItemOutput> => {
logger.info({ workflow: "getItemWorkflow", input }, "triggered");
const { itemId } = input;
// ResultAsync を使った処理の組み立て
const operationResult = getItemByIdFromRepo(dbClient, { id: itemId }, logger)
.map((repoData: RepoSuccessOutput) => {
// Repoの成功データをWorkflowの成功DTOにマッピング
const successPayload: GetItemSuccessPayload = { itemName: repoData.name, itemId: repoData.id };
return successPayload;
})
.mapErr(repoError => {
logger.error({ error: repoError, itemId }, "getItemByIdFromRepo failed in workflow");
// AppError インスタンスをそのまま返すか、新しいエラーでラップする
if (repoError instanceof AppError) return repoError;
return new InternalServerError("アイテムの取得に失敗しました。", repoError);
});
// 最終的に match でプレーンなオブジェクトを返す
return operationResult.match<GetItemOutput>(
(mappedData: GetItemSuccessPayload) => {
return { success: true, data: mappedData };
},
(error: AppError) => {
return { success: false, error: { message: error.message, originalError: error.originalError, statusCode: error.statusCode } };
}
);
};
```
### フェーズ4: Route層 (Action/Loader) のリファクタリング
1. **Action/Loader 関数の変更**:
* `request` オブジェクトや `params` オブジェクトから、Workflow の入力DTOに必要なデータを抽出します。
* `userId` は `fetchUserIdFromSession(request)` などで取得します。
* フォームデータは `await request.formData()` で取得し、`Object.fromEntries()` でオブジェクトに変換後、必要に応じてバリデーションスキーマ (例: Zod) で検証します。
* DBクライアントのインスタンスを取得します (例: `await supabaseServerClientWithSession(...)`)。
* 外部サービスクライアント(例: Stripe SDK)が必要な場合は、Route層でインスタンス化するのではなく、 `apps/frontend/app/infrastructure/stripe/stripe-client.ts` の `stripeClient` を利用します。開発環境やテストで注入するオブジェクトを柔軟に変更できるようにしておくのが目的です。
```typescript
// 例: Route (Action/Loader) 内でのStripeクライアント初期化
import { stripeClient as stripeClientBase } from "../../infrastructure/stripe/stripe-client";
import { env } from "~/env.server"; // 環境変数を読み込む
// ...
const stripeClient = stripeClientBase({
stripeSecretKey: env.STRIPE_SECRET_KEY,
});
```
* リファクタリングされた Workflow 関数を `await` で呼び出し、取得したDBクライアント、初期化した外部サービスクライアント、作成した入力DTO、ロガーを渡します。Workflowからは `Promise<OutputDTO>` が返されます。
* Workflow から返された出力DTO(プレーンなオブジェクト)を元に、Action/Loader の戻り値(レスポンス、リダイレクト、エラー表示用のデータなど)を組み立てます。
* 出力DTOの `success` プロパティで処理結果を判断します。
* エラーの場合、出力DTO内のエラー情報を利用してユーザーにフィードバックします。`redirectLoginPageIfSupabaseJwtExpired` はそのまま残し、エラーオブジェクトを渡します。
* **フレームワークについての注意**: WebフレームワークとしてReact Routerを利用しています。例えば、Remixでは `json()` ヘルパーでレスポンスを構築しますが、本プロジェクトはReact Routerなので `data({ hoge: 'fuga' }, { status: 200 })` を返してください。
* **動的な設定を必要とする外部サービスクライアントの初期化**:
* クライアントの初期化にDBアクセスや追加のAPIコールが伴う場合(例: APIキーやアクセストークンを動的に取得・更新する必要がある場合)、その複雑な初期化ロジック自体を **Infrastructure層にカプセル化するファクトリ関数またはクラスを設けること**を推奨します。
* Route層 (Action/Loader) は、このファクトリに必要な基本的な情報(例: ユーザーID、アカウント識別子、DBクライアントインスタンスなど)を提供し、ファクトリから完成したクライアントインスタンスを受け取る形とします。
* これにより、Route層はクライアント初期化の詳細なロジックから解放され、責務が明確に保たれます。また、テスト容易性も向上します。
* 例 (Loader - WorkflowがPromise<OutputDTO>を返す場合):
```typescript
// apps/frontend/app/routes/sample/$itemId.tsx
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await fetchUserIdFromSession(request);
const logger = pinoLogger.child({ userId, route: "sampleItemLoader" });
if (!params.itemId) {
return { item: null, error: { message: "Item ID is missing" } }; // プレーンオブジェクトを返す
}
// Supabaseクライアントの初期化 (WorkflowがDBを使用する場合)
const authInput: SupabaseAuthInput = {
supabaseUrl: env.SUPABASE_URL,
supabaseKey: env.SUPABASE_KEY,
cookie: request.headers.get("Cookie") ?? "",
};
const dbClient = await supabaseServerClientWithSession(authInput);
// Stripeクライアントの初期化 (WorkflowがStripeを使用する場合)
const stripeClient = stripeClientBase({
stripeSecretKey: env.STRIPE_SECRET_KEY,
});
const input: GetItemInput = { itemId: params.itemId, userId };
// Workflow呼び出し時にdbClientやstripeClientを渡す
const result = await getItemWorkflow(dbClient, stripeClient, input, logger);
if (!result.success) {
// result.error は WorkflowError 型 (または AppError を基にしたオブジェクト)
if (result.error.originalError) { // originalError があればそれを渡す
redirectLoginPageIfSupabaseJwtExpired(result.error.originalError as { message: string });
}
return { item: null, error: { message: result.error.message } };
}
return { item: result.data, error: null };
};
```
### フェーズ5: Workflowのテスト記述 (推奨)
1. **テストファイルの作成**:
* リファクタリングした Workflow ごとにテストファイルを作成します (例: `apps/frontend/app/workflows/{domain}/{workflowName}.workflow.test.ts`)。
2. **テストの実装**:
* テストフレームワーク (Vitestなど) を使用します。
* DBクライアントと Repository 関数をモックします。
* itは日本語で記述します(例:更新が失敗した場合は、エラーを返す)
* ロガーもモックするか、テスト用の簡易ロガーを使用します。
* **正常系テスト**:
* 適切な入力DTOを与えた場合に、期待される成功時の出力DTOが返ることを確認します。
* モックされた Repository 関数が正しい引数(DBクライアント、必要なパラメータ)で呼び出されることを確認します。
* **異常系テスト**:
* モックされた Repository 関数が `errAsync(new AppErrorSubclass(...))` を返すように設定し、Workflow が期待される失敗時の出力DTOを返すことを確認します。
* (もし Workflow 内で入力バリデーションを行う場合)バリデーションエラー時の動作もテストします。
* テストにて型エラーが解決できない場合は深追いせず、保留してください。
* Repository のモックは、`ResultAsync` を返すようにします。
```typescript
// 例: テストファイル内
import { okAsync, errAsync } from "neverthrow";
import { YourRepositoryFunction } from "..."; // モック対象
import { NotFoundError, InternalServerError } from "~/domain/error";
// Loggerのモック
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn(() => mockLogger),
} as unknown as Logger;
// SupabaseClientのモック
const mockDbClient = {} as SupabaseClient<Database>;
vi.mock("...", () => ({
YourRepositoryFunction: vi.fn(),
}));
const mockedRepoFn = YourRepositoryFunction as MockedFunction<typeof YourRepositoryFunction>;
// 成功ケースのモック
mockedRepoFn.mockReturnValue(okAsync({ id: "1", name: "Test" }));
// 失敗ケースのモック
mockedRepoFn.mockReturnValue(errAsync(new NotFoundError("Not found")));
```
## 注意点とベストプラクティス
* **段階的な適用**: 一度に多くの箇所を変更せず、一つの Workflow とそれに関連する数珠つなぎのコンポーネント群から着手し、動作確認とテストを行いながら進めてください。
* **型定義の活用**: TypeScript の型システムを最大限に活用し、DTO や各関数のシグネチャを明確に定義することで、リファクタリング中のエラーを早期に発見しやすくなります。
* **エラーハンドリング**: Workflow 層でのエラー集約と、Route 層での適切なユーザーフィードバックを意識してください。Neverthrow などのライブラリを使用している場合は、その方針に沿ってエラー処理を行います。
* **ロギング**: 各層で適切なログを出力し、デバッグや問題追跡を容易にします。
* **既存テストの維持・更新**: リファクタリングによって既存のテストが失敗する場合は、テストコードも適切に修正してください。
* **チーム内共有**: リファクタリングの方針や進捗はチーム内で共有し、認識を合わせて進めることが重要です。
* **進捗管理**: リファクタリングの進捗は、別途用意されたタスク管理ドキュメント(例: `REFACTORING_TASK.md`)のチェックボックスを使用して追跡します。各フェーズが完了するごとに、該当するチェックボックスをオンにしてください。これにより、全体の進捗状況が明確になり、作業の抜け漏れを防ぐことができます。
|
REFACTORING_TASK.md (長いので折りたたみ表示)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
# リファクタリングタスク進捗
このドキュメントは、`REFACTORING_GUIDE.md` に基づくリファクタリングの進捗と次のステップを記録します。
## 残りのタスク
リファクタリング対象の Route (Action/Loader) を特定し、関連する Workflow と Repository を順次リファクタリングします。
### 1. 対象の洗い出し
`apps/frontend/app/routes/` ディレクトリ配下で、リファクタリングが必要な `loader` および `action` を持つファイルをリストアップします。
特定後、以下の「個別タスクの進捗」セクションに追記していきます。
### 2. 個別タスクの進捗
各 Route (Action/Loader) ごとに、以下のステップで進捗を管理します。
**テンプレート:**
- **[Route Path]**
- 対象Action/Loader: `[action名 or loader名]`
- 関連Workflow(既存または新規作成): `[Workflow名]`
- 関連Repository関数(既存または新規作成): `[Repository関数名]`
- [ ] **フェーズ1: 準備とDTO定義**
- [ ] 入力DTO定義 (`apps/frontend/app/workflows/{domain}/types.ts`)
- [ ] 出力DTO定義 (`apps/frontend/app/workflows/{domain}/types.ts`)
- [ ] **フェーズ2: Infrastructure層のリファクタリング**
- [ ] Repository 関数のシグネチャ変更 (DBクライアント引数)
- [ ] Repository 関数の戻り値型変更 (`ResultAsync`)
- [ ] **フェーズ3: Workflow層のリファクタリング**
- [ ] Workflow 関数のシグネチャ変更 (DBクライアント, 入力DTO, Logger引数)
- [ ] Workflow 関数の内部実装変更 (`ResultAsync` 対応)
- [ ] Workflow ファイル作成 (例: `apps/frontend/app/workflows/{domain}/{workflowName}.ts`)
- [ ] **フェーズ4: Route層のリファクタリング**
- [ ] Action/Loader 関数の変更 (DTO生成, Workflow呼び出し)
- [ ] **フェーズ5: Workflowのテスト記述**
- [ ] テストファイル作成 (例: `apps/frontend/app/workflows/{domain}/{workflowName}.workflow.test.ts`)
- [ ] 正常系テスト実装
- [ ] 異常系テスト実装
---
- **apps/frontend/app/routes/sample/$itemId.tsx**
- 対象Action/Loader: `loader` `action`
- 関連Workflow(既存または新規作成): `loadItemWorkflow`
- 関連Repository関数(既存または新規作成): `getItemByUserId`
- [ ] **フェーズ1: 準備とDTO定義**
- [ ] **フェーズ2: Infrastructure層のリファクタリング**
- [ ] **フェーズ3: Workflow層のリファクタリング**
- [ ] **フェーズ4: Route層のリファクタリング**
- [ ] **フェーズ5: Workflowのテスト記述** (テスト失敗修正済み)
- ...(同様にRoute起点のチェックリストが続く)
|
類似するリファクタリングのたびに毎回がっつり指示を出すのは骨が折れる。かといって同一チャットで全てのリファクタリングを完了させることはできない。コンテクストウィンドウが足りなくなるためだ。
リファクタリングの起点となる Route ごとにClineのチャットを新規作成する。リファクタリングの一貫性は REFACTORING_GUIDE.md
を参照させて担保し、タスク管理は REFACTORING_TASK.md
のチェックリストで行う。こうすることで、コンテクストのサイズを抑えた状態でリファクタリングを繰り返すことができる。
パターンさえできてしまえば、以下の基本指示を繰り返すだけ。
1
2
|
@REFACTORING_GUIDE.md を参照し、リファクタリングを実施せよ。
@REFACTORING_TASK.md の `apps/frontend/app/routes/sample/$itemId.tsx` のタスクを計画し、完了させよ。
|
テストが通らずにハマってしまったり、型エラーが解決できなかったりすることがあるので、都度介入して修正しつつ、うまくいかなかった原因を指摘してGUIDEの更新をさせる。
淡々と指示を出したり修正をしながら、Clineがコードを書き換えてくれている間は、並行で別の作業を進めたり技術書を読んだり。
アーキテクチャの最終調整
ひととおり計画していたリファクタリングを完了させたところで、もう一つレイヤーを追加しておいたほうがより良いことに気がついた。
最終的なアーキテクチャは以下となった:
graph TD
subgraph Frontend React Router
Routes[Routes(Action/Loader)]
end
subgraph Backend Potentially separate server
Services[Service Layer]
Workflows[Workflow Layer]
Infrastructure[Infrastructure Layer(Repositories, Client Factories)]
ExternalSystems[(External Systems: DB, Stripe, etc...)]
end
Routes -- Request Data --> Services
Services -- Initializes/Injects Clients --> Workflows
Workflows -- Uses Clients, Calls --> Infrastructure
Infrastructure -- Interacts with --> ExternalSystems
Services -- Calls --> Workflows
Workflows -- Returns Output DTO --> Services
Services -- Returns Data/Error --> Routes
追加リファクタのGUIDEも以下に残しておく。TASKは似たり寄ったりなので割愛。
SERVICE_LAYER_REFACTORING_GUIDE.md (長いので折りたたみ表示)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
|
# Serviceレイヤー導入 リファクタリングガイド
このドキュメントは、アプリケーションに Service レイヤーを導入し、Route 層、Workflow 層、Infrastructure 層の連携を改善するためのリファクタリング手順をまとめたものです。
## 主な目的
* Route 層 (Action/Loader) と Workflow 層の間に Service レイヤーを導入し、Route 層の責務を HTTP リクエストのハンドリングと Service 呼び出しに限定する。
* 依存性注入 (DI) の起点を Route 層から Service レイヤーに移動し、外部サービス (例: Supabase, Stripe) への依存を Service レイヤーで解決する。
* Service レイヤー、Workflow 層、Infrastructure 層を「バックエンド」としてより明確に分離し、将来的なサーバー構成の変更 (例: フロントエンドサーバーとバックエンドサーバーの物理的な分離) への対応力を向上させる。
* 各レイヤーのテスト容易性をさらに向上させる。
## 対象範囲
このガイドは、主に以下のコンポーネント間のやり取りに焦点を当てています。
* Route (Action/Loader)
* Service (新規作成)
* Workflow
* Infrastructure (Repository, Client Factory)
* 外部サービスクライアント (DBクライアント, Stripe SDK など)
## リファクタリング手順
以下の手順に従って、各 Route とそれに関連するコンポーネントをリファクタリングします。
### フェーズ1: Serviceレイヤーの準備
1. **対象特定**:
* リファクタリング対象の Route (Action/Loader) を特定します。
* その Action/Loader が呼び出している (または呼び出すべき) Workflow を特定します。
2. **Service ファイルの作成**:
* 関連するドメインごとに Service ファイルを作成します。
* 例: `apps/frontend/app/services/{domain}/service.ts`
* または、Workflow の機能に応じてより具体的なファイル名を採用しても構いません (例: `apps/frontend/app/services/user/user-account-service.ts`)。
3. **Service 関数のDTO定義 (入力/出力)**:
* Service 関数が受け取る入力と返す出力を定義します。
* 多くの場合、既存の Workflow の入力DTO (またはその一部) を Service の入力として利用し、Workflow の出力DTOを Service の出力としてそのまま利用できます。
* 必要に応じて、Service 層独自のDTOを定義することも検討します。特に、複数の Workflow をオーケストレーションする場合などです。
* DTOの型定義は、関連する Service ファイル内、または共通の `types.ts` に配置します。
```typescript
// 例: apps/frontend/app/services/{domain}/types.ts (または service.ts 内)
import type { SampleWorkflowInput, SampleWorkflowOutput } from "~/workflows/{domain}/types";
// Serviceの入力DTO (Workflowのものを流用する例)
export type SampleServiceInput = SampleWorkflowInput;
// Serviceの出力DTO (Workflowのものを流用する例)
export type SampleServiceOutput = SampleWorkflowOutput;
```
### フェーズ2: Serviceレイヤーの実装
1. **Service 関数のシグネチャ定義**:
* Service 関数の引数として、フェーズ1で定義した入力DTOと、ロガーインスタンスを受け取るようにします。
* 戻り値の型を、フェーズ1で定義した出力DTOの型を `Promise` でラップしたもの(例: `Promise<SampleServiceOutput>`)にします。
2. **Service 関数の内部実装**:
* **クライアントの初期化**:
* DBクライアント (例: `SupabaseClient`) や外部サービスクライアント (例: `Stripe` SDK) のインスタンスを Service 関数内で取得・初期化します。
* 既存のリファクタリングガイド (`REFACTORING_GUIDE.md`) に記載されているクライアント初期化ファクトリ (例: `stripeClientBase`, `supabaseServerClientWithSession`) を利用します。
* Route 層から渡される `request` オブジェクトが必要な場合は、Service 関数の引数に追加することも検討できますが、基本的には Service の入力DTOに必要な情報(例: `userId`, `cookie` の一部など)を詰めて渡す方が望ましいです。
* **Workflow 入力DTOの作成**:
* Service の入力DTOや、Service 内で取得した情報 (例: `userId` from session) を元に、呼び出す Workflow の入力DTOを構築します。
* **Workflow の呼び出し**:
* 初期化したクライアントインスタンス群、作成した Workflow 入力DTO、ロガーを引数として、対応する Workflow 関数を呼び出します。
* **結果の返却**:
* Workflow から返された出力DTOを、そのまま Service 関数の戻り値として返します。
* Service 層で追加のエラーハンドリングやデータ変換が必要な場合は、ここで行います。
* 例:
```typescript
// apps/frontend/app/services/sample/sample-service.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type Stripe from "stripe";
import { env } from "~/env.server"; // 環境変数
import { supabaseServerClientWithSession, type SupabaseAuthInput } from "~/infrastructure/supabase/supabase-client"; // 仮
import { stripeClient as stripeClientBase } from "~/infrastructure/stripe/stripe-client"; // 仮
import { getItemWorkflow } from "~/workflows/sample/get-item-workflow"; // Workflowをインポート
import type { Database } from "~/schema";
import type { Logger } from "~/utils/logger";
import type { SampleServiceInput, SampleServiceOutput } from "./types"; // Service DTO
export const getSampleItemService = async (
// request: Request, // 必要に応じて request を受け取る
authCookie: string | null, // 例: Cookieは文字列で渡す
input: SampleServiceInput,
logger: Logger,
): Promise<SampleServiceOutput> => {
logger.info({ service: "getSampleItemService", input }, "triggered");
// 1. クライアント初期化
const authInput: SupabaseAuthInput = { // SupabaseAuthInput は適切な型に置き換えてください
supabaseUrl: env.SUPABASE_URL,
supabaseKey: env.SUPABASE_KEY,
cookie: authCookie ?? "",
};
const dbClient: SupabaseClient<Database> = await supabaseServerClientWithSession(authInput);
const stripeClient: Stripe = stripeClientBase({
stripeSecretKey: env.STRIPE_SECRET_KEY,
});
// 2. Workflow入力DTOの作成 (この例ではService入力DTOをそのまま利用)
// const workflowInput: GetItemInput = { itemId: input.itemId, userId: input.userId };
// 3. Workflow呼び出し
// Workflow が dbClient, stripeClient, workflowInput, logger を受け取るように修正されている前提
const result = await getItemWorkflow(dbClient, stripeClient, input, logger);
// 4. 結果返却
return result;
};
```
### フェーズ3: Route層 (Action/Loader) のリファクタリング
1. **Action/Loader 関数の変更**:
* `request` オブジェクトや `params` オブジェクトから、Service の入力DTOに必要なデータを抽出します。
* `userId` は `fetchUserIdFromSession(request)` などで取得します。
* フォームデータは `await request.formData()` で取得し、オブジェクトに変換します。
* Cookie情報は `request.headers.get("Cookie")` で取得します。
* **Service 関数の呼び出し**:
* 作成した Service 入力DTO、必要な認証情報 (例: Cookie文字列)、ロガーを引数として、リファクタリングされた Service 関数を `await` で呼び出します。
* **クライアント初期化ロジックの削除**:
* 従来 Route 層で行っていた DBクライアントや外部サービスクライアントの初期化ロジックを削除します (Service 層に移管済みのため)。
* **レスポンスの構築**:
* Service 関数から返された出力DTOを元に、Action/Loader の戻り値(レスポンス、リダイレクト、エラー表示用のデータなど)を組み立てます。
* 例 (Loader):
```typescript
// apps/frontend/app/routes/sample/$itemId.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node"; // (お使いのフレームワークに合わせて変更)
import { pinoLogger } from "~/utils/logger"; // Logger
import { fetchUserIdFromSession } from "~/utils/session.server"; // セッションユーティリティ
import { getSampleItemService } from "~/services/sample/sample-service"; // Serviceをインポート
import type { SampleServiceInput, SampleServiceOutput } from "~/services/sample/types";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await fetchUserIdFromSession(request); // userIdはServiceに渡すか、Service内で取得
const logger = pinoLogger.child({ userId, route: "sampleItemLoader" });
const authCookie = request.headers.get("Cookie");
if (!params.itemId) {
return json({ item: null, error: { message: "Item ID is missing" } }, { status: 400 });
}
const serviceInput: SampleServiceInput = { itemId: params.itemId, userId };
// Service呼び出し
const result: SampleServiceOutput = await getSampleItemService(authCookie, serviceInput, logger);
if (!result.success) {
// エラーハンドリング (redirectLoginPageIfSupabaseJwtExpired なども考慮)
return json({ item: null, error: { message: result.error.message } }, { status: result.error.statusCode || 500 });
}
return json({ item: result.data, error: null });
};
```
### フェーズ4: Serviceレイヤーのテスト記述 (推奨)
1. **テストファイルの作成**:
* リファクタリングした Service ごとにテストファイルを作成します (例: `apps/frontend/app/services/{domain}/{serviceName}.service.test.ts`)。
2. **テストの実装**:
* テストフレームワーク (Vitestなど) を使用します。
* **Workflow 関数をモック**します。Service のテストでは、Workflow が正しく呼び出され、その結果に基づいて Service が適切に動作することを確認します。
* クライアント初期化処理 (例: `supabaseServerClientWithSession`, `stripeClientBase`) も必要に応じてモックし、期待通りに呼び出されるか、またはモックされたクライアントインスタンスが Workflow に渡されることを確認します。
* ロガーもモックするか、テスト用の簡易ロガーを使用します。
* **正常系テスト**:
* 適切な入力DTOと認証情報(モックされた `request` や `cookie`)を与えた場合に、モックされた Workflow が期待通りに呼び出され、期待される成功時の出力DTOが Service から返ることを確認します。
* **異常系テスト**:
* モックされた Workflow がエラー時の出力DTOを返すように設定し、Service がそれを適切にハンドリングして期待される失敗時の出力DTOを返すことを確認します。
* クライアント初期化が失敗するケースなどもテストします。
* 例:
```typescript
// apps/frontend/app/services/sample/sample-service.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { getItemWorkflow } from "~/workflows/sample/get-item-workflow"; // モック対象
import { getSampleItemService } from "./sample-service";
import type { SampleServiceInput, SampleServiceOutput } from "./types";
import { supabaseServerClientWithSession } from "~/infrastructure/supabase/supabase-client"; // モック対象
import { stripeClient } from "~/infrastructure/stripe/stripe-client"; // モック対象
import type { Logger } from "~/utils/logger";
// Workflowとクライアント初期化をモック
vi.mock("~/workflows/sample/get-item-workflow");
vi.mock("~/infrastructure/supabase/supabase-client");
vi.mock("~/infrastructure/stripe/stripe-client");
vi.mock("~/env.server", () => ({
env: {
SUPABASE_URL: "dummy-supabase-url",
SUPABASE_KEY: "dummy-supabase-key",
STRIPE_SECRET_KEY: "dummy-stripe-key",
},
}));
const mockedGetItemWorkflow = vi.mocked(getItemWorkflow);
const mockedSupabaseClientSession = vi.mocked(supabaseServerClientWithSession);
const mockedStripeClient = vi.mocked(stripeClient);
const mockLogger = { info: vi.fn(), error: vi.fn(), /* ...他のメソッドも同様に */ child: vi.fn(() => mockLogger) } as unknown as Logger;
const mockDbClient = {} as any; // SupabaseClientのモック
const mockStripeSdk = {} as any; // Stripe SDKのモック
describe("getSampleItemService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedSupabaseClientSession.mockResolvedValue(mockDbClient);
mockedStripeClient.mockReturnValue(mockStripeSdk);
});
it("正常系: Workflowが成功データを返した場合、Serviceも成功データを返す", async () => {
const input: SampleServiceInput = { itemId: "test-id", userId: "user-1" };
const workflowSuccessOutput = { success: true, data: { itemName: "Test Item", itemId: "test-id" } };
mockedGetItemWorkflow.mockResolvedValue(workflowSuccessOutput as any); // Workflowの型に合わせる
const result = await getSampleItemService(null, input, mockLogger);
expect(result).toEqual(workflowSuccessOutput);
expect(mockedSupabaseClientSession).toHaveBeenCalled();
expect(mockedStripeClient).toHaveBeenCalled();
expect(mockedGetItemWorkflow).toHaveBeenCalledWith(mockDbClient, mockStripeSdk, input, mockLogger);
});
it("異常系: Workflowがエラーを返した場合、Serviceもエラーを返す", async () => {
const input: SampleServiceInput = { itemId: "test-id", userId: "user-1" };
const workflowErrorOutput = { success: false, error: { message: "Workflow error" } };
mockedGetItemWorkflow.mockResolvedValue(workflowErrorOutput as any);
const result = await getSampleItemService(null, input, mockLogger);
expect(result).toEqual(workflowErrorOutput);
});
});
```
### フェーズ5: (オプション) Workflow層の調整
Service レイヤーの導入により、Workflow 層の責務がより純粋なビジネスロジックの実行に特化される可能性があります。
このフェーズでは、必要に応じて Workflow 層の内部実装を見直し、Service 層との役割分担をより明確にします。
例えば、これまで Workflow で行っていた軽微なデータ整形処理などを Service 層に移管することを検討できます。ただし、ビジネスロジックに関わる重要な変換は引き続き Workflow 層が担うべきです。
## 注意点とベストプラクティス
* **段階的な適用**: 一度に多くの箇所を変更せず、一つの Route (Action/Loader) とそれに関連する Service/Workflow から着手し、動作確認とテストを行いながら進めてください。
* **責務の明確化**: Service レイヤー、Workflow レイヤー、Infrastructure レイヤーの責務を常に意識し、適切なロジックを適切なレイヤーに配置してください。
* **DTOの設計**: Service レイヤーと Workflow レイヤー間のDTOは、データの受け渡しに必要な最小限の情報を含むように設計します。
* **既存テストの維持・更新**: リファクタリングによって既存のテスト (特に Workflow のテスト) が影響を受ける場合は、適切に修正してください。Service のテストは新規に作成します。
* **進捗管理**: リファクタリングの進捗は、別途用意されたタスク管理ドキュメント (`SERVICE_LAYER_REFACTORING_TASK.md`) を使用して追跡します。
|
追加のリファクタリングも同様にチェックリストを潰す形で順次作業させ、無事に全ての書き換えが完了した。
リファクタリング結果
今回のリファクタリングの結果として、定量的な情報を以下に示しておく:
項目 |
数値 |
対象エンドポイント数 |
20 |
Clineチャット数 |
45 |
総合コスト |
$78(約11,310円) |
コード差分 |
+16,669行、-3,933行 |
作業時間 |
約15時間 |
※レイヤー追加・テスト追加のため、増分が多くなった
まとめ
1人で手作業で進めるには作業量が膨大なリファクタリングをLLMと共にやってみた。
リファクタリング結果の定量的な数値を眺めてみると、完全に手作業で進める場合と比較して、生産性は5倍程度になっているようだ。
本記事にはコードを添付していないが、いままで部分的にしか書けておらず不足していたUnitテストもしっかり拡充できた。
大量のコードを破綻なくリファクタリングできてよかったものの、今回利用した Gemini 2.5 Pro の不要なコメント出力には悩まされた。
書くべきコメントをしっかり残してくれるのは助かるが、 // 追加
といった変更差分についてのコメントも大量に作成されたため、削除して回るのが面倒だった。
また、LLMの世界的需要の高まりによるものなのか、APIレスポンスが500となってしまうケースも頻発した。もどかしいが、こういった場合はおそらく待つしかない。
1つ実装例を作らせ、横展開させてゆくのはいい感じだった。最も頭を使うのは最初の1つであり、残りは流れ作業だ。こういったつまらない作業をLLMは嫌な顔ひとつせずやってくれるのでありがたい。
今回リファクタが完了し、ようやく基盤が整ったので、今後は .clinerules
などもしっかり書き、今後の指示の精度を高めてゆければと思う。
Geminiはコンテキストウィンドウが大きいとはいえ、人間のように俯瞰することはできない。
個別の作業はAIが圧倒的に速いので、人間は適切にタスクを切り出してAIに与えてゆくことに集中すべき だと感じた。
LLMはステートレスで記憶をもたないので、一貫性を持たせるためには、こちらで記憶の手綱を握ってあげる必要がある。
このリファクタリングを通じて、AIを活用した大規模コード改修の可能性を実感できた。
適切なタスク分割と品質担保の仕組みさえ整えれば、従来では考えられないスピードでコードを改善してゆくことができる。