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",
};
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);
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);
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) };
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...
}
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:
- Returning
null
- a dangerous option, it's called the "billion-dollar mistake" by Tony Hoare for a reason. - Throwing an exception - terrible choice for a predictable validation failure.
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.
}
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);
}
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,
...)
{
// ...
}
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();
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();
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();
}
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);
}
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);
// ...
}
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)