Builder

Separate the construction of a complex object from its representation so that the same construction process can create different representations.

Participants

RoleIn this exampleResponsibility
BuilderLevelBuilderCollects settings one step at a time and assembles the level at the end.
DirectorTutorialLevelKnows a recipe, the steps to call, without knowing how the level is put together.
ProductLevelThe finished object. Immutable once built.

Ruby implementation

builder.rb
# The finished product: a level, immutable once built.
Level = Data.define(:gold, :lives, :waves)
Wave = Data.define(:enemy, :count)

# The Builder collects choices one step at a time, then assembles the level.
class LevelBuilder
  def self.build
    builder = new
    yield builder
    builder.level
  end

  def initialize
    @gold = 120
    @lives = 20
    @waves = []
  end

  def gold(amount)
    @gold = amount
    self
  end

  def lives(count)
    @lives = count
    self
  end

  def wave(enemy, count)
    @waves << Wave.new(enemy:, count:)
    self
  end

  def level
    raise ArgumentError, "a level needs at least one wave" if @waves.empty?

    Level.new(gold: @gold, lives: @lives, waves: @waves.dup.freeze)
  end
end

# A Director knows a recipe, and can drive any builder through it.
class TutorialLevel
  def construct(builder)
    builder.gold(200).wave(:goblin, 3).wave(:goblin, 6).wave(:orc, 2)
  end
end

In use

# Chained: each step returns the builder.
LevelBuilder.new.gold(300).lives(5).wave(:goblin, 8).wave(:troll, 1).level
# => #<data Level gold=300, lives=5, waves=[...]>

# Block form: reads like a level description.
LevelBuilder.build do |level|
  level.lives(1)
  level.wave(:orc, 4)
end

# With a director supplying the recipe.
TutorialLevel.new.construct(LevelBuilder.new).level.waves.size
# => 3

Notes

Ruby idioms

  • Return self from each step to allow chaining.
  • Yield the builder from a class method (LevelBuilder.build { |level| ... }) for a block-based DSL. This is how many Ruby libraries, like gem specifications and Rails routes, build configuration.
  • Check at the end. #level is the one place that enforces rules, such as “at least one wave”, before handing back a valid, frozen product.

Reach for it when

  • A constructor would need many optional arguments, or arguments that only make sense together.
  • Tests need to set up an interesting state in a few readable lines instead of many separate calls.

Watch for

  • If keyword arguments with defaults cover it, Level.new(gold: 300, waves: [...]) is simpler. Use a builder when the process matters.

The director knows the steps. The builder knows how to put the parts together.