The Builder Pattern in Ruby
The Builder Pattern in Ruby
The builder pattern is a creational design pattern that separates the construction of a complex object from its representation. Instead of initializing an object with a constructor that takes many arguments — some optional, some required — you use a dedicated builder object that constructs the target step by step. This gives you readable code, validation at each step, and flexibility to construct variations of the same object without multiplying constructors.
In this guide you will learn what the builder pattern solves, how to implement it with fluent interfaces, how to use blocks with instance_eval for nicer syntax, and how to build nested structures like HTML or XML documents. You will also see how to validate inputs during construction.
What Problem Does the Builder Pattern Solve?
Consider a User class with several optional fields:
class User
attr_accessor :first_name, :last_name, :email, :phone, :address, :date_of_birth, :avatar_url, :role
end
With a traditional constructor, you might do this:
user = User.new
user.first_name = "Alice"
user.last_name = "Smith"
user.email = "alice@example.com"
user.role = "admin"
This works but is verbose and scattered. You could use a constructor with default values:
user = User.new(
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com",
role: "admin"
)
But as optional fields grow, this becomes unwieldy. What if you need to validate that email looks like an email address, or that date_of_birth is in the past? You end up cramming validation logic into the initializer or spreading it across your codebase.
The builder pattern solves this by giving you a dedicated object whose sole job is to construct another object. Each builder method can validate its input and accumulate state until you call a final #build method.
A Basic Builder
Here is a simple builder for the User class:
class UserBuilder
def initialize
@user = User.new
end
def first_name(name)
@user.first_name = name
self
end
def last_name(name)
@user.last_name = name
self
end
def email(email)
raise ArgumentError, "Invalid email format" unless email.include?("@")
@user.email = email
self
end
def role(role)
@user.role = role
self
end
def build
raise ArgumentError, "first_name is required" if @user.first_name.nil?
raise ArgumentError, "email is required" if @user.email.nil?
@user
end
end
Notice that each setter returns self. This enables method chaining, also known as a fluent interface:
user = UserBuilder.new
.first_name("Alice")
.last_name("Smith")
.email("alice@example.com")
.role("admin")
.build
The builder reads almost like a sentence. Each step is explicit, and validation happens at the point where the data is set, not somewhere buried in a constructor.
Separating Construction from Representation
One of the core ideas behind the builder pattern is that the builder and the product are separate objects. The UserBuilder knows how to construct a User, but it is not the User itself. This separation has several benefits:
- The product object (
User) stays clean and focused on its domain behavior - The builder can have its own internal state and validation logic
- You can have multiple builders for the same product, each tailored to a different use case
For example, you might have a UserBuilder for the public signup flow and a UserAdminBuilder for creating users via an admin panel. Both produce User objects, but they have different required fields and defaults:
class UserAdminBuilder < UserBuilder
def initialize
super
@user.role = "member" # default role
end
def as_admin
@user.role = "admin"
self
end
end
user = UserAdminBuilder.new
.first_name("Bob")
.last_name("Jones")
.email("bob@example.com")
.as_admin
.build
Using Blocks with instance_eval
Passing a block to the builder makes the syntax even cleaner. Instead of chaining methods, you pass a block to the builder and use instance_eval to evaluate it in the context of the builder instance:
class UserBuilder
def initialize
@user = User.new
end
def first_name(name)
@user.first_name = name
self
end
def last_name(name)
@user.last_name = name
self
end
def email(email)
raise ArgumentError, "Invalid email format" unless email.include?("@")
@user.email = email
self
end
def role(role)
@user.role = role
self
end
def build
raise ArgumentError, "first_name is required" if @user.first_name.nil?
raise ArgumentError, "email is required" if @user.email.nil?
@user
end
def self.build(&block)
builder = new
builder.instance_eval(&block)
builder.build
end
end
Now you can construct users with a block:
user = UserBuilder.build do
first_name "Carol"
last_name "White"
email "carol@example.com"
role "moderator"
end
The instance_eval call executes the block in the context of the builder, so first_name, last_name, and so on are resolved as method calls on the builder. This removes the need for any explicit receiver inside the block.
Note that instance_eval with a block makes self point to the builder inside the block. This means you lose access to self from the calling context. If you need to call methods from the outer scope, pass the block to instance_exec instead and pass any needed context as arguments.
Builder for Nested Structures
One of the most powerful uses of the builder pattern is constructing nested data structures, such as HTML documents. Because each builder method can return self, you can nest calls to build child elements:
class HtmlBuilder
def initialize(tag)
@tag = tag
@children = []
@attributes = {}
end
def add_class(value)
@attributes["class"] = value
self
end
def text(content)
@children << content
self
end
def append(&block)
child_builder = HtmlBuilder.new("div")
child_builder.instance_eval(&block)
@children << child_builder.to_html
self
end
def to_html
attrs = @attributes.map { |k, v| " #{k}=\"#{v}\"" }.join
if @children.empty?
"<#{@tag}#{attrs} />"
else
"<#{@tag}#{attrs}>#{@children.join}</#{@tag}>"
end
end
end
html = HtmlBuilder.new("div").add_class("container").append do
text "Hello, World!"
end
html.to_html
# => "<div class=\"container\"><div>Hello, World!</div></div>"
This pattern is the basis for popular HTML builder libraries like Nokogiri and Markaby. Each nested element is itself a builder, and the whole structure is built up through composition.
Validating During Construction
A key advantage of builders is that validation can happen at each step, not just at the end. This gives you faster feedback and keeps validation logic close to the data it checks:
class OrderBuilder
def initialize
@order = Order.new
end
def customer_name(name)
raise ArgumentError, "Customer name cannot be blank" if name.to_s.strip.empty?
@order.customer_name = name
self
end
def add_item(product_id, quantity, price)
raise ArgumentError, "Quantity must be positive" if quantity <= 0
raise ArgumentError, "Price must be positive" if price < 0
@order.items << { product_id: product_id, quantity: quantity, price: price }
self
end
def discount(code)
valid_codes = ["SAVE10", "SAVE20", "WELCOME"]
raise ArgumentError, "Unknown discount code" unless valid_codes.include?(code)
@order.discount_code = code
self
end
def build
raise ArgumentError, "Customer name is required" if @order.customer_name.nil?
raise ArgumentError, "Order must have at least one item" if @order.items.empty?
@order.total = @order.items.sum { |i| i[:quantity] * i[:price] }
@order
end
end
With this builder, you get an immediate error if you try to add an item with a negative quantity or use an invalid discount code. You never end up with an invalid Order object because the builder rejects bad data as it is provided.
Practical Example: Building an API Response
A common real-world use for the builder pattern is constructing API responses. Instead of manually assembling hashes, you use a builder:
class ApiResponseBuilder
def initialize
@response = { meta: {}, data: nil, errors: [] }
end
def meta(key, value)
@response[:meta][key] = value
self
end
def data(payload)
@response[:data] = payload
self
end
def error(message, code: nil)
err = { message: message }
err[:code] = code if code
@response[:errors] << err
self
end
def paginated(page:, per_page:, total:)
meta(:page, page)
meta(:per_page, per_page)
meta(:total, total)
self
end
def build
raise ArgumentError, "Response must have data or errors" if @response[:data].nil? && @response[:errors].empty?
@response
end
end
Usage:
response = ApiResponseBuilder.new
.meta(:request_id, "req-123")
.data({ users: [{ name: "Alice" }, { name: "Bob" }] })
.paginated(page: 1, per_page: 20, total: 2)
.build
This keeps API response construction consistent and ensures every response has the expected shape.
When to Use the Builder Pattern
The builder pattern is a good fit when:
- An object has many optional parameters or configuration steps
- You want validation at each step rather than only at construction
- You need to construct different variations of the same object
- The construction process itself is complex or involves nested objects
- You want a readable, expressive way to construct objects
For simple objects with a few required fields and no validation, a plain initializer or a factory method is usually sufficient. The builder pattern adds complexity that is only justified when that complexity pays off in readability and safety.
See Also
- /guides/ruby-struct-guide/ — Struct gives you lightweight data objects that pair well with builders
- /guides/ruby-command-pattern/ — Another Gang of Four pattern for encapsulating actions as objects
- /guides/ruby-service-objects/ — Service objects organize business logic and often use builders internally