Builder パターン — 複雑なオブジェクトを段階的に構築する
「コンストラクタの引数が多すぎる」
ケンタはレポートメール機能の実装を依頼された。
「マネージャーに週次の売上レポートをメールで送りたいんだけど、いくつかオプションが必要で……」と田中マネージャーは言った。「CSV添付、グラフ付き、送信時刻指定、英語版もあるといいな。」
ケンタはコードを書き始めた。
# こんな感じで呼び出したかったが……
ReportEmail.new(
to: "manager@example.com",
subject: "月次売上レポート",
period: Date.current.last_month.beginning_of_month..Date.current.last_month.end_of_month,
include_csv: true,
include_chart: true,
chart_type: "bar",
highlight_top_n: 5,
currency: "JPY",
locale: "ja",
footer_text: "このメールは自動生成されました",
send_at: Date.current.next_day.beginning_of_day + 8.hours,
cc: ["director@example.com"],
bcc: ["archive@example.com"]
)「引数が12個……これ毎回書くの辛すぎる」とケンタは独り言を言った。「しかも必須なのはどれで、オプションはどれ? 呼び出す側が全部把握しないといけない。」
山田さんにチャットで相談すると、すぐに返信が来た。
「Builderパターンだね。複雑なオブジェクトを段階的に組み立てる。メソッドチェーンで書けるようにすると自然に読める。」
Builder パターンとは
Builder パターンは、複雑なオブジェクトの構築を段階的なステップに分解するパターンだ。同じ構築手順で異なるバリエーションを生成でき、引数の爆発問題を解消する。
日常の比喩はカスタムバーガーの注文だ。「バンズ → パティ → チーズ → トッピング → ソース」という順序で選んでいく。全部一度に言う必要はなく、必要なものだけ選び、最後に「これでお願いします」と言えばできあがりが出てくる。何を追加して何を省いたかが明確だ。
もう一つの比喩は旅行パッケージのカスタマイズだ。基本プランに「ホテルグレードアップ」「空港送迎」「現地ガイド」を追加していく。全部込みのプランを一から説明しなくても、基本プラン+オプションという段階的な構築で希望通りの旅行プランが完成する。
実装例: ReportEmailBuilder
# app/builders/report_email_builder.rb
class ReportEmailBuilder
def initialize
@config = {
to: nil,
cc: [],
bcc: [],
subject: "売上レポート",
period: Date.current.last_month.beginning_of_month..Date.current.last_month.end_of_month,
attachments: [],
charts: [],
highlight_top_n: nil,
currency: "JPY",
locale: "ja",
footer_text: nil,
send_at: nil,
reply_to: nil
}
end
# メソッドチェーンができるよう self を返す
def to(email_address)
@config[:to] = email_address
self
end
def cc(*addresses)
@config[:cc] += Array(addresses).flatten
self
end
def bcc(*addresses)
@config[:bcc] += Array(addresses).flatten
self
end
def subject(text)
@config[:subject] = text
self
end
def for_period(start_date, end_date)
@config[:period] = start_date..end_date
self
end
def for_last_month
@config[:period] = Date.current.last_month.beginning_of_month..
Date.current.last_month.end_of_month
self
end
def for_last_week
@config[:period] = 1.week.ago.beginning_of_day..Time.current
self
end
def for_quarter(year:, quarter:)
start_month = (quarter - 1) * 3 + 1
end_month = start_month + 2
@config[:period] = Date.new(year, start_month, 1)..Date.new(year, end_month, -1)
self
end
def with_csv_attachment(filename: nil, encoding: "UTF-8")
@config[:attachments] << {
type: :csv,
filename: filename || "report_#{Date.current.strftime('%Y%m%d')}.csv",
encoding: encoding
}
self
end
def with_chart(type: :bar, highlight_top: nil, color_scheme: :default)
@config[:charts] << {
type: type,
color_scheme: color_scheme
}
@config[:highlight_top_n] = highlight_top if highlight_top
self
end
def in_currency(currency)
@config[:currency] = currency
self
end
def in_locale(locale)
@config[:locale] = locale.to_s
self
end
def with_footer(text)
@config[:footer_text] = text
self
end
def scheduled_for(datetime)
@config[:send_at] = datetime
self
end
def send_immediately
@config[:send_at] = nil
self
end
def with_reply_to(email)
@config[:reply_to] = email
self
end
def build
validate!
ReportEmail.new(@config.dup.freeze)
end
# ショートカット: buildして即deliver
def deliver
build.deliver
end
# デバッグ用: 現在の設定を確認
def preview_config
@config
end
private
def validate!
errors = []
errors << "送信先メールアドレスが必要です" if @config[:to].blank?
errors << "期間が設定されていません" if @config[:period].nil?
raise ArgumentError, errors.join(", ") if errors.any?
end
end# app/models/report_email.rb
class ReportEmail
def initialize(config)
@config = config
end
def deliver
if @config[:send_at]
ReportEmailJob.set(wait_until: @config[:send_at]).perform_later(serializable_config)
else
ReportEmailJob.perform_later(serializable_config)
end
end
def deliver_now
ReportEmailMailer.report(@config).deliver_now
end
private
def serializable_config
# Dateオブジェクトなどをシリアライズ可能な形式に変換
@config.merge(
period_start: @config[:period].begin.iso8601,
period_end: @config[:period].end.iso8601
).except(:period)
end
end使い方 — メソッドチェーンで流れるように書ける
# 基本的な使い方(週次レポート)
ReportEmailBuilder.new
.to("manager@example.com")
.subject("週次売上レポート")
.for_last_week
.with_csv_attachment
.deliver
# 月次フルオプション
ReportEmailBuilder.new
.to("director@example.com")
.cc("manager@example.com", "finance@example.com")
.subject("月次売上サマリー #{Date.current.last_month.strftime('%Y年%-m月')}")
.for_last_month
.with_chart(type: :bar, highlight_top: 5)
.with_csv_attachment(filename: "monthly_sales_#{Date.current.strftime('%Y%m')}.csv")
.in_currency("JPY")
.with_footer("このメールは自動送信されています。問い合わせは sales@example.com まで")
.scheduled_for(Date.tomorrow.beginning_of_day + 8.hours)
.deliver
# 英語版(海外向け)
ReportEmailBuilder.new
.to("global-director@example.com")
.subject("Monthly Sales Report - #{Date.current.last_month.strftime('%B %Y')}")
.for_last_month
.with_chart(type: :line)
.in_currency("USD")
.in_locale(:en)
.deliver
# 四半期レポート
ReportEmailBuilder.new
.to("ceo@example.com")
.cc("cfo@example.com")
.subject("Q3 2024 Sales Report")
.for_quarter(year: 2024, quarter: 3)
.with_chart(type: :bar, highlight_top: 10)
.with_chart(type: :line)
.with_csv_attachment
.in_currency("JPY")
.with_reply_to("sales-team@example.com")
.deliverINFO
メソッドチェーンパターン(Fluent Interface)と組み合わせることで、コードが英語の文章のように読める。for_last_month、with_csv_attachmentという名前が、コードを読む人に意図を伝える。10番目の引数がtrueだから……と解読する苦労がなくなる。
ActiveRecord のクエリビルダ
実は、RailsのActiveRecordが最も身近なBuilderパターンの実装だ。毎日使っているのに気づいていない人が多い。
# 段階的にクエリを構築する
scope = Order.all
# 条件を積み上げる(各メソッドがBuilderの役割)
scope = scope.where(status: :completed)
scope = scope.where("total_price > ?", 10_000) if params[:high_value].present?
scope = scope.where(created_at: 1.month.ago..) if params[:recent].present?
scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
# N+1問題を防ぐeager loading
scope = scope.includes(:user, :order_items, order_items: :product)
# ソートとページネーション
scope = scope.order(created_at: :desc)
scope = scope.page(params[:page]).per(20)
# 最終的に.to_aや.eachで実行される(遅延評価)
orders = scope.to_aこれがBuilderパターンだ。各メソッド(where、includes、order)が条件を積み上げ、最後にto_aやeachで「ビルド(実行)」される。
ActiveRecord::Relation がBuilderの役割を担い、メソッドチェーンのたびに新しいRelationオブジェクトを返す(イミュータブルなBuilder)。
# ActiveRecord::Relation の動作確認
query = Order.where(status: :pending)
puts query.class # => ActiveRecord::Relation(まだSQLは実行されていない)
query = query.order(:created_at)
puts query.class # => ActiveRecord::Relation(まだSQLは実行されていない)
orders = query.to_a # ← ここで初めてSQLが発行される
puts query.to_sql
# => SELECT "orders".* FROM "orders" WHERE "orders"."status" = 'pending' ORDER BY "orders"."created_at" ASCカスタムクエリビルダ
複雑な検索フォームにはカスタムBuilderが有効だ。コントローラをシンプルに保てる。
# app/builders/order_search_builder.rb
class OrderSearchBuilder
def initialize(base_scope: Order.all)
@scope = base_scope
end
def by_status(status)
return self if status.blank?
@scope = @scope.where(status: status)
self
end
def by_user(user_id)
return self if user_id.blank?
@scope = @scope.where(user_id: user_id)
self
end
def by_user_email(email)
return self if email.blank?
@scope = @scope.joins(:user).where(users: { email: email })
self
end
def in_date_range(start_date, end_date)
return self if start_date.blank? && end_date.blank?
range = (start_date.to_date rescue nil)..(end_date.to_date rescue nil)
@scope = @scope.where(created_at: range)
self
end
def with_min_amount(amount)
return self if amount.blank?
@scope = @scope.where("total_price >= ?", amount.to_i)
self
end
def with_max_amount(amount)
return self if amount.blank?
@scope = @scope.where("total_price <= ?", amount.to_i)
self
end
def containing_product(product_id)
return self if product_id.blank?
@scope = @scope.joins(:order_items).where(order_items: { product_id: product_id })
self
end
def sorted_by(column, direction: :desc)
allowed_columns = %w[created_at total_price status updated_at]
return self unless allowed_columns.include?(column.to_s)
@scope = @scope.order(column => direction)
self
end
def paginated(page, per_page: 20)
@scope = @scope.page(page).per(per_page)
self
end
def build
@scope.includes(:user, :order_items)
end
# Builderの状態をデバッグ用に確認
def to_sql
@scope.to_sql
end
end# app/controllers/admin/orders_controller.rb
class Admin::OrdersController < Admin::BaseController
def index
@orders = OrderSearchBuilder.new
.by_status(params[:status])
.by_user_email(params[:email])
.in_date_range(params[:start_date], params[:end_date])
.with_min_amount(params[:min_amount])
.with_max_amount(params[:max_amount])
.containing_product(params[:product_id])
.sorted_by(params[:sort] || "created_at", direction: params[:direction] || "desc")
.paginated(params[:page])
.build
# ページネーション情報
@total_count = @orders.total_count
end
endWARNING
Builderを使っても、検索条件が多くなりすぎるとBuilderクラス自体が肥大化する。30個以上のフィルタ条件がある場合は、Ransack gemなどの専用ライブラリの使用を検討しよう。また、検索条件の組み合わせによってはN+1問題が発生しないか確認すること。
HTMLメール生成でのBuilder
メールのHTMLを構築する際にもBuilderが活躍する。
# app/builders/email_html_builder.rb
class EmailHtmlBuilder
def initialize(theme: :default)
@sections = []
@header = nil
@footer = nil
@theme = theme
@styles = default_styles
end
def with_header(title:, logo_url: nil, background_color: nil)
@header = {
title: title,
logo_url: logo_url,
background_color: background_color || @styles[:header_bg]
}
self
end
def add_hero_text(text, subtitle: nil)
@sections << { type: :hero, text: text, subtitle: subtitle }
self
end
def add_text(content)
@sections << { type: :text, content: content }
self
end
def add_table(headers:, rows:, caption: nil)
@sections << {
type: :table,
headers: headers,
rows: rows,
caption: caption
}
self
end
def add_key_value_list(items)
# items: [{ key: "注文番号", value: "#12345" }, ...]
@sections << { type: :key_value_list, items: items }
self
end
def add_button(text:, url:, color: nil)
@sections << {
type: :button,
text: text,
url: url,
color: color || @styles[:accent_color]
}
self
end
def add_divider
@sections << { type: :divider }
self
end
def with_footer(text:, unsubscribe_url: nil)
@footer = { text: text, unsubscribe_url: unsubscribe_url }
self
end
def build
ApplicationController.render(
template: "email_templates/base",
layout: false,
locals: {
header: @header,
sections: @sections,
footer: @footer,
styles: @styles
}
)
end
private
def default_styles
{
header_bg: "#1a1a2e",
accent_color: "#d4a050",
text_color: "#333333",
background: "#f8f8f8"
}
end
end# 注文確認メールの生成
html = EmailHtmlBuilder.new
.with_header(title: "ご注文確認", logo_url: "https://example.com/logo.png")
.add_hero_text("ありがとうございます!", subtitle: "ご注文を受け付けました")
.add_key_value_list([
{ key: "注文番号", value: "##{order.id}" },
{ key: "注文日時", value: order.created_at.strftime("%Y年%m月%d日 %H:%M") },
{ key: "合計金額", value: "¥#{order.total_price.to_s(:delimited)}" }
])
.add_divider
.add_table(
headers: ["商品名", "数量", "単価", "小計"],
rows: order.order_items.map { |i|
[i.product.name, i.quantity, "¥#{i.unit_price}", "¥#{i.subtotal}"]
},
caption: "ご注文商品一覧"
)
.add_button(text: "注文詳細を確認する", url: order_url(order))
.with_footer(
text: "このメールは自動送信されています",
unsubscribe_url: unsubscribe_url(token: order.user.unsubscribe_token)
)
.buildテスト
Builderのテストは、バリデーションと生成結果の両方を検証する。
# spec/builders/report_email_builder_spec.rb
RSpec.describe ReportEmailBuilder do
describe "#build" do
context "送信先が設定されていない場合" do
it "ArgumentErrorを発生させる" do
expect {
described_class.new.for_last_month.build
}.to raise_error(ArgumentError, /送信先メールアドレスが必要です/)
end
end
context "最小限の設定がある場合" do
subject(:email) do
described_class.new
.to("test@example.com")
.for_last_month
.build
end
it "ReportEmailオブジェクトを返す" do
expect(email).to be_a(ReportEmail)
end
end
context "複数のオプションを指定した場合" do
subject(:builder) do
described_class.new
.to("manager@example.com")
.cc("director@example.com")
.for_last_month
.with_chart(type: :bar, highlight_top: 5)
.with_csv_attachment
end
it "設定が正しく積み上げられる" do
config = builder.preview_config
expect(config[:to]).to eq("manager@example.com")
expect(config[:cc]).to include("director@example.com")
expect(config[:charts]).to have(1).item
expect(config[:attachments]).to have(1).item
end
end
describe "#for_quarter" do
it "正しいQ3の期間を設定する" do
builder = described_class.new.to("test@example.com").for_quarter(year: 2024, quarter: 3)
config = builder.preview_config
expect(config[:period].begin).to eq(Date.new(2024, 7, 1))
expect(config[:period].end).to eq(Date.new(2024, 9, 30))
end
end
end
describe "#deliver" do
it "ジョブをエンキューする" do
expect {
described_class.new
.to("test@example.com")
.for_last_month
.deliver
}.to have_enqueued_job(ReportEmailJob)
end
context "scheduled_for が設定されている場合" do
let(:send_time) { 1.day.from_now }
it "指定時刻にジョブをスケジュールする" do
expect {
described_class.new
.to("test@example.com")
.for_last_month
.scheduled_for(send_time)
.deliver
}.to have_enqueued_job(ReportEmailJob).at(send_time)
end
end
end
endAWSでのBuilder的発想
AWS CDK(Cloud Development Kit) はBuilderパターンの最も明確なインフラ実装例だ。
// CDKでのBuilder的なコード(TypeScript)
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// VPC(段階的に構築)
const vpc = new ec2.Vpc(this, "AppVpc", {
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{ name: "public", subnetType: ec2.SubnetType.PUBLIC },
{ name: "private", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }
]
});
// ECSクラスタ(VPCの上に構築)
const cluster = new ecs.Cluster(this, "AppCluster", {
vpc,
containerInsights: true
});
// RDS(プライベートサブネットに構築)
const database = new rds.DatabaseInstance(this, "AppDatabase", {
engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_15 }),
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }
});
// Fargateサービス(全てを組み合わせてビルド)
const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
this, "AppService", {
cluster,
cpu: 512,
memoryLimitMiB: 1024,
desiredCount: 2,
taskImageOptions: {
image: ecs.ContainerImage.fromAsset("./"),
containerPort: 3000,
environment: {
DATABASE_URL: database.dbInstanceEndpointAddress
}
}
}
);
}
}各クラス(Vpc、Cluster、DatabaseInstance、FargateService)がBuilderの役割を担い、引数で必要なオプションだけ指定する。残りはCDKがデフォルト値で構築する。cdk deployで初めて実際のリソースが作成される——これがBuilderの「build()を呼ぶまで実際のオブジェクトを作らない」という特性に対応している。
CloudFormationテンプレートもBuilderの発想だ。Parameters(オプション設定)を定義し、Resources(段階的構築)を積み上げ、最後にCREATE_COMPLETE(build完了)でスタックが出来上がる。
ケンタの気づき
コードを書き終えてレビューを出すと、山田さんからコメントが来た。
「
for_last_monthとfor_quarterのメソッドが良い。呼ぶ人が日付計算を意識しなくていい。Builderがドメイン知識を吸収している。」
ケンタは嬉しかった。
「Builderって、コンストラクタの引数爆発問題の解決策なんですね。引数が多いクラスはBuilderに置き換えられる。」
「そう」と山田さんは言った。「もう一つの効果は意図が明確になることだ。with_csv_attachmentと書けば『CSVを添付したい』という意図が伝わる。trueを何番目の引数に渡すかを覚えなくていい。」
「ActiveRecordの.where.order.limitもBuilderパターンだと気づいたら、普段から使ってたじゃないですか。」
「Railsを書いている時点でBuilderパターンのユーザーだ。あとは自分でもこのパターンを適用できるようにするだけ。難しく考える必要はない——『メソッドチェーンで組み立てられるクラス』というだけだよ。」
ケンタはノートにメモした。
Builderパターン = 複雑なオブジェクトを段階的に構築。メソッドチェーンで流れるような記述ができる。selfを返すメソッドがポイント。ActiveRecordのクエリビルダが最もよく使う例。
INFO
この章のまとめ
- Builderパターンは複雑なオブジェクトの構築を段階的なステップに分解する
- 各メソッドが
selfを返すことでメソッドチェーン(Fluent Interface)が実現する - 必要なオプションだけ指定でき、残りはデフォルト値が使われる
- ActiveRecordのクエリビルダが最も身近な例(
where.order.limit) - カスタムBuilderで検索フォームやメール生成をシンプルに整理できる
buildを呼ぶまで実際のオブジェクトを生成しない(遅延生成)- AWS CDKもBuilderパターンでインフラを記述する