こんにちは!DeNAでエンジニアをしている山形昌弘です。
CursorなどのAIコーディングツールによって、まっさらな状態から機能を実装する時間は驚くほど短くなりました。しかし、その一方で、こんな問題を感じたことはありませんか?
- 機能追加や変更を繰り返すと、コードが複雑化しがち
- コードが複雑化すると、AIによる実装がなかなか進まなくなる
本記事では、これらの問題を極力回避し、AIを使った開発のスピードを維持するために、私が実践している開発の流れを具体例とともに紹介します。
AIを使った開発のスピード維持のための取り組み
AIを活用した手探りのリファクタリング
AIがスムーズに実装を行える状態を保つため、AIの反応を見ながらリファクタリングを行っています。
リファクタリングの流れ
- 問題発見:「的外れな回答が増えた」「何度も同じ間違いをしている」など、実装を進める上での不調を感じたら、コードやドキュメントに問題があると捉え、リファクタリングを開始
- 課題の分析:コードやドキュメントの問題は何か、AIと壁打ちしながら言語化
- 改善:AIの改善提案を取捨選択し、改善
- 検証:AIがスムーズに実装を行えるようになったことを確認
この流れの特に良い点は、自分の理解が足りなくても、AIを活用して手探りで改善し、AIがスムーズに実装を行える状態に向かっていけることだと考えています。
- 問題発見:「自分のコードに問題などない」と思い込んでいても、AIが実装に苦戦するという事実から問題を発見できる
- 課題の分析:コードの課題と解決策を、自分が知らなかった設計パターンや概念も使って分析できる
- 改善:改善策をAIで素早く実装し、設計理論と具体的なコードを合わせて考えることができる
- 検証:AIに追加実装をさせた際の迷い具合から、課題分析と改善策が妥当だったかをある程度推し量れる
一方で、作りたい機能や現状の複雑化したコードに対する理解が浅すぎると、「改善(実装)→検証」のサイクルを延々に繰り返すことになりかねません。
リファクタリングを通じて設計を学習
そこで、対象機能や既存実装の複雑化に合わせて、自分自身も設計についての理解を深め、手探りの部分を減らしていくことが必要と考えています。
そして、その学習は上記のリファクタリングサイクルで実現できています。 上で述べた「この流れの特に良い点」は、実装を進める上でのメリットであると同時に、学習としても優れている点だと考えています。そのため、サイクルを繰り返すと自然と設計への理解が深まっていくと考えています。
実践例
あるデータ取得処理のリファクタリング
ここでは、Cursorのチャット機能(エージェントモード、モデルはClaude 4 Sonnet)を活用し、手探りでリファクタリングを行った流れを、業務での実践例を単純化して説明します。
問題発見
とある業務で、「API A
から取得した情報をもとに、API B
から関連情報を取得し、それらを結合してテーブルC
に保存する」という処理を実装しました。この時点では、「処理のロジックと外部アクセスやDB操作を別モジュールにする」という方針で、以下のようなディレクトリ構成・コードとなっていました。
src/
├── logic/
│ ├── process_a.py
│ ├── process_b.py
│ ├── match_ab.py # AとBを結合するメインロジック
│ └── utils.py # ユーティリティ関数群
├── api/
│ ├── fetcher_a.py
│ └── fetcher_b.py
└── db/
└── db.py # DB操作関数群
# logic/match_ab.py
from api.fetcher_a import FetcherA
from api.fetcher_b import FetcherB
import db.db as db
from .utils import generate_data_c
def match_and_save(b_id: int): # 指定したBのデータを取得し、DBに保存する
# 1. `API A`からデータを全件取得し、新規データを`テーブルA`に追加
fetcher_a = FetcherA()
data_a = fetcher_a.fetch()
## 差分更新
updated_ids = db.update_a(data_a)
logger.info(f"Updated table A: {len(updated_ids)} records with ids {updated_ids}")
# 2. `テーブルA`から、取得対象のデータに対応する情報を取得
param_for_b = db.get_param_from_a(b_id)
# 3. `API B`からデータを取得して保存
fetcher_b = FetcherB()
data_b = fetcher_b.fetch(param_for_b)
db.save_b(data_b)
logger.info(f"Saved data_b: {data_b}")
# 4. AとBのデータを結合して`テーブルC`に保存
data_c = generate_data_c(param_for_b, data_b)
db.save_c(data_c)
logger.info(f"Saved data_c: {data_c}")
問題は、テストコードを書く際に起きました。AIが match_and_save
関数のテストを書くのに苦戦し、指示を変えて試行錯誤してもなかなか解消できませんでした。
解消できなかった問題
- 指示通りに実装できない:「実際にアクセスするテストも書いて」と指示しても、勝手に諦めてしまう
- 返答までの時間が伸びた:AIが長考したり、関係のないファイルをあちこち見に行っている
- AIの誤答:AIに「このテストは何を検証してる?」と聞いても、しばしば間違った回答が返ってくる
これらの現象は、単に「プロンプトが良くない」「AIが良くない」と考えることも可能です。しかし私は、「既存実装に問題がある」と捉えました。
課題の分析
この問題を解消するため、AIと壁打ちしながら課題を特定します。まず、AIが苦戦していたテスト関数を見て、極力詳細に問題を言語化し、AIに問いかけます。
# AIが生成したテストコードの例
from unittest.mock import patch, MagicMock
# ケース1: 全てをモック化する単体テスト
@patch('logic.match_ab.db')
@patch('logic.match_ab.FetcherB')
@patch('logic.match_ab.FetcherA')
def test_match_and_save_unit(MockFetcherA, MockFetcherB, mock_db):
# --- セットアップ ---
# 各モックのインスタンスと戻り値を設定
mock_fetcher_a_instance = MockFetcherA.return_value
mock_fetcher_a_instance.fetch.return_value = {'id_a': 1, 'data_a': 'test_a'}
mock_fetcher_b_instance = MockFetcherB.return_value
mock_fetcher_b_instance.fetch.return_value = {'id_b': 2, 'data_b': 'test_b'}
mock_db.get_param_from_a.return_value = 'param_from_a'
# --- セットアップここまで ---
match_and_save(123)
# 検証
mock_db.update_a.assert_called_once()
mock_db.save_c.assert_called_with({...}) # 省略
私:「match_and_save関数のテストが、モックが多すぎてちゃんとテストできてるか分かりにくい。また、セットアップが複雑で、これ自体が間違っていないか確認するのが大変。実装自体を破壊的変更して良いので、良くして。」
Cursor(意訳):「ご指摘の問題は、match_and_save関数がFetcherAやdbモジュールといった具体的な実装に直接依存していることが原因です。この依存関係を解消するために、依存性逆転の原則を適用することをお勧めします。データ取得や保存のロジックを抽象的なインターフェースとして定義し、ビジネスロジックはそのインターフェースにのみ依存するように変更します。こうすることで、テスト時には本物の実装の代わりに、偽のモックオブジェクトを注入することで対応できます。以下が変更後のソースコードとテストのサンプルコードです。この方針で進めてよろしいですか?」
改善
AIが提案した「依存性逆転の原則を適用する」という方針に従い、リファクタリングを行いました。
# 改善後のディレクトリ構成
src/
├── usecase/
│ ├── match_ab_usecase.py
│ ├── protocols.py # 依存するプロトコル群
│ └── utils.py # ユーティリティ関数群
└── infrastructure/
├── api/ # APIクライアントの実装
│ ├── fetcher_a.py
│ └── fetcher_b.py
└── db/ # DB操作の実装
└── repository.py
# usecase/protocols.py
from typing import Protocol
class FetcherAProtocol(Protocol):
def fetch(self) -> list[dict]: """API Aからデータを全件取得"""
class FetcherBProtocol(Protocol):
def fetch(self, param: str) -> dict: """API Bから特定のデータを取得"""
class RepositoryProtocol(Protocol):
def update_a(self, data: list[dict]) -> list[int]: """テーブルAを差分更新し、更新したレコードのidを返す"""
def get_param_from_a(self, b_id: int) -> str: """テーブルAから、B取得のための情報を取得"""
def save_b(self, data: dict) -> None: """テーブルBにレコードを追加"""
def save_c(self, data: dict) -> None: """テーブルCにレコードを追加"""
# 改善後の usecase/match_ab_usecase.py
from .protocols import FetcherAProtocol, FetcherBProtocol, RepositoryProtocol
from .utils import generate_data_c
def match_and_save(
b_id: int,
fetcher_a: FetcherAProtocol,
fetcher_b: FetcherBProtocol,
repo: RepositoryProtocol
):
data_a = fetcher_a.fetch()
updated_ids = repo.update_a(data_a)
logger.info(f"Updated table A: {len(updated_ids)} records with ids {updated_ids}")
param_for_b = repo.get_param_from_a(b_id)
data_b = fetcher_b.fetch(param_for_b)
repo.save_b(data_b)
logger.info(f"Saved data_b: {data_b}")
data_c = generate_data_c(param_for_b, data_b)
repo.save_c(data_c)
logger.info(f"Saved data_c: {data_c}")
この変更で本当に改善されたのでしょうか。コードを眺めるだけでは判断が難しいため、当初の目的に立ち返り、AIにmatch_and_save関数のテストを書かせることで効果を検証します。
検証
# 改善後のテストコード
from usecase.match_ab_usecase import match_and_save
from usecase.protocols import FetcherAProtocol, FetcherBProtocol, RepositoryProtocol
class MockFetcherA(FetcherAProtocol):
def fetch(self) -> list[dict]:
return [{'id_a': 1, 'data_a': 'test_a'}]
class MockFetcherB(FetcherBProtocol):
def fetch(self, param: str) -> dict:
return {'id_b': 2, 'data_b': 'test_b'}
class MockRepository(RepositoryProtocol):
def __init__(self):
self.saved_c = None
def get_param_from_a(self, b_id: int) -> str:
return "param_for_b"
def update_a(self, data: list[dict]) -> list[int]:
return [-1]
def save_b(self, data: dict) -> None:
pass
def save_c(self, data: dict) -> None:
self.saved_c = data
def test_match_and_save():
# Protocolを継承した偽オブジェクトを作成
mock_repo = MockRepository()
# 偽オブジェクトを注入してテストを実行
match_and_save(
b_id=123,
fetcher_a=MockFetcherA(),
fetcher_b=MockFetcherB(),
repo=mock_repo
)
# 検証
expected_data_c = {...} # 省略
assert mock_repo.saved_c == expected_data_c
コード量は増えましたが、テスト時限定のパッチ処理を廃止し、実行時と同じ渡し方をできるようになったことで何をテストしているか分かりやすくなったと感じました。また、詳細は割愛しますが、この実装をAIがスムーズに進めることができました。そのため、「テストが書きやすい実装になった」と判断し、ここでリファクタリングを終了しました。
この例では、特に大きな変更として依存性逆転の導入を挙げましたが、ドキュメントの充実なども含め、課題に応じて大小様々なリファクタリングを日々大量に繰り返しながら開発を進めています。
リファクタリングを通じた設計の学び
「依存性逆転」への理解
このリファクタリングを通じて、「依存性逆転」という設計原則について理解を深めることができました。以前から概要は知っていたものの、意義の理解が浅かったため、適切に使えた経験がありませんでした。
今回のリファクタリングで「モックの多さ」という目に見える問題から「ビジネスロジックとデータアクセスの依存関係」という根本原因を理解し、「依存性逆転」で解決するプロセスを体験したことで、その意義や使い方を深く理解できました。
学びが開発に活きた場面
今回同様に外部からデータを取得する処理を、「新規データのみの取得と、全データ取得を選べる」という要件も含めて実装する際に役立ちました。
この追加要件に対する適切な設計は分かっていなかったものの、データ取得部分で「依存性逆転」の設計原則を適用すべきと一発で判断できたため、以下のように指示して開発を進めることができました。
私:「API C
からデータを取得してDBに保存する。まずはentityとusecaseから実装して。infrastructure側は、idを受け取ってentityを返すようにして。新規データの取得と全データ取得を選べるようにusecaseに2つのメソッドを作り、共通化できる部分は共通化して」
ここから、依存関係については試行錯誤を省略し、usecase層内部のみに絞って今回のような手探りの改善を行いながら開発することができました。もし今回の学びがなかったら、複数の要因が絡み合う複雑さから、膨大な試行錯誤が必要になっていたでしょう。
このような学びが無数に積み重なって、機能が複雑化した後でも開発スピードを維持するための大きな助けになっていると考えています。
まとめ
本記事では、開発スピードを維持するため、AIを活用した手探りのリファクタリングを行い、その過程で設計を学ぶことで手探りの部分を減らしていく実践例を紹介しました。
この記事が、AIと共に開発を進める皆さんにとって、少しでもヒントになれば幸いです。
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。