Creational Design Patterns for Dart and Flutter: Builder
The purpose of the Builder pattern is to separate the construction of a complex object from its representation. This is especially useful for objects with many properties, some of which may have obvious defaults, and some of which may be optional. Early object-oriented languages didn't have syntax for making this situation easy to manage, but Dart, along with most other newer languages, has since added features that make the Builder pattern less advantageous. Even so, it's worth examining both the classic pattern and the syntax that can make it unnecessary.
About creational design patterns: These patterns, as the name implies, help us with the creation of objects and processes related to creating objects. With these techniques in your arsenal, you can code faster, create more flexible, reusable object templates, and sculpt a universally recognizable structure for your projects. The creational design patterns are blueprints you can follow to reliably tackle object creation issues that arise for many software projects.
In this article, we'll first learn what the Builder pattern is and why it can help in some object-oriented languages, then we'll see how Dart can make use of a simpler form of the pattern to solve the same problems. Knowing how to make great data models helps tremendously when constructing Flutter applications, too.
The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.
A typical Builder
One of the most common examples for demonstrating the Builder pattern involves constructing pizza data models. There can be a staggering number of variables when ordering a pizza, and in older OOP languages, that can make the model class awkward to work with. As with most data models, a best practice is to keep properties immutable wherever possible, and this can be difficult in situations where a user may be constructing the model bit by bit.
To save space here, our pizza model won't have nearly as many customizable properties as a real-world model might need, and it will still present some clear challenges. Imagine double or triple the number of properties, and you'll see how useful it can be to separate the build process from the final representation.
First, let's define a few enum
types:
enum PizzaSize {
S,
M,
L,
XL,
}
enum PizzaSauce {
none,
marinara,
garlic,
}
enum PizzaCrust {
classic,
deepDish,
}
Defining a discrete set of legal values for model properties is a great way to reduce bugs resulting from invalid values and can make the code more self-documenting. That's a fancy way of saying that using an enum
for many settings is superior to using generic strings or numbers.
The pizza model might start out something like this:
class Pizza {
PizzaSize _size;
PizzaCrust _crust;
PizzaSauce _sauce;
List<String> _toppings;
bool _hasExtraCheese;
bool _hasDoubleMeat;
String _notes;
}
To protect the properties from outside interference, they're all private, which means only code within the library can access them. We'll need to set them either in constructors or setters. Languages of the past didn't have some of Dart's nicer features, such as implicit getters and setters, optional named and positional parameters, and default argument values. Without those things, constructing complex models can be inelegant.
Imagine Pizza has a default constructor that takes each property in order. Invocation might look like the following:
final pizza = Pizza(
PizzaSize.M,
PizzaCrust.classic,
PizzaSauce.marinara,
['pepperoni, olives'],
false,
false,
null,
);
The enumerated values and our formatting help, but there's still some awkwardness here. Positional constructor parameters require values to be passed, even when we don't need them. What if there were no toppings? We'd have to pass an empty list or null
for that argument. Also, there's no easy way to tell what the boolean arguments are affecting. This pizza needs no special notes, so that gets a null
argument too, adding another useless line of code.
And what if you were trying to store user choices in the model as selections were being made? After a size was selected, you'd have to create a Pizza and pass null
for all but the first parameter. This is not ideal. You can add setter methods for every property, but that would be no better than making them all public, exposed to accidental modification by outside code. You could create lots of different constructors, each taking some property values and not others, but the number of permutations required is staggering with even this modest model and increases exponentially with each new option.
One way to solve these problems is to use a builder class. At its core, this is a mutable version of the model that can be updated incrementally and from which a final model can be constructed. Let's look at how that might change the approach:
class PizzaBuilder {
PizzaSize size;
PizzaCrust crust;
PizzaSauce sauce;
List<String> toppings;
bool hasExtraCheese;
bool hasDoubleMeat;
String notes;
}
class Pizza {
final PizzaSize size;
final PizzaCrust crust;
final PizzaSauce sauce;
final List<String> toppings;
final bool hasExtraCheese;
final bool hasDoubleMeat;
final String notes;
Pizza(PizzaBuilder builder) :
size = builder.size,
crust = builder.crust,
sauce = builder.sauce,
toppings = builder.toppings,
hasExtraCheese = builder.hasExtraCheese,
hasDoubleMeat = builder.hasDoubleMeat,
notes = builder.notes;
}
Now the pizza model is immutable, which is nice, and there's a mutable builder class we can use to put together the final pizza piecemeal. Pizza has only one constructor, and it accepts an instance of PizzaBuilder. To build a pizza, we can avail ourselves of the flexibility inherent in Dart's cascade operator:
final builder = PizzaBuilder()
..size = PizzaSize.M
..crust = PizzaCrust.classic
..sauce = PizzaSauce.marinara
..toppings = ['pepperoni, olives']
..hasExtraCheese = false
..hasDoubleMeat = false;
final pizza = Pizza(builder);
Since PizzaBuilder has public, mutable properties, it can be built up one property at a time, or with any combination of arguments we have at hand. Any properties we don't set will default to null
, so they don't have to be explicitly assigned. The properties can be modified as a user makes selections, and we still end up with a protected, immutable Pizza. This is an improvement, but the cost is a lot of boilerplate code, and now we must keep the builder and model in sync when any alterations are made.
Without certain modern syntax features, the costs of using the Builder pattern seemed worth paying, but maybe not so much anymore. So, what is a more current best practice for managing a complex model?
Building models the Dart way
To get the best of all worlds, you can use the immutability patterns discussed at length in Immutable Data Patterns in Dart and Flutter. Here's what using those techniques might look like with our pizza example:
class Pizza {
final PizzaSize size;
final PizzaCrust crust;
final PizzaSauce sauce;
final List<String> toppings;
final bool hasExtraCheese;
final bool hasDoubleMeat;
final String notes;
Pizza({
this.size,
this.crust,
this.sauce,
this.toppings,
this.hasExtraCheese = false,
this.hasDoubleMeat = false,
this.notes
});
Pizza copyWith({
PizzaSize size,
PizzaCrust crust,
PizzaSauce sauce,
List<String> toppings,
bool hasExtraCheese,
bool hasDoubleMeat,
String notes
}) {
return Pizza(
size: size ?? this.size,
crust: crust ?? this.crust,
sauce: sauce ?? this.sauce,
toppings: toppings ?? this.toppings,
hasExtraCheese: hasExtraCheese ?? this.hasExtraCheese,
hasDoubleMeat: hasDoubleMeat ?? this.hasDoubleMeat,
notes: notes ?? this.notes
);
}
}
All the properties in this model are marked final
, which keeps them from being unexpectedly modified, but the copyWith()
method enables you to make immutable copies of the model, only modifying values that are passed into the method. You still need to keep the properties and copyWith()
parameters in sync, but at least there's not a separate builder class to maintain. Named parameters help keep invocations readable and let us define sensible default values, model instances are immutable, and this version of the model has a builder included as part of its design, with no loss of the flexibility the Builder pattern exists to provide.
Conclusion
We've just seen that the Builder pattern can be used to keep the details of constructing an object separated from its final representation. To read more about creational design patterns in Dart, check out these related articles: