DEV Community

Guillaume Faas
Guillaume Faas

Posted on

Why I use (and abuse) the Builder pattern

When presenting my talk about Monads, one question kept coming up: "Why do you rely so much on the Builder pattern?".

I didn't implement that pattern just because it looks cool. I've observed recurring problems, and solving them led me to that particular solution.

So, when people see one of my builders, they often don't see what led to this particular implementation.

Let's answer that one today.

Disclaimer: This post is highly personal and opinionated. It's based on my experience, observations and preferences. It's not a one-size-fits-all solution, nor the only way to solve the problems I'm going to present. It's just the way I do it.

How we got here

We've all, at some point, instantiated an object like this:

public class Food
{
    public string Name { get; set; }
    public DateTime ExpirationDate { get; set; }
    public string Description { get; set; }
}

var apple = new Food
{
    Name = "Apple",
    ExpirationDate = DateTime.Now.AddDays(7),
    Description = "An apple",
};
Enter fullscreen mode Exit fullscreen mode

It's simple, clean, efficient and works... except, I see a multitude of issues. I won't even talk about Food being an anemic model (pure DTO style) without any proper behavior and absolutely no encapsulation. This is for another time.

Problem #1: Mutability

Our Food class is mutable - we can change its properties anytime:

var apple = new Food
{
    Name = "Apple",
    ExpirationDate = DateTime.Now.AddDays(7),
    Description = "An apple",
};
apple.ExpirationDate = DateTime.Now.AddDays(14);
Enter fullscreen mode Exit fullscreen mode

Mutability seems useful, but it's a ticking bomb. Changing an object's state introduces complexity, and you'll find yourself constantly evaluating the state of such object.

Immutability, on the other hand, ensures an object cannot be changed after its creation, leading to benefits like data integrity, thread safety, and a limitation of side effects.

We have options to make our Food class immutable, such as using readonly/init-only properties or transforming it into a record.

// Init-only properties
public class Food
{
    public string Name { get; init; }
    public DateTime ExpirationDate { get; init; }
    public string Description { get; init; }
}

// Record
public record Food(string Name, DateTime ExpirationDate, string Description);
Enter fullscreen mode Exit fullscreen mode

A question often arises at this stage: "What if we need to change the state of an immutable object?". Simple, you don't - you create a new object with a new state:

var freshApple = apple with { ExpirationDate = DateTime.Now.AddDays(14) };
Enter fullscreen mode Exit fullscreen mode

Happy yet? Not really. We fixed one problem, but others remain.

Problem #2: Parsing

Making our class immutable already simplifies validation: since the object can’t change, validation occurs only once.

I follow the "Parse, don't Validate" approach which ensures only valid objects can be created. Parsing is about transforming a less structured input into a more structured output - in our case, turning {string, DateTime, string} into a Food object.

However, validation with auto-properties is tricky. One option is to define the validation as a lambda inside each init property with an explicit backing fields - but that feels clunky, especially since properties aren't mandatory, meaning you can't enforce required field. So... you'll have to validate post-creation, and we're back to square one. Another option is to enforce the use of a constructor, which record types already do.

For example, we don't want a Food instance to be created with past expiration dates:

var apple = new Food("Apple", DateTime.Now.AddDays(-10), string.Empty };

public record Food(string Name, DateTime ExpirationDate, string Description)
{
     // Validation logic...
}
Enter fullscreen mode Exit fullscreen mode

But here's the problem: A valid input will produce a valid Food object - what will an invalid input produce?

With constructors, we have no control over the return type - the constructor must return a Food instance. This leaves two possible options:

Neither is great.

A better approach is to rely on a static factory method as the single point of entry to create a Food object, giving us control over the return type.

var apple = Food.Create("Apple", DateTime.Now.AddDays(-10), string.Empty);

public static Food Create(string name, DateTime expirationDate, string description)
{
    // Validation logic.
}
Enter fullscreen mode Exit fullscreen mode

One important thing to note: we have to rule out the record type. Even with a private constructor, with expressions can bypass our single point of entry.

I put code transparency and predictability quite high in my priorities, which is why I would favor Monads for this (see my "The Monad Invasion" series).

var apple = Food.Create("Apple", DateTime.Now.AddDays(-10), string.Empty);

public record Error(string Reason);
public static Either<Error, Food> Create(string name, DateTime expirationDate, string description)
{
    if (string.IsNullOrEmpty(name))
    {
        return new Error("Name cannot be empty");
    }

    if (expirationDate < DateTime.Now)
    {
        return new Error("Expiration date cannot be in the past");
    }

    return new Food(name, expirationDate, description);
}
Enter fullscreen mode Exit fullscreen mode

The intent is clear, and the outcome is transparent: creating a Food instance can fail.

Problem #3: Too many parameters

Our Food class currently has three properties. What if it had 15? 20? 50?

public static Food Create(string name, 
    DateTime expirationDate, 
    string description,
    string category,
    decimal weight,
    decimal price,
    ...)
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Our static factory method solved a problem, but it struggles as the number of parameters grows. IDEs often provide warnings against constructors with more than 5 parameters, and for good reason. Methods with many parameters or multiple overloads are a nightmare to maintain.

We need a better way to create complex objects while keeping the benefits we've gained so far.

This is where the Builder pattern offers an interesting solution.

var apple = Food.Builder()
    .WithName("Apple")
    .WithExpirationDate(DateTime.Now.AddDays(7))
    .WithDescription("An apple")
    .Build();
Enter fullscreen mode Exit fullscreen mode

Each method takes one parameter, and Build handles validation before creating the object.

The Builder ticks all the boxes

  • Immutability - the builder acts as a factory, and is able to create immutable objects.
  • Validation - the builder can validate the object when we want to create it.
  • Control over validation failure - the builder is able to return a predictable result when validation fails.
  • Scalability - the builder handles a large number of parameters without becoming a mess.

A well-designed Builder pattern also helps guide users. By chaining methods, developers naturally discover what fields are required and what comes next - your IDE will show it for you.

For example, our API ensures Name and ExpirationDate are mandatory, while Description is optional (can be null or empty).

var apple = Food.Builder()
    // Only option is .WithName
    .WithName("Apple")
    // Only option is .WithExpirationDate
    .WithExpirationDate(DateTime.Now.AddDays(7))
    // User has two options:
    // Optional properties like .WithDescription
    // Or .Build() to create the object
    .WithDescription("An apple")
    // User has two options:
    // Other optional properties
    // Or .Build() to create the object
    .Build();
Enter fullscreen mode Exit fullscreen mode

In open-source software, making code self-explanatory is a game-changer.

Downsides

If Builders were free, everybody would use them. Sadly, they're not and require a fair amount of boilerplate. Your builder needs a class, a method for each property, and interfaces to guide the user - not to mention that you should also test your code.

Overall, we're talking about 50+ extra lines of code for an object like Food. This is one. simple. object.

Libraries like FluentBuilder generate builders via SourceGenerators, helping a lot with the boilerplate, but we're losing the flexibility for custom scenarios.

While Builders solve many problems, they aren't always the best choice. If your object is simple and rarely changes, a factory method might be sufficient. On my end, I prefer to start with a factory method and switch to a Builder when the object becomes more complex.

Another downside is that validation gets separated from the object itself - data is on the object, while validation is on the Buidler. This can potentially lead to encapsulation issues (See Tell, Don't Ask). My advice: keep them close to maintain strong cohesion and use the internal keyword to expose only what's necessary. That being said, I've never encountered a scenario where it was a critical problem.

Example

Here's an actual implementation from my current codebase, the Vonage .NET SDK.

This is the initial object we're willing to create.

public readonly struct CheckRequest : IVonageRequest
{
    // Immutable properties
    public PhoneNumber PhoneNumber { get; internal init; }
    public int Period { get; internal init; }
    // The static Build() method exposes the builder
    public static IBuilderForPhoneNumber Build() => new CheckRequestBuilder();
}
Enter fullscreen mode Exit fullscreen mode

Source: CheckRequest

This is the builder itself. We're using interfaces to direct what the user can do: the phone number is mandatory, the period is optional (with a default value).

There are two possible paths here:

  • PhoneNumber (mandatory) -> Build
  • PhoneNumber (mandatory) -> Period (optional) -> Build
internal struct CheckRequestBuilder : IBuilderForPhoneNumber, IBuilderForOptional
{
    private const int DefaultPeriod = 240;
    private const int MaximumPeriod = 2400;
    private const int MinimumPeriod = 1;
    private int period = DefaultPeriod;
    private string number = default;

    public Result<CheckRequest> Create() =>
        Result<CheckRequest>.FromSuccess(new CheckRequest
            {
                Period = this.period,
            }).Merge(PhoneNumber.Parse(this.number), (request, validNumber) => request with {PhoneNumber = validNumber})
            .Map(InputEvaluation<CheckRequest>.Evaluate)
            .Bind(evaluation => evaluation.WithRules(VerifyAgeMinimumPeriod, VerifyMaximumPeriod));

    // Assign value on the builder
    public IVonageRequestBuilder<CheckRequest> WithPeriod(int value) => this with {period = value};
    // Assign value on the builder
    public IBuilderForOptional WithPhoneNumber(string value) => this with {number = value};

    // Validation
    private static Result<CheckRequest> VerifyAgeMinimumPeriod(CheckRequest request) =>
        InputValidation.VerifyHigherOrEqualThan(request, request.Period, MinimumPeriod, nameof(request.Period));
    // Validation
    private static Result<CheckRequest> VerifyMaximumPeriod(CheckRequest request) =>
        InputValidation.VerifyLowerOrEqualThan(request, request.Period, MaximumPeriod, nameof(request.Period));
}

public interface IBuilderForPhoneNumber
{
    // Will return the next step in the builder, for optional values or to build the object
    IBuilderForOptional WithPhoneNumber(string value);
}

public interface IBuilderForOptional : IVonageRequestBuilder<CheckRequest>
{
    // Will return the final step in the builder to build the object
    IVonageRequestBuilder<CheckRequest> WithPeriod(int value);
}
Enter fullscreen mode Exit fullscreen mode

Source: CheckRequestBuilder

This is a usage example, from the test suite.

[Trait("Category", "Request")]
public class RequestBuilderTest
{
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    [InlineData(null)]
    public void Build_ShouldReturnFailure_GivenNumberIsNullOrWhitespace(string value) =>
        CheckRequest.Build()
            .WithPhoneNumber(value)
            .Create()
            .Should()
            .BeFailure(ResultFailure.FromErrorMessage("Number cannot be null or whitespace."));

    // ...

    [Theory]
    [InlineData(1)]
    [InlineData(2400)]
    public void Build_ShouldSetPeriod(int value) =>
        CheckRequest.Build()
            .WithPhoneNumber("1234567")
            .WithPeriod(value)
            .Create()
            .Map(number => number.Period)
            .Should()
            .BeSuccess(value);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Source: RequestBuilderTest

Wrapping Up

Each problem I solved narrowed down the pool of possible solutions, and I landed on Builders because they address issues I consider important. You may disagree, and that's totally fine.

I hope you learned something during that post, and I'm curious to know your thoughts on the subject.

Until next time, cheers!

Top comments (0)