Strangler Fig・Sprout/Wrapで進めるレガシーコード改善の実践ガイド
この記事でわかること
- レガシーコード改善の3大パターン(Strangler Fig・Branch by Abstraction・Sprout/Wrap)の使い分け
- テストのないコードに安全にテストを追加する「Seam」の概念と実装方法
- 循環的複雑度・認知的複雑度を指標にしたリファクタリング優先度の決め方
- AI支援リファクタリングツールの活用と限界
- Shopifyの3,000行超God Object改善から学ぶ段階的移行の実践手順
対象読者
- 想定読者: テストの少ないコードベースを改善したい中級者のソフトウェアエンジニア
-
必要な前提知識:
- Python または TypeScript の基礎文法
- Git の基本操作(ブランチ、マージ)
- ユニットテストの基本概念(ML で言う pytest のような自動テストフレームワークの経験)
結論・成果
レガシーコード改善は「一括書き直し」ではなく、段階的な改善パターンの組み合わせで進めるのが現実的です。DX社のレポートによると、高インパクトなコンポーネントを優先してAI支援リファクタリングを適用した組織は、網羅的にリファクタリングを試みた組織と比較して4倍のROIを達成したと報告されています。また、ある金融機関の事例では、段階的リファクタリングによりコード複雑度を40%削減し、処理速度を25%改善したとされています。
本記事では、Michael Feathersの『Working Effectively with Legacy Code』で提唱された手法と、Martin Fowlerが命名したStrangler Figパターンを中心に、2026年現在のAI支援ツールも交えた実践的な改善戦略を解説します。
レガシーコードにテストを追加する「Seam」の考え方を理解する
Michael Feathersは『Working Effectively with Legacy Code』の中で、レガシーコードとは「テストのないコード」であると定義しています。テストがなければリファクタリングの安全性を担保できず、変更のたびに「Edit and Pray」(編集して祈る)アプローチに頼ることになります。
これに対して Feathers が提唱するのが「Cover and Modify」(テストで保護してから変更する)アプローチです。しかし、テストのないコードにテストを追加すること自体が難しいという鶏と卵の問題があります。この問題を解決する鍵がSeam(シーム)です。
Seamとは何か
Seamとは、コードを編集せずにプログラムの振る舞いを変更できる箇所のことです。ML エンジニアの方には、学習済みモデルの推論パイプラインで、前処理モジュールだけを差し替えてテストするイメージが近いでしょう。
オブジェクト指向言語では、主に3種類のSeamがあります。
| Seamの種類 | 仕組み | 使いどころ |
|---|---|---|
| Object Seam | インターフェースや継承を使い、テスト用のモックに差し替え | 最も一般的。依存オブジェクトの差し替え |
| Link Seam | リンク時に実装を差し替え(DI コンテナ等) | フレームワークレベルの依存関係 |
| Preprocessing Seam | プリプロセッサやビルド設定で振る舞いを切り替え | C/C++ のマクロ、環境変数による分岐 |
Characterization Test を書く
Seamを見つけたら、次はCharacterization Test(特性テスト)を書きます。これは「コードがどう動くべきか」ではなく、**「コードが今どう動いているか」**を記録するテストです。MLエンジニアの方は、スナップショットテストやゴールデンファイルテストと同じ考え方です。
# characterization_test.py
# 既存の OrderProcessor の振る舞いを記録するテスト
import pytest
from legacy_app.order_processor import OrderProcessor
class TestOrderProcessorCharacterization:
"""既存の振る舞いを記録する特性テスト。
リファクタリング前にこのテストを書き、
リファクタリング後もパスすることを確認する。"""
def test_calculate_total_with_discount(self):
# Arrange: 既存コードが期待する入力
processor = OrderProcessor()
items = [
{"name": "Widget", "price": 1000, "quantity": 3},
{"name": "Gadget", "price": 2500, "quantity": 1},
]
# Act: 既存の振る舞いを実行
total = processor.calculate_total(items, discount_rate=0.1)
# Assert: 「今の」振る舞いを記録(正しいかどうかは問わない)
assert total == 4950 # (1000*3 + 2500) * 0.9
def test_calculate_total_negative_discount_is_allowed(self):
"""発見: 負のディスカウントが許容されている(バグの可能性)"""
processor = OrderProcessor()
items = [{"name": "Widget", "price": 1000, "quantity": 1}]
total = processor.calculate_total(items, discount_rate=-0.5)
# 負のディスカウント = 値上げになる。バグだが現状の振る舞いを記録
assert total == 1500 # 1000 * 1.5
注意点: Characterization Testは「正しい振る舞い」を定義するものではありません。テスト名やコメントで「これは現状の振る舞いの記録である」ことを明記しましょう。リファクタリング後にこのテストが壊れた場合、意図した変更なのかバグなのかを判断する材料になります。
ハマりポイント: Characterization Testを書く際、テストの実行速度に注意してください。Feathersは「1テスト100ms以内」を推奨しています。データベースやネットワークに依存するテストは、Seamを使ってモックに差し替えないと、テストスイート全体の実行時間が膨大になります。
改善パターンを選択して段階的にリファクタリングする
テストの安全網を確保したら、具体的な改善パターンを選択します。ここでは4つの主要パターンを紹介します。
Sprout Method / Sprout Class
新しい機能を追加する際に、既存コードに直接書かず、テスト可能な新しいメソッドやクラスとして「芽生え」させる手法です。
# Before: レガシーコードに直接機能を追加したくなる誘惑
class ReportGenerator:
def generate(self, data: list[dict]) -> str:
# 500行のレガシーコード...
result = ""
for item in data:
# 複雑なフォーマット処理
result += self._format_legacy(item)
# ここに新しいフィルタリング機能を追加したい...
return result
# After: Sprout Method で新機能を分離
class ReportGenerator:
def generate(self, data: list[dict]) -> str:
# Sprout: 新しいフィルタリングロジックは別メソッドに
filtered_data = self._filter_active_items(data)
result = ""
for item in filtered_data:
result += self._format_legacy(item)
return result
def _filter_active_items(self, data: list[dict]) -> list[dict]:
"""新しく追加するフィルタリングロジック(テスト可能)"""
return [item for item in data if item.get("status") == "active"]
# sprout_method_test.py
# Sprout Method はテストが容易
class TestFilterActiveItems:
def test_filters_inactive_items(self):
generator = ReportGenerator()
data = [
{"name": "A", "status": "active"},
{"name": "B", "status": "inactive"},
{"name": "C", "status": "active"},
]
result = generator._filter_active_items(data)
assert len(result) == 2
assert all(item["status"] == "active" for item in result)
なぜこの実装を選ぶか:
- レガシーコード本体を変更するリスクを最小化できる
- 新しいロジックは独立してテスト可能
- 段階的にSproutしたメソッドを別クラスに移動できる(Sprout Class への発展)
制約条件: Sprout Method は「新しい機能の追加」には適していますが、「既存ロジックの修正」には使えません。既存ロジックを変更する必要がある場合は、次のWrap MethodやStrangler Figを検討してください。
Wrap Method / Wrap Class
既存メソッドの前後に新しい処理を追加したい場合に使う手法です。既存メソッドをリネームし、元の名前で新しいメソッドを作り、その中からリネームしたメソッドを呼び出します。
# Before: 既存のsave メソッドにログ機能を追加したい
class UserRepository:
def save(self, user: dict) -> None:
# 200行のレガシーな保存処理...
self._db.execute("INSERT INTO users ...", user)
# After: Wrap Method でログを追加
class UserRepository:
def save(self, user: dict) -> None:
"""Wrap: 元のsaveをラップして前後に処理を追加"""
self._log_before_save(user)
self._save_impl(user)
self._log_after_save(user)
def _save_impl(self, user: dict) -> None:
"""元のsaveメソッドをリネーム(レガシーコード本体)"""
self._db.execute("INSERT INTO users ...", user)
def _log_before_save(self, user: dict) -> None:
"""新しいログ処理(テスト可能)"""
self._logger.info(f"Saving user: {user.get('id')}")
def _log_after_save(self, user: dict) -> None:
"""新しいログ処理(テスト可能)"""
self._logger.info(f"User saved: {user.get('id')}")
よくある間違い: Wrap Method を適用する際に、元のメソッド名を _old_save のようにしてしまうケースがあります。これだと外部からの呼び出しがすべて壊れます。元のメソッド名は新しいラッパーが引き継ぎ、レガシー実装を _save_impl のような内部名に変更するのが正しいアプローチです。
Strangler Fig パターン
Martin Fowler が2004年に命名したこのパターンは、レガシーシステムを新しいシステムで徐々に「絞め殺す」段階的移行手法です。名前の由来は熱帯の絞め殺しの木(Strangler Fig)で、宿主の木に巻きつきながら成長し、最終的に宿主に取って代わります。
Shopify のエンジニアリングチームは、このパターンを使って3,000行を超える「God Object」(Shop モデル)を段階的に改善した事例を公開しています。
Shopifyの7ステップ実装手順:
- 新しいパブリックインターフェースを定義: 抽出する機能の新しいクラスを作成
- トラフィックの段階的リダイレクト: 呼び出し元を新APIに徐々に切り替え
- 新しいデータストアの作成: 新テーブルやデータ構造を準備
- デュアルライト(二重書き込み): トランザクション内で新旧両方に書き込み
- バックフィル: 既存データを新ストアに移行(悲観的ロックを使用)
- 読み取りの切り替え: リーダーメソッドを新データソースに切り替え
- レガシーコードの削除: 使われなくなったコードとカラムを削除
# strangler_fig_example.py
# Strangler Fig パターンの実装例(TypeScriptではなくPythonで示す)
from abc import ABC, abstractmethod
from dataclasses import dataclass
# Step 1: 新しいインターフェースを定義
@dataclass
class ShippingInfo:
address: str
method: str
cost: int
class ShippingService(ABC):
@abstractmethod
def calculate_cost(self, order_id: str) -> int: ...
@abstractmethod
def get_shipping_info(self, order_id: str) -> ShippingInfo: ...
# Step 2: レガシー実装をラップ(初期段階)
class LegacyShippingService(ShippingService):
"""レガシーのShopモデルから配送ロジックを委譲"""
def __init__(self, legacy_shop_model):
self._legacy = legacy_shop_model
def calculate_cost(self, order_id: str) -> int:
# レガシーのGod Objectに処理を委譲
return self._legacy.calc_shipping(order_id)
def get_shipping_info(self, order_id: str) -> ShippingInfo:
raw = self._legacy.get_order_shipping(order_id)
return ShippingInfo(
address=raw["addr"],
method=raw["method"],
cost=raw["cost"],
)
# Step 3-4: 新しい実装(デュアルライト対応)
class NewShippingService(ShippingService):
"""新しい配送サービス実装"""
def __init__(self, db, legacy_service: LegacyShippingService):
self._db = db
self._legacy = legacy_service
def calculate_cost(self, order_id: str) -> int:
# 新しいロジックで計算
order = self._db.get_order(order_id)
return self._calculate_from_rules(order)
def get_shipping_info(self, order_id: str) -> ShippingInfo:
return self._db.get_shipping_info(order_id)
def save_with_dual_write(self, order_id: str, info: ShippingInfo):
"""デュアルライト: 新旧両方に書き込み"""
# トランザクション内で両方に書き込む
with self._db.transaction():
self._db.save_shipping(order_id, info)
self._legacy._legacy.save_shipping_legacy(order_id, info)
def _calculate_from_rules(self, order) -> int:
# 新しい計算ロジック
base_cost = 500
if order.total > 10000:
return 0 # 1万円以上で送料無料
return base_cost
トレードオフ: Strangler Figパターンはデュアルライト期間中のデータ整合性管理が複雑になります。Shopify のチームはトランザクション内での二重書き込みと悲観的ロックを組み合わせましたが、分散システムではこのアプローチが使えないケースもあります。マイクロサービス間での整合性が必要な場合は、Sagaパターンとの併用を検討してください。
Branch by Abstraction
Martin Fowlerが提唱したBranch by Abstractionは、Strangler Figがシステムの境界(API層)で適用されるのに対し、コードベース内部の深い依存関係を段階的に置き換えるパターンです。
# branch_by_abstraction_example.py
from abc import ABC, abstractmethod
# Step 1: 抽象レイヤーを導入
class NotificationSender(ABC):
"""通知送信の抽象インターフェース"""
@abstractmethod
def send(self, user_id: str, message: str) -> bool: ...
# Step 2: レガシー実装を抽象の下に配置
class LegacyEmailNotifier(NotificationSender):
"""既存のメール通知(レガシー)"""
def send(self, user_id: str, message: str) -> bool:
# 元々の複雑なメール送信ロジック
# SMTP直接接続、リトライなし、エラーハンドリング不十分
return self._send_smtp_direct(user_id, message)
def _send_smtp_direct(self, user_id, message):
# レガシー実装...
return True
# Step 3: 新しい実装を並行して開発
class ModernNotifier(NotificationSender):
"""新しい通知サービス(メール + Slack + Push通知対応)"""
def send(self, user_id: str, message: str) -> bool:
# 新しい実装: リトライ付き、マルチチャネル対応
channels = self._get_user_channels(user_id)
results = []
for channel in channels:
results.append(channel.deliver(message))
return any(results)
def _get_user_channels(self, user_id):
# ユーザー設定に基づくチャネル選択
return [] # 実装省略
# Step 4: Feature Flagで段階的に切り替え
class NotificationFactory:
"""Feature Flagで新旧実装を切り替え"""
def __init__(self, feature_flags):
self._flags = feature_flags
def create(self) -> NotificationSender:
if self._flags.is_enabled("modern_notification"):
return ModernNotifier()
return LegacyEmailNotifier()
なぜStrangler FigでなくBranch by Abstractionか:
- Strangler Figはシステム境界(API、ルーティング層)での適用に向いている
- Branch by Abstractionはコードベース内部の依存関係に向いている
- AWSのドキュメントでも、「モノリス内部の深いコンポーネント」にはBranch by Abstractionが推奨されている
複雑度メトリクスを活用してリファクタリングの優先度を決める
レガシーコードベースのどこからリファクタリングを始めるべきかを決めるには、定量的な指標が必要です。主に2つの複雑度メトリクスを使います。
循環的複雑度(Cyclomatic Complexity)
Thomas McCabe が1976年に提唱した指標で、コード内の独立した実行パスの数を測定します。条件分岐(if/else、switch、ループ)が増えるほど値が大きくなります。
| 複雑度スコア | リスクレベル | 推奨アクション |
|---|---|---|
| 1-10 | 低リスク | 維持管理可能 |
| 11-20 | 中リスク | リファクタリング検討 |
| 21-50 | 高リスク | リファクタリング推奨 |
| 50+ | 非常に高リスク | 即座にリファクタリング |
NISTの報告では、循環的複雑度10以下が推奨ラインとされています。McCabeの原論文でも上限10が提案されており、実務でも15程度までが許容範囲とされています。
認知的複雑度(Cognitive Complexity)
SonarSource が提唱した比較的新しい指標で、人間がコードを読んで理解する難しさを測定します。循環的複雑度が「テストの難しさ」を測るのに対し、認知的複雑度は「読みやすさ」を測ります。
# 循環的複雑度は同じでも、認知的複雑度が異なる例
# パターンA: 認知的複雑度が低い(読みやすい)
def get_price_a(user_type: str, quantity: int) -> int:
"""Early return で条件を平坦化"""
if user_type == "premium":
return quantity * 800
if user_type == "member":
return quantity * 900
return quantity * 1000
# パターンB: 認知的複雑度が高い(読みにくい)
def get_price_b(user_type: str, quantity: int) -> int:
"""ネストした条件分岐"""
price = 0
if user_type == "premium":
if quantity > 10:
price = quantity * 700
else:
price = quantity * 800
else:
if user_type == "member":
if quantity > 10:
price = quantity * 800
else:
price = quantity * 900
else:
price = quantity * 1000
return price
パターンAとBの比較:
- 循環的複雑度: パターンAは3、パターンBは5
- 認知的複雑度: パターンAは3、パターンBは9(ネストのペナルティ)
- 認知的複雑度のほうが、人間の「読みにくさ」をより正確に反映する
リファクタリング優先度マトリクス
DX社のレポートによると、高インパクトなコンポーネントを優先したチームは4倍のROIを達成しています。以下のマトリクスで優先度を決めましょう。
| 変更頻度が高い | 変更頻度が低い | |
|---|---|---|
| 複雑度が高い | 最優先(Hotspot) | 中優先 |
| 複雑度が低い | 低優先 | 対応不要 |
# radon を使って Python プロジェクトの循環的複雑度を測定
pip install radon
# 関数ごとの複雑度を表示(A-F のランクで表示)
radon cc src/ -a -s
# 複雑度Cランク以上(11+)の関数だけ表示
radon cc src/ -n C
# 変更頻度と組み合わせてHotspotを特定
# git log でファイルごとの変更回数を集計
git log --format=format: --name-only --since="2025-01-01" | sort | uniq -c | sort -rn | head -20
制約条件: 複雑度メトリクスだけでリファクタリング対象を決めると、ビジネス上重要だが複雑度の低いコードを見落とす可能性があります。CodeSceneのような「行動分析」ツールを併用し、開発者がよく触るファイル(Hotspot)と複雑度を掛け合わせて優先度を決めることが推奨されます。
AI支援リファクタリングツールを活用する
2025-2026年にかけて、AI支援のリファクタリングツールが急速に進化しています。ただし、その活用には明確な限界もあります。
主要ツールの比較
| ツール | 特徴 | 適用場面 |
|---|---|---|
| GitHub Copilot | IDE統合、コードスメル検出、PR レビュー支援 | 日常的なリファクタリング |
| Google Gemini Code Assist | 大規模コードベース理解、構造認識型編集 | モノリスの分析・分割 |
| Moderne | OpenRewriteベース、自動マイグレーション | フレームワークバージョンアップ |
| CodeScene | 行動分析、Hotspot特定、技術的負債の可視化 | リファクタリング優先度の決定 |
AI支援リファクタリングの実践的なワークフロー
# AI支援リファクタリングの典型的なワークフロー(概念コード)
# Step 1: AI にレガシーコードの理解を依頼
# (Claude Code や Copilot Chat を使用)
# プロンプト例: 「このクラスの責務を分析し、
# 単一責任原則に基づいて分割案を提案してください」
# Step 2: AI が提案した分割案を人間がレビュー
# AI の提案例:
# - OrderProcessor -> OrderValidator + OrderCalculator + OrderPersister
# Step 3: Characterization Test を書く(ここは人間が主導)
# AI はテスト生成を補助できるが、
# 「何をテストすべきか」の判断は人間が行う
# Step 4: AI の支援でリファクタリングを実行
# コード変換は AI が得意だが、
# ビジネスロジックの正しさは人間が検証する
AI支援の限界と注意点
Qodo社の調査によると、AI生成のリファクタリングは68-79%のコード品質向上と99%の構文正確性を達成しています。しかし、開発者の96%がAI出力を完全には信頼しておらず、48%しか常にAIコードを確認していないという問題もあります。
AI支援リファクタリングで守るべきルール:
- AIの提案は必ず人間がレビューする: 特にビジネスロジックの変更は自動マージしない
- Characterization Testを先に書く: AIがリファクタリングしたコードが元の振る舞いを保持しているか検証する安全網
- 小さな単位で適用する: 一度に大量のAI提案を適用すると、問題の切り分けが困難になる
- AIが「構文的に正しい」コードを生成しても、「意味的に正しい」とは限らない: 型チェックやテストのパスだけでなく、エッジケースの動作確認を行う
よくある間違い: AIにレガシーコード全体を渡して「リファクタリングして」と依頼するアプローチは失敗しやすいです。DX社のレポートでは、高インパクトなコンポーネントに絞ってAIを活用した組織のほうが、網羅的にAIリファクタリングを試みた組織よりも成果が出ていると報告されています。
よくある問題と解決方法
| 問題 | 原因 | 解決方法 |
|---|---|---|
| テストがないのでリファクタリングできない | レガシーコードの典型的な鶏と卵問題 | Seamを見つけてCharacterization Testから書き始める |
| Strangler Figでデータ不整合が発生 | デュアルライト期間の整合性管理不足 | トランザクション内での二重書き込み + バックフィルジョブ |
| リファクタリングが完了しない | スコープが大きすぎる | Hotspot分析で優先度をつけ、小さな単位で進める |
| AIのリファクタリング提案が既存テストを壊す | AIがビジネスロジックの意図を誤解 | Characterization Testを先に書き、AI適用後に実行 |
| チームメンバーの賛同が得られない | 技術的負債の可視化ができていない | CodeScene等で複雑度・変更頻度をダッシュボード化 |
| Feature Flagが増えすぎて管理困難 | Branch by Abstractionの完了ステップを怠った | 移行完了後のFeature Flag削除をタスクに含める |
まとめと次のステップ
まとめ:
- レガシーコード = テストのないコード。改善の第一歩はSeamを見つけてCharacterization Testを書くこと
- 新機能追加にはSprout Method/Class、既存機能の拡張にはWrap Method/Class、大規模な置き換えにはStrangler Fig、内部実装の段階的変更にはBranch by Abstractionを使い分ける
- 循環的複雑度と認知的複雑度を組み合わせ、変更頻度の高いHotspotから優先的にリファクタリングする
- AI支援ツールはコード品質を68-79%向上させるが、Characterization Testによる安全網と人間のレビューが不可欠
次にやるべきこと:
-
radon ccや SonarQube で現在のコードベースの複雑度を測定する -
git logで変更頻度の高いファイルを特定し、Hotspot マトリクスを作成する - 最も優先度の高いファイルに対して Characterization Test を書き始める
参考
- Shopify Engineering: Refactoring Legacy Code with the Strangler Fig Pattern
- Martin Fowler: BranchByAbstraction
- Understanding Legacy Code: Key Points of Working Effectively with Legacy Code
- AWS Prescriptive Guidance: Strangler fig pattern
- DX: AI code refactoring - Strategic approaches to enterprise software modernization
- SonarSource: Cyclomatic Complexity Guide
- Axify: Cognitive Complexity Explained
- Microsoft Learn: Code metrics - Cyclomatic complexity
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。