Object creation is one of the most common tasks in any application. For simple classes, a constructor is usually enough to initialize the required properties and return a usable object. However, as applications grow, object creation often becomes much more complex. An object may contain dozens of properties, require business validations, depend on optional values, or even need data from external APIs before it can be fully initialized.
Consider a scenario where you're creating a user profile in an ASP.NET Core application. While the user's basic information comes from the client request, additional details such as location, weather information, subscription details, or user preferences might need to be retrieved from different services. Creating such an object using a constructor quickly becomes difficult, and placing all this logic inside a controller makes the code harder to maintain and violates the Single Responsibility Principle (SRP).
This is where the Builder Pattern becomes useful. Instead of creating an object in a single step, it allows us to construct it gradually by separating the object creation process from the object's representation. Each step contributes a part of the final object, and once all the required information has been collected and validated, the builder returns a fully initialized instance.
In this article, we'll understand what the Builder Pattern is, why it's needed, and how to implement it in an ASP.NET Core application. We'll also explore how it can coordinate multiple asynchronous API calls, execute them efficiently using Task.WhenAll(), and build the final object only after all the required data has been retrieved.
What is the Builder Pattern?
The Builder Pattern is one of the Creational Design Patterns defined by the Gang of Four (GoF). It is used to construct complex objects step by step, allowing the same construction process to create fully configured objects without exposing the complexity to the client.
Unlike constructors, which require all the necessary information upfront, a builder gradually collects the required data through a series of methods. Once every required step has been completed, the builder creates and returns the final object using a Build() or BuildAsync() method. This approach makes object creation more readable, flexible, and easier to maintain, especially when the object contains many properties or requires additional processing before it can be created.
A typical Builder Pattern implementation consists of the following components:
- Product – The complex object that needs to be created.
- Builder – Contains the logic required to construct the product step by step.
- Builder Interface (optional but recommended) – Defines the contract for the builder.
- Client – Uses the builder to configure and create the final object.
The overall workflow is fairly straightforward. Instead of creating the object directly, the client interacts with the builder by calling a sequence of methods. Each method performs a small part of the construction process, and after all the necessary information has been provided, the builder returns a fully initialized object.
Client
│
▼
Builder
│
├── Configure Step 1
├── Configure Step 2
├── Configure Step 3
└── Build()
│
▼
Final ObjectThis approach keeps the construction logic in a single place, making it easier to modify, extend, and test without affecting the rest of the application.
Why Do We Need the Builder Pattern?
The Builder Pattern isn't meant to replace constructors. Constructors are still the simplest and most appropriate way to create objects when they contain only a few required properties. However, as the complexity of an object increases, constructors begin to show their limitations.
Imagine creating a UserProfile object that contains basic user information, address details, location information, weather information, subscription details, language preferences, notification settings, and several optional properties.
Using a constructor, object creation might look like this:
var profile = new UserProfile(
firstName,
lastName,
email,
phone,
city,
country,
language,
subscription,
notificationEnabled,
weather,
location,
...);While this code is valid, it isn't particularly readable. As the number of constructor parameters grows, it becomes increasingly difficult to understand which value corresponds to which property. This becomes even more problematic when multiple parameters share the same data type, making it easy to accidentally swap two values without producing a compile-time error.
Another challenge arises when the object depends on information that isn't immediately available. Modern applications frequently retrieve data from databases, external APIs, or microservices before constructing an object. Since constructors cannot execute asynchronous operations, developers often end up performing these operations inside controllers or services before manually creating the object. This spreads the construction logic across the application and makes it harder to maintain.
The Builder Pattern addresses these problems by moving the entire construction process into a dedicated component. Instead of creating the object immediately, the builder gathers all the required information, performs any necessary validation or asynchronous operations, and returns the completed object only after everything is ready.
Implementing the Builder Pattern
Now that we understand what the Builder Pattern is and why we need it, let's implement it in an ASP.NET Core Web API application.
Instead of using the traditional Pizza or House example, we'll build a UserProfile object. The client will provide only the basic user information, while the builder will be responsible for enriching the object by calling external services to fetch additional details such as the user's location and current weather. Only after all the required information has been collected will the builder return the completed UserProfile.
The complete object construction process in our application looks like this:
Client Request
│
WithFirstName()
│
WithLastName()
│
WithEmail()
│
FetchLocationDetailsAsync()
│
FetchWeatherAsync()
│
BuildAsync()
│
Completed UserProfileNotice that the object isn't created immediately after calling WithFirstName() or WithEmail(). Each method simply contributes another step to the construction process. The final object is returned only when BuildAsync() is called.
The Product
In every Builder Pattern implementation, there is an object that the builder is responsible for constructing. This object is commonly referred to as the Product. In our example, the product is the UserProfile class.
public class UserProfile
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string City { get; set; }
public string Country { get; set; }
public string Weather { get; set; }
}This class contains only the data that represents a user profile. It doesn't know how to retrieve location details, call weather services, or validate input. Those responsibilities belong to the builder. Keeping the model free from business logic makes it easier to maintain and allows it to be reused throughout the application.
Creating the Builder Interface
The next step is to define the contract that every builder implementation must follow.
public interface IUserProfileBuilder
{
IUserProfileBuilder WithFirstName(string firstName);
IUserProfileBuilder WithLastName(string lastName);
IUserProfileBuilder WithEmail(string email);
IUserProfileBuilder FetchLocationDetailsAsync();
IUserProfileBuilder FetchWeatherAsync();
Task<UserProfile> BuildAsync(
CancellationToken cancellationToken = default);
}Instead of exposing a single method that accepts every value required to create a UserProfile, the interface breaks the construction process into multiple small steps. Each method has a single responsibility, making the API much easier to understand and extend.
Another thing worth noticing is that every configuration method returns IUserProfileBuilder. This allows methods to be chained together, resulting in a clean and readable Fluent API.
var profile = await _userProfileBuilder
.WithFirstName(request.FirstName)
.WithLastName(request.LastName)
.WithEmail(request.Email)
.FetchLocationDetailsAsync()
.FetchWeatherAsync()
.BuildAsync(cancellationToken);Reading this code almost feels like reading plain English. We first configure the basic user information, then request additional details, and finally ask the builder to construct the object.
Implementing the Builder
Let's now implement the builder.
public class UserProfileBuilder : IUserProfileBuilder
{
private readonly ILocationService _locationService;
private readonly IWeatherService _weatherService;
private readonly UserProfile _userProfile = new();
private readonly List<Task> _pendingTasks = [];
public UserProfileBuilder(
ILocationService locationService,
IWeatherService weatherService)
{
_locationService = locationService;
_weatherService = weatherService;
}
}The builder maintains a private instance of UserProfile, which represents the object currently being constructed. Every builder method updates this instance until all the required information has been populated.
The builder also receives ILocationService and IWeatherService through Dependency Injection. Instead of directly creating service instances, it relies on abstractions, making the implementation easier to test and keeping it loosely coupled.
Another important member is the _pendingTasks collection.
private readonly List<Task> _pendingTasks = [];Rather than waiting for each asynchronous operation immediately, the builder stores all pending operations inside this collection. Once the client has finished configuring the object, BuildAsync() waits for every pending task before returning the completed UserProfile. We'll explore this in detail later in the article.
Configuring Basic Information
Let's start with the simplest builder methods.
public IUserProfileBuilder WithFirstName(string firstName)
{
_userProfile.FirstName = firstName;
return this;
}This method simply assigns the provided first name to the UserProfile object currently being built. Once the value has been assigned, it returns the current builder instance so that the next method can be called immediately.
The remaining methods follow exactly the same pattern.
public IUserProfileBuilder WithLastName(string lastName)
{
_userProfile.LastName = lastName;
return this;
}
public IUserProfileBuilder WithEmail(string email)
{
_userProfile.Email = email;
return this;
}Instead of creating separate objects or returning new builder instances, each method continues modifying the same UserProfile object. This allows the builder to gradually assemble the final object while keeping the API simple and easy to read.
An important detail here is that each method returns the current builder instance by using return this. This enables method chaining, allowing us to build the object through a clean Fluent API where every method represents a single construction step.
var profile = await _userProfileBuilder
.WithFirstName(request.FirstName)
.WithLastName(request.LastName)
.WithEmail(request.Email)
.FetchLocationDetailsAsync()
.FetchWeatherAsync()
.BuildAsync(cancellationToken);Reading this code almost feels like reading plain English. We first configure the basic user information, then request additional details, and finally ask the builder to construct the object. This makes the code much easier to understand while keeping the construction process expressive and readable.
Queuing Asynchronous Operations
The next two methods are slightly different from the previous ones because they don't simply assign values to the object. Instead, they initiate asynchronous operations that retrieve additional information from external services.
public IUserProfileBuilder FetchLocationDetailsAsync()
{
_pendingTasks.Add(FetchLocationDetailsInternalAsync());
return this;
}Similarly, weather information is queued using:
public IUserProfileBuilder FetchWeatherAsync()
{
_pendingTasks.Add(FetchWeatherInternalAsync());
return this;
}One thing that often surprises developers is that these methods don't use the async keyword, even though their names end with Async.
This is intentional because these methods are not responsible for waiting for the external services to complete. Their responsibility is only to register the work that needs to be performed. They achieve this by creating the asynchronous operations and storing the returned Task objects inside the _pendingTasks collection.
This design has two important benefits. First, it keeps the Fluent API intact because each method can continue returning the builder instance instead of a Task. Second, it allows multiple asynchronous operations to be queued before the builder waits for any of them. Once every required operation has been registered, BuildAsync() will execute the remaining part of the construction process by waiting for all pending tasks together. At last once all the jobs completes it assign the value to the object as defined.
private async Task FetchLocationDetailsInternalAsync()
{
var location = await _locationService.GetLocationDetailsAsync(_userProfile.Email);
_userProfile.City = location.City;
_userProfile.Country = location.Country;
}
private async Task FetchWeatherInternalAsync()
{
_userProfile.Weather = await _weatherService.GetCurrentWeatherAsync();
}Understanding the BuildAsync() Method
So far, we've configured the basic user information and queued the asynchronous operations required to fetch additional details. However, none of those operations have actually been awaited yet. Their corresponding Task objects are simply stored inside the _pendingTasks collection.
The actual construction of the UserProfile happens inside the BuildAsync() method.
public async Task<UserProfile> BuildAsync(
CancellationToken cancellationToken = default)
{
ValidateInput();
if (_pendingTasks.Count > 0)
{
await Task.WhenAll(_pendingTasks)
.WaitAsync(cancellationToken);
}
return _userProfile;
}Although this method contains only a few lines of code, it is the heart of the entire Builder Pattern implementation. It ensures that the object is valid, waits for every queued asynchronous operation to complete, and only then returns the fully constructed UserProfile.
Let's understand each part in detail.
Validating the Object Before Building
The first thing the builder does is validate the object.
ValidateInput();There's no point in calling external services if the object itself is incomplete. For example, our location service retrieves location details using the user's email address. If the email hasn't been provided, the service call is unnecessary and will most likely fail.
That's why the builder validates the required properties before doing any additional work.
private void ValidateInput()
{
if (string.IsNullOrWhiteSpace(_userProfile.FirstName))
{
throw new InvalidOperationException(
"First name is required before building the profile.");
}
if (string.IsNullOrWhiteSpace(_userProfile.LastName))
{
throw new InvalidOperationException(
"Last name is required before building the profile.");
}
if (string.IsNullOrWhiteSpace(_userProfile.Email))
{
throw new InvalidOperationException(
"Email is required before building the profile.");
}
}This is another advantage of the Builder Pattern. Instead of forcing the controller or client code to perform validation, the builder ensures that it always returns a valid object. If any required information is missing, the construction process stops immediately with a meaningful exception.
As a result, the controller doesn't need to worry about whether the object is complete before calling BuildAsync(). The builder itself guarantees that.
Waiting for All Pending Operations
Once validation succeeds, the builder checks whether there are any queued operations.
if (_pendingTasks.Count > 0)
{
await Task.WhenAll(_pendingTasks).WaitAsync(cancellationToken);
}Earlier in the construction process, both FetchLocationDetailsAsync() and FetchWeatherAsync() added their work to the _pendingTasks collection.
_pendingTasks.Add(FetchLocationDetailsInternalAsync());
_pendingTasks.Add(FetchWeatherInternalAsync());At this point, the builder simply waits for every queued task to complete.
Instead of awaiting each task individually, it uses Task.WhenAll(), which waits for all tasks together. This keeps the implementation clean and allows the builder to coordinate multiple asynchronous operations from a single place.
A common question is why we don't simply await the service calls inside FetchLocationDetailsAsync() and FetchWeatherAsync().
For example, we could write something like this:
await FetchLocationDetailsInternalAsync();
await FetchWeatherInternalAsync();Doing this would certainly work, but it would break the fluent experience.
If FetchLocationDetailsAsync() returned a Task<IUserProfileBuilder>, every subsequent method in the chain would also need to be awaited.
await (await _userProfileBuilder
.WithFirstName(request.FirstName)
.WithLastName(request.LastName)
.WithEmail(request.Email)
.FetchLocationDetailsAsync())
.FetchWeatherAsync();This makes the builder much harder to read and use.
Instead, our implementation keeps every builder method synchronous from the caller's perspective. Each method simply records the work that needs to be performed and immediately returns the current builder instance. The caller only performs a single await when the entire object is ready to be built.
var profile = await _userProfileBuilder
.WithFirstName(request.FirstName)
.WithLastName(request.LastName)
.WithEmail(request.Email)
.FetchLocationDetailsAsync()
.FetchWeatherAsync()
.BuildAsync(cancellationToken);This results in a much cleaner and more intuitive API.
Returning the Completed Object
Once validation succeeds and every queued operation has finished, there's only one step remaining.
return _userProfile;At this point, the UserProfile contains:
- The basic information provided by the client.
- The location details retrieved from the location service.
- The weather information retrieved from the weather service.
Instead of exposing a partially initialized object, the builder guarantees that the returned object is fully constructed and ready to use.
This is the primary goal of the Builder Pattern. The client doesn't need to know how many steps were involved, which services were called, or in what order the object was assembled. It simply receives a completed UserProfile and can start using it immediately.
Using the Builder in the Controller
Now that our builder is complete, using it inside the controller becomes very straightforward. The controller no longer needs to know how a UserProfile is created or which external services need to be called. Its only responsibility is to receive the incoming request, pass the required information to the builder, and return the completed object to the client.
The implementation in our project looks like this.
[HttpPost("WithBuilder")]
[ProducesResponseType(typeof(UserProfile), StatusCodes.Status200OK)]
public async Task<ActionResult<UserProfile>> WithBuilder(
[FromBody] CreateUserProfileRequest request,
CancellationToken cancellationToken)
{
var profile = await _userProfileBuilder
.WithFirstName(request.FirstName)
.WithLastName(request.LastName)
.WithEmail(request.Email)
.FetchLocationDetailsAsync()
.FetchWeatherAsync()
.BuildAsync(cancellationToken);
return Ok(profile);
}One of the biggest advantages here is readability. Even someone who has never seen the implementation of UserProfileBuilder can understand what is happening. The code clearly describes each step involved in constructing the object, making it much easier to understand than manually creating and populating the object inside the controller.
Another important benefit is that the controller doesn't need to know anything about location or weather services. It simply delegates the entire construction process to the builder. If the implementation changes in the future—for example, if another API needs to be called or additional validation is required—the controller remains exactly the same. All those changes are isolated inside the builder.
Comparing with the Traditional Approach
To understand the value of the Builder Pattern, let's compare it with the traditional way of creating the same object.
Without the builder, the controller is responsible for creating the object, calling external services, assigning the returned values, and finally returning the completed model. The following implementation from our project demonstrates this approach.
[HttpPost("WithoutBuilder")]
public async Task<ActionResult<UserProfile>> WithoutBuilder(
[FromBody] CreateUserProfileRequest request,
CancellationToken cancellationToken)
{
var profile = new UserProfile
{
FirstName = request.FirstName,
LastName = request.LastName,
Email = request.Email
};
var location = await _locationService
.GetLocationDetailsAsync(request.Email, cancellationToken);
var weather = await _weatherService
.GetCurrentWeatherAsync(cancellationToken);
profile.City = location.City;
profile.Country = location.Country;
profile.Weather = weather;
return Ok(profile);
}For this example, the difference may not appear significant because the object contains only a few properties. However, in real-world applications, object construction is often much more complicated. You may need to call multiple APIs, perform validation, calculate derived values, initialize optional properties, or execute additional business logic before the object is ready. As these requirements grow, the controller quickly becomes cluttered with construction logic that doesn't belong there.
The Builder Pattern solves this problem by moving the entire construction process into a dedicated class. The controller simply tells the builder what information is available, while the builder decides how the final object should be assembled. This keeps the controller focused on handling HTTP requests and responses instead of worrying about object creation.
Registering the Builder with Dependency Injection
Since UserProfileBuilder depends on ILocationService and IWeatherService, it is registered with ASP.NET Core's Dependency Injection container along with its required services.
builder.Services.AddTransient<IUserProfileBuilder, UserProfileBuilder>();
builder.Services.AddScoped<ILocationService, LocationService>();
builder.Services.AddScoped<IWeatherService, WeatherService>();The builder is registered as a Transient service because it maintains internal state during the construction process. It stores the object currently being built in the _userProfile field and also keeps track of queued asynchronous operations in the _pendingTasks collection. Reusing the same builder instance across multiple requests could cause different requests to modify the same object, resulting in unexpected behavior.
Creating a new builder instance for every request ensures that each construction process is completely isolated. At the same time, the location and weather services are registered as Scoped services because they don't maintain any object-specific state and can safely be reused throughout the lifetime of a single HTTP request.
Builder Pattern vs Factory Pattern
Since both Builder and Factory belong to the creational design pattern family, they are often confused with each other. Although both patterns deal with object creation, they solve different problems and should be used in different scenarios.
The Factory Pattern focuses on deciding which object should be created. Based on a condition or input, the factory selects the appropriate implementation and returns it to the caller. Once the object is created, the factory's responsibility ends.
The Builder Pattern, on the other hand, already knows which object it needs to create. Its responsibility is to determine how that object should be constructed. Instead of choosing between different implementations, it gradually builds a single complex object by performing multiple construction steps before returning the final result.
Our project demonstrates this perfectly. The builder always creates a UserProfile. It doesn't decide between multiple profile types or implementations. Instead, it constructs the UserProfile by assigning the user's basic information, retrieving location details, fetching weather information, validating the required fields, and finally returning the completed object.
If we were building a notification system that needed to choose between Email, SMS, or Slack notification providers, the Factory Pattern would be a better fit because the application must decide which implementation to instantiate. In contrast, when the goal is to construct a single object through several steps, as we did in this article, the Builder Pattern is the appropriate choice.
In simple terms, you can remember the difference like this:
- Factory Pattern → Decides which object to create.
- Builder Pattern → Decides how the object should be constructed.
Both patterns solve different problems, and it's quite common to see them used together in the same application.
When Should You Use the Builder Pattern?
The Builder Pattern is most useful when creating an object requires multiple independent steps or when the construction process is too complex to fit comfortably inside a constructor or controller. It becomes especially valuable when building an object involves calling external services, performing validation, applying business rules, or configuring optional properties.
It's also a great choice when the same object needs to be created from multiple places within the application. Instead of duplicating construction logic across controllers, background services, or scheduled jobs, a single builder can encapsulate the entire process and provide a consistent way of creating the object.
However, like every design pattern, the Builder Pattern should be used only when it solves a real problem. If an object contains only a few properties and can be initialized using a constructor or object initializer, introducing a builder may add unnecessary complexity. The goal isn't to use a design pattern everywhere but to choose the right one when the complexity of object construction justifies it.
Conclusion
The Builder Pattern is an excellent choice when constructing an object requires multiple steps or involves complex initialization logic. Instead of scattering object creation across controllers and services, it centralizes the entire construction process into a dedicated builder, making the code easier to read, maintain, and extend.
If you'd like to explore the complete implementation used in this article, you can find the full source code on my GitHub repository:
🔗 GitHub Repository: https://github.com/YogeshHadiya33/BuilderPatternDemo
Feel free to clone the repository, run the project locally, and experiment with the implementation. Exploring the complete source code alongside this article will help you better understand how the Builder Pattern works in a real-world ASP.NET Core Web API application.
If you found this article helpful, I'd really appreciate it if you shared it with others who are learning .NET and design patterns.
