Step builder pattern to ensure correctness and ease of testing

Tomas M
4 min readMay 28, 2023

--

Everybody knows builder pattern. It helps us to manage complexity when building large and sophisticated objects. But what if at some point builder becomes complex itself? Large, having multiple dependencies and cumbersome to test. It is best explained with an example, beginning with models that participate:

class Address
{
// ...
}

class User
{
public Address Address { get; set; }
// ...
}

class Item
{
public int Id { get; set; }
public Order Order { get; set; }
// ...
}

class Order
{
public int Id { get; set; }
public User Owner { get; set; }
public Address ShippingAddress { get; set; }
public ICollection<Item> Items { get; set; }
// ...
}

Regular builder to construct order might be implemented like so:

class OrderBuilder
{
private Order order;

public static OrderBuilder Create() => new OrderBuilder();

public OrderBuilder()
{
order = new Order();
order.Items = new List<Item>();
}

public OrderBuilder OwnedBy(User user)
{
order.Owner = user;

return this;
}

public OrderBuilder ShipTo(Address address)
{
order.ShippingAddress = address;

return this;
}

public OrderBuilder AddItem(Item item)
{
item.Order = order;
order.Items.Add(item);

return this;
}

public Order Build()
{
if (order.Owner == null)
{
throw new InvalidOperationException("Order must have an owner");
}

if (order.Items.Count == 0)
{
throw new InvalidOperationException("Order must have at least one item");
}

if (order.ShippingAddress == null)
{
order.ShippingAddress = order.Owner.Address;
}

return order;
}
}

Right away some issues are obvious:

  • Methods call order is not enforced and the final Build() call may throw.
  • Cumbersome testing. To make assertions on the final product many methods must be called.

With the given example, both issues may seem not so bad (method call order is set on compile time so just make sure it is correct before going prod, and for testing execution paths count is not that high), but what if the type being built has much more properties, more interdependence and what if a builder is exposed to clients of a library. With scale, complexity grows.

Step builder pattern

Step Builder Patter (example in java), for which info is quite scarce, is a lesser-known, more strict variant of the builder pattern. At its core, a set of interfaces should be defined, each representing a step (or a set of cohesive steps) as interface methods, those methods must return the next step’s interface instead of an all-in-one builder. Using it previous builder implementation can be rewritten to make sure that the final product is in the correct state and get rid of exception throwing:

interface IOwnerBuilderStep
{
IShippingAddressBuilderStep OwnedBy(User user);
}

interface IShippingAddressBuilderStep
{
IItemsBuilderStep ShipTo(Address address);
IItemsBuilderStep UseOwnerAddressAsShipping();
}

interface IItemsBuilderStep
{
IBuildBuilderStep AddItem(Item item);
}

interface IBuildBuilderStep : IItemsBuilderStep
{
Order Build();
}

class OrderBuilder :
IOwnerBuilderStep,
IShippingAddressBuilderStep,
IItemsBuilderStep,
IBuildBuilderStep
{
private Order order;

public static IOwnerBuilderStep Create() => new OrderBuilder();

private OrderBuilder()
{
order = new Order();
order.Items = new List<Item>();
}

IShippingAddressBuilderStep IOwnerBuilderStep.OwnedBy(User user)
{
order.Owner = user;

return this;
}

IItemsBuilderStep IShippingAddressBuilderStep.ShipTo(Address address)
{
order.ShippingAddress = address;

return this;
}

IItemsBuilderStep IShippingAddressBuilderStep.UseOwnerAddressAsShipping()
{
order.ShippingAddress = order.Owner.Address;

return this;
}

IBuildBuilderStep IItemsBuilderStep.AddItem(Item item)
{
item.Order = order;
order.Items.Add(item);

return this;
}

public Order Build()
{
return order;
}
}

Using this implementation limits the number of methods intellisense suggests and Build , for example, is available only at the very end, when all required info has been provided:

At this point issue with ensuring the call order is solved, however, it did not help with testing, yet.

Divide and conquer (or test in this case)

It is easier to test smaller code modules than large ones, so lets create a class for each building step interface:

static class OrderBuilder
{
public static IOwnerBuilderStep Create() => new OwnerBuilderStep(new Order());

class OwnerBuilderStep : IOwnerBuilderStep
{
private readonly Order order;

public OwnerBuilderStep(Order order)
{
this.order = order;
}

public IShippingAddressBuilderStep OwnedBy(User user)
{
order.Owner = user;

return new ShippingAddressBuilderStep(order);
}
}

class ShippingAddressBuilderStep : IShippingAddressBuilderStep
{
private readonly Order order;

public ShippingAddressBuilderStep(Order order)
{
this.order = order;
}

public IItemsBuilderStep ShipTo(Address address)
{
order.ShippingAddress = address;

return new ItemsBuilderStep(order);
}

public IItemsBuilderStep UseOwnerAddressAsShipping()
{
order.ShippingAddress = order.Owner.Address;

return new ItemsBuilderStep(order);
}
}

class ItemsBuilderStep : IItemsBuilderStep
{
private readonly Order order;

public ItemsBuilderStep(Order order)
{
this.order = order;
this.order.Items = new List<Item>();
}

public IBuildBuilderStep AddItem(Item item)
{
item.Order = order;
order.Items.Add(item);

return new BuildBuilderStep(this, order);
}
}

class BuildBuilderStep : IBuildBuilderStep
{
private readonly IItemsBuilderStep itemsBuilderStep;
private readonly Order order;

public BuildBuilderStep(IItemsBuilderStep itemsBuilderStep, Order order)
{
this.itemsBuilderStep = itemsBuilderStep;
this.order = order;
}

public IBuildBuilderStep AddItem(Item item)
{
itemsBuilderStep.AddItem(item);

return this;
}

public Order Build()
{
return order;
}
}
}

Now with step builders exposed to a tests project, all can be tested individually. For example, to test AddItem, owner and shipping steps can be disregarded. Each step class still has knowledge about what comes next but amount of variables is reduced.

To sum up, this pattern gives us the following pros and cons:

Pros:

  • Ensures order;
  • Makes sure that some steps happen exactly once while still allowing others to be called as many times as necessary;
  • Makes sure that a mandatory set of information is provided before building the actual product;
  • Easier testing with less mocking and potentially single method call under test.

Cons:

  • More boilerplate;
  • No way back after moving to the next step (or it has to be implemented each time like with AddItem);
  • To the unsuspecting user, it could be surprising that available methods change with each call.

--

--