Remigiusz ZalewskiRemigiusz Zalewski

Fluent Builder Design Pattern in C#

fluent-builder

Introduction

You know the constructor. Ten parameters, four of them bool, two DateTime, and a call site that reads new Test(name, desc, start, end, true, tags, difficulty, 50, sections) where nobody can tell you what true means without opening the definition. Object initializers help, but they cannot enforce required fields, run validation, or transform input on the way in.

The fluent builder pattern separates how you describe an object from how it gets constructed. You call a chain of named methods - WithBasicInfo(...), WithTags(...), WithSections(...) - each returning the builder, then Build() to get the finished object. This video builds one for a Test entity (an exam/quiz) in an ASP.NET Core minimal API on .NET 9.

🎬 Watch the full video here:


The mechanics

A builder holds a partially-constructed instance and exposes methods that set parts of it, each returning this:

public class TestBuilder
{
    private readonly Test _test = new();

    public TestBuilder WithBasicInfo(string name, string description, DateTime start, DateTime end)
    {
        _test.Id = Guid.NewGuid();
        _test.TestName = name;
        _test.Description = description;
        _test.StartDate = start;
        _test.EndDate = end;
        return this;
    }

    public TestBuilder WithTags(List<string> tags) { _test.Tags = tags; return this; }
    public TestBuilder WithDifficulty(TestDifficulty difficulty) { _test.Difficulty = difficulty; return this; }

    public Test Build() => _test;
}

return this is the whole trick. Because every method hands the builder back, calls chain:

var test = new TestBuilder()
    .WithBasicInfo(request.TestName, request.Description, request.StartDate, request.EndDate)
    .WithIsActive(request.IsActive)
    .WithTags(request.Tags)
    .WithDifficulty(request.Difficulty)
    .WithMaxParticipants(request.MaxParticipants)
    .WithSections(request.Sections)
    .Build();

The call site now documents itself. Each line says what it does.

The builder is also where transformation happens

Notice WithSections does not just assign - it maps request DTOs to domain objects:

public TestBuilder WithSections(List<CreateTestRequestSection> sections)
{
    _test.Sections = sections.Select(s => new TestSection
    {
        Title = s.Title,
        TimeLimit = s.TimeLimit,
        QuestionCount = s.QuestionCount
    }).ToList();
    return this;
}

This is a real advantage over an object initializer. The builder is a natural home for input mapping, default values, computed fields (Id = Guid.NewGuid()), and - in a fuller version - validation inside Build() that throws if a required step was skipped.

Builder vs the alternatives

  • Many-parameter constructor: unreadable at the call site, hard to evolve, no room for logic.
  • Object initializer: readable, but cannot enforce required fields, run validation, or transform values.
  • record with init properties: great for simple immutable data; still no validation or transformation step, and with expressions get unwieldy for large objects.
  • Fluent builder: verbose to write (you maintain a second class), but the call site is clear, construction logic has a home, and you can enforce invariants in Build().

Reach for a builder when an object has many optional parts, needs validation or transformation during construction, or is assembled in several different configurations (the classic example being test-data builders in unit tests).

Where you already use this pattern

WebApplication.CreateBuilder(args) then builder.Services.Add... then builder.Build() is the fluent builder pattern. So is StringBuilder, DbContextOptionsBuilder, and most of the ASP.NET Core startup API. Recognizing it in the framework makes the hand-rolled version feel less exotic.

Common pitfalls

  • No validation in Build(). If the pattern's selling point is enforcing a valid object, Build() should check that required steps ran and throw a clear error otherwise.
  • Mutable object leaking early. Returning _test directly means callers can mutate it after Build(). For a true immutable result, construct a fresh object inside Build() from the accumulated values.
  • Reusing a builder instance. After Build(), the internal object is shared. Treat a builder as single-use unless you explicitly reset it.
  • Building a builder for a two-field class. The ceremony is not worth it for simple objects - use an initializer or a record.

Key Takeaways

  • A fluent builder replaces a hard-to-read constructor with a chain of named WithX(...) steps, ending in Build().
  • return this from every step is what makes the chain work.
  • The builder is the right place for input mapping, defaults, computed fields, and validation - things an object initializer cannot do.
  • Prefer it when an object has many optional parts, needs construction-time logic, or is assembled in multiple configurations; skip it for simple data.
  • Put validation in Build() and consider constructing a fresh, immutable instance there.
  • You already use this pattern every time you write WebApplication.CreateBuilder(args).

Get the Full Source Code

The complete runnable solution - the Test model, the TestBuilder, the request DTOs, and the minimal API endpoint that uses the builder - is available to Patreon supporters. If you want to POST a request and watch the builder assemble the object instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources