DEV Community

Cover image for ๐Ÿ“ Beyond WithXyz().Build(): Taking the Fluent Builder Pattern Further in C# (.NET 9)
Spyros Ponaris
Spyros Ponaris

Posted on

๐Ÿ“ Beyond WithXyz().Build(): Taking the Fluent Builder Pattern Further in C# (.NET 9)

๐Ÿ‘‹ 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();
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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; 
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)