生成AIを使ったレガシー移行とは、長く動いているコードを新しい言語仕様やフレームワークへ載せ替える作業のうち、読み解き・書き換え・説明をAIに任せ、「元と同じ結果が出るか」を機械的に突き合わせて確かめる進め方です。 変換そのものより、変換の前に現行の挙動をテストで固定し、変換の後に新旧の出力を同じ入力で比べるところで品質が決まります。

この記事には、工程ごとにそのまま貼れるプロンプト7本、作業を「規則で置換/AIに任せる/人が決める」に分ける振り分け表30行、新旧の出力を200件で突き合わせるPythonスクリプトと実行結果、移行単位ごとの受け入れ判定表を載せました。題材は Java の日付処理と料金計算で、外部ライブラリなしで動かせます。

確認環境:Ubuntu 24.04/OpenJDK 21.0.10/Python 3.11.15。Spring Boot は公式リファレンス(4.1.1・3.5.16・2.7.18)と公式移行ガイドで確認し、Java 単体で再現できる部分は Java 21 で実行しました(確認日 2026年10月7日)。


生成AIでのレガシー移行の早見表

工程 AIに頼むこと 成果物 次へ進む目安
1 棚卸し ファイル・依存・入出力の一覧化 棚卸し表 対象と対象外が決まった
2 仕様の書き起こし コードから仕様を日本語にする 仕様メモと質問一覧 質問に人が答え終えた
3 挙動の固定 現行の出力を記録するテスト作成 入力データと期待出力 現行コードで全件通る
4 機械置換の洗い出し 規則で置換できる箇所の列挙 置換一覧 ツールに回す分が決まった
5 変換 1単位ずつ書き換える 変換後のコード コンパイルとテストが通る
6 差分の説明 変えた点と変えていない点の説明 差分メモ 挙動の変化がゼロか承認済み
7 新旧比較 同じ入力で出力を突き合わせる 比較結果 不一致0件

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「生成AIでレガシー移行|工程別プロンプトと検証チェック表」

7つの工程のうち、3と7が「同じ結果か」を確かめる工程です。 ここを省くと、読みやすくなったコードが元と違う金額や日数を返していても気づけません。後半の題材では、見た目の整った変換後コードが200件中138件で元と違う結果を返す例を、スクリプトで見つけて直します。


移行先の版を決める:Spring Bootのサポート期限と前提

載せ替え先を決めるときは、まずサポート期限と前提(Java・Servlet の版)を並べて見ます。次の表は、Spring 公式の API(api.spring.io)と各版の公式リファレンスを 2026年10月7日に開いて確認した内容です。

系列 初回リリース OSS支援終了 商用支援終了
2.7.x 2022-05-31 2023-06-30 2029-06-30
3.5.x 2025-05-31 2026-06-30 2032-06-30
4.0.x 2025-11-30 2026-12-31 2027-12-31
4.1.x 2026-06-30 2027-07-31 2028-07-31

出典:https://api.spring.io/projects/spring-boot/generations (同じ一覧には、初回リリース 2026-11-30 の 4.2.x も載っています)。spring.io の Spring Boot のページでは、2026年10月7日時点の表示は 4.1.1 でした。

系列 Java Spring Framework Servlet
2.7(2.7.18) 8〜21 5.3.31 以上 3.1 または 4.0
3.5(3.5.16) 17〜25 6.2.19 以上 6.0(5.0 以上の互換コンテナに配置可)
4.0 17 以上 7.x 6.1(Jakarta EE 11)
4.1(4.1.1) 17〜26 7.0.9 以上 6.1

出典:各版の System Requirements(https://docs.spring.io/spring-boot/system-requirements.html 、https://docs.spring.io/spring-boot/3.5/system-requirements.html 、https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/getting-started.html )と、Spring Boot 4.0 Migration Guide。

2026年10月7日時点で、2.7.x と 3.5.x は OSS のサポート期間を過ぎ、4.0.x は 2026年12月31日まで、4.1.x は 2027年7月31日までです。 公式の移行ガイドは段階を踏む進め方を示しています。3.0 のガイドは「最新の 2.7.x に上げてから」、4.0 のガイドは「最新の 3.5.x に上げてから」始めるよう書いており、2.7 → 3.5 → 4.x の順に、1段ずつ新旧比較を通すのが公式の道筋です。

Spring Boot 4.0 公式移行ガイドの主な見出し早見

ガイドの見出し 主な内容 担当の目安
Before You Start 最新の 3.5.x へ先に上げる。依存・前提・3.x の非推奨を確認 人+AI
Upgrade to Spring Boot 4 spring-boot-properties-migrator で設定名の変更を診断 ツール
Features Removed From This Release Undertow、Pulsar Reactive、起動スクリプト埋め込みなどの削除 人
Module Dependencies スターターとモジュールの再編、Classic スターター ツール+人
Migration Strategy 直接モジュール化に合わせるか、2段階で上げるか 人
Upgrading Core Features JSpecify、Logback の既定文字コード(UTF-8)など AI
Upgrading Dependencies and Build Plugins Maven の optional 依存、AOP スターターなど ツール
Upgrading Jackson Jackson 3 が既定。com.fasterxml.jackson → tools.jackson ツール+AI
Upgrading Actuator and Production-Ready Features liveness/readiness プローブが既定で有効 人
Upgrading Web Features HttpMessageConverters の非推奨など AI
Upgrading Data Features 永続化モジュール、MongoDB の設定名、Hibernate の依存 ツール+AI
Upgrading Messaging Features Kafka・AMQP のリトライ機能 AI
Upgrading IO Features Spring Batch(データベースなしで動作可能に) 人
Upgrading Testing Features @MockBean/@SpyBean の削除、@SpringBootTest と MockMVC AI

出典:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide (2026年10月7日確認)。前提は「Java 17 以上、Kotlin は 2.2 以上、GraalVM の native-image は 25 以上、Jakarta EE 11、Servlet 6.1、Spring Framework 7.x」と書かれています。


規則で置換・AIに任せる・人が決めるの振り分け表30行

移行の作業は「決まった規則で置換できるもの」「文脈を読んで書き換えるもの」「方針として決めるもの」の3種類に分かれます。 規則で置換できるものは、同じ入力に同じ結果を返すレシピ型のツールに回し、AIには文脈が要る部分を任せ、人は判断に集中する、という分担にすると、レビューする差分が小さくなります。

No 作業 例 担当 確かめ方
1 import の名前空間変更 javax.servlet → jakarta.servlet 規則 コンパイル
2 依存の座標の変更 javax.servlet:javax.servlet-api → jakarta.servlet:jakarta.servlet-api 規則 ビルド
3 ビルドファイルの版数引き上げ 親 POM・プラグインの版 規則 ビルド
4 設定プロパティ名の変更 spring.session.redis → spring.session.data.redis 規則 起動時の診断
5 スターター名の変更 spring-boot-starter-web → spring-boot-starter-webmvc 規則 ビルド
6 テスト用スターター名の変更 spring-boot-starter-<technology>-test 規則 テスト実行
7 Jackson のパッケージ変更 com.fasterxml.jackson → tools.jackson 規則 コンパイル
8 非推奨 API の決まった置換 置換先が1対1で決まるメソッド 規則 コンパイル
9 依存の名前変更 hibernate-jpamodelgen → hibernate-processor 規則 ビルド
10 書式と import の整理 フォーマッタの適用 規則 差分が書式だけ
11 現行コードの棚卸し ファイル・依存・入出力の一覧 AI 人が抜けを確認
12 仕様の書き起こし コードから条件と計算式を文章に AI 人が質問に回答
13 現行挙動を固定するテスト 入力と現行出力の記録 AI 現行で全件通過
14 日付 API の書き換え java.util.Date → java.time AI 新旧比較
15 構造の整理 手続き的な処理 → record と小さなメソッド AI 新旧比較
16 テスト用アノテーションの書き直し @MockBean/@SpyBean の削除への対応 AI テスト実行
17 テスト設定の追加 @SpringBootTest で MockMVC を使う設定 AI テスト実行
18 置換後に残ったコンパイルエラー 型の不一致、削除された API AI コンパイル
19 差分の説明 変えた点・変えていない点の要約 AI 人がレビュー
20 比較用データとスクリプト 境界値を含む入力の生成 AI 人が境界値を追加
21 移行先の版 3.5 で止めるか 4.x まで進むか 人 期限と前提の表
22 たまたまの挙動を残すか 存在しない日付の受け入れ 人 利用者への確認
23 端数の扱い 切り捨てか四捨五入か 人 仕様書・利用者
24 組み込みサーバーの選定 4.0 での Undertow 削除への対応 人 負荷試験
25 互換設定を使うか Jackson 2 に寄せる設定 人 出力 JSON の比較
26 上げ方の方針 Classic スターターで一度動かすか 人 移行計画
27 性能とログの基準 所要時間・ログ量の許容幅 人 計測値
28 移行単位の切り方と順番 依存の少ない単位から 人 依存関係の図
29 切り替えと切り戻しの条件 並行稼働の期間 人 判定表
30 外部と約束した出力の変更可否 エラー文言、ファイル形式 人 連携先への確認

No.1〜9 は Spring Boot の公式移行ガイドに載っている変更です。規則で置換できる作業は、レシピ型ツールの対象になっていることが多いため、AIに書かせる前に対応するレシピがあるかを調べます。たとえば OpenRewrite には次のレシピがあります(2026年10月7日に公式サイトで確認)。

レシピ ID 対象
Migrate to Jakarta EE 9 org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta javax → jakarta(43 の下位レシピで構成)
Migrate to Spring Boot 3.5 io.moderne.java.spring.boot3.UpgradeSpringBoot_3_5 ビルドファイル、非推奨 API、設定の移行

Jakarta のレシピは、公式ページに次のコマンドが載っています。

mvn -U org.openrewrite.maven:rewrite-maven-plugin:6.49.0:run \
  --define rewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
  --define rewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta \
  --define rewrite.exportDatatables=true

ライセンスはレシピごとに異なります。公式ページでは Jakarta のレシピが Moderne Source Available License、Spring Boot 3.5 のレシピが Moderne Proprietary License(Moderne CLI で mod run . --recipe UpgradeSpringBoot_3_5)と案内されているので、導入前に各ページの記載を確認してください。出典:https://docs.openrewrite.org/recipes/java/migrate/jakarta/javaxmigrationtojakarta 、https://docs.openrewrite.org/recipes/java/spring/boot3/upgradespringboot_3_5


工程別のコピペ用プロンプト7本

[ ] の中を自分の対象に置き換えて使います。どのプロンプトにも「やらないこと」と「出力の形」を書いてあるのは、AIが気を利かせて範囲を広げるのを防ぎ、次の工程にそのまま渡せる成果物にするためです。

1. 現行コードの棚卸し

[src/legacy/] 以下を読み、移行のための棚卸し表を作ってください。コードは変更しないでください。

出力は Markdown の表1つにしてください。列は次のとおりです。
- ファイル
- 役割(1行)
- 入力(引数・ファイル・DB・環境変数)
- 出力(戻り値・ファイル・標準出力・DB更新)
- 依存している他ファイル
- 日付・時刻、金額や端数、文字コードを扱う箇所の行番号
- 現在のテストの有無

表の後に「移行単位の候補」を、他への依存が少ない順に並べてください。
読んでも役割が分からなかったファイルは、推測で埋めずに「不明」と書いてください。

2. 仕様の書き起こし

[LegacyRentalFee.java] の現在の挙動を、仕様として書き起こしてください。コードは変更しないでください。

- 「こうあるべき」ではなく、「いまのコードがこう動く」を書いてください。
- 計算式・分岐の条件・丸め方・エラー時の出力を、行番号つきで箇条書きにしてください。
- 境界になる値(0、ちょうど、1つ超えた値、日付またぎ、月末、うるう年)での結果を表にしてください。
- 意図したものか偶然なのか判断できない挙動は「確認したい点」として別に並べ、
  それぞれ「現状のまま残す/直す」を私が選べる形にしてください。

3. 現行挙動を固定するテストの作成

[LegacyRentalFee.java] の現在の出力を記録するテストを作ってください。本体のコードは変更しないでください。

- 入力データを [200] 件作ってください。先頭の10件は仕様メモの境界値、残りは乱数で、乱数の種は固定してください。
- 現行のプログラムを実行し、その出力を期待値として保存してください。
- 期待値は必ず実行結果から作り、手計算や推測で書かないでください。
- テストを実行し、現行コードで全件通ることを、実行したコマンドと出力で示してください。
- 不具合に見える挙動があっても、この工程では期待値をそのまま記録し、気づいた点だけ報告してください。

4. 機械的に置換できる箇所の洗い出し

[対象ディレクトリ] を [移行先:例 Spring Boot 3.5] に上げるための変更点を洗い出してください。コードは変更しないでください。

変更点を次の3つに分類した表にしてください。
A. 決まった規則で置換できるもの(import の名前空間、依存の座標、設定プロパティ名、1対1で置換先が決まる API)
B. 文脈を読まないと書き換えられないもの
C. 方針を人が決める必要があるもの

A には、該当ファイル数と、置換前 → 置換後の対応を書いてください。
B と C には、該当箇所のファイル名と行番号、判断が要る理由を書いてください。
公式の移行ガイドに根拠がある項目は、その見出し名を添えてください。根拠が見つからない項目は「根拠なし」と書いてください。

5. 1単位ずつの変換

[LegacyRentalFee.java] を、[java.time と record を使う形] に書き換えた [RentalFeeV2.java] を作ってください。

守ること:
- 外から見た挙動(出力の文字列、金額、日数、エラー時の出力、文字コード)を1文字も変えないでください。
- 改善したくなる点(丸め方、エラー文言、入力チェックの厳しさ)があっても変えず、最後に「提案」として列挙してください。
- 変更するのはこの1ファイルだけにしてください。元のファイルは残してください。
- 新しい API に置き換えたことで挙動が変わりうる箇所(例:日付の解釈、整数と小数の計算)には、コメントで元の挙動に合わせた理由を書いてください。

終わったら [python3 compare_old_new.py LegacyRentalFee RentalFeeV2 200] を実行し、出力をそのまま貼ってください。
不一致が1件でもあれば、原因を調べて直し、0件になるまで繰り返してください。期待値やスクリプトは変更しないでください。

6. 差分の説明

[LegacyRentalFee.java] と [RentalFeeV2.java] の違いを、レビューする人向けに説明してください。コードは変更しないでください。

次の3つの表にしてください。
1. 構造を変えた点(何を、なぜ)
2. 挙動を変えていないことを、どの処理で保証しているか(元の行番号 → 新しい行番号)
3. 挙動が変わった点、または変わる可能性が残る点(入力の例つき)

3 が空の場合は「なし」と書き、その根拠として比較スクリプトの出力を引用してください。
時刻帯、ロケール、既定の文字コードに依存する処理が残っていれば、必ず 3 に書いてください。

7. 新旧の出力比較

旧 [LegacyRentalFee] と新 [RentalFeeV2] に同じ入力を渡して結果を突き合わせるスクリプトを Python で作ってください。

- 入力は [200] 件。乱数の種を固定し、境界値を先頭に固定で入れてください。
- 標準出力を行単位で比べ、一致件数・不一致件数を出してください。
- 終了コード、標準エラーの文字数、所要時間も新旧で並べて出してください。
- 不一致は「入力/旧の出力/新の出力」を先頭から数件表示してください。
- ロケールを引数で切り替えられるようにし、C.UTF-8 と POSIX の両方で実行して結果を貼ってください。
- 不一致が0件のときだけ終了コード0で終わるようにしてください。

題材で試す:java.util.Dateの料金計算をjava.timeとrecordへ

ここからは、実際のコードで工程3・5・7を通します。題材は、備品レンタルの料金を計算する小さなプログラムです。

移行前のコード

java.util.Date・Calendar・SimpleDateFormat を使い、1つの main に計算を並べた書き方です。

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

/** 移行前:java.util.Date と手続き的な計算で書かれた備品レンタル料金。 */
public class LegacyRentalFee {
    public static void main(String[] args) throws Exception {
        PrintStream out = new PrintStream(System.out, true, "UTF-8");
        BufferedReader in = new BufferedReader(
                new InputStreamReader(new FileInputStream(args[0]), "UTF-8"));
        SimpleDateFormat fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm");
        String line;
        while ((line = in.readLine()) != null) {
            if (line.length() == 0) continue;
            String[] c = line.split(",");
            String id = c[0];
            try {
                Date start = fmt.parse(c[1]);
                Date end = fmt.parse(c[2]);
                String rank = c[3];
                String item = c[4];
                if (end.before(start)) {
                    out.println(id + ",ERROR,end before start");
                    continue;
                }
                // 日数:経過ミリ秒を24時間で割って切り上げ。最低1日
                long ms = end.getTime() - start.getTime();
                int days = (int) (ms / 86400000L);
                if (ms % 86400000L != 0) days++;
                if (days == 0) days = 1;
                // 基本料金 830円/日。土日は1日あたり 250円加算
                int fee = 0;
                Calendar cal = Calendar.getInstance();
                cal.setTime(start);
                for (int i = 0; i < days; i++) {
                    int dow = cal.get(Calendar.DAY_OF_WEEK);
                    fee += 830;
                    if (dow == Calendar.SATURDAY || dow == Calendar.SUNDAY) fee += 250;
                    cal.add(Calendar.DAY_OF_MONTH, 1);
                }
                // 7日以上は1日分(830円)を引く
                if (days >= 7) fee -= 830;
                // 会員ランク割引:A 15%、B 5%。整数演算なので1円未満は切り捨て
                if (rank.equals("A")) fee = fee * 85 / 100;
                else if (rank.equals("B")) fee = fee * 95 / 100;
                out.println(id + "," + item + "," + days + "," + fee);
            } catch (Exception e) {
                out.println(id + ",ERROR,bad input");
            }
        }
        in.close();
    }
}

変換後の第1版

次は、java.time と record に書き換えた第1版です。変換でよく起きる5種類の変化を入れて用意した例で、読みやすさは上がっていますが、元と同じ結果になるとは限りません。変化した箇所にはコメント(変更1〜5)を付けてあります。

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DayOfWeek;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

/** 変換後・第1版:読みやすくなったが、挙動が5か所変わっている例。 */
public class RentalFeeV1 {
    record Rental(String id, LocalDateTime start, LocalDateTime end, String rank, String item) {}

    static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm");

    static Rental parse(String line) {
        String[] c = line.split(",");
        return new Rental(c[0], LocalDateTime.parse(c[1], FMT), LocalDateTime.parse(c[2], FMT), c[3], c[4]);
    }

    static long days(Rental r) {
        // 変更1:日付の差で数えている(時刻を見ていない)
        return Math.max(1, ChronoUnit.DAYS.between(r.start().toLocalDate(), r.end().toLocalDate()));
    }

    static long fee(Rental r) {
        long days = days(r);
        long fee = 0;
        for (int i = 0; i < days; i++) {
            DayOfWeek dow = r.start().toLocalDate().plusDays(i).getDayOfWeek();
            fee += 830;
            if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) fee += 250;
        }
        if (days >= 7) fee -= 830;
        double rate = switch (r.rank()) {
            case "A" -> 0.85;
            case "B" -> 0.95;
            default -> 1.0;
        };
        // 変更2:四捨五入になっている(元は切り捨て)
        return Math.round(fee * rate);
    }

    public static void main(String[] args) throws Exception {
        for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) {
            if (line.isEmpty()) continue;
            String id = line.split(",")[0];
            try {
                Rental r = parse(line);   // 変更3:2026/02/30 が 2/28 に丸められる(元は 3/2 扱い)
                if (r.end().isBefore(r.start())) {
                    // 変更4:エラー文言が変わっている
                    System.out.println(id + ",ERROR,終了日時が開始日時より前です");
                    continue;
                }
                // 変更5:System.out のまま(出力の文字コードが実行環境のロケールで変わる)
                System.out.println(id + "," + r.item() + "," + days(r) + "," + fee(r));
            } catch (Exception e) {
                System.out.println(id + ",ERROR,bad input");
            }
        }
    }
}

新旧を200件で突き合わせるスクリプト

同じ入力ファイルを新旧のプログラムに渡し、標準出力を行単位で比べます。 入力は、境界になりやすい10件を先頭に固定し、残り190件を種を固定した乱数で作ります。

#!/usr/bin/env python3
"""新旧プログラムに同じ入力を渡し、出力・終了コード・標準エラー・所要時間を突き合わせる。

使い方:
  python3 compare_old_new.py <旧クラス名> <新クラス名> [件数] [ロケール]
  ロケールを省くと C.UTF-8。POSIX を渡すと、ロケール未設定のジョブ実行環境を再現できる。
前提:
  javac -d out *.java でコンパイル済み(out/ にクラスがある)
"""
import os
import random
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path

SEED = 20261007
ITEMS = ["プロジェクター", "ノートPC", "会議用マイク", "三脚", "延長コード", "ホワイトボード"]


def make_cases(n: int) -> list[str]:
    """乱数の種を固定して、毎回同じ入力を作る。境界になりやすい値を先頭に固定で入れる。"""
    rows = [
        "E001,2026/10/01 09:00,2026/10/01 09:00,C,三脚",            # 0分
        "E002,2026/10/01 09:00,2026/10/02 09:00,A,三脚",            # ちょうど24時間
        "E003,2026/10/01 09:00,2026/10/02 09:01,A,三脚",            # 24時間+1分
        "E004,2026/10/02 23:30,2026/10/03 00:10,B,延長コード",      # 日付をまたぐ40分
        "E005,2026/10/05 10:00,2026/10/04 10:00,C,ノートPC",        # 終了が開始より前
        "E006,2026/02/30 10:00,2026/03/05 10:00,A,プロジェクター",  # 存在しない日付
        "E007,2026/10/01 10:00,2026/10/08 10:00,B,会議用マイク",    # ちょうど7日
        "E008,2026-10-01 10:00,2026/10/08 10:00,B,会議用マイク",    # 区切り文字の違い
        "E009,2026/12/30 18:00,2027/01/03 09:00,A,ホワイトボード",  # 年またぎ
        "E010,2028/02/28 12:00,2028/03/01 12:00,C,三脚",            # うるう年
    ]
    rnd = random.Random(SEED)
    base = datetime(2026, 1, 1, 0, 0)
    i = len(rows)
    while len(rows) < n:
        i += 1
        start = base + timedelta(minutes=rnd.randrange(0, 365 * 24 * 60))
        end = start + timedelta(minutes=rnd.choice([30, 90, 600, 1440, 1500, 2880, 4000, 10080, 14500, 20000])
                                + rnd.randrange(0, 180))
        rows.append(
            f"R{i:03d},{start:%Y/%m/%d %H:%M},{end:%Y/%m/%d %H:%M},{rnd.choice('ABC')},{rnd.choice(ITEMS)}"
        )
    return rows


def run(cls: str, input_path: Path, locale: str) -> dict:
    env = {k: v for k, v in os.environ.items() if not k.startswith("LC_") and k != "LANG"}
    env["LC_ALL"] = locale
    env.pop("JAVA_TOOL_OPTIONS", None)
    t0 = time.perf_counter()
    p = subprocess.run(
        ["java", "-Duser.timezone=Asia/Tokyo", "-cp", "out", cls, str(input_path)],
        capture_output=True,
        env=env,
    )
    return {
        "lines": p.stdout.decode("utf-8", errors="replace").splitlines(),
        "stderr": p.stderr.decode("utf-8", errors="replace").strip(),
        "code": p.returncode,
        "sec": time.perf_counter() - t0,
    }


def main() -> int:
    old_cls, new_cls = sys.argv[1], sys.argv[2]
    n = int(sys.argv[3]) if len(sys.argv) > 3 else 200
    locale = sys.argv[4] if len(sys.argv) > 4 else "C.UTF-8"
    cases = make_cases(n)
    input_path = Path("cases.csv")
    input_path.write_text("\n".join(cases) + "\n", encoding="utf-8")

    old, new = run(old_cls, input_path, locale), run(new_cls, input_path, locale)
    diffs = []
    for idx, case in enumerate(cases):
        o = old["lines"][idx] if idx < len(old["lines"]) else "<出力なし>"
        nw = new["lines"][idx] if idx < len(new["lines"]) else "<出力なし>"
        if o != nw:
            diffs.append((case, o, nw))

    print(f"入力 {len(cases)} 件 / 旧 {old_cls} / 新 {new_cls} / ロケール {locale}")
    print(f"出力行数   旧 {len(old['lines'])} 行 / 新 {len(new['lines'])} 行")
    print(f"終了コード 旧 {old['code']} / 新 {new['code']}")
    print(f"標準エラー 旧 {len(old['stderr'])} 文字 / 新 {len(new['stderr'])} 文字")
    print(f"所要時間   旧 {old['sec']:.2f} 秒 / 新 {new['sec']:.2f} 秒")
    print(f"一致 {len(cases) - len(diffs)} 件 / 不一致 {len(diffs)} 件")
    for case, o, nw in diffs[:6]:
        print("---")
        print(f"入力: {case}")
        print(f"  旧: {o}")
        print(f"  新: {nw}")
    if len(diffs) > 6:
        print(f"--- ほか {len(diffs) - 6} 件")
    ok = not diffs and old["code"] == new["code"] and len(old["lines"]) == len(new["lines"])
    print("判定:", "合格(新旧の出力が一致)" if ok else "不合格(差分を確認する)")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

実行手順と、第1版の結果です。

javac -d out *.java
python3 compare_old_new.py LegacyRentalFee RentalFeeV1 200
入力 200 件 / 旧 LegacyRentalFee / 新 RentalFeeV1 / ロケール C.UTF-8
出力行数   旧 200 行 / 新 200 行
終了コード 旧 0 / 新 0
標準エラー 旧 0 文字 / 新 0 文字
所要時間   旧 0.22 秒 / 新 0.14 秒
一致 62 件 / 不一致 138 件
---
入力: E002,2026/10/01 09:00,2026/10/02 09:00,A,三脚
  旧: E002,三脚,1,705
  新: E002,三脚,1,706
---
入力: E003,2026/10/01 09:00,2026/10/02 09:01,A,三脚
  旧: E003,三脚,2,1411
  新: E003,三脚,1,706
---
入力: E004,2026/10/02 23:30,2026/10/03 00:10,B,延長コード
  旧: E004,延長コード,1,788
  新: E004,延長コード,1,789
---
入力: E005,2026/10/05 10:00,2026/10/04 10:00,C,ノートPC
  旧: E005,ERROR,end before start
  新: E005,ERROR,終了日時が開始日時より前です
---
入力: E006,2026/02/30 10:00,2026/03/05 10:00,A,プロジェクター
  旧: E006,プロジェクター,3,2116
  新: E006,プロジェクター,5,3953
---
入力: E009,2026/12/30 18:00,2027/01/03 09:00,A,ホワイトボード
  旧: E009,ホワイトボード,4,3034
  新: E009,ホワイトボード,4,3035
--- ほか 132 件
判定: 不合格(差分を確認する)
終了コード: 1

コンパイルは通り、終了コードも標準エラーも新旧で同じなのに、200件中138件で結果が違います。 出力を内訳で数えると、日数が違うもの89件、日数は同じで金額が1円違うもの48件、エラー文言が違うもの1件でした。先頭の10件に境界値を入れておいたので、原因がすぐに読み取れます。

元の挙動に合わせた第2版

見つかった差分を、1つずつ元の挙動に合わせたのが第2版です。

import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;

/** 変換後・第2版:突き合わせで見つかった4か所を元の挙動に合わせたもの。 */
public class RentalFeeV2 {
    record Rental(String id, LocalDateTime start, LocalDateTime end, String rank, String item) {
        /** 経過時間を24時間で割って切り上げ。最低1日(移行前と同じ) */
        int days() {
            long minutes = Duration.between(start, end).toMinutes();
            int days = (int) Math.ceilDiv(minutes, 24L * 60);
            return Math.max(1, days);
        }

        int fee() {
            int days = days();
            int fee = 0;
            for (int i = 0; i < days; i++) {
                DayOfWeek dow = start.toLocalDate().plusDays(i).getDayOfWeek();
                fee += 830;
                if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) fee += 250;
            }
            if (days >= 7) fee -= 830;
            // 整数演算で1円未満を切り捨て(移行前と同じ)
            return switch (rank) {
                case "A" -> fee * 85 / 100;
                case "B" -> fee * 95 / 100;
                default -> fee;
            };
        }
    }

    // SimpleDateFormat の既定(lenient)と同じく、2026/02/30 を 3/2 として受け取る
    static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("uuuu/M/d H:m")
            .withResolverStyle(ResolverStyle.LENIENT);

    static Rental parse(String line) {
        String[] c = line.split(",");
        return new Rental(c[0], LocalDateTime.parse(c[1], FMT), LocalDateTime.parse(c[2], FMT), c[3], c[4]);
    }

    public static void main(String[] args) throws Exception {
        PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
        for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) {
            if (line.isEmpty()) continue;
            String id = line.split(",")[0];
            try {
                Rental r = parse(line);
                if (r.end().isBefore(r.start())) {
                    out.println(id + ",ERROR,end before start");
                    continue;
                }
                out.println(id + "," + r.item() + "," + r.days() + "," + r.fee());
            } catch (Exception e) {
                out.println(id + ",ERROR,bad input");
            }
        }
    }
}
python3 compare_old_new.py LegacyRentalFee RentalFeeV2 200
入力 200 件 / 旧 LegacyRentalFee / 新 RentalFeeV2 / ロケール C.UTF-8
出力行数   旧 200 行 / 新 200 行
終了コード 旧 0 / 新 0
標準エラー 旧 0 文字 / 新 0 文字
所要時間   旧 0.18 秒 / 新 0.12 秒
一致 200 件 / 不一致 0 件
判定: 合格(新旧の出力が一致)
終了コード: 0

ロケールを変えてもう一度比べる

出力の文字コードは、開発用の端末では差が出ず、ロケールが設定されていないジョブ実行環境で初めて差になることがあります。スクリプトの第4引数に POSIX を渡して、第1版をもう一度比べます。

python3 compare_old_new.py LegacyRentalFee RentalFeeV1 200 POSIX
入力 200 件 / 旧 LegacyRentalFee / 新 RentalFeeV1 / ロケール POSIX
出力行数   旧 200 行 / 新 200 行
終了コード 旧 0 / 新 0
標準エラー 旧 0 文字 / 新 0 文字
所要時間   旧 0.18 秒 / 新 0.12 秒
一致 1 件 / 不一致 199 件
---
入力: E001,2026/10/01 09:00,2026/10/01 09:00,C,三脚
  旧: E001,三脚,1,830
  新: E001,??,1,830
…(省略)…
判定: 不合格(差分を確認する)
終了コード: 1

第1版は System.out をそのまま使っているため、ロケールが POSIX の環境では日本語が ?? になります。 移行前のコードは PrintStream に UTF-8 を指定していたので、どの環境でも同じ出力でした。第2版は同じ指定を引き継いでいます。

python3 compare_old_new.py LegacyRentalFee RentalFeeV2 200 POSIX
入力 200 件 / 旧 LegacyRentalFee / 新 RentalFeeV2 / ロケール POSIX
出力行数   旧 200 行 / 新 200 行
終了コード 旧 0 / 新 0
標準エラー 旧 0 文字 / 新 0 文字
所要時間   旧 0.16 秒 / 新 0.11 秒
一致 200 件 / 不一致 0 件
判定: 合格(新旧の出力が一致)
終了コード: 0

Java の既定の文字コードは Java 18 から UTF-8 になりました(JEP 400)。ただし JEP 400 には、System.out と System.err の文字コードはこの変更の対象外と書かれています。ファイルの読み書きと標準出力は、文字コードが別々に決まるので、比較はロケールを変えて2回行うと取りこぼしが減ります。出典:https://openjdk.org/jeps/400 (2026年10月7日確認)


AIが黙って仕様を変える例と、直した指示5組

第1版に入っていた5つの変化を、「どう見つかったか」と「どう指示すると元に合うか」で整理します。共通しているのは、どれも新しい API の「素直な書き方」を選んだ結果だという点です。書き換えたコードだけを読んでも不自然さがないため、出力の突き合わせで見つけます。

変化 見つかった入力 元 → 第1版 直した指示
日数の数え方 24時間と1分 2日 → 1日 日数は経過時間を24時間で割って切り上げる。日付の差では数えない
端数の丸め 830円の15%引き 705円 → 706円 割引は整数演算(fee * 85 / 100)のまま。小数と Math.round は使わない
存在しない日付 2026/02/30 3/2 扱いで3日 → 2/28 扱いで5日 日付の解釈は元と同じ寛容な解釈に合わせる(ResolverStyle.LENIENT)。変えるかどうかは提案に書く
エラー文言 終了が開始より前 end before start → 日本語の文 出力する文字列は1文字も変えない
出力の文字コード ロケール POSIX 三脚 → ?? 標準出力は UTF-8 を指定した PrintStream で出す

3つ目の「存在しない日付」は、直す前に人が決める項目です。元のコードは SimpleDateFormat の既定の設定で 2026/02/30 を3月2日として受け取っていました。第1版の DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm") は同じ入力を2月28日として受け取り、エラーにもなりません。どちらも例外を出さずに計算を続けるので、テストデータに存在しない日付を1件入れておくことが、この違いに気づく手段になります。「エラーにする」が望ましい仕様だとしても、載せ替えと仕様変更を同じ差分に混ぜると新旧比較が成り立たなくなるため、まず元に合わせ、仕様の変更は別の作業として扱います。


移行単位ごとの受け入れ判定表

移行単位(1クラス、1バッチ、1画面など)ごとに、次の表を埋めてから次の単位へ進みます。右端の列は、今回の題材での結果です。

観点 確かめ方 合格の目安 題材での結果
出力の一致 同じ入力での行単位の比較 不一致0件 200件一致
出力の件数 新旧の行数 同数 200行と200行
終了コード 正常時と異常時 新旧で同じ 0と0
例外とエラー出力 エラーになる入力を含める 文言まで同じ 2件とも一致
標準エラー・ログ 文字数や行数の比較 増減に説明がつく 0文字と0文字
文字コード ロケールを変えて再実行 両方で不一致0件 C.UTF-8 と POSIX で一致
日付と時刻帯 月末・年またぎ・うるう年 境界値で一致 先頭10件で一致
性能 同じ入力での所要時間 許容幅の内側 旧0.16秒、新0.11秒
差分の範囲 変更ファイルの一覧 対象の単位だけ 新規1ファイル
挙動の変化の一覧 差分の説明(工程6) 「なし」または承認済み なし

性能の数値は、200件を1回処理したときの JVM 起動込みの時間です。性能を判定に使う場合は、件数を増やして複数回測り、許容幅(例:元の1.2倍以内)を先に決めておきます。 時刻帯は -Duser.timezone=Asia/Tokyo を指定して実行しました。移行前のコードは経過ミリ秒で日数を数えているため、夏時間のある時刻帯では結果が変わります。稼働環境の時刻帯で比較を行ってください。


対象が多いときの進め方:一覧を作って1件ずつ

対象のファイルが多いときは、1回の会話で全部を変換させず、一覧を作って1件ずつ同じ指示で処理し、1件ごとに比較を通す形にします。Claude Code の公式ベストプラクティスには、移行対象の一覧をファイルに書かせ、claude -p をループで呼ぶ手順が載っています。

for file in $(cat files.txt); do
  claude -p "Migrate $file from Python 2 to Python 3. Return OK or FAIL." \
    --allowedTools "Edit,Bash(git commit *)"
done

同じページには、最初の2〜3ファイルで試してプロンプトを直してから全体に流すこと、--allowedTools で実行できる操作を絞ること、git リポジトリでは /batch <instruction> で変更を5〜30個のサブエージェントに分け、それぞれが独自の worktree で作業してプルリクエストを開けることも書かれています。出典:https://code.claude.com/docs/ja/best-practices (2026年10月7日確認)

この記事の工程に当てはめると、files.txt が工程1の棚卸し表、ループの中のプロンプトが工程5、OK or FAIL の判定が工程7の比較スクリプトの終了コードにあたります。AIの自己申告ではなく、比較スクリプトの終了コードで合否を決めると、件数が増えても判定がぶれません。


よくある質問

Q. 変換は全部AIに任せて、最後にまとめてテストすればよいですか? A. 1単位ずつ変換して、そのたびに新旧比較を通す進め方をおすすめします。今回の題材では1ファイルに5つの変化が入っていました。複数の単位をまとめて変換すると、不一致の原因がどの変更かを切り分ける手間が大きくなります。

Q. 現行コードに不具合らしい挙動がありました。移行のついでに直してよいですか? A. まず元の挙動に合わせ、直すのは別の変更にします。載せ替えと仕様変更を分けると、「載せ替えで結果が変わっていない」を不一致0件で示せます。直すかどうかは、振り分け表の No.22・23 のように人が決める項目です。

Q. 規則で置換できるものまでAIに書かせると何が起きますか? A. 動くことは多いのですが、同じ指示でもファイルごとに書き方が少しずつ変わり、レビューする差分が増えます。import の名前空間や設定名のような1対1の置換は、レシピ型のツールや一括置換に回すと、差分が揃って確認が短く済みます。

Q. 比較用の入力は何件あればよいですか? A. 件数よりも、境界値が入っているかが効きます。今回は200件のうち先頭の10件(ちょうど24時間、1分超過、存在しない日付、終了が開始より前、年またぎ、うるう年など)で、5つの変化のうち4つが見つかりました。残り1つはロケールを変えた再実行で見つかっています。稼働中のデータが使える場合は、個人を特定できる情報を除いたうえで、実際の入力の分布に近いものを加えると安心です。


まとめ

  • 変換の前に現行の挙動をテストで固定し、変換の後に新旧の出力を同じ入力で突き合わせる。 合否は不一致0件と終了コードで決める
  • 作業は「規則で置換/AIに任せる/人が決める」に分ける。 規則で置換できるものはレシピ型ツール、文脈が要るものはAI、方針は人
  • プロンプトには「挙動を1文字も変えない」「改善案は提案として列挙する」を書く。 載せ替えと仕様変更を同じ差分に混ぜない
  • 比較データの先頭に境界値を固定で入れる。 ちょうど・1つ超え・存在しない日付・エラーになる入力・年またぎ
  • 比較はロケールを変えて2回行う。 ファイルの文字コードと標準出力の文字コードは別々に決まる
  • Spring Boot は 2.7 → 3.5 → 4.x と1段ずつ上げる。 2026年10月7日時点で OSS のサポート期間内は 4.0.x(2026年12月31日まで)と 4.1.x(2027年7月31日まで)
  • 対象が多いときは一覧を作って1件ずつ処理し、1件ごとに比較を通す

参考・出典

確認日はすべて 2026年10月7日です。

  • Spring Boot のサポート期間:https://api.spring.io/projects/spring-boot/generations
  • Spring Boot 4.0 Migration Guide:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
  • Spring Boot 3.0 Migration Guide:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide
  • Spring Boot System Requirements(4.1.1):https://docs.spring.io/spring-boot/system-requirements.html
  • Spring Boot System Requirements(3.5.16):https://docs.spring.io/spring-boot/3.5/system-requirements.html
  • Spring Boot 2.7.18 Getting Started:https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/getting-started.html
  • OpenRewrite「Migrate to Jakarta EE 9」:https://docs.openrewrite.org/recipes/java/migrate/jakarta/javaxmigrationtojakarta
  • OpenRewrite「Migrate to Spring Boot 3.5」:https://docs.openrewrite.org/recipes/java/spring/boot3/upgradespringboot_3_5
  • JEP 400: UTF-8 by Default:https://openjdk.org/jeps/400
  • Claude Code のベストプラクティス:https://code.claude.com/docs/ja/best-practices

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「生成AIでレガシー移行|工程別プロンプトと検証チェック表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?