StackPractices
beginner By Mathias Paulenko

Builder Pattern for Complex Configuration Objects

Use the Builder pattern to construct complex configuration objects with optional parameters and sensible defaults without telescoping constructors

Topics: design

The Builder pattern separates the construction of a complex object from its representation. Instead of passing eight constructor arguments or creating an empty object and setting fields individually, the builder provides a readable, step-by-step API with defaults and validation.

When to Use This

  • An object has many optional parameters and sensible defaults
  • You want to prevent objects from being created in an invalid state
  • Constructor telescoping becomes unreadable with more than three optional arguments

Problem

Constructing a database connection config with optional pooling, SSL, and retry settings leads to either 12-argument constructors or partially-initialized mutable objects.

Solution

// config/DatabaseConfig.ts
interface DatabaseConfig {
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
  ssl?: boolean;
  poolSize?: number;
  maxRetries?: number;
  connectionTimeout?: number;
}

class DatabaseConfigBuilder {
  private config: Partial<DatabaseConfig> = {
    port: 5432,
    ssl: false,
    poolSize: 10,
    maxRetries: 3,
    connectionTimeout: 5000,
  };

  setHost(host: string): this {
    this.config.host = host;
    return this;
  }

  setPort(port: number): this {
    this.config.port = port;
    return this;
  }

  setCredentials(username: string, password: string): this {
    this.config.username = username;
    this.config.password = password;
    return this;
  }

  setDatabase(name: string): this {
    this.config.database = name;
    return this;
  }

  enableSSL(): this {
    this.config.ssl = true;
    return this;
  }

  setPoolSize(size: number): this {
    this.config.poolSize = size;
    return this;
  }

  setMaxRetries(retries: number): this {
    this.config.maxRetries = retries;
    return this;
  }

  build(): DatabaseConfig {
    if (!this.config.host || !this.config.username || !this.config.database) {
      throw new Error('Host, username, and database are required');
    }
    return this.config as DatabaseConfig;
  }
}

Usage

const config = new DatabaseConfigBuilder()
  .setHost('db.example.com')
  .setCredentials('app_user', process.env.DB_PASSWORD!)
  .setDatabase('analytics')
  .enableSSL()
  .setPoolSize(20)
  .build();

Variations

  • Immutable Builder: Return a new builder on each step instead of mutating state
  • Director: Encapsulate common configurations behind a director class
  • Step Builder: Enforce build order through separate interfaces for each step

What Works

  • Validate only at build() time, not on every setter; see Builder pattern for validation strategies
  • Return this for method chaining (fluent interface)
  • Freeze or seal the returned object to prevent post-construction mutation

Common Mistakes

  • Adding business logic to the builder instead of keeping it as pure construction
  • Forgetting to reset internal state when a builder is reused
  • Returning partially built objects without validation
  • Overusing builders for simple objects with 2-3 parameters
  • Mixing validation logic with construction logic
  • Not documenting required vs optional parameters
  • Allowing mutable state after build() is called
  • Inconsistent method naming conventions
  • Missing null checks for required parameters
  • Not providing sensible defaults for common use cases

Best Practices

  1. Validate at build time only. Validate all constraints in the build() method to provide complete error context with all validation issues at once.

  2. Provide sensible defaults. Set reasonable default values for optional parameters to reduce the number of required method calls for common use cases.

  3. Use descriptive method names. Method names should clearly indicate what they configure (e.g., enableSSL() vs setSSL(true)).

  4. Document required parameters. Clearly distinguish between required and optional configuration steps in your documentation and code comments.

  5. Make the product immutable. Once build() returns the object, it should not be modifiable. This prevents inconsistent state.

  6. Support environment-specific configurations. Provide factory methods or presets for different environments (development, staging, production).

  7. Handle null gracefully. Decide whether to allow null values or throw exceptions, and be consistent throughout the builder.

  8. Consider thread-safety. If builders are reused across threads, ensure they are either thread-safe or not shared.

  9. Support configuration merging. Allow builders to merge configurations from multiple sources (environment variables, files, programmatic overrides).

  10. Keep builders focused. A builder should construct one type of object. Don’t add unrelated construction logic.

Frequently Asked Questions

When should I prefer a builder over an object literal?

When validation is needed, defaults are complex, or the same construction logic is reused across multiple call sites. For simple cases, consider Factory Method instead.

Is the Builder pattern still relevant with object spread syntax?

Yes. Spreads are convenient for simple cases but do not enforce validation, defaults, or construction order.

How do I handle circular dependencies in configuration builders?

Avoid circular dependencies in configuration. If needed, use lazy initialization or post-construction resolution methods.

Can I use builders for nested configuration objects?

Yes. Builders can accept other builders as parameters, enabling composition of complex configurations from simpler builders.

How do I add support for configuration versioning?

Add version information to the configuration and provide migration logic to handle different versions during construction and serialization.

Should I use builders for API client configuration?

Yes. Builders are excellent for configuring API clients with timeouts, retries, authentication, headers, and other optional settings.

How do I handle configuration validation errors?

Throw exceptions in the build() method with descriptive error messages. Consider collecting all validation errors and throwing a single exception with a list of issues.

Can builders be used for test data configuration?

Yes. Builders are excellent for creating test configurations with variations. Define common configurations as methods and customize as needed for each test.

How do I add support for configuration templates?

Provide methods to load templates and apply them to the builder state. This allows creating configurations from predefined templates with minimal customization.

Should I use builders for logging configuration?

Yes. Builders are excellent for configuring loggers with levels, appenders, formatters, and filters. They provide a clean API for logging setup.