Some objects are simple, like a string, number, or boolean. You create them in one line and move on.

Other objects aren't simple at all, like a carousel widget that needs an item count, an item builder function, a controller, a height, a viewport fraction, autoplay settings, page change callbacks, and infinite scroll configuration. Or like an HTTP request that needs a URL, headers, authentication tokens, a body, a timeout, and retry logic. Or a notification that needs a title, body, icon, channel, priority, sound, vibration, and action buttons.

When you need to construct objects like these, the naïve approach is a constructor with many parameters. It works, but it creates problems that compound as the object grows more complex. Parameters become hard to tell apart. Optional parameters require null checks everywhere. The order of arguments matters and is easy to get wrong. The constructor call becomes a wall of values that nobody wants to read or maintain.

The Builder Design Pattern solves this. It separates the construction of a complex object from its representation, allowing the same construction process to create different configurations through a readable, step-by-step interface.

Table of Contents

Prerequisites

Before reading this article, you should be comfortable with:

  • Object-oriented programming: classes, constructors, and methods

  • What a design pattern is at a conceptual level

  • Basic Dart or C# syntax

You don't need prior experience with design patterns. This article introduces the Builder pattern from first principles.

What is the Builder Pattern?

The Builder Pattern is a creational design pattern. Creational patterns deal with how objects are created. The Builder pattern specifically deals with the construction of complex objects that require many configuration steps.

The pattern separates two concerns that are often tangled together in simpler code: what an object is, and how it's built. The object holds its own data and behavior. The Builder holds the construction logic and accumulates the configuration step by step before producing the final object.

The result is a construction process that reads like a description of what you're building rather than a list of values to pass to a constructor.

The Problem It Solves

Here's what constructing a complex widget looks like without the Builder pattern:

// constructing a carousel directly — hard to read, easy to get wrong
CarouselSlider.builder(
  options: CarouselOptions(
    height: 200,
    viewportFraction: 0.97,
    enableInfiniteScroll: false,
    autoPlayCurve: Curves.easeIn,
    enlargeCenterPage: true,
    pauseAutoPlayOnManualNavigate: true,
    onPageChanged: onPageChanged,
    autoPlay: false,
  ),
  itemBuilder: (context, index, realIndex) => AdCard(ad: ads[index]),
  itemCount: ads.length,
)

This works. But look at what happens when you need to create two different carousels in the same screen: one for ads and one for account balances. Both need different heights, viewport fractions, item builders, and item counts. You copy the entire construction block, modify the values, and now you have two walls of configuration that are visually similar but subtly different.

When a new requirement comes in to add autoplay to the ads carousel but not the balance carousel, you have to find the right block, modify it carefully, and hope you're modifying the right one.

The deeper problem is that the construction logic is scattered across the codebase. Every place a carousel is created knows all the details of carousel construction. There's no single place where that knowledge lives.

The Builder pattern collects construction knowledge into one place and exposes it through a clean interface.

Core Components

The Builder pattern has three components.

The Product

The complex object being built. It doesn't know about the Builder. It holds its configuration and has behavior based on that configuration. The Product is often constructed with a private constructor so it can only be created by its Builder.

The Builder

The class responsible for accumulating configuration and producing the Product. Each method on the Builder configures one aspect of the Product and returns the Builder itself. This return is what enables method chaining. The final method on the Builder produces the completed Product.

The Director (optional)

A class that knows how to use a Builder to produce specific pre-configured Products. The Director encodes the knowledge of how to build common configurations so callers don't need to know the details. In practice, a Factory method often serves this role.

Method Chaining: The Fluent Interface

Method chaining is the technique that makes Builder code read naturally. Each Builder method returns this (the Builder itself) so the next method call can follow immediately on the same line or the next line.

// without method chaining
final builder = RequestBuilder();
builder.setUrl('https://api.example.com/users');
builder.setMethod('POST');
builder.addHeader('Authorization', 'Bearer $token');
builder.setBody({'name': 'John'});
final request = builder.build();

// with method chaining
final request = RequestBuilder()
    .setUrl('https://api.example.com/users')
    .setMethod('POST')
    .addHeader('Authorization', 'Bearer $token')
    .setBody({'name': 'John'})
    .build();

Both produce exactly the same result. The chained version reads like a sentence describing the request. The unchained version is a sequence of imperative statements.

Method chaining is sometimes called a Fluent Interface. The name comes from how the code reads: fluently, like natural language, from left to right or top to bottom.

This is a real production implementation from a Flutter fintech application. The app needs to show two different carousels on the dashboard: one for promotional ads and one for account balances. Each carousel has a different configuration but shares the same underlying construction mechanism.

The Configuration Objects

import 'package:flutter/widgets.dart';
import 'package:equatable/equatable.dart';
import 'package:carousel_slider/carousel_slider.dart';

class CarouselArgs extends Equatable {
  final CarouselSliderController? carouselController;
  final int itemCount;
  final Widget Function(BuildContext, int, int) itemBuilder;
  final CarouselOptions options;

  const CarouselArgs({
    this.carouselController,
    required this.itemCount,
    required this.itemBuilder,
    required this.options,
  });

  @override
  List<Object?> get props => [
        carouselController,
        itemCount,
        itemBuilder,
        options,
      ];
}

class CarouselOptions extends Equatable {
  final double? height;
  final double? viewPortFraction;
  final bool? enableInfiniteScroll;
  final bool? enlargeCenterPage;
  final bool? pauseAutoPlayOnManualNavigate;
  final bool? autoplay;
  final Curve? autoplayCurve;
  final void Function(int, CarouselPageChangedReason)? onPageChanged;

  const CarouselOptions({
    this.height,
    this.viewPortFraction,
    this.enableInfiniteScroll,
    this.enlargeCenterPage,
    this.pauseAutoPlayOnManualNavigate,
    this.autoplay,
    this.autoplayCurve,
    this.onPageChanged,
  });

  @override
  List<Object?> get props => [
        height,
        viewPortFraction,
        enableInfiniteScroll,
        enlargeCenterPage,
        pauseAutoPlayOnManualNavigate,
        autoplay,
        autoplayCurve,
        onPageChanged,
      ];
}

CarouselArgs and CarouselOptions are the configuration objects. They hold all the data needed to construct a carousel. They're simple data containers with no construction logic of their own.

The Product

import 'package:flutter/material.dart';
import 'package:carousel_slider/carousel_slider.dart' as n;

class CustomCarousel extends StatelessWidget {
  final CarouselArgs dto;

  // private constructor only the Builder can create this widget
  CustomCarousel._builder(CustomCarouselBuilder builder)
      : dto = builder._dto!;

  @override
  Widget build(BuildContext context) {
    return n.CarouselSlider.builder(
      options: n.CarouselOptions(
        height: dto.options.height,
        viewportFraction: dto.options.viewPortFraction!,
        enableInfiniteScroll: dto.options.enableInfiniteScroll!,
        enlargeCenterPage: dto.options.enlargeCenterPage,
        onPageChanged: dto.options.onPageChanged,
        autoPlayCurve: dto.options.autoplayCurve!,
        autoPlay: dto.options.autoplay!,
      ),
      itemBuilder: dto.itemBuilder,
      itemCount: dto.itemCount,
    );
  }
}

CustomCarousel is the Product. Its constructor is private: CustomCarousel._builder. The underscore prefix and the named constructor ensure that nobody outside this class can instantiate a CustomCarousel directly. The only way to create one is through the Builder.

This is intentional. It enforces that all carousel construction goes through the Builder, where the configuration is validated and assembled consistently.

The Builder

class CustomCarouselBuilder {
  BuildContext? _context;
  CarouselArgs? _dto;

  CustomCarouselBuilder setArgs(BuildContext context, CarouselArgs value) {
    _context = context;
    _dto = value;
    return this;
  }

  Widget get buildCarousel =>
      CustomCarousel._builder(this).build(_context!);
}

CustomCarouselBuilder is the Builder. It accumulates the BuildContext and the CarouselArgs through setArgs. The setArgs method returns this, enabling the call to be chained.

buildCarousel is the terminal step. It calls the private constructor of CustomCarousel, passing itself as the argument, and then calls build to produce the final widget. The carousel can't be built until both the context and the args have been provided.

The Director: A Factory That Uses the Builder

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:carousel_slider/carousel_slider.dart' as n;

class CarouselWidgetFactory {
  static CustomCarouselBuilder showCarouselAds(
    BuildContext context, {
    void Function(int, n.CarouselPageChangedReason)? onPageChanged,
  }) =>
      CustomCarouselBuilder().setArgs(
        context,
        CarouselArgs(
          itemCount: context.read<DashboardLogic>().dashboardAds.length,
          itemBuilder: (context, index, realIndex) => DashboardAdsList(
            dto: context.read<DashboardLogic>().dashboardAds[index],
          ),
          options: CarouselOptions(
            height: 200,
            viewPortFraction: 0.97,
            enableInfiniteScroll: false,
            autoplayCurve: Curves.easeIn,
            enlargeCenterPage: true,
            pauseAutoPlayOnManualNavigate: true,
            onPageChanged: onPageChanged,
            autoplay: false,
          ),
        ),
      );

  static CustomCarouselBuilder showCarouselAccountBalance(
    BuildContext context, {
    void Function(int, n.CarouselPageChangedReason)? onPageChanged,
  }) =>
      CustomCarouselBuilder().setArgs(
        context,
        CarouselArgs(
          itemCount: context.read<DashboardLogic>().allBalances.length,
          itemBuilder: (context, index, realIndex) => BalanceList(
            dto: context.read<DashboardLogic>().allBalances[index],
          ),
          options: CarouselOptions(
            height: 230,
            viewPortFraction: 1,
            enableInfiniteScroll: false,
            autoplayCurve: Curves.decelerate,
            enlargeCenterPage: true,
            pauseAutoPlayOnManualNavigate: true,
            onPageChanged: onPageChanged,
            autoplay: false,
          ),
        ),
      );
}

CarouselWidgetFactory is the Director. It knows exactly how to configure the Builder for each specific carousel type. The knowledge of what a dashboard ads carousel looks like (height 200, viewport 0.97, easeIn curve) lives in one place. The knowledge of what an account balance carousel looks like (height 230, viewport 1, decelerate curve) lives in one place.

A developer who needs to add a new carousel type adds one static method to CarouselWidgetFactory. They don't need to understand the internals of CustomCarouselBuilder or CustomCarousel. They describe what they want using the Factory.

Using It

class DashboardPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // the factory creates the right builder configuration
        // buildCarousel produces the final widget
        CarouselWidgetFactory.showCarouselAds(context).buildCarousel,
        const SizedBox(height: 16),
        CarouselWidgetFactory.showCarouselAccountBalance(context).buildCarousel,
      ],
    );
  }
}

Two carousels, two lines. The calling code has zero knowledge of carousel configuration. It doesn't know about viewport fractions, autoplay curves, or item builders. It calls the Factory, which uses the Builder, which produces the Product. Each layer knows only what it needs to know.

Real World Example Two: HTTP Request Builder

The carousel example shows Builder for widget construction. This second example shows it for non-UI object construction: building HTTP requests. This is a common pattern in data layers and API clients.

The Product

class ApiRequest {
  final String url;
  final String method;
  final Map<String, String> headers;
  final Map<String, dynamic>? body;
  final Duration timeout;
  final int maxRetries;

  // private constructor — only the Builder can create ApiRequest
  ApiRequest._({
    required this.url,
    required this.method,
    required this.headers,
    this.body,
    required this.timeout,
    required this.maxRetries,
  });
}

ApiRequest holds everything needed to make an HTTP request. Its private constructor ensures it's always created through the Builder, where defaults are applied and validation happens.

The Builder

class ApiRequestBuilder {
  String? _url;
  String _method = 'GET';
  final Map<String, String> _headers = {};
  Map<String, dynamic>? _body;
  Duration _timeout = const Duration(seconds: 30);
  int _maxRetries = 0;

  ApiRequestBuilder url(String url) {
    _url = url;
    return this;
  }

  ApiRequestBuilder method(String method) {
    _method = method;
    return this;
  }

  ApiRequestBuilder header(String key, String value) {
    _headers[key] = value;
    return this;
  }

  ApiRequestBuilder bearerToken(String token) {
    _headers['Authorization'] = 'Bearer $token';
    return this;
  }

  ApiRequestBuilder contentType(String type) {
    _headers['Content-Type'] = type;
    return this;
  }

  ApiRequestBuilder body(Map<String, dynamic> body) {
    _body = body;
    return this;
  }

  ApiRequestBuilder timeout(Duration timeout) {
    _timeout = timeout;
    return this;
  }

  ApiRequestBuilder withRetries(int maxRetries) {
    _maxRetries = maxRetries;
    return this;
  }

  ApiRequest build() {
    if (_url == null || _url!.isEmpty) {
      throw ArgumentError('URL is required to build an ApiRequest');
    }

    return ApiRequest._(
      url: _url!,
      method: _method,
      headers: Map.unmodifiable(_headers),
      body: _body,
      timeout: _timeout,
      maxRetries: _maxRetries,
    );
  }
}

Each method on ApiRequestBuilder sets one configuration value and returns this. The build() method is the terminal step. It validates that required fields are present and constructs the immutable ApiRequest.

Notice that _method, _timeout, and _maxRetries all have sensible defaults. A caller doesn't need to specify these unless they want to override the defaults. This is one of the key advantages of the Builder over a constructor: optional configuration is genuinely optional, with no null checks or default parameter workarounds.

Using It

// a standard authenticated POST request
final createUserRequest = ApiRequestBuilder()
    .url('https://api.example.com/users')
    .method('POST')
    .bearerToken(authToken)
    .contentType('application/json')
    .body({'name': 'Oluwaseyi', 'email': 'seyi@example.com'})
    .timeout(const Duration(seconds: 15))
    .build();

// a GET request with retry logic
final getUserRequest = ApiRequestBuilder()
    .url('https://api.example.com/users/$userId')
    .bearerToken(authToken)
    .withRetries(3)
    .build();

// a request with custom headers for a third-party service
final webhookRequest = ApiRequestBuilder()
    .url('https://webhook.example.com/events')
    .method('POST')
    .header('X-API-Key', apiKey)
    .header('X-Webhook-Secret', webhookSecret)
    .contentType('application/json')
    .body(eventPayload)
    .timeout(const Duration(seconds: 5))
    .build();

Each request reads like a description of itself. The URL, the method, the authentication, the body, and the timeout. You can read any of these and understand immediately what kind of request it is and what it contains.

Compare this to calling a constructor directly:

// without Builder — hard to read, parameter order matters
final request = ApiRequest._(
  url: 'https://api.example.com/users',
  method: 'POST',
  headers: {
    'Authorization': 'Bearer $authToken',
    'Content-Type': 'application/json',
  },
  body: {'name': 'Oluwaseyi', 'email': 'seyi@example.com'},
  timeout: const Duration(seconds: 15),
  maxRetries: 0,
);

The constructor version requires you to know every field and its order. The Builder version lets you specify only what you need and reads like documentation.

The Builder Pattern in C#

The same pattern in C# demonstrates that this is a universal design principle, not a Dart-specific technique. C# is particularly expressive for Builder implementations because of its method chaining conventions.

HTTP Request Builder in C#

public class ApiRequest
{
    public string Url { get; }
    public string Method { get; }
    public Dictionary<string, string> Headers { get; }
    public object? Body { get; }
    public TimeSpan Timeout { get; }
    public int MaxRetries { get; }

    // private constructor
    private ApiRequest(
        string url,
        string method,
        Dictionary<string, string> headers,
        object? body,
        TimeSpan timeout,
        int maxRetries)
    {
        Url = url;
        Method = method;
        Headers = headers;
        Body = body;
        Timeout = timeout;
        MaxRetries = maxRetries;
    }

    public static ApiRequestBuilder Create() => new ApiRequestBuilder();
}

public class ApiRequestBuilder
{
    private string? _url;
    private string _method = "GET";
    private readonly Dictionary<string, string> _headers = new();
    private object? _body;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);
    private int _maxRetries = 0;

    public ApiRequestBuilder Url(string url)
    {
        _url = url;
        return this;
    }

    public ApiRequestBuilder Method(string method)
    {
        _method = method;
        return this;
    }

    public ApiRequestBuilder Header(string key, string value)
    {
        _headers[key] = value;
        return this;
    }

    public ApiRequestBuilder BearerToken(string token)
    {
        _headers["Authorization"] = $"Bearer {token}";
        return this;
    }

    public ApiRequestBuilder ContentType(string contentType)
    {
        _headers["Content-Type"] = contentType;
        return this;
    }

    public ApiRequestBuilder Body(object body)
    {
        _body = body;
        return this;
    }

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

    public ApiRequestBuilder WithRetries(int maxRetries)
    {
        _maxRetries = maxRetries;
        return this;
    }

    public ApiRequest Build()
    {
        if (string.IsNullOrEmpty(_url))
            throw new ArgumentException("URL is required");

        return new ApiRequest(
            _url!,
            _method,
            new Dictionary<string, string>(_headers),
            _body,
            _timeout,
            _maxRetries
        );
    }
}

Using It in C#

// authenticated POST request
var createUserRequest = ApiRequest.Create()
    .Url("https://api.example.com/users")
    .Method("POST")
    .BearerToken(authToken)
    .ContentType("application/json")
    .Body(new { name = "Oluwaseyi", email = "seyi@example.com" })
    .Timeout(TimeSpan.FromSeconds(15))
    .Build();

// GET with retry logic
var getUserRequest = ApiRequest.Create()
    .Url($"https://api.example.com/users/{userId}")
    .BearerToken(authToken)
    .WithRetries(3)
    .Build();

The pattern is identical. The method names are capitalized following C# conventions. The Build() method is the terminal step. The private constructor is enforced. The result reads exactly like its Dart equivalent.

A UI Builder in C# (ASP.NET)

The Builder pattern also appears naturally in .NET for constructing complex objects in backend systems. Here's a notification builder:

public class Notification
{
    public string Title { get; }
    public string Body { get; }
    public string? ImageUrl { get; }
    public NotificationPriority Priority { get; }
    public Dictionary<string, string> Data { get; }
    public bool Silent { get; }

    private Notification(
        string title,
        string body,
        string? imageUrl,
        NotificationPriority priority,
        Dictionary<string, string> data,
        bool silent)
    {
        Title = title;
        Body = body;
        ImageUrl = imageUrl;
        Priority = priority;
        Data = data;
        Silent = silent;
    }

    public static NotificationBuilder Builder(string title, string body)
        => new NotificationBuilder(title, body);
}

public class NotificationBuilder
{
    private readonly string _title;
    private readonly string _body;
    private string? _imageUrl;
    private NotificationPriority _priority = NotificationPriority.Default;
    private readonly Dictionary<string, string> _data = new();
    private bool _silent = false;

    internal NotificationBuilder(string title, string body)
    {
        _title = title;
        _body = body;
    }

    public NotificationBuilder WithImage(string imageUrl)
    {
        _imageUrl = imageUrl;
        return this;
    }

    public NotificationBuilder WithPriority(NotificationPriority priority)
    {
        _priority = priority;
        return this;
    }

    public NotificationBuilder WithData(string key, string value)
    {
        _data[key] = value;
        return this;
    }

    public NotificationBuilder AsSilent()
    {
        _silent = true;
        return this;
    }

    public Notification Build() => new Notification(
        _title,
        _body,
        _imageUrl,
        _priority,
        new Dictionary<string, string>(_data),
        _silent
    );
}

// usage
var notification = Notification
    .Builder("New Transaction", "You received NGN 50,000")
    .WithPriority(NotificationPriority.High)
    .WithData("transaction_id", "txn_001")
    .WithData("type", "credit")
    .Build();

var silentNotification = Notification
    .Builder("Background Sync", "")
    .AsSilent()
    .WithData("sync_type", "full")
    .Build();

Builder vs Constructor vs Factory

Understanding when to reach for a Builder versus a constructor or a Factory method requires understanding what problem each one solves.

A constructor is the right choice when the object is simple enough that all its parameters can be understood at a glance and there are few optional configurations. A User with an id, name, and email doesn't need a Builder.

A Factory method is the right choice when you need to control which type of object is created, or when creation requires logic that determines which concrete type to instantiate. A Repository.create() that returns either a SqlRepository or a HiveRepository based on the environment is a Factory.

A Builder is the right choice when the object has many optional or complex configuration parameters, when the construction requires multiple steps, when you want to prevent the creation of invalid objects by deferring construction until all required parameters are present, or when you want construction code to read clearly and be self-documenting.

The carousel example uses both Builder and Factory together deliberately. The Factory provides named, pre-configured entry points (showCarouselAds, showCarouselAccountBalance). The Builder handles the step-by-step construction of the complex configuration. Each pattern does its job.

When to Use the Builder Pattern

There are various solid use cases for the Builder pattern.

Use it when the object being constructed has many parameters, especially many optional ones. Named constructors with ten optional parameters are hard to read and easy to misconfigure.

It's also a good choice when the construction requires multiple steps that should be validated before the object is created. A Builder can enforce that required fields are present before calling build().

Choose it when you want the construction code to be self-documenting. Method chaining with descriptive names reads like documentation. A reader can understand what is being built without knowing the internals.

It's helpful when you need different representations of the same object. The same Builder can be used to create a test request, a staging request, and a production request by changing which methods are called, without modifying the Request class itself.

And it works well when you want to prevent the creation of invalid objects. By making the Product's constructor private and putting validation in the Builder's build() method, you ensure that invalid objects simply can't be created.

When Not to Use It

Avoid the Builder pattern when the object is simple and its constructor is already clear. Adding a Builder to a class with two required parameters is over-engineering that adds complexity without adding value.

It's also not a great choice when immutability isn't a concern and the object can be configured after creation through property setters. Some objects benefit from simple post-construction configuration rather than Builder-pattern construction.

And it's best to avoid it when the construction steps have strict ordering that a linear Builder can't represent. If step B absolutely must know the result of step A before it can run, a different pattern may be more appropriate.

Conclusion

The Builder Design Pattern addresses a problem that every developer encounters as their objects grow more complex. Constructors with many parameters become walls of values that are hard to read, hard to maintain, and easy to misconfigure. Optional parameters require null checks and default value workarounds that obscure the intent of the code.

The Builder separates the construction of a complex object from the object itself. Configuration accumulates step by step through descriptive method calls. Construction happens in one terminal step that validates and produces the final object. The Product's private constructor ensures that bypassing the Builder isn't possible.

The carousel example from a real Flutter fintech application shows this in a UI context: a Builder that accumulates widget configuration, a Factory that provides pre-configured Builder calls for specific carousel types, and a Product that can only be constructed through its Builder. Adding a new carousel type means one new Factory method. Changing carousel configuration means changing the relevant Factory method. The calling code never touches carousel internals.

The HTTP Request Builder shows the same pattern in a data layer context: step by step configuration through method chaining, sensible defaults for optional values, validation before construction, and an immutable Product that can't be created in an invalid state.

Method chaining is what makes Builder code readable. Each method call returns the Builder, enabling the next call to follow immediately. The chain reads from left to right or top to bottom like a description of what's being built. This isn't just aesthetics. It's what makes Builder code self-documenting and maintainable over time.

Complex object construction is a problem every codebase will encounter. The Builder pattern is how experienced engineers solve it.