mybook

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 パターンは、複雑なオブジェクトの構築を段階的なステップに分解するパターンだ。同じ構築手順で異なるバリエーションを生成でき、引数の爆発問題を解消する。

日常の比喩はカスタムバーガーの注文だ。「バンズ → パティ → チーズ → トッピング → ソース」という順序で選んでいく。全部一度に言う必要はなく、必要なものだけ選び、最後に「これでお願いします」と言えばできあがりが出てくる。何を追加して何を省いたかが明確だ。

もう一つの比喩は旅行パッケージのカスタマイズだ。基本プランに「ホテルグレードアップ」「空港送迎」「現地ガイド」を追加していく。全部込みのプランを一から説明しなくても、基本プラン+オプションという段階的な構築で希望通りの旅行プランが完成する。

Loading diagram...

実装例: 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")
  .deliver

INFO

メソッドチェーンパターン(Fluent Interface)と組み合わせることで、コードが英語の文章のように読める。for_last_monthwith_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パターンだ。各メソッド(whereincludesorder)が条件を積み上げ、最後にto_aeachで「ビルド(実行)」される。

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
end

WARNING

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
end

AWSでのBuilder的発想

AWS CDK(Cloud Development Kit) はBuilderパターンの最も明確なインフラ実装例だ。

Loading diagram...
// 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
          }
        }
      }
    );
  }
}

各クラス(VpcClusterDatabaseInstanceFargateService)がBuilderの役割を担い、引数で必要なオプションだけ指定する。残りはCDKがデフォルト値で構築する。cdk deployで初めて実際のリソースが作成される——これがBuilderの「build()を呼ぶまで実際のオブジェクトを作らない」という特性に対応している。

CloudFormationテンプレートもBuilderの発想だ。Parameters(オプション設定)を定義し、Resources(段階的構築)を積み上げ、最後にCREATE_COMPLETE(build完了)でスタックが出来上がる。

ケンタの気づき

コードを書き終えてレビューを出すと、山田さんからコメントが来た。

for_last_monthfor_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パターンでインフラを記述する