Ruby Builder Pattern: A Step-by-Step Guide to Creating Complex Objects
Posted at 2-October-2023 / Written by Rohit Bhatt
30-sec summary
When writing code, we all face a common issue of creating complex objects with many possible configuration options. This can be a daunting task, especially if we need to create these objects in a consistent and error-prone. It is where the pattern comes into play. The Builder pattern is a design pattern that can help us to solve this problem. It allows us to create complex objects step by step, and it provides a clear and concise way to structure our code. In this article, we will take a look at how to implement the Builder pattern in Ruby on Rails. We will also discuss the benefits of using the Builder pattern and when you should use it in your own code.
What is a Builder Pattern?
The Builder pattern is created on the principle of separation of concerns. This means that the construction of an object is separated from its representation. This makes it easier to create objects with a lot of possible configuration options, and it also helps to ensure that objects are created correctly.
How the Builder pattern works
In the Builder pattern, the responsibility for constructing an object is delegated to a separate class called the Builder. The Builder class provides methods that can be used to configure the object step by step. Once the object is configured, the Builder class can be used to create the final object.
This separation of concerns makes it easier to test and maintain our code. For example, we can test the Builder class without having to worry about the specific implementation of the object being built. We can also easily add new configuration options to the object without having to modify the code that uses the object.
Let's see an example where we're making a pizza. What we'll do is initialize the pizza class by taking the values and putting them into the variables. This approach can become cumbersome, especially when creating objects with many optional attributes. Keeping track of which values to set and which to leave out can quickly become a headache.
Example of a Problem
1class Pizza
2 attr_accessor :size, :cheese, :sauce, :toppings
3 def initialize(size, cheese, sauce, toppings)
4 @size = size
5 @cheese = cheese
6 @sauce = sauce
7 @toppings = toppings
8 end
9
10 def describe
11 puts "Size: #{@size}"
12 puts "Cheese: #{@cheese}"
13 puts "Sauce: #{@sauce}"
14 puts "Toppings: #{@toppings.join(', ')}"
15 end
16end
17
18
19# Usage
20pizza = Pizza.new("Medium", "Mozzarella", "Tomato", ["Mushrooms", "Pepperoni"])
21pizza.describe
That's where the Builder Pattern comes into play.
The Builder Pattern provides an elegant solution to this challenge. It allows us to construct complex objects step by step, providing clear methods to set each attribute. This not only makes the code more readable and maintainable, but it also ensures that we don't miss any important details in the object creation process.
Let's delve into an example using the Builder Pattern to create our pizza.
Example of Builder Pattern
1. First, we'll create a Pizza Class
1class Pizza
2 attr_accessor :size, :cheese, :sauce, :toppings
3
4 def describe
5 puts "Size: #{@size}"
6 puts "Cheese: #{@cheese}"
7 puts "Sauce: #{@sauce}"
8 puts "Toppings: #{@toppings.join(', ')}"
9 end
10
11end
Next, we'll create specialized builders for different types of pizzas, each inheriting from the base builder.
1class BasePizzaBuilder
2 def initialize
3 @pizza = Pizza.new
4 end
5
6 def build
7 raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
8 end
9end
Now, we'll create the BasePizzaBuilder class, which will serve as a placeholder for potential future extensions.
1class SpecialPizzaBuilder < BasePizzaBuilder
2 def build
3 @pizza.size = 'Large'
4 @pizza.cheese = 'Mozzarella'
5 @pizza.sauce = 'Pesto'
6 @pizza.toppings = %w[Tomatoes Basil]
7 @pizza
8 end
9end
10
11
12class VegetarianPizzaBuilder < BasePizzaBuilder
13 def build
14 @pizza.size = 'Medium'
15 @pizza.cheese = 'Feta'
16 @pizza.sauce = 'Marinara'
17 @pizza.toppings = %w[Spinach Olives]
18 @pizza
19 end
20end
Finally, we'll create the Director, which will coordinate the construction process using a specific builder.
1# Optional Step: Create a Director (Optional)
2# The Director is optional in the Builder Pattern. It's useful when there's a need to
3# coordinate the construction process of complex objects. If the construction logic
4# is straightforward and doesn't require any specific coordination, you can omit the
5# Director and directly use the builder to construct the object.
6class PizzaDirector
7 def initialize(builder)
8 @builder = builder
9 end
10
11 def build_pizza
12 @builder.build
13 end
14end
15# The role of the Director is similar to that of a waiter in a restaurant.
16# It receives the order (in our case, from the builder) and passes it on to the chef.
Usage
1# Usage
2special_pizza_builder = SpecialPizzaBuilder.new
3vegetarian_pizza_builder = VegetarianPizzaBuilder.new
4
5director = PizzaDirector.new(special_pizza_builder)
6special_pizza = director.build_pizza
7special_pizza.describe
8
9director = PizzaDirector.new(vegetarian_pizza_builder)
10vegetarian_pizza = director.build_pizza
11vegetarian_pizza.describe
We can also do it without director class here is an example -
1vegetarian_pizza2_builder = VegetarianPizzaBuilder.new
2vegetarian_pizza2 = vegetarian_pizza2_builder.build
3vegetarian_pizza2.describe
Another way of doing it
We can do it another way as well. Unlike the traditional approach that involves creating a base builder class, we've opted for a simpler alternative. In this alternative, we focus on a single class: PizzaBuilder. This class takes on the responsibility of assembling pizzas. It provides straightforward methods for specifying the pizza's size, adding cheese, sauce, and toppings.
To enhance flexibility, we've introduced a special feature - the self.build method. This method accepts a block of code, allowing you to customize the pizza's attributes on-the-fly. This approach eliminates the need for a separate base builder class, making the process more streamlined and intuitive.
In practice, this method proves to be highly adaptable and user-friendly. It grants developers the ability to craft pizzas with precision, tailoring them to specific preferences. This approach can be extended to various scenarios involving complex object creation, offering a clean and efficient solution.
1class Pizza
2 attr_accessor :size, :cheese, :sauce, :toppings
3
4 def describe
5 puts "Size: #{@size}"
6 puts "Cheese: #{@cheese}"
7 puts "Sauce: #{@sauce}"
8 puts "Toppings: #{@toppings.join(', ')}"
9 end
10end
11
12class PizzaBuilder
13 def initialize
14 @pizza = Pizza.new
15 end
16
17 def set_size(size)
18 @pizza.size = size
19 end
20
21 def add_cheese(cheese)
22 @pizza.cheese = cheese
23 end
24
25 def add_sauce(sauce)
26 @pizza.sauce = sauce
27 end
28
29 def add_toppings(toppings)
30 @pizza.toppings = [] if @pizza.toppings.nil?
31 @pizza.toppings << toppings
32 end
33
34 def pizza
35 @pizza
36 end
37
38 def self.build
39 builder = new
40 yield(builder)
41 builder.pizza
42 end
43end
44
45pizza_builder = PizzaBuilder.new
46pizza_builder.set_size("small")
47pizza_builder.add_cheese("swiss")
48pizza_builder.add_sauce("tomato")
49pizza_builder.add_toppings("mushrooms")
50pizza = pizza_builder.pizza
51pizza.describe
52
53pizza2 = PizzaBuilder.build do |b|
54 b.set_size("large")
55 b.add_cheese("swiss")
56 b.add_sauce("tomato")
57 b.add_toppings("mushrooms")
58 b.add_toppings("peppers")
59end
60pizza2.describe
Conclusion
In coding, building complex objects with many choices can be tricky. The Builder pattern steps in to make this easier. It helps us create these objects bit by bit, giving us a clear way to organize our code. In this article, we explored how to use the Builder pattern in Ruby on Rails. We learned its benefits and when it's best to use it in our own code.
The Builder pattern is like having a special assistant just for making things. It separates the job of putting together an object from how it looks. This means we can create objects with lots of options without getting lost, and it ensures they're made just right. It's a handy tool in our coding toolkit!