Vol.042026年5月9日
Logged3 min · SoraEndo
Process

リファクタリング 1 日分を、PR 5 個に分割した記録

200 行の Service クラスを 4 つに割り、最後に整える 5 PR 構成。各 PR は 80 行未満で独立にレビュー / マージできる。

SoSoraEndo2026年5月9日3 min1,093

巨大 PR の引力

リファクタリングの誘惑は「1 つの PR で全部直したい」という気持ちから始まる。コードを触り始めると関連箇所が芋づる式に出てきて、気付いたら 50 ファイル / 800 行差分の PR ができている。

これは本人にしかレビューできない。マージするのも怖い。Pull Request の体をなさない

ある土曜日に丸 1 日かけて行ったリファクタリングを、5 個の PR に分割した記録 を書いておく。

元のコード

題材は AetherEchoes のサーバーサイド。Posts::SearchService という Service クラスが 200 行に肥大化していて、以下の責務を全部抱えていた:

  • query string の parse
  • カテゴリフィルタ
  • タグフィルタ
  • 全文検索(MySQL FULLTEXT)
  • 関連記事のスコアリング
  • ソート(newest / popular / read_time)
  • ページネーション

土曜日午前中に「これを綺麗に分けよう」と決めた。最初の作業は PR 5 個に分割する設計

PR 設計

最終形(コード)から逆算して、5 つの PR に分けた:

#タイトル変更ファイル行数
1refactor: extract Posts::QueryParser2+60 / -20
2refactor: extract Posts::FilterApplier3+85 / -45
3refactor: extract Posts::SortApplier2+40 / -30
4refactor: extract Posts::Paginator2+35 / -25
5refactor: SearchService becomes a thin orchestrator4+30 / -55

各 PR は テストが通る独立した状態 で完結する。順番にマージしていく。

なぜ 5 個に割るのか

3 つの理由:

1. レビューの認知負荷を下げる

1 PR が 80 行未満だと、5 分でレビューできる。レビュアー(私が自分自身でも)が「全体を 1 度に理解する」必要がない。

2. ロールバック粒度を小さくする

1 つの PR で問題が出たら その PR だけ revert できる。8 時間分の作業を全部失わずに済む。

3. 区切りで止まれる

5 個の PR を すべて同じ日に書く必要はない。途中で疲れたら 3 個までで止めて、残りは翌週にできる。完了の単位を小さくすると、休日の体力に合わせて調整できる。

進め方

1 日の作業ログ:

  • 9
    PR 1 着手 / 10
    PR 1 マージ
  • 10
    PR 2 着手 / 11
    PR 2 マージ
  • 11
    昼食
  • 13
    PR 3 着手 / 14
    PR 3 マージ
  • 14
    PR 4 着手 / 14
    PR 4 マージ
  • 14
    PR 5 着手 / 16
    PR 5 マージ

休憩込みで 7 時間。これを 1 つの巨大 PR で進めていたら 12 時間 はかかったはず(途中でデバッグループに入る確率が高い)。

失敗から学んだこと

最初の試みでは「5 個に分けたつもりが、PR 1 → PR 2 でテストが落ちる」が起きた。原因は PR 1 の段階で SearchServiceQueryParser を使う形に変えた ため、PR 2 でさらに FilterApplier を抽出する変更がコンフリクトを起こした。

教訓: 各 PR で SearchService の API は変えない。新しいクラスを抽出して、SearchService 内部で使い始めるが、外部からは同じ API。最後の PR 5 で SearchService 自体を整える。

# PR 1〜4 では SearchService の外部 API は変えない
class Posts::SearchService
  def call
    # 段階的に内部実装を新クラスに置き換え
    parsed = Posts::QueryParser.new(@query).call    # PR 1 で追加
    filtered = Posts::FilterApplier.new(...).call    # PR 2 で追加
    sorted = Posts::SortApplier.new(...).call        # PR 3 で追加
    paged = Posts::Paginator.new(...).call           # PR 4 で追加
    paged
  end
end

PR 5 で call を一気にきれいにする:

def call
  pipeline = Posts::QueryParser >> Posts::FilterApplier >> Posts::SortApplier >> Posts::Paginator
  pipeline.call(query: @query)
end

計測

PR 分割によって変わった指標:

  • レビュー時間: 1 個 5〜15 分 / 全体 50 分(巨大 PR なら 90 分以上)
  • revert 確率: 0%(1 PR が独立しているので問題があれば部分 revert)
  • CI 時間: 各 PR で 8 分 × 5 = 40 分 / 巨大 PR なら 25 分(CI コストは増える)

CI コストは増えるが、レビュー / マージの心理的負荷が劇的に下がるので、トータルで効率は高い。

まとめ

リファクタリング 1 日分を PR 5 個に分割するパターン:

  1. 最終形から逆算して PR の段階を設計 する
  2. PR 1〜N-1 は 外部 API を変えない
  3. 各 PR は テストが通る独立した状態
  4. 1 個 80 行未満を目安に切る
  5. 最後の PR で全体を整える

巨大 PR を作りそうになったら 「これを 5 個に割れないか?」 を最初に問う。割れなければ、設計が悪い。

リファクタリングはサイズではなく刻みで進める。1 歩ずつ main にマージできるか。

Tags

Reaction

Share

X (Twitter)