Builder Lite

In this short post, I describe and name a cousin of the builder pattern — builder lite.

Unlike a traditional builder, which uses a separate builder object, builder lite re-uses the object itself to provide builder functionality.

Here’s an illustrative example

Builder Lite
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub struct Shape {
  position: Vec3,
  geometry: Geometry,
  material: Option<Material>,
}

impl Shape {
  pub fn new(geometry: Geometry) -> Shape {
    Shape {
      position: Vec3::default(),
      geometry,
      material: None,
    }
  }

  pub fn with_position(mut self, position: Vec3) -> Shape {
    self.position = position;
    self
  }

  pub fn with_material(mut self, material: Material) -> Shape {
    self.material = Some(material);
    self
  }
}

// Call site

let shape = Shape::new(Geometry::Sphere::with_radius(1))
  .with_position(Vec3(0, 9, 2))
  .with_material(Material::SolidColor(Color::Red));

In contrast, the full builder is significantly wordier at the definition site, and requires a couple of extra invocations at the call site:

Builder
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub struct Shape {
  position: Vec3,
  geometry: Geometry,
  material: Option<Material>,
}

pub struct ShapeBuilder {
  position: Option<Vec3>,
  geometry: Option<Geometry>,
  texture: Option<Texture>,
}

impl Shape {
  pub fn builder() -> ShapeBuilder { ... }
}

impl ShapeBuilder {
  pub fn position(&mut self, position: Vec3) -> &mut Self { ... }
  pub fn geometry(&mut self, geometry: Geometry) -> &mut Self { ... }
  pub fn material(&mut self, material: Material) -> &mut Self { ... }
  pub fn build(&self) -> Shape { ... }
}

// Call site

let shape = Shape::builder()
  .position(Vec3(9, 2))
  .geometry(Geometry::Sphere::with_radius(1))
  .material(Material::SolidColor(Color::Red))
  .build();

The primary benefit of builder-lite is that it is an incremental, zero-cost evolution from the new method. As such, it is especially useful in the context where the code evolves rapidly, in an uncertain direction. That is, when building applications rather than library.

To pull a motivational example from work, we had the following typical code:

1
2
3
4
5
6
7
8
impl PeerManagerActor {
  pub fn new(
    store: Store,
    config: NetworkConfig,
    client_addr: Recipient<NetworkClientMessages>,
    view_client_addr: Recipient<NetworkViewClientMessages>,
    routing_table_addr: Addr<RoutingTableActor>,
  ) -> anyhow::Result<Self> {

Here’s a new method with a whole bunch of arguments for various dependencies. What we needed to do is to add yet another dependency, so that it could be overwritten in tests. The first attempt just added one more parameter to the new method:

1
2
3
4
5
6
7
8
  pub fn new(
    store: Store,
    config: NetworkConfig,
    client_addr: Recipient<NetworkClientMessages>,
    view_client_addr: Recipient<NetworkViewClientMessages>,
    routing_table_addr: Addr<RoutingTableActor>,
+   ping_counter: Box<dyn PingCounter>,
  ) -> anyhow::Result<Self> {

However, this change required update of the seven call-sites where the new was called to supply the default counter. Switching that to builder lite allowed us to only modify a single call-site where we cared to override the counter.

A note on naming:

If builder methods are to be used only occasionally, with_foo is the best naming. If most call-sites make use of builder methods, just .foo might work better. For boolean properties, sometimes it makes sense to have both:

1
2
3
4
5
6
7
8
pub fn fancy(mut self) -> Self {
  self.with_fancy(true)
}

pub fn with_fancy(mut self, yes: bool) -> Self {
  self.fancy = yes;
  self
}

Discussion on /r/rust.