Template Method パターン — 処理の骨格を定義する
「レポートを3種類出力してほしい」
ケンタの次のタスクは売上レポートの実装だった。
「CSV、Excel、PDFの3種類でレポートを出力してほしい。でも内容は同じで、フォーマットだけが違う。」
ケンタは意気揚々とコードを書き始めた。CsvSalesReport、ExcelSalesReport、PdfSalesReport の3つのクラスをそれぞれ実装した。2時間後、ようやく完成したと思って見返したとき、嫌な予感がした。
「……あれ。aggregate_by_product がここにも、ここにも、ここにも同じコードがある。」
コードエディタの「検索」で aggregate_by_product と打つと、3ファイルにわたって全く同一のメソッドが出てきた。集計ロジック、バリデーション、ファイル保存処理——全部3か所に重複していた。
「これ、もし集計のバグが見つかったら3か所直さないといけないじゃないか……」
その呟きが聞こえたのか、山田さんが椅子を回した。
「それがTemplate Methodパターンを使うべきタイミングだ。ちょっとコードを見せてごらん。」
Template Method パターンとは
Template Method パターンは、処理の「骨格(テンプレート)」を親クラスに定義し、具体的な実装はサブクラスに委ねるパターンだ。
日常の比喩はカクテルのレシピだ。「グラスを用意する → 氷を入れる → ベースを注ぐ → ミックスする → デコレーションする」という骨格は同じ。でも「ベース」がウォッカかラムかでカクテルが変わる。骨格を変えずに、特定のステップだけを差し替える。
別の比喩で言えば料理番組の調理工程だ。「材料を切る → 炒める → 味付けする → 盛り付ける」という手順は変わらない。「炒め物」か「煮物」かで「調理する」ステップの内容が変わるだけだ。
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
endINFO
generate メソッドは一切変わらない。CSV・Excel・PDFの違いは format_output と build_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
endINFO
Template Methodは「処理の流れが決まっていて、一部のステップだけが異なる」ケースなら何にでも使える。レポート出力、データインポート、メール送信フロー——同じ発想で重複を消せる。
Railsでの Template Method の例
ApplicationController
RailsのApplicationControllerは教科書的なTemplate Methodだ。**全コントローラに共通の「リクエスト処理の骨格」**がActionController::Baseに定義されており、before_action・after_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
endActionController::Base → ApplicationController → 各コントローラ、という継承チェーンはTemplate Methodの連鎖だ。
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
endINFO
rescue_from や retry_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
endWARNING
フックは便利だが、増やしすぎると「どのフックがどの順で動くか」が把握しにくくなる。フックは3〜4個程度に絞り、それ以上複雑になったらStrategyパターンを検討する。
ActiveRecord callbacks — Template Methodの応用
実はActiveRecordのコールバックもTemplate Methodパターンの応用だ。save というテンプレートメソッドの中に before_save、after_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
endINFO
before_save や after_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
endWARNING
継承ツリーが3層を超えたら要注意。super の呼び出しチェーンを追い続けないといけなくなる。そのときは「継承より合成を優先せよ」の原則に従い、Strategyパターンへの移行を検討する。
Template Method vs Strategy
「じゃあ、いつStrategyに切り替えればいいですか?」ケンタが聞いた。
| Template Method | Strategy | |
|---|---|---|
| 仕組み | 継承(サブクラスが実装) | 合成(戦略オブジェクトを注入) |
| 変更方法 | サブクラスを作る | 戦略オブジェクトを差し替える |
| テスト | サブクラスをテスト | 戦略をモックしやすい |
| 向く場面 | 処理の骨格が安定している | 実行時に戦略を変えたい |
| 継承の深さ | 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的発想
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点で選ぶ