Back to blog
Backend Systemsbeginner

Builder Pattern in C#

Learn the Builder pattern for constructing complex objects step-by-step. See fluent builder APIs, test data builders, and query builders in real C# code.

Asma HafeezApril 17, 20264 min read
csharpdesign-patternsbuilderdotnetfluent-api
Share:𝕏

Builder Pattern

Builder constructs complex objects step-by-step. Instead of a constructor with 10 parameters (hard to read, error-prone), you get a fluent API that reads like a sentence.


The Problem

C#
// Confusing — what does each parameter mean?
var email = new Email("alice@example.com", "bob@example.com", "Welcome!", "<h1>Hi</h1>", true, 3, null, "urgent");

// Better with named parameters... but still not great at scale
var email = new Email(
    from:     "alice@example.com",
    to:       "bob@example.com",
    subject:  "Welcome!",
    body:     "<h1>Hi</h1>",
    isHtml:   true,
    retryCount: 3,
    cc:       null,
    priority: "urgent"
);

Fluent Builder

C#
public class Email
{
    public string From     { get; init; } = string.Empty;
    public string To       { get; init; } = string.Empty;
    public string Subject  { get; init; } = string.Empty;
    public string Body     { get; init; } = string.Empty;
    public bool   IsHtml   { get; init; }
    public string Priority { get; init; } = "normal";
    public List<string> Cc { get; init; } = [];
}

public class EmailBuilder
{
    private string _from    = string.Empty;
    private string _to      = string.Empty;
    private string _subject = string.Empty;
    private string _body    = string.Empty;
    private bool   _isHtml  = false;
    private string _priority = "normal";
    private readonly List<string> _cc = [];

    public EmailBuilder From(string from)       { _from = from;       return this; }
    public EmailBuilder To(string to)           { _to = to;           return this; }
    public EmailBuilder WithSubject(string sub) { _subject = sub;     return this; }
    public EmailBuilder WithBody(string body)   { _body = body;       return this; }
    public EmailBuilder AsHtml()                { _isHtml = true;     return this; }
    public EmailBuilder Urgent()                { _priority = "high"; return this; }
    public EmailBuilder Cc(string address)      { _cc.Add(address);   return this; }

    public Email Build()
    {
        if (string.IsNullOrWhiteSpace(_from)) throw new InvalidOperationException("From is required");
        if (string.IsNullOrWhiteSpace(_to))   throw new InvalidOperationException("To is required");

        return new Email
        {
            From     = _from,
            To       = _to,
            Subject  = _subject,
            Body     = _body,
            IsHtml   = _isHtml,
            Priority = _priority,
            Cc       = [.._cc]
        };
    }
}

// Usage — reads like English
var email = new EmailBuilder()
    .From("alice@example.com")
    .To("bob@example.com")
    .WithSubject("Welcome!")
    .WithBody("<h1>Hello Bob!</h1>")
    .AsHtml()
    .Cc("manager@example.com")
    .Urgent()
    .Build();

Test Data Builder

Builders shine in tests — create valid default objects and override only what the test needs.

C#
public class OrderBuilder
{
    private int     _customerId = 1;
    private decimal _total      = 100m;
    private string  _status     = "pending";
    private DateTime _createdAt = DateTime.UtcNow;

    public OrderBuilder WithCustomer(int id)    { _customerId = id;  return this; }
    public OrderBuilder WithTotal(decimal t)    { _total = t;        return this; }
    public OrderBuilder WithStatus(string s)    { _status = s;       return this; }
    public OrderBuilder CreatedAt(DateTime dt)  { _createdAt = dt;   return this; }

    public Order Build() => new Order
    {
        CustomerId = _customerId,
        Total      = _total,
        Status     = _status,
        CreatedAt  = _createdAt,
    };
}

// Tests read clearly — only the relevant property is set
[Fact]
public void LargeOrders_GetFreeShipping()
{
    var order = new OrderBuilder().WithTotal(500m).Build();
    Assert.True(shippingService.IsFreeShipping(order));
}

[Fact]
public void CancelledOrders_CannotBeShipped()
{
    var order = new OrderBuilder().WithStatus("cancelled").Build();
    Assert.False(shippingService.CanShip(order));
}

Query Builder

Builders are also excellent for dynamic SQL or API queries.

C#
public class ProductQueryBuilder
{
    private string?  _search;
    private string?  _category;
    private decimal? _minPrice;
    private decimal? _maxPrice;
    private string   _sort = "name";
    private bool     _ascending = true;
    private int      _page = 1;
    private int      _pageSize = 20;

    public ProductQueryBuilder Search(string term)       { _search = term;   return this; }
    public ProductQueryBuilder Category(string cat)      { _category = cat;  return this; }
    public ProductQueryBuilder PriceRange(decimal min, decimal max)
    {
        _minPrice = min; _maxPrice = max; return this;
    }
    public ProductQueryBuilder SortBy(string field, bool asc = true)
    {
        _sort = field; _ascending = asc; return this;
    }
    public ProductQueryBuilder Page(int page, int size = 20)
    {
        _page = page; _pageSize = size; return this;
    }

    public IQueryable<Product> Apply(IQueryable<Product> query)
    {
        if (_search is not null)
            query = query.Where(p => p.Name.Contains(_search));
        if (_category is not null)
            query = query.Where(p => p.Category == _category);
        if (_minPrice.HasValue)
            query = query.Where(p => p.Price >= _minPrice.Value);
        if (_maxPrice.HasValue)
            query = query.Where(p => p.Price <= _maxPrice.Value);

        query = _ascending
            ? query.OrderBy(p => EF.Property<object>(p, _sort))
            : query.OrderByDescending(p => EF.Property<object>(p, _sort));

        return query.Skip((_page - 1) * _pageSize).Take(_pageSize);
    }
}

// Usage
var results = new ProductQueryBuilder()
    .Category("Electronics")
    .PriceRange(50, 500)
    .SortBy("price", ascending: false)
    .Page(1, 10)
    .Apply(db.Products)
    .ToListAsync();

Key Takeaways

  1. Builder is best when constructing objects with many optional parameters or complex validation
  2. Return this from each method to enable fluent chaining
  3. Test data builders with sensible defaults let tests set only what's relevant — tests become readable
  4. Validate in Build() — catch missing required fields before creating the object
  5. init properties (C# 9+) give you immutable objects that still work with the builder pattern

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.