👋 Why Builders Still Matter
We’ve all been there. You start with a simple class, add a few optional parameters, and suddenly your constructor looks like a Christmas tree. Overloads everywhere. You forget the order of parameters. Tests become a mess.
That’s exactly why the Fluent Builder Pattern exists: a clean, chainable way to construct objects without constructor hell.
But here’s the thing the classic WithXyz().Build() you see in tutorials? That’s just scratching the surface. In real projects, builders can be way smarter, safer, and even integrated into your Blazor apps.
🧱 The Classic Fluent Builder
Here’s the “textbook” example:
var order = new OrderBuilder()
.ForCustomer("CUST1")
.DeliverOn(DateTime.Today.AddDays(2))
.AddLine("SKU123", 2, new Money(10, "EUR"))
.Build();
Readable, testable, no constructor overload mess. Nice. But let’s level it up.
🚀 Leveling Up the Fluent Builder
✅ Validation Rules in Build() (with the Result Pattern)
Most tutorials show builders that throw exceptions when something’s invalid. That works, but it mixes flow control with error handling. In our demo, we use the Result Pattern instead — the builder returns a Result<T> with either the built object or a list of errors.
The Result Pattern
public sealed class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public IReadOnlyList<string> Errors { get; }
private Result(bool success, T? value, IReadOnlyList<string> errors)
=> (IsSuccess, Value, Errors) = (success, value, errors);
public static Result<T> Success(T value)
=> new(true, value, Array.Empty<string>());
public static Result<T> Failure(IEnumerable<string> errors)
=> new(false, default, errors.ToArray());
public static Result<T> Failure(params string[] errors)
=> new(false, default, errors);
}
A Real OrderBuilder Example
public sealed class OrderBuilder
{
private string? _customerId;
private DateTime? _delivery;
private readonly List<OrderLine> _lines = [];
private bool _built;
public static OrderBuilder Create() => new();
public OrderBuilder ForCustomer(string id)
{
_customerId = id;
return this;
}
public OrderBuilder DeliverOn(DateTime date)
{
_delivery = date;
return this;
}
public OrderBuilder AddLine(string sku, int qty, Money unitPrice)
{
_lines.Add(new OrderLine(sku, qty, unitPrice));
return this;
}
public Result<Order> Build()
{
if (_built)
return Result<Order>.Failure("This builder was already used.");
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(_customerId))
errors.Add("CustomerId is required.");
if (_delivery is DateTime d && d.Date < DateTime.Today)
errors.Add("Delivery date cannot be in the past.");
if (_lines.Count == 0)
errors.Add("At least one order line is required.");
if (_lines.Select(l => l.UnitPrice.Currency).Distinct().Count() > 1)
errors.Add("All lines must use the same currency.");
for (int i = 0; i < _lines.Count; i++)
{
var l = _lines[i];
if (string.IsNullOrWhiteSpace(l.Sku))
errors.Add($"Lines[{i}].Sku is required.");
if (l.Quantity <= 0)
errors.Add($"Lines[{i}].Quantity must be positive.");
if (string.IsNullOrWhiteSpace(l.UnitPrice.Currency))
errors.Add($"Lines[{i}].Currency is required.");
if (l.UnitPrice.Amount <= 0)
errors.Add($"Lines[{i}].UnitPrice.Amount cannot be negative or zero");
}
if (errors.Count > 0)
return Result<Order>.Failure(errors);
var currency = _lines[0].UnitPrice.Currency;
var total = new Money(_lines.Sum(l => l.Quantity * l.UnitPrice.Amount), currency);
_built = true;
return Result<Order>.Success(new Order(
_customerId!,
_delivery,
_lines.ToArray(),
total));
}
public OrderBuilder Reset()
{
_customerId = null;
_delivery = null;
_lines.Clear();
_built = false;
return this;
}
}
This builder does more than just “create an object”:
- It enforces business rules (no empty customer, no past delivery, at least one line).
- It prevents multiple builds from the same instance.
- It ensures currency consistency across all lines.
- It aggregates errors instead of stopping at the first.
⚖️ Pros & Cons of the Result Pattern
Pros
✅ Predictable control flow (if (result.IsSuccess))
✅ Aggregates all validation errors
✅ Works great with UI (show a list of errors in Blazor/MudBlazor)
✅ Easier to test
Cons
❌ More boilerplate (Result wrapper)
❌ Less idiomatic for teams used to exceptions
❌ Chaining multiple results needs helpers
👉 My rule of thumb: Result Pattern for domain validation, exceptions for truly exceptional conditions (DB down, network failure, etc.).
🎨 Fluent Builder + Blazor (with MudBlazor ❤️)
This pattern really shines in UI scenarios.
In my demo, I wired the builder into a Blazor Server form using MudBlazor (my favorite Blazor component library) or you can use Syncfusion as well.
The flow looks like this:
- User fills form (CustomerId, DeliveryDate, Lines)
- FluentValidation checks the ViewModel
- DTO → Builder mapping
- Builder.Build() returns Result
- MudBlazor shows either a success alert or a list of validation errors
Pipeline:
Blazor UI → DTO → Builder → Result<Order> → UI Feedback
hat makes the pattern not just “academic,” but something you can plug into real .NET apps today.
🧩 Other Advanced Variations
- Immutable Builders → return records/read-only objects
- Nested Builders → build aggregates step by step
- Step Builders → enforce correct order at compile time
- Test Data Builders → preconfigured defaults for unit tests
- DI-Aware Builders → inject policies or services into the build process
⚖️ When to Use / When Not To
✅ Great for domain objects with complexity, rules, or aggregates
❌ Overkill for simple DTOs or EF entities
🎯 Conclusion
The Fluent Builder Pattern is way more than WithXyz().Build(). With validation, immutability, nested builders, test data defaults, and Blazor integration, it becomes a practical tool for building safe, maintainable .NET applications.
So next time you face a constructor with seven parameters, ask yourself:
👉 Would a builder make this cleaner and safer?
👉 Source code :
https://github.com/stevsharp/FluentBuilderDemo-BlazorServer
📚 References & Further Reading
- Refactoring.Guru – Builder Pattern
- Microsoft Docs – Exceptions in .NET
- [Vaughn Vernon – Implementing Domain-Driven Design
- (Result/Error patterns)](https://www.oreilly.com/library/view/implementing-domain-driven-design/9780133039900/) [- Railway-Oriented Programming
- (conceptual origin of Result pattern)](https://fsharpforfunandprofit.com/rop/)
- FluentValidation Docs
- MudBlazor – Blazor Component Library
Top comments (0)