mybook

Template Method パターン — 処理の骨格を定義する

「レポートを3種類出力してほしい」

ケンタの次のタスクは売上レポートの実装だった。

「CSV、Excel、PDFの3種類でレポートを出力してほしい。でも内容は同じで、フォーマットだけが違う。」

ケンタは意気揚々とコードを書き始めた。CsvSalesReportExcelSalesReportPdfSalesReport の3つのクラスをそれぞれ実装した。2時間後、ようやく完成したと思って見返したとき、嫌な予感がした。

「……あれ。aggregate_by_product がここにも、ここにも、ここにも同じコードがある。」

コードエディタの「検索」で aggregate_by_product と打つと、3ファイルにわたって全く同一のメソッドが出てきた。集計ロジック、バリデーション、ファイル保存処理——全部3か所に重複していた。

「これ、もし集計のバグが見つかったら3か所直さないといけないじゃないか……」

その呟きが聞こえたのか、山田さんが椅子を回した。

「それがTemplate Methodパターンを使うべきタイミングだ。ちょっとコードを見せてごらん。」

Template Method パターンとは

Template Method パターンは、処理の「骨格(テンプレート)」を親クラスに定義し、具体的な実装はサブクラスに委ねるパターンだ。

日常の比喩はカクテルのレシピだ。「グラスを用意する → 氷を入れる → ベースを注ぐ → ミックスする → デコレーションする」という骨格は同じ。でも「ベース」がウォッカかラムかでカクテルが変わる。骨格を変えずに、特定のステップだけを差し替える。

別の比喩で言えば料理番組の調理工程だ。「材料を切る → 炒める → 味付けする → 盛り付ける」という手順は変わらない。「炒め物」か「煮物」かで「調理する」ステップの内容が変わるだけだ。

Loading diagram...

INFO

Template Methodパターンの核心は「変わる部分と変わらない部分を分離する」こと。親クラスに「変わらない処理の流れ」を書き、「変わる部分」だけをサブクラスに委ねる。

問題のあるコード(Before)

# 3つのクラスが似たようなコードを重複して持っている
class CsvSalesReport
  def generate(start_date, end_date)
    orders = Order.where(created_at: start_date..end_date, status: :completed)
    sales_data = aggregate_by_product(orders)  # 重複!
    validate_data(sales_data)                   # 重複!
 
    CSV.generate(headers: true) do |csv|
      csv << ["商品名", "数量", "売上"]
      sales_data.each { |row| csv << [row[:name], row[:quantity], row[:revenue]] }
    end
  end
 
  private
 
  def aggregate_by_product(orders)
    # 集計ロジック(3クラスで全く同じコードが存在する)
    orders.flat_map(&:order_items).group_by(&:product).map do |product, items|
      {
        name: product.name,
        quantity: items.sum(&:quantity),
        revenue: items.sum { |i| i.quantity * i.unit_price }
      }
    end
  end
 
  def validate_data(data)
    raise "データが空です" if data.empty?
  end
end
 
class ExcelSalesReport
  def generate(start_date, end_date)
    orders = Order.where(created_at: start_date..end_date, status: :completed)
    sales_data = aggregate_by_product(orders)  # 重複!
    validate_data(sales_data)                   # 重複!
 
    workbook = RubyXL::Workbook.new
    # ...Excelの処理...
  end
 
  # aggregate_by_product と validate_data を再度まったく同じように定義している
  def aggregate_by_product(orders)
    # コピペ!!
  end
 
  def validate_data(data)
    # コピペ!!
  end
end

問題: 集計ロジックが3か所に重複している。バグがあれば3か所直さなければならない。テストも3回書く必要がある。

WARNING

重複コードは「技術的負債」の最たる例だ。今は3クラスでも、フォーマットが増えるたびに重複が広がり続ける。「XMLも追加して」と言われたとき、また同じコードを書くことになる。

Template Methodパターンの適用(After)

# app/services/reports/base_report_generator.rb
module Reports
  class BaseReportGenerator
    # テンプレートメソッド — 処理の骨格を定義。サブクラスは変更しない
    def generate(start_date, end_date)
      before_generate(start_date, end_date)             # フック(任意)
      orders = fetch_orders(start_date, end_date)
      sales_data = aggregate_by_product(orders)
      validate_data!(sales_data)
      output = format_output(sales_data)                # ← サブクラスが実装
      filename = build_filename(start_date, end_date)   # ← サブクラスが実装
      path = save_file(output, filename)
      after_generate(path)                              # フック(任意)
      path
    end
 
    private
 
    # ---- 共通処理(全サブクラスで同じ) ----
 
    def fetch_orders(start_date, end_date)
      Order.includes(:order_items, :products)
           .where(created_at: start_date..end_date, status: :completed)
    end
 
    def aggregate_by_product(orders)
      orders.flat_map(&:order_items).group_by(&:product).map do |product, items|
        {
          name: product.name,
          quantity: items.sum(&:quantity),
          revenue: items.sum { |i| i.quantity * i.unit_price }
        }
      end.sort_by { |row| -row[:revenue] }
    end
 
    def validate_data!(data)
      raise EmptyReportError, "対象期間に売上データがありません" if data.empty?
    end
 
    def save_file(output, filename)
      path = Rails.root.join("tmp", "reports", filename)
      FileUtils.mkdir_p(File.dirname(path))
      File.write(path, output)
      path
    end
 
    # ---- フックメソッド(デフォルトは何もしない) ----
 
    def before_generate(start_date, end_date); end
    def after_generate(path); end
 
    # ---- 抽象メソッド(サブクラスで必ず実装する) ----
 
    def format_output(_sales_data)
      raise NotImplementedError, "#{self.class}#format_output を実装してください"
    end
 
    def build_filename(start_date, end_date)
      raise NotImplementedError, "#{self.class}#build_filename を実装してください"
    end
 
    class EmptyReportError < StandardError; end
  end
end
# app/services/reports/csv_report_generator.rb
module Reports
  class CsvReportGenerator < BaseReportGenerator
    private
 
    def format_output(sales_data)
      CSV.generate(headers: true, encoding: "UTF-8") do |csv|
        csv << ["商品名", "販売数量", "売上金額(円)"]
        sales_data.each do |row|
          csv << [row[:name], row[:quantity], row[:revenue]]
        end
      end
    end
 
    def build_filename(start_date, end_date)
      "sales_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.csv"
    end
  end
end
# app/services/reports/excel_report_generator.rb
module Reports
  class ExcelReportGenerator < BaseReportGenerator
    private
 
    def format_output(sales_data)
      workbook = RubyXL::Workbook.new
      sheet = workbook[0]
      sheet.sheet_name = "売上レポート"
 
      ["商品名", "販売数量", "売上金額"].each_with_index do |header, i|
        sheet.add_cell(0, i, header)
        sheet[0][i].change_font_bold(true)
      end
 
      sales_data.each_with_index do |row, idx|
        sheet.add_cell(idx + 1, 0, row[:name])
        sheet.add_cell(idx + 1, 1, row[:quantity])
        sheet.add_cell(idx + 1, 2, row[:revenue])
      end
 
      workbook.stream.string
    end
 
    def build_filename(start_date, end_date)
      "sales_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.xlsx"
    end
  end
end
# app/services/reports/pdf_report_generator.rb
module Reports
  class PdfReportGenerator < BaseReportGenerator
    private
 
    def format_output(sales_data)
      Prawn::Document.new do |pdf|
        pdf.font_families.update("NotoSans" => {
          normal: "#{Rails.root}/fonts/NotoSansJP-Regular.ttf"
        })
        pdf.font("NotoSans")
        pdf.text "売上レポート", size: 20, style: :bold
        pdf.move_down 10
 
        table_data = [["商品名", "販売数量", "売上金額(円)"]]
        table_data += sales_data.map { |row| [row[:name], row[:quantity], row[:revenue]] }
        pdf.table(table_data, header: true)
      end.render
    end
 
    def build_filename(start_date, end_date)
      "sales_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.pdf"
    end
  end
end

使う側

# app/controllers/reports_controller.rb
class ReportsController < ApplicationController
  GENERATORS = {
    "csv"   => Reports::CsvReportGenerator,
    "excel" => Reports::ExcelReportGenerator,
    "pdf"   => Reports::PdfReportGenerator,
  }.freeze
 
  def create
    generator_class = GENERATORS[params[:format]]
    raise ArgumentError, "不明なフォーマット: #{params[:format]}" unless generator_class
 
    generator = generator_class.new
    path = generator.generate(
      Date.parse(params[:start_date]),
      Date.parse(params[:end_date])
    )
 
    send_file path, disposition: "attachment"
  end
end

INFO

generate メソッドは一切変わらない。CSV・Excel・PDFの違いは format_outputbuild_filename の2メソッドだけ。集計ロジックに修正が必要になっても、BaseReportGenerator の1か所を直すだけで全フォーマットに反映される。

新しい例:外部APIインポート処理をTemplate Methodで統一

「実はもう一つ依頼があって」とケンタは続けた。「商品データを外部ソースからインポートする機能なんですが、CSVとExcelとJSONの3種類に対応してほしくて……」

「また同じパターンだね」と山田さんは即答した。

インポート処理も「ファイルを読み込む → パースする → バリデーションする → DBに保存する」という骨格は共通だ。

# app/services/importers/base_product_importer.rb
module Importers
  class BaseProductImporter
    def import(file_path)
      before_import(file_path)
      raw_data = parse_file(file_path)       # ← サブクラスが実装
      records = transform(raw_data)
      validate_all!(records)
      result = save_records(records)
      after_import(result)
      result
    end
 
    private
 
    def transform(raw_data)
      raw_data.map do |row|
        {
          name: row["商品名"] || row[:name],
          price: (row["価格"] || row[:price]).to_i,
          stock: (row["在庫"] || row[:stock]).to_i
        }
      end
    end
 
    def validate_all!(records)
      records.each_with_index do |record, i|
        raise ImportError, "#{i + 1}行目: 商品名が空です" if record[:name].blank?
        raise ImportError, "#{i + 1}行目: 価格が不正です" if record[:price] < 0
      end
    end
 
    def save_records(records)
      Product.transaction do
        records.map { |r| Product.find_or_create_by(name: r[:name]).tap { |p| p.update!(r) } }
      end
    end
 
    def before_import(file_path); end
    def after_import(result); end
 
    def parse_file(_file_path)
      raise NotImplementedError, "#{self.class}#parse_file を実装してください"
    end
 
    class ImportError < StandardError; end
  end
end
# app/services/importers/csv_product_importer.rb
module Importers
  class CsvProductImporter < BaseProductImporter
    private
 
    def parse_file(file_path)
      CSV.read(file_path, headers: true, encoding: "UTF-8").map(&:to_h)
    end
  end
end
 
# app/services/importers/json_product_importer.rb
module Importers
  class JsonProductImporter < BaseProductImporter
    private
 
    def parse_file(file_path)
      JSON.parse(File.read(file_path))
    end
  end
end

INFO

Template Methodは「処理の流れが決まっていて、一部のステップだけが異なる」ケースなら何にでも使える。レポート出力、データインポート、メール送信フロー——同じ発想で重複を消せる。

Railsでの Template Method の例

ApplicationController

RailsのApplicationControllerは教科書的なTemplate Methodだ。**全コントローラに共通の「リクエスト処理の骨格」**がActionController::Baseに定義されており、before_actionafter_actionでステップを差し込める。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :set_locale
  after_action :log_response_time
 
  private
 
  def authenticate_user!
    redirect_to login_path unless current_user
  end
 
  def set_locale
    I18n.locale = current_user&.locale || :ja
  end
 
  def log_response_time
    Rails.logger.info "[#{self.class}##{action_name}] #{response.status}"
  end
end
 
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  # authenticate_user! は継承チェーンで自動実行される(テンプレートの一部)
  # このコントローラは「注文に固有の処理」だけを書く
 
  def index
    @orders = current_user.orders.includes(:products)
  end
 
  def show
    @order = current_user.orders.find(params[:id])
  end
end

ActionController::BaseApplicationController → 各コントローラ、という継承チェーンはTemplate Methodの連鎖だ。

Loading diagram...

ApplicationJob

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  rescue_from StandardError, with: :handle_error
  retry_on Faraday::TimeoutError, wait: 5.seconds, attempts: 3
 
  private
 
  def handle_error(error)
    Sentry.capture_exception(error, extra: { job: self.class.name })
    raise error
  end
end
 
# app/jobs/order_notification_job.rb
class OrderNotificationJob < ApplicationJob
  def perform(order_id)
    # エラーハンドリングはApplicationJobが担う。ここは本来の処理だけ書く
    order = Order.find(order_id)
    OrderMailer.notification(order).deliver_now
  end
end

INFO

rescue_fromretry_on は「ジョブ処理の骨格」への差し込みポイントだ。個別ジョブは perform の中身だけに集中できる。Template Methodの恩恵を継承チェーン全体で享受している。

フックメソッド — 任意でオーバーライドできるステップ

テンプレートメソッドには「必須のステップ」と「任意のステップ」がある。任意のステップをフックメソッドと呼ぶ。

**フックは「必要なサブクラスだけがオーバーライドする」**のがポイントだ。デフォルトは何もしないので、意識しないサブクラスへの影響はゼロ。

class BaseReportGenerator
  def generate(start_date, end_date)
    before_generate(start_date, end_date)  # フック
    orders = fetch_orders(start_date, end_date)
    sales_data = aggregate_by_product(orders)
    output = format_output(sales_data)
    after_generate(output)                 # フック
    output
  end
 
  private
 
  def before_generate(start_date, end_date); end  # デフォルトは何もしない
  def after_generate(output); end                 # デフォルトは何もしない
end
 
# 監査ログが必要なPDFレポートだけフックをオーバーライド
class AuditedPdfReportGenerator < PdfReportGenerator
  private
 
  def before_generate(start_date, end_date)
    AuditLog.create!(
      action: "report_generate_started",
      user: Current.user,
      params: { format: "pdf", start_date:, end_date: }
    )
  end
 
  def after_generate(path)
    AuditLog.create!(
      action: "report_generate_completed",
      user: Current.user,
      params: { path: }
    )
  end
end

WARNING

フックは便利だが、増やしすぎると「どのフックがどの順で動くか」が把握しにくくなる。フックは3〜4個程度に絞り、それ以上複雑になったらStrategyパターンを検討する。

ActiveRecord callbacks — Template Methodの応用

実はActiveRecordのコールバックもTemplate Methodパターンの応用だ。save というテンプレートメソッドの中に before_saveafter_create などのフックが埋め込まれている。

class Order < ApplicationRecord
  # ActiveRecordのsaveメソッドが「骨格」を持つ
  # コールバックはそのフックメソッドを実装しているに過ぎない
 
  before_validation :normalize_phone_number
  before_save       :calculate_total
  after_create      :send_confirmation_email
  after_commit      :sync_to_warehouse, on: :create
 
  private
 
  def normalize_phone_number
    self.phone = phone&.gsub(/[^\d]/, "")
  end
 
  def calculate_total
    self.total_amount = order_items.sum { |item| item.quantity * item.unit_price }
  end
 
  def send_confirmation_email
    OrderMailer.confirmation(self).deliver_later
  end
 
  def sync_to_warehouse
    WarehouseSyncJob.perform_later(id)
  end
end

INFO

before_saveafter_create を書くとき、私たちは知らず知らずのうちにTemplate Methodパターンを使っている。ActiveRecordが「レコード保存の骨格」を提供し、私たちはフックを実装しているだけだ。

「継承の罠」— 深くなりすぎると管理不能

山田さんがホワイトボードに書いた。「でも、Template Methodは継承に依存するため、クラス階層が深くなると管理が難しくなるという落とし穴がある。」

# 危険な例:3段以上の継承
class BaseReportGenerator            # 第1層
class SalesReportGenerator           # 第2層 < BaseReportGenerator
class MonthlySalesReportGenerator    # 第3層 < SalesReportGenerator
class RegionalMonthlySalesReport     # 第4層 < MonthlySalesReportGenerator(危険!)

これは**「脆弱な基底クラス問題」**と呼ばれる。4層目のクラスを修正しようとしたとき、1層目のコードまで追いかけないといけない。比喩で言えば「親の親の親から受け継いだ癖」を把握しないといけない状態だ。

# 危険なパターン:サブクラスがスーパークラスの実装に依存しすぎる
class BaseReportGenerator
  def generate(start_date, end_date)
    data = fetch_data(start_date, end_date)
    format_output(data)
  end
 
  def fetch_data(start_date, end_date)
    Order.where(created_at: start_date..end_date)
  end
end
 
class RegionalSalesReport < BaseReportGenerator
  def fetch_data(start_date, end_date)
    # スーパークラスのfetch_dataの実装を知っていないといけない
    super.where(region: @region)
  end
end

WARNING

継承ツリーが3層を超えたら要注意。super の呼び出しチェーンを追い続けないといけなくなる。そのときは「継承より合成を優先せよ」の原則に従い、Strategyパターンへの移行を検討する。

Template Method vs Strategy

「じゃあ、いつStrategyに切り替えればいいですか?」ケンタが聞いた。

Template MethodStrategy
仕組み継承(サブクラスが実装)合成(戦略オブジェクトを注入)
変更方法サブクラスを作る戦略オブジェクトを差し替える
テストサブクラスをテスト戦略をモックしやすい
向く場面処理の骨格が安定している実行時に戦略を変えたい
継承の深さ2層まで推奨制限なし

Template MethodからStrategyへのリファクタリング

継承が深くなってきたら、Strategyパターンへ移行する。

# Before: Template Method(継承が深くなってきた)
class PdfReportGenerator < BaseReportGenerator
  def format_output(data)
    # PDF生成
  end
end
 
# After: Strategy(フォーマット戦略を外から注入)
class ReportGenerator
  def initialize(formatter:)
    @formatter = formatter  # 戦略を注入
  end
 
  def generate(start_date, end_date)
    orders = fetch_orders(start_date, end_date)
    data = aggregate_by_product(orders)
    @formatter.format(data)  # 戦略に委譲
  end
 
  private
  # fetch_orders, aggregate_by_product は同じ
end
 
class PdfFormatter
  def format(data)
    # PDF生成(継承なし)
  end
end
 
class CsvFormatter
  def format(data)
    # CSV生成(継承なし)
  end
end
 
# 使う側
generator = ReportGenerator.new(formatter: PdfFormatter.new)
generator.generate(start_date, end_date)
 
# 実行時に切り替えも簡単
formatter = params[:format] == "pdf" ? PdfFormatter.new : CsvFormatter.new
ReportGenerator.new(formatter: formatter).generate(start_date, end_date)

INFO

Template MethodとStrategyは兄弟パターン。「クラス階層が自然か」「実行時に切り替える必要があるか」で使い分ける。複雑になったらStrategyへ、というリファクタリングパスを頭に入れておこう。

AWSでのTemplate Method的発想

Loading diagram...

AWS CodePipelineはTemplate Methodパターンの典型例だ。「ソース → ビルド → テスト → デプロイ」という骨格は固定。各ステージの中身(何のビルドツールを使うか、どこにデプロイするか)をカスタマイズする。

Step Functionsも同じ発想だ。「ステートマシン(骨格)」を定義し、各ステートの実装をLambdaで差し替える。CloudFormation のカスタムリソースも同様で、「作成 → 更新 → 削除」という骨格を管理し、具体的な処理はLambdaで実装する。

INFO

AWSのマネージドサービスは「パイプラインの骨格だけ提供し、中身はユーザーが実装する」という設計が多い。これはまさにTemplate Methodの発想だ。クラウド設計でも同じパターンが繰り返し現れる。

ケンタの気づきと「パターンを選ぶ判断基準」

「Template Methodパターンって、『変わる部分』と『変わらない部分』を分けるという点でStrategyパターンと似てますね。でも使う仕組みが継承か合成かが違う。」

「まさに」と山田さんは言った。「使い分けの判断基準は3つだ。クラス階層が自然に存在するか実行時に動的に切り替える必要があるか継承ツリーが2層以内に収まるか。この3点で考えると迷わない。」

ケンタはノートにメモした。

Template Methodパターン = 処理の骨格を親クラスに定義し、可変ステップをサブクラスに委ねる。RailsのApplicationController・ApplicationJobが典型例。継承が深くなったらStrategyへ移行する。


INFO

この章のまとめ

  • Template Methodは処理の骨格を固定し、可変ステップをサブクラスに委ねる
  • 重複コードを親クラスに集約でき、変更が1か所で済む
  • RailsのApplicationController・ApplicationJob・ActiveRecord callbacksが典型例
  • フックメソッドで任意のサブクラスだけカスタマイズできる(デフォルトは何もしない)
  • 継承ツリーが3層を超えたらStrategyパターンへのリファクタリングを検討する
  • AWS CodePipelineやStep Functionsも「骨格を固定してステップを差し替える」同じ発想
  • 「クラス階層が自然か」「動的切り替えが必要か」「継承は2層以内か」の3点で選ぶ