Software Design Pattern: the Builder

Say goodbye to positional parameters and long signatures with the builder pattern!

The Builder Pattern!

Have you ever seen a constructor that looked something like this new Employee(firstName, lastName, date, pay, frequency, department, title, org, location, international, timezone, can, the, signature, of, this, constructor, get, any, longer)? Although, this is a little facetious, it is not uncommon to see method signatures that are way too long and become somewhat complex to manage. The builder pattern is a great design pattern to address issues like this!

Why the builder pattern?

Advantages

Positional parameters in a constructor can be a mess to manage and can lead to unintended errors very easily, especially during a refactor. In the example above, what if you changed the department to a different requirement? It would be easy to miss the change in a refactor and really, without diving deeper into the class constructor's parameters, you might miss what the positional parameters are. The builder helps with all of this by allowing you to be more explicit with the parameters of the object that is to be built.

The builder also encapsulates the construction code and provides the ability to control the order of the parameters. Complex objects are complex 😉. But if you use a builder, you can simplify the construction process, make your code a little bit more readable, and give the object creator the freedom to build the objects as they need to.

Disadvantages

A drawback of the builder is a builder class is required for each object that you are trying to build. This can lead to more code and potentially additional complexity. But the flexibility and readability that you get from using the builder can outweigh the cons if employed correctly.

Another potential drawback is that by using the builder, there can be less support for automated or full dependency injection although this should be looked at case by case.

TypeScript Implementation

We are going to build a PizzaBuilder for our Pizza class, so that creating each Pizza is more simple, readable, and gives us flexibility in the creation. Let's start with our Pizza class along with a few types.

type Size = 10 | 12 | 14 | 16;
type DoughType = "normal" | "thin crust";

class Pizza {
    private _cheese: string;
    private _meat: string;
    private _doughType: DoughType;
    private _vegetable: string;
    private _size: Size;

    constructor(pizzaBuilder: PizzaBuilder) {
        this._cheese = pizzaBuilder.cheese;
        this._meat = pizzaBuilder.meat;
        this._doughType = pizzaBuilder.doughType;
        this._vegetable = pizzaBuilder.vegetable;
        this._size = pizzaBuilder.size;
    }

    get cheese(): string {
        return this._cheese;
    }
    get meat(): string {
        return this._meat;
    }
    get doughType(): DoughType {
        return this._doughType;
    }
    get vegetable(): string {
        return this._vegetable;
    }
    get size(): Size {
        return this._size;
    }
}

Now let's add our PizzaBuilder!

class PizzaBuilder {
    private _cheese: string = "";
    private _meat: string = "";
    private _doughType: DoughType = "normal";
    private _vegetable: string = "";
    private _size: Size

    constructor(size: Size) {
        this._size = size;
    }

    get cheese(): string {
        return this._cheese;
    }
    withCheese(cheese: string): PizzaBuilder {
        this._cheese = cheese;
        return this;
    }

    get meat(): string {
        return this._meat;
    }
    withMeat(meat: string): PizzaBuilder {
        this._meat = meat;
        return this;
    }

    get doughType(): DoughType {
        return this._doughType;
    }
    setDoughType(type: DoughType): PizzaBuilder {
        this._doughType = type;
        return this;
    }

    get vegetable(): string {
        return this._vegetable;
    }
    withVegetable(veg: string): PizzaBuilder {
        this._vegetable = veg;
        return this;
    }

    get size(): Size {
        return this._size;
    }

    build(): Pizza {
        return new Pizza(this);
    }
}

And with our PizzaBuilder ready, we can run our demo!

function runBuilderDemo(): void {
    let pepPizza: Pizza = new PizzaBuilder(12)
        .withCheese("mozzarella")
        .withMeat("pepperoni")
        .build();

    let cheesePizza: Pizza = new PizzaBuilder(16)
        .withCheese("mozzarella")
        .build();

    let comboPizza: Pizza = new PizzaBuilder(14)
        .withCheese("mozzarella")
        .withMeat("sausage")
        .withVegetable("onion")
        .setDoughType("thin crust")
        .build();

    [pepPizza, cheesePizza, comboPizza].forEach(pizza => {
        let toppings = [pizza.cheese, pizza.meat, pizza.vegetable].filter(
            topping => topping
        );

        console.log(
            `*** One ${pizza.size}" ${
                pizza.doughType === "thin crust" ? pizza.doughType + " " : ""
            }Pizza! ***`
        );
        console.log(`Toppings: ${toppings.join(", ")}`);
        console.log();
    });
}

runBuilderDemo();

Sweet! Look how easy it is to read over our intent in the last code block!

Check out the full demo here: The Builder. To try it out, simply run the typescript compiler tsc then run node builder.js from the console.

Conclusion

When you are seeing complexity in your projects, especially around the construction of objects, then seriously consider the builder pattern. It is a great way to give flexibility, freedom, and readability to your code!


Want to be notified about new posts?

Then sign up to get posts directly to your email. You can opt-out at any time.

Drake Loud - © 2024