Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

The Builder Pattern: A Comprehensive Guide to Fluent Object Construction in .NET

Introduction

The Builder pattern is a creational design pattern that separates the construction of complex objects from their representation, allowing you to build objects step-by-step in a fluent, readable manner. Instead of creating objects through complex constructors with many parameters, the Builder pattern provides a clean, intuitive API for object construction.

This article explores the Builder pattern comprehensive implementation in modern .NET applications, and demonstrates how FrenchExDev.Net.Builder provides a sophisticated, async-first framework that handles complex scenarios including circular references, validation accumulation, and concurrent access patterns.

The Problem with Traditional Construction

1. Constructor Telescoping Problem

public class Order
{
    public Order(int id, string customerName, List<OrderItem> items, 
                 decimal discount, string notes, DateTime createdDate,
                 string shippingAddress, string billingAddress,
                 bool urgent, int invoiceNumber)
    {
        Id = id;
        CustomerName = customerName;
        Items = items;
        Discount = discount;
        Notes = notes;
        CreatedDate = createdDate;
        ShippingAddress = shippingAddress;
        BillingAddress = billingAddress;
        Urgent = urgent;
        InvoiceNumber = invoiceNumber;
    }

    public int Id { get; }
    public string CustomerName { get; }
    // ... more properties
}

// Difficult to use and maintain
var order = new Order(1, "John Doe", items, 0.1m, "VIP", DateTime.Now,
                      "123 Main St", "123 Main St", true, 12345);

// Hard to understand what each parameter means

2. Constructor Overloading Explosion

public class Order
{
    // Basic constructor
    public Order() { }

    // Optional parameters
    public Order(int id) { }
    public Order(int id, string customerName) { }
    public Order(int id, string customerName, List<OrderItem> items) { }
    public Order(int id, string customerName, List<OrderItem> items, decimal discount) { }
    // ... more overloads
}

This rapidly becomes unmaintainable.

3. Immutability and Consistency

// Creating an object and then modifying it step-by-step
var order = new Order();
order.CustomerName = "John Doe"; // Mutable state
order.Items = items;
order.Discount = 0.1m;

// Object may be in an invalid state at intermediate steps

Introducing the Builder Pattern

The Builder pattern addresses these issues by:

  • Separating construction from representation - The builder encapsulates the construction logic
  • Fluent API - Method chaining for readable, expressive construction
  • Immutability - Building and then returning a final immutable object
  • Mandatory vs Optional - Clear separation of required and optional properties
  • Validation - Construction logic can validate the final object

Basic Concept

public class OrderBuilder
{
    private int _id;
    private string _customerName = string.Empty;
    private List<OrderItem> _items = new();
    private decimal _discount = 0;

    public OrderBuilder WithId(int id)
    {
        _id = id;
        return this;
    }

    public OrderBuilder WithCustomer(string name)
    {
        _customerName = name;
        return this;
    }

    public OrderBuilder AddItem(OrderItem item)
    {
        _items.Add(item);
        return this;
    }

    public OrderBuilder WithDiscount(decimal discount)
    {
        _discount = discount;
        return this;
    }

    public Order Build()
    {
        if (_id == 0) throw new InvalidOperationException("ID is required");
        if (string.IsNullOrEmpty(_customerName)) throw new InvalidOperationException("Customer name is required");

        return new Order(_id, _customerName, _items, _discount);
    }
}

// Usage - Clear and readable
var order = new OrderBuilder()
    .WithId(1)
    .WithCustomer("John Doe")
    .AddItem(new OrderItem { ProductId = 100, Quantity = 2 })
    .AddItem(new OrderItem { ProductId = 200, Quantity = 1 })
    .WithDiscount(0.1m)
    .Build();

FrenchExDev.Net.Builder: Async-First, Production-Grade Implementation

FrenchExDev.Net.Builder provides a sophisticated framework designed specifically for enterprise scenarios, featuring:

  • Async-first design - All building is async by default with async/await support
  • Reference-based construction - Handles circular references and complex graphs using Reference<T>
  • Validation accumulation - Collects all validation errors before failing, not fail-fast
  • Concurrent safety - Single-flight execution with semaphore locking
  • Type-safe errors - Generic AbstractBuilder<T, TException> returns Result<T, TException> with typed exceptions
  • Graph support - Build complex object hierarchies with parent-child relationships

Core Abstractions

namespace FrenchExDev.Net.Builder;

// Core marker interface for all builders
public interface IBuilder
{
}

// Reference to a lazy-loaded or to-be-built object
public sealed record Reference<TClass> where TClass : notnull
{
    public bool IsResolved { get; }
    public Reference<TClass> Resolve(TClass referenced);
    public bool TryResolved(out TClass referenced);
    public TClass Resolved();
}

// Accumulates validation errors without throwing
public sealed class ValidationResult
{
    public bool IsSuccess { get; }
    public void AddError(MemberName memberName, Exception exception);
    public void AddErrors(MemberName memberName, IEnumerable<Exception> exceptions);
    public DataAnnotationsValidationResult ToDataAnnotationsValidationResult();
}

// Core builder interface - returns Reference<T> for lazy/circular handling
public interface IBuilder<TClass> : IBuilder where TClass : notnull
{
    Task<Result<Reference<TClass>>> BuildAsync(
        VisitedObjects? visitedObjects = null, 
        CancellationToken cancellationToken = default);
    Reference<TClass> Reference();
}

// Generic builder with custom exception type
public interface IBuilder<TResult, TError> : IBuilder 
    where TResult : notnull 
    where TError : Exception
{
    Task<Result<TResult, TError>> BuildAsync(CancellationToken cancellationToken = default);
    Reference<Result<TResult, TError>> Reference();
}

The Build Flow

Every builder follows this internal flow:

  1. Validation - ValidateAsync() collects all errors into ValidationResult
  2. Check Errors - If errors exist, return Result.Failure()
  3. Instantiate - InstantiateAsync() creates the object and resolves the reference
  4. Cache - Result is cached using semaphore-based single-flight pattern
  5. Return - Subsequent calls return cached reference immediately

Implementation Patterns

Pattern 1: Basic AbstractBuilder

The simplest pattern: inherit from AbstractBuilder<T> and implement three methods.

public class UserBuilder : AbstractBuilder<User>
{
    private string _email = string.Empty;
    private string _name = string.Empty;

    public UserBuilder WithEmail(string email)
    {
        _email = email;
        return this;
    }

    public UserBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    // Validate before building
    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        if (string.IsNullOrWhiteSpace(_email))
            result.AddError(
                new MemberName(nameof(_email), typeof(UserBuilder)),
                new InvalidOperationException("Email is required"));

        if (string.IsNullOrWhiteSpace(_name))
            result.AddError(
                new MemberName(nameof(_name), typeof(UserBuilder)),
                new InvalidOperationException("Name is required"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    // Create the instance
    protected override Task<Result<Reference<User>>> InstantiateAsync(
        Reference<User> reference, 
        VisitedObjects visitedObjects, 
        CancellationToken ct)
    {
        var user = new User { Email = _email, Name = _name };
        reference.Resolve(user);
        return Task.FromResult(Result<Reference<User>>.Success(reference));
    }

    // Convert validation errors to exception
    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException(vr.ErrorMessage);
    }
}

// Usage - BuildAsync() returns Result<Reference<User>>
var userResult = await new UserBuilder()
    .WithEmail("john@example.com")
    .WithName("John Doe")
    .BuildAsync();

if (userResult.IsSuccess)
{
    var user = userResult.ValueOrThrow().Resolved();
    Console.WriteLine($"Built user: {user.Name}");
}
else
{
    Console.WriteLine($"Build failed: {userResult.ValidationResult?.ErrorMessage}");
}

Key Points:

  • ValidateAsync() accumulates ALL errors before returning (not fail-fast)
  • InstantiateAsync() resolves the reference before returning
  • Automatic caching - second BuildAsync() call returns cached reference
  • Thread-safe with semaphore-based single-flight pattern

Pattern 2: Generic Builder with Custom Exception

For scenarios where you want strongly-typed exceptions instead of generic InvalidOperationException:

public class OrderCreationException : Exception
{
    public OrderCreationException(string message) : base(message) { }
}

public class OrderBuilder : AbstractBuilder<Order, OrderCreationException>
{
    private int _id;
    private List<OrderItem> _items = new();
    private decimal _discount = 0;

    public OrderBuilder WithId(int id)
    {
        _id = id;
        return this;
    }

    public OrderBuilder AddItem(OrderItem item)
    {
        _items.Add(item);
        return this;
    }

    public OrderBuilder WithDiscount(decimal discount)
    {
        _discount = discount;
        return this;
    }

    // The base AbstractBuilder<T, TException> handles BuildAsync for you.
    // Implement validation, instantiation, and typed exception conversion.
    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        if (_id <= 0)
            result.AddError(
                new MemberName(nameof(_id), typeof(OrderBuilder)),
                new InvalidOperationException("ID must be positive"));

        if (!_items.Any())
            result.AddError(
                new MemberName(nameof(_items), typeof(OrderBuilder)),
                new InvalidOperationException("Order must have at least one item"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Order, OrderCreationException>> InstantiateAsync(CancellationToken ct)
    {
        // Simulate async work
        await Task.Delay(10, ct).ConfigureAwait(false);

        var order = new Order { Id = _id, Items = _items, Discount = _discount };
        return Result<Order, OrderCreationException>.Success(order);
    }

    protected override OrderCreationException TypedBuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new OrderCreationException(vr.ErrorMessage);
    }
}

// Usage - Returns Result<Order, OrderCreationException>
var orderResult = await new OrderBuilder()
    .WithId(1)
    .AddItem(new OrderItem { ProductId = 100, Quantity = 2 })
    .WithDiscount(0.1m)
    .BuildAsync();

if (orderResult.IsSuccess)
{
    Console.WriteLine($"Order {orderResult.Value.Id} created");
}
else
{
    Console.WriteLine($"OrderCreationException: {orderResult.Error!.Message}");
}

Pattern 3: Building Object Graphs with Parent-Child Relationships

Complex hierarchies with circular references handled transparently via VisitedObjects:

public class Person
{
    public string Name { get; set; } = "";
    public List<Child> Children { get; set; } = new();
}

public class Child
{
    public string Name { get; set; } = "";
    public Person? Parent { get; set; }
}

public class PersonBuilder : AbstractBuilder<Person>
{
    private string _name = string.Empty;
    private readonly List<ChildBuilder> _childBuilders = new();

    public PersonBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public PersonBuilder AddChild(string childName)
    {
        _childBuilders.Add(new ChildBuilder(childName, this));
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();
        
        if (string.IsNullOrWhiteSpace(_name))
            result.AddError(
                new MemberName(nameof(_name), typeof(PersonBuilder)),
                new InvalidOperationException("Name is required"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Reference<Person>>> InstantiateAsync(
        Reference<Person> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        var person = new Person { Name = _name };
        reference.Resolve(person);

        // Build children, reusing visited for circular reference handling
        foreach (var childBuilder in _childBuilders)
        {
            var childResult = await childBuilder.BuildAsync(visitedObjects, ct);
            
            if (!childResult.IsSuccess)
                return childResult;

            var child = childResult.ValueOrThrow().Resolved();
            child.Parent = person; // Back-reference
            person.Children.Add(child);
        }

        return Result<Reference<Person>>.Success(reference);
    }

    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException(vr.ErrorMessage);
    }
}

public class ChildBuilder : AbstractBuilder<Child>
{
    private string _name = string.Empty;
    private readonly PersonBuilder _parentBuilder;

    public ChildBuilder(string name, PersonBuilder parentBuilder)
    {
        _name = name;
        _parentBuilder = parentBuilder;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
        => Task.FromResult(Result<ValidationResult>.Success(new ValidationResult()));

    protected override Task<Result<Reference<Child>>> InstantiateAsync(
        Reference<Child> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        var child = new Child { Name = _name };
        reference.Resolve(child);
        return Task.FromResult(Result<Reference<Child>>.Success(reference));
    }

    protected override Exception BuildException(Result<ValidationResult> vr)
        => new InvalidOperationException("Child build failed");
}

// Usage - Builds entire graph with parent-child relationships
var personBuilder = new PersonBuilder()
    .WithName("Alice")
    .AddChild("Bob")
    .AddChild("Claire");

var result = await personBuilder.BuildAsync();

if (result.IsSuccess)
{
    var person = result.ValueOrThrow().Resolved();
    Console.WriteLine($"{person.Name} has {person.Children.Count} children");
    foreach (var child in person.Children)
    {
        Console.WriteLine($"  - {child.Name} (parent: {child.Parent?.Name})");
    }
}

Key Points:

  • VisitedObjects tracks already-built objects to prevent infinite recursion
  • Recursive calls reuse the same visited dictionary
  • Parent-child relationships automatically connected
  • Thread-safe graph construction

Pattern 4: Concurrent Access and Caching

Multiple concurrent calls automatically coordinated via semaphore:

public class ConfigurationBuilder : AbstractBuilder<Configuration>
{
    private readonly Dictionary<string, string> _settings = new();
    
    public ConfigurationBuilder Set(string key, string value)
    {
        _settings[key] = value;
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
        => Task.FromResult(Result<ValidationResult>.Success(new ValidationResult()));

    protected override async Task<Result<Reference<Configuration>>> InstantiateAsync(
        Reference<Configuration> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        // Simulate expensive async initialization (I/O, computation, etc.)
        await Task.Delay(100, ct).ConfigureAwait(false);
        
        var config = new Configuration(_settings);
        reference.Resolve(config);
        return Result<Reference<Configuration>>.Success(reference);
    }

    protected override Exception BuildException(Result<ValidationResult> vr)
        => new InvalidOperationException("Configuration build failed");
}

// Usage - Multiple concurrent calls, only one instantiation
var builder = new ConfigurationBuilder()
    .Set("Database", "ProductionDB")
    .Set("Timeout", "30000");

// All these calls will wait their turn and share the same result
var task1 = builder.BuildAsync();
var task2 = builder.BuildAsync();
var task3 = builder.BuildAsync();

var results = await Task.WhenAll(task1, task2, task3);

// All results point to the same Reference<Configuration>
Assert.True(results.All(r => ReferenceEquals(
    results[0].ValueOrThrow(),
    r.ValueOrThrow())));

Pattern 5: Validation Error Accumulation

Unlike fail-fast builders, FrenchExDev.Net.Builder accumulates all errors:

public class StrictUserBuilder : AbstractBuilder<User>
{
    private string _email = string.Empty;
    private string _name = string.Empty;
    private int _age = 0;
    private string _phone = string.Empty;

    public StrictUserBuilder WithEmail(string email)
    {
        _email = email;
        return this;
    }

    public StrictUserBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public StrictUserBuilder WithAge(int age)
    {
        _age = age;
        return this;
    }

    public StrictUserBuilder WithPhone(string phone)
    {
        _phone = phone;
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        // All validations run - none are skipped
        if (string.IsNullOrWhiteSpace(_email))
            result.AddError(
                new MemberName(nameof(_email), typeof(StrictUserBuilder)),
                new InvalidOperationException("Email is required"));
        else if (!_email.Contains("@"))
            result.AddError(
                new MemberName(nameof(_email), typeof(StrictUserBuilder)),
                new InvalidOperationException("Email format is invalid"));

        if (string.IsNullOrWhiteSpace(_name))
            result.AddError(
                new MemberName(nameof(_name), typeof(StrictUserBuilder)),
                new InvalidOperationException("Name is required"));

        if (_age < 18)
            result.AddError(
                new MemberName(nameof(_age), typeof(StrictUserBuilder)),
                new InvalidOperationException("Age must be 18 or older"));

        if (string.IsNullOrWhiteSpace(_phone))
            result.AddError(
                new MemberName(nameof(_phone), typeof(StrictUserBuilder)),
                new InvalidOperationException("Phone is required"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override Task<Result<Reference<User>>> InstantiateAsync(
        Reference<User> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        var user = new User { Email = _email, Name = _name, Age = _age, Phone = _phone };
        reference.Resolve(user);
        return Task.FromResult(Result<Reference<User>>.Success(reference));
    }

    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException(vr.ErrorMessage);
    }
}

// Usage - All error messages returned together
var result = await new StrictUserBuilder()
    .WithEmail("invalid") // Invalid format
    .WithName("") // Empty
    .WithAge(16) // Too young
    // .WithPhone() missing
    .BuildAsync();

if (!result.IsSuccess)
{
    var errors = result.ValidationResult!.ErrorMessage;
    // Output: "Email format is invalid; Name is required; Age must be 18 or older; Phone is required"
    Console.WriteLine($"All validation errors: {errors}");
}

Best Practices with FrenchExDev.Net.Builder

1. Implement Three Core Methods

Every builder must implement these abstract methods:

// ✓ Good: Implement all three required methods
public class MyBuilder : AbstractBuilder<MyType>
{
    // 1. Validate - collect errors, don't throw
    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();
        // Add errors for each invalid condition
        if (invalid)
            result.AddError(new MemberName(nameof(Property), typeof(MyBuilder)),
                new InvalidOperationException("Property is invalid"));
        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    // 2. Instantiate - create and resolve reference
    protected override Task<Result<Reference<MyType>>> InstantiateAsync(
        Reference<MyType> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        var instance = new MyType { /*...*/ };
        reference.Resolve(instance);
        return Task.FromResult(Result<Reference<MyType>>.Success(reference));
    }

    // 3. BuildException - convert validation result to exception
    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException(vr.ErrorMessage);
    }
}

// ✗ Avoid: Throwing directly from builder properties
public OrderBuilder WithQuantity(int quantity)
{
    if (quantity <= 0)
        throw new InvalidOperationException("Quantity must be positive");
    _quantity = quantity;
    return this;
}

2. Accumulate Validation Errors, Not Fail-Fast

// ✓ Good: Accumulate ALL errors before reporting
protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
{
    var result = new ValidationResult();
    
    if (string.IsNullOrEmpty(Email))
        result.AddError(new MemberName(nameof(Email), typeof(UserBuilder)),
            new InvalidOperationException("Email is required"));
    
    if (Age < 18)
        result.AddError(new MemberName(nameof(Age), typeof(UserBuilder)),
            new InvalidOperationException("Age must be 18+"));
    
    if (string.IsNullOrEmpty(Phone))
        result.AddError(new MemberName(nameof(Phone), typeof(UserBuilder)),
            new InvalidOperationException("Phone is required"));
    
    return Task.FromResult(Result<ValidationResult>.Success(result));
}

// ✗ Avoid: Early return after first error
protected override async Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
{
    var result = new ValidationResult();
    
    if (string.IsNullOrEmpty(Email))
    {
        result.AddError(new MemberName(nameof(Email), typeof(UserBuilder)),
            new InvalidOperationException("Email is required"));
        return Task.FromResult(Result<ValidationResult>.Success(result)); // Only one error
    }
    // ...remaining validations skipped
}

3. Use Reference for Circular References

// ✓ Good: Handle parent-child circular references
protected override async Task<Result<Reference<Person>>> InstantiateAsync(
    Reference<Person> reference,
    VisitedObjects visitedObjects,
    CancellationToken ct)
{
    var person = new Person { Name = _name };
    reference.Resolve(person); // Resolve early, before building children

    foreach (var childBuilder in _childBuilders)
    {
        var childResult = await childBuilder.BuildAsync(visitedObjects, ct);
        if (!childResult.IsSuccess)
            return childResult;

        var child = childResult.ValueOrThrow().Resolved();
        child.Parent = person; // Back-reference now valid
        person.Children.Add(child);
    }

    return Result<Reference<Person>>.Success(reference);
}

4. Always Use CancellationToken

// ✓ Good: Pass cancellation token through the chain
protected override async Task<Result<Reference<Configuration>>> InstantiateAsync(
    Reference<Configuration> reference,
    VisitedObjects visitedObjects,
    CancellationToken ct)
{
    await Task.Delay(100, ct).ConfigureAwait(false); // Respect cancellation
    var config = new Configuration();
    reference.Resolve(config);
    return Result<Reference<Configuration>>.Success(reference);
}

// Usage
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await builder.BuildAsync(cancellationToken: cts.Token);

5. Leverage Automatic Caching

// ✓ Good: Call BuildAsync multiple times - reuses cached reference
var builder = new ConfigurationBuilder().Set("key", "value");

var result1 = await builder.BuildAsync(); // Calls InstantiateAsync
var result2 = await builder.BuildAsync(); // Returns cached reference (no InstantiateAsync call)
var result3 = await builder.BuildAsync(); // Returns cached reference

// All point to the same Reference<T> instance
Assert.Same(result1.ValueOrThrow(), result2.ValueOrThrow());

6. Handle Generic Exception Type When Appropriate

// ✓ Good: Use AbstractBuilder<T, TException> for domain exceptions
public class PaymentBuilder : AbstractBuilder<Payment, PaymentException>
{
    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();
        // Add validation errors here, e.g.:
        // if (amount <= 0) result.AddError(...);
        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Payment, PaymentException>> InstantiateAsync(CancellationToken ct)
    {
        // Simulate async initialization (e.g., I/O, external service)
        await Task.Delay(10, ct).ConfigureAwait(false);
        var payment = new Payment();
        return Result<Payment, PaymentException>.Success(payment);
    }

    protected override PaymentException TypedBuildException(Result<ValidationResult> vr)
        => new PaymentException(vr.ValueOrThrow()
            .ToDataAnnotationsValidationResult().ErrorMessage);
}

// Usage - Result carries the specific exception type
var result = await builder.BuildAsync();
if (!result.IsSuccess)
{
    var paymentException = result.Error; // PaymentException, not generic Exception
}

Real-World Examples

Example 1: Asynchronous Database Configuration Builder

public class DatabaseConnectionBuilder : AbstractBuilder<DatabaseConnection>
{
    private string _host = string.Empty;
    private int _port = 5432;
    private string _database = string.Empty;
    private string _username = string.Empty;
    private string _password = string.Empty;
    private bool _verifyConnection = false;

    public DatabaseConnectionBuilder WithHost(string host)
    {
        _host = host;
        return this;
    }

    public DatabaseConnectionBuilder WithPort(int port)
    {
        _port = port;
        return this;
    }

    public DatabaseConnectionBuilder WithDatabase(string database)
    {
        _database = database;
        return this;
    }

    public DatabaseConnectionBuilder WithCredentials(string username, string password)
    {
        _username = username;
        _password = password;
        return this;
    }

    public DatabaseConnectionBuilder VerifyConnection()
    {
        _verifyConnection = true;
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        if (string.IsNullOrWhiteSpace(_host))
            result.AddError(new MemberName(nameof(_host), typeof(DatabaseConnectionBuilder)),
                new InvalidOperationException("Host is required"));

        if (_port <= 0 || _port > 65535)
            result.AddError(new MemberName(nameof(_port), typeof(DatabaseConnectionBuilder)),
                new InvalidOperationException("Port must be between 1 and 65535"));

        if (string.IsNullOrWhiteSpace(_database))
            result.AddError(new MemberName(nameof(_database), typeof(DatabaseConnectionBuilder)),
                new InvalidOperationException("Database name is required"));

        if (string.IsNullOrWhiteSpace(_username))
            result.AddError(new MemberName(nameof(_username), typeof(DatabaseConnectionBuilder)),
                new InvalidOperationException("Username is required"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Reference<DatabaseConnection>>> InstantiateAsync(
        Reference<DatabaseConnection> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        var connection = new DatabaseConnection
        {
            Host = _host,
            Port = _port,
            Database = _database,
            Username = _username,
            Password = _password
        };

        if (_verifyConnection)
        {
            var connectionString = $"Server={_host};Port={_port};Database={_database};User Id={_username};Password={_password}";
            
            // Simulate async verification (real implementation would test the connection)
            await Task.Delay(100, ct).ConfigureAwait(false);
            
            // In real code, this would actually attempt to connect
            connection.IsVerified = true;
        }

        reference.Resolve(connection);
        return Result<Reference<DatabaseConnection>>.Success(reference);
    }

    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException($"DatabaseConnection build failed: {vr.ErrorMessage}");
    }
}

// Usage
var dbResult = await new DatabaseConnectionBuilder()
    .WithHost("db.example.com")
    .WithPort(5432)
    .WithDatabase("ProductionDb")
    .WithCredentials("admin", "SecurePassword123")
    .VerifyConnection()
    .BuildAsync();

if (dbResult.IsSuccess)
{
    var connection = dbResult.ValueOrThrow().Resolved();
    Console.WriteLine($"Connected to {connection.Database} at {connection.Host}:{connection.Port}");
    Console.WriteLine($"Verified: {connection.IsVerified}");
}
else
{
    Console.WriteLine($"Connection build failed: {dbResult.ValidationResult?.ErrorMessage}");
}

Example 2: Dependency Injection Container Builder

public class ServiceContainerBuilder : AbstractBuilder<ServiceContainer>
{
    private readonly List<ServiceDescriptor> _services = new();
    private string _name = string.Empty;

    public ServiceContainerBuilder Named(string name)
    {
        _name = name;
        return this;
    }

    public ServiceContainerBuilder AddService<TInterface, TImplementation>(ServiceLifetime lifetime = ServiceLifetime.Transient)
        where TInterface : class
        where TImplementation : class, TInterface
    {
        _services.Add(new ServiceDescriptor(typeof(TInterface), typeof(TImplementation), lifetime));
        return this;
    }

    public ServiceContainerBuilder AddSingleton<TInterface>(TInterface instance) where TInterface : class
    {
        _services.Add(ServiceDescriptor.Singleton(typeof(TInterface), instance));
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        if (string.IsNullOrWhiteSpace(_name))
            result.AddError(new MemberName(nameof(_name), typeof(ServiceContainerBuilder)),
                new InvalidOperationException("Container name is required"));

        if (!_services.Any())
            result.AddError(new MemberName(nameof(_services), typeof(ServiceContainerBuilder)),
                new InvalidOperationException("At least one service must be registered"));

        // Check for duplicate service registrations
        var duplicates = _services
            .GroupBy(s => s.ServiceType)
            .Where(g => g.Count() > 1);

        foreach (var dup in duplicates)
            result.AddError(new MemberName(nameof(_services), typeof(ServiceContainerBuilder)),
                new InvalidOperationException($"Service {dup.Key.Name} is registered multiple times"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Reference<ServiceContainer>>> InstantiateAsync(
        Reference<ServiceContainer> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        // Simulate async container initialization
        await Task.Delay(50, ct).ConfigureAwait(false);

        var container = new ServiceContainer(_name, _services);
        reference.Resolve(container);
        return Result<Reference<ServiceContainer>>.Success(reference);
    }

    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException($"ServiceContainer build failed: {vr.ErrorMessage}");
    }
}

// Usage
var containerResult = await new ServiceContainerBuilder()
    .Named("WebServices")
    .AddService<IUserRepository, UserRepository>(ServiceLifetime.Scoped)
    .AddService<IOrderService, OrderService>(ServiceLifetime.Scoped)
    .AddSingleton<ILogger>(new ConsoleLogger())
    .BuildAsync();

if (containerResult.IsSuccess)
{
    var container = containerResult.ValueOrThrow().Resolved();
    Console.WriteLine($"Container '{container.Name}' initialized with {container.ServiceCount} services");
}

Example 3: Multi-Client API Request Builder

public class ApiClientBuilder : AbstractBuilder<ApiClient>
{
    private Uri? _baseUrl;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);
    private readonly Dictionary<string, string> _defaultHeaders = new();
    private readonly List<ApiEndpoint> _endpoints = new();

    public ApiClientBuilder BaseUrl(string url)
    {
        _baseUrl = new Uri(url);
        return this;
    }

    public ApiClientBuilder Timeout(TimeSpan timeout)
    {
        _timeout = timeout;
        return this;
    }

    public ApiClientBuilder AddHeader(string name, string value)
    {
        _defaultHeaders[name] = value;
        return this;
    }

    public ApiClientBuilder MapEndpoint(string name, string path, HttpMethod method)
    {
        _endpoints.Add(new ApiEndpoint(name, path, method));
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();

        if (_baseUrl == null)
            result.AddError(new MemberName(nameof(_baseUrl), typeof(ApiClientBuilder)),
                new InvalidOperationException("Base URL is required"));

        if (_timeout <= TimeSpan.Zero)
            result.AddError(new MemberName(nameof(_timeout), typeof(ApiClientBuilder)),
                new InvalidOperationException("Timeout must be positive"));

        if (!_defaultHeaders.ContainsKey("Content-Type"))
            result.AddError(new MemberName(nameof(_defaultHeaders), typeof(ApiClientBuilder)),
                new InvalidOperationException("Content-Type header is required"));

        if (!_endpoints.Any())
            result.AddError(new MemberName(nameof(_endpoints), typeof(ApiClientBuilder)),
                new InvalidOperationException("At least one endpoint must be mapped"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    protected override async Task<Result<Reference<ApiClient>>> InstantiateAsync(
        Reference<ApiClient> reference,
        VisitedObjects visitedObjects,
        CancellationToken ct)
    {
        // Simulate async client initialization
        await Task.Delay(100, ct).ConfigureAwait(false);

        var client = new ApiClient(_baseUrl!)
        {
            Timeout = _timeout,
            DefaultHeaders = _defaultHeaders,
            Endpoints = _endpoints
        };

        reference.Resolve(client);
        return Result<Reference<ApiClient>>.Success(reference);
    }

    protected override Exception BuildException(Result<ValidationResult> validationResult)
    {
        var vr = validationResult.ValueOrThrow().ToDataAnnotationsValidationResult();
        return new InvalidOperationException($"ApiClient build failed: {vr.ErrorMessage}");
    }
}

// Usage
var clientResult = await new ApiClientBuilder()
    .BaseUrl("https://api.example.com/v1")
    .Timeout(TimeSpan.FromSeconds(60))
    .AddHeader("Content-Type", "application/json")
    .AddHeader("User-Agent", "MyApp/1.0")
    .MapEndpoint("GetUsers", "/users", HttpMethod.Get)
    .MapEndpoint("CreateUser", "/users", HttpMethod.Post)
    .MapEndpoint("GetUser", "/users/{id}", HttpMethod.Get)
    .BuildAsync();

if (clientResult.IsSuccess)
{
    var client = clientResult.ValueOrThrow().Resolved();
    Console.WriteLine($"API client configured for {client.BaseAddress}");
    Console.WriteLine($"Endpoints: {string.Join(", ", client.Endpoints.Select(e => e.Name))}");
}

Traditional vs. FrenchExDev.Net.Builder vs. Fluent Builders

Aspect Traditional Fluent Builder FrenchExDev.Net.Builder
Synchronous Yes Yes No (async-first)
Validation Early,fail-fast Fail-fast Late, accumulate all errors
Circular References Manual handling Not supported Auto-handled via Reference
Concurrency N/A Manual locking Built-in single-flight
Result Typing Value only Value only Result<Reference>
Caching Manual Manual Automatic
Exception Types Limited Limited Custom (TException)
Graph Building Complex Not supported First-class support
Learning Curve Low Low-Medium Medium
Enterprise Ready No Moderate Yes

Testing with FrenchExDev.Net.Builder

Because validation and instantiation are separated, builders are highly testable:

[TestFixture]
public class UserBuilderTests
{
    [Test]
    public async Task BuildAsync_WithValidData_ReturnsSuccess()
    {
        // Arrange
        var builder = new UserBuilder()
            .WithEmail("john@example.com")
            .WithName("John Doe");

        // Act
        var result = await builder.BuildAsync();

        // Assert
        Assert.True(result.IsSuccess);
        var user = result.ValueOrThrow().Resolved();
        Assert.Equal("john@example.com", user.Email);
        Assert.Equal("John Doe", user.Name);
    }

    [Test]
    public async Task BuildAsync_WithMissingEmail_ReturnsFMemberValidationError()
    {
        // Arrange
        var builder = new UserBuilder()
            .WithName("John Doe");

        // Act
        var result = await builder.BuildAsync();

        // Assert
        Assert.False(result.IsSuccess);
        var errors = result.ValidationResult!.ErrorMessage;
        Assert.Contains("Email is required", errors);
    }

    [Test]
    public async Task BuildAsync_MultipleCalls_ReturnsCachedReference()
    {
        // Arrange
        var builder = new UserBuilder()
            .WithEmail("test@example.com")
            .WithName("Test User");

        // Act
        var result1 = await builder.BuildAsync();
        var result2 = await builder.BuildAsync();

        // Assert
        Assert.True(result1.IsSuccess);
        Assert.True(result2.IsSuccess);
        
        // Same reference instance (cached)
        var ref1 = result1.ValueOrThrow();
        var ref2 = result2.ValueOrThrow();
        Assert.Same(ref1, ref2);
    }

    [Test]
    public async Task BuildAsync_WithCancellation_ThrowsOperationCanceledException()
    {
        // Arrange
        var builder = new LongRunningBuilder();
        var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(10));

        // Act & Assert
        await Assert.ThrowsAsync<OperationCanceledException>(
            () => builder.BuildAsync(cancellationToken: cts.Token));
    }
}

Inheritance and Shared Validation

For complex hierarchies, use abstract base builders:

public abstract class BaseEntityBuilder<T> : AbstractBuilder<T> where T : BaseEntity, new()
{
    protected Guid _id = Guid.NewGuid();
    protected DateTime _createdAt = DateTime.UtcNow;

    public void SetId(Guid id) => _id = id;
    public void SetCreatedAt(DateTime date) => _createdAt = date;

    // Common validations for all entities
    protected void AddBaseValidations(ValidationResult result)
    {
        if (_id == Guid.Empty)
            result.AddError(new MemberName(nameof(_id), GetType()),
                new InvalidOperationException("ID cannot be empty"));

        if (_createdAt == default)
            result.AddError(new MemberName(nameof(_createdAt), GetType()),
                new InvalidOperationException("CreatedAt must be set"));
    }
}

public class SpecificEntityBuilder : BaseEntityBuilder<SpecificEntity>
{
    private string _name = string.Empty;

    public SpecificEntityBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    protected override Task<Result<ValidationResult>> ValidateAsync(CancellationToken ct)
    {
        var result = new ValidationResult();
        
        // Add inherited validations
        AddBaseValidations(result);
        
        // Add specific validations
        if (string.IsNullOrEmpty(_name))
            result.AddError(new MemberName(nameof(_name), typeof(SpecificEntityBuilder)),
                new InvalidOperationException("Name is required"));

        return Task.FromResult(Result<ValidationResult>.Success(result));
    }

    // ... Instantiate and BuildException methods
}

Conclusion

The Builder pattern remains a cornerstone of maintainable object construction, and FrenchExDev.Net.Builder elevates it to production-grade complexity. By providing:

  • Async-first architecture - Natural fit for modern async/await .NET applications
  • Comprehensive validation - Accumulate and report all errors at once, not fail-fast
  • Automatic reference handling - Circular references and object graphs handled transparently
  • Built-in concurrency - Single-flight pattern with caching prevents redundant work
  • Type-safe results - Result<Reference<T>> conveys both success/failure and lazy evaluation
  • Strong testability - Separated concerns enable unit testing each phase independently

The library excels in enterprise scenarios:

  • Configuration objects with many optional parameters and async initialization
  • Complex object graphs with parent-child relationships and circular references
  • Multi-step initialization requiring async I/O (database, API, file system)
  • Concurrent building where multiple requests must coordinate
  • Validation-heavy domain models where error accumulation is critical
  • Domain-driven design where builders act as factories with rich behavior

When to Use FrenchExDev.Net.Builder

  • Building objects with 5+ configuration options
  • Async initialization is required
  • Circular references exist in your object graph
  • Comprehensive validation error reporting needed
  • Multiple concurrent builders accessing same resource
  • Building test data fixtures that mirror production scenarios

When Traditional Builders Suffice

  • Simple synchronous object construction
  • Fail-fast validation is appropriate
  • No circular references
  • Every parameter required (no optional config)

By adopting FrenchExDev.Net.Builder systematically, teams build more resilient, maintainable, and testable .NET applications that handle real-world complexity with elegance.

FrenchExDev.Net.Builder is used throughout the ecosystem: BinaryWrapper generates fluent command builders from CLI help text, the Docker Compose Bundle generates model builders from JSON Schema, and DDD entity factories use AbstractBuilder<T> for validated aggregate construction.