Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Guillaume Faas
Guillaume Faas

Posted on • Edited 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 theBuilder 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:

publicclassFood{publicstringName{get;set;}publicDateTimeExpirationDate{get;set;}publicstringDescription{get;set;}}varapple=newFood{Name="Apple",ExpirationDate=DateTime.Now.AddDays(7),Description="An apple",};
Enter fullscreen modeExit fullscreen mode

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

Problem #1: Mutability

OurFood class is mutable - we can change its properties anytime:

varapple=newFood{Name="Apple",ExpirationDate=DateTime.Now.AddDays(7),Description="An apple",};apple.ExpirationDate=DateTime.Now.AddDays(14);
Enter fullscreen modeExit 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 objectcannot be changed after its creation, leading to benefits like data integrity, thread safety, and a limitation of side effects.

We have options to make ourFood class immutable, such as usingreadonly/init-only properties or transforming it into arecord.

// Init-only propertiespublicclassFood{publicstringName{get;init;}publicDateTimeExpirationDate{get;init;}publicstringDescription{get;init;}}// RecordpublicrecordFood(stringName,DateTimeExpirationDate,stringDescription);
Enter fullscreen modeExit 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:

varfreshApple=applewith{ExpirationDate=DateTime.Now.AddDays(14)};
Enter fullscreen modeExit 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 aFood object.

However, validation with auto-properties is tricky. One option is to define the validation as a lambda inside eachinit 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, whichrecord types already do.

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

varapple=newFood("Apple",DateTime.Now.AddDays(-10),string.Empty};publicrecordFood(stringName,DateTimeExpirationDate,stringDescription){// Validation logic...}
Enter fullscreen modeExit fullscreen mode

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

With constructors, we have no control over the return type - the constructor must return aFood 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 aFood object, giving us control over the return type.

varapple=Food.Create("Apple",DateTime.Now.AddDays(-10),string.Empty);publicstaticFoodCreate(stringname,DateTimeexpirationDate,stringdescription){// Validation logic.}
Enter fullscreen modeExit fullscreen mode

One important thing to note: we have to rule out therecord 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 favorMonads for this (see my "The Monad Invasion" series).

varapple=Food.Create("Apple",DateTime.Now.AddDays(-10),string.Empty);publicrecordError(stringReason);publicstaticEither<Error,Food>Create(stringname,DateTimeexpirationDate,stringdescription){if(string.IsNullOrEmpty(name)){returnnewError("Name cannot be empty");}if(expirationDate<DateTime.Now){returnnewError("Expiration date cannot be in the past");}returnnewFood(name,expirationDate,description);}
Enter fullscreen modeExit fullscreen mode

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

Problem #3: Too many parameters

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

publicstaticFoodCreate(stringname,DateTimeexpirationDate,stringdescription,stringcategory,decimalweight,decimalprice,...){// ...}
Enter fullscreen modeExit 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.

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

Each method takes one parameter, andBuild 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 helpsguide 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 ensuresName andExpirationDate are mandatory, whileDescription is optional (can be null or empty).

varapple=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 modeExit fullscreen mode

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

Downsides

If Builders were free, everybody would use them. Sadly, they're not and require a fair amount ofboilerplate. 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 likeFood. This is one. simple. object.

Libraries likeFluentBuilder 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 (SeeTell, Don't Ask). My advice: keep them close to maintain strong cohesion and use theinternal 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, theVonage .NET SDK.

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

publicreadonlystructCheckRequest:IVonageRequest{// Immutable propertiespublicPhoneNumberPhoneNumber{get;internalinit;}publicintPeriod{get;internalinit;}// The static Build() method exposes the builderpublicstaticIBuilderForPhoneNumberBuild()=>newCheckRequestBuilder();}
Enter fullscreen modeExit 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
internalstructCheckRequestBuilder:IBuilderForPhoneNumber,IBuilderForOptional{privateconstintDefaultPeriod=240;privateconstintMaximumPeriod=2400;privateconstintMinimumPeriod=1;privateintperiod=DefaultPeriod;privatestringnumber=default;publicResult<CheckRequest>Create()=>Result<CheckRequest>.FromSuccess(newCheckRequest{Period=this.period,}).Merge(PhoneNumber.Parse(this.number),(request,validNumber)=>requestwith{PhoneNumber=validNumber}).Map(InputEvaluation<CheckRequest>.Evaluate).Bind(evaluation=>evaluation.WithRules(VerifyAgeMinimumPeriod,VerifyMaximumPeriod));// Assign value on the builderpublicIVonageRequestBuilder<CheckRequest>WithPeriod(intvalue)=>thiswith{period=value};// Assign value on the builderpublicIBuilderForOptionalWithPhoneNumber(stringvalue)=>thiswith{number=value};// ValidationprivatestaticResult<CheckRequest>VerifyAgeMinimumPeriod(CheckRequestrequest)=>InputValidation.VerifyHigherOrEqualThan(request,request.Period,MinimumPeriod,nameof(request.Period));// ValidationprivatestaticResult<CheckRequest>VerifyMaximumPeriod(CheckRequestrequest)=>InputValidation.VerifyLowerOrEqualThan(request,request.Period,MaximumPeriod,nameof(request.Period));}publicinterfaceIBuilderForPhoneNumber{// Will return the next step in the builder, for optional values or to build the objectIBuilderForOptionalWithPhoneNumber(stringvalue);}publicinterfaceIBuilderForOptional:IVonageRequestBuilder<CheckRequest>{// Will return the final step in the builder to build the objectIVonageRequestBuilder<CheckRequest>WithPeriod(intvalue);}
Enter fullscreen modeExit fullscreen mode

Source:CheckRequestBuilder

This is a usage example, from the test suite.

[Trait("Category","Request")]publicclassRequestBuilderTest{[Theory][InlineData("")][InlineData(" ")][InlineData(null)]publicvoidBuild_ShouldReturnFailure_GivenNumberIsNullOrWhitespace(stringvalue)=>CheckRequest.Build().WithPhoneNumber(value).Create().Should().BeFailure(ResultFailure.FromErrorMessage("Number cannot be null or whitespace."));// ...[Theory][InlineData(1)][InlineData(2400)]publicvoidBuild_ShouldSetPeriod(intvalue)=>CheckRequest.Build().WithPhoneNumber("1234567").WithPeriod(value).Create().Map(number=>number.Period).Should().BeSuccess(value);// ...}
Enter fullscreen modeExit 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 issuesI 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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Developer Advocate @Vonage, Passionate Software Craftsman, TDD Advocate @les-tontons-crafters
  • Location
    France
  • Education
    SUPINFO
  • Work
    Senior .Net Developer Advocate @Vonage
  • Joined

More fromGuillaume Faas

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp