Skip to main content
  1. Blog/

Object Creation Design Patterns: From Messy Constructors to Clean Code

Piotr Muras
Author
Piotr Muras
Table of Contents

As developers, we often juggle tight deadlines, complex requirements, and evolving codebases. In the rush to deliver, it’s tempting to take shortcuts, like using simple constructors or scattering object creation logic throughout our code. While this might work temporarily, it can lead to a tangled web that’s hard to maintain or extend. In this blog post, we’ll delve into the realm of object creation design patterns: Creation Methods, the Factory Pattern, and the Builder Pattern. Through detailed examples hopefully relevant to everyday development, we’ll explore how these patterns can transform messy code into clean, adaptable solutions.

All examples come in Java, Python, C# and JavaScript. Pick your favourite tab and the whole article will follow.

Why Design Patterns Matter Even More in the AI Era
#

AI assistants changed how code gets written, but they didn’t change what good code looks like. If anything, they raised the stakes. Pattern literacy pays off more today than it ever did.

Patterns are how you steer. An AI assistant will happily generate whatever shape of code you ask for. “Refactor this into a builder” is a one-line instruction that reliably produces the right structure. “Clean this up somehow” is a coin flip. The vocabulary of design patterns turns out to be remarkably effective prompt engineering: a compact, unambiguous language for describing the code you want, refined by the industry over thirty years.

You review more than you write. When the assistant generates the code, your job shifts from typing to judging. Patterns compress that judgment. Spot a builder and you know exactly what to check: defaults in one place, validation in build(), immutable product. Spot creation logic smeared across a constructor and you know what to ask for instead. Reviewing AI output without pattern literacy is like proofreading a language you can’t read fluently.

AI amplifies whatever structure it finds. Models imitate the surrounding codebase. Give them a factory with a clear seam and the new action type lands exactly where it should. Give them a four-way overloaded constructor and they will cheerfully generate the fifth overload, comment it nicely, and move on. A well-patterned codebase makes every future AI contribution better. A messy one makes every contribution worse. Compounding works in both directions.

Code is cheap now, and entropy is even cheaper. The marginal cost of “just one more constructor” has dropped to a single sentence in a prompt. Volume is no longer the bottleneck. Judgment is. And judgment about when object creation deserves structure, and when a simple constructor is enough, is precisely the part that didn’t get automated.

So knowing patterns is not obsolete. The skill just changed its job: less typing them out by hand, more recognising when to ask for them and whether what you got back is right. The rest of this article gives you exactly that: what each creational pattern is for, what it looks like when it’s missing, and how to tell good from bad.

The Pitfalls of Basic Constructors
#

Imagine you’re developing an e-commerce system that deals with money everywhere: product prices, discounts, shipping costs, refunds. Monetary values arrive from many places (your own database, payment provider APIs, CSV imports from suppliers) and each source represents them differently. You start with a simple Money class:

public class Money {
    private BigDecimal amount;
    private Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // Other methods...
}
from decimal import Decimal

class Money:
    def __init__(self, amount: Decimal, currency: str):
        self.amount = amount
        self.currency = currency

    # Other methods...
public class Money
{
    private readonly decimal amount;
    private readonly string currency;

    public Money(decimal amount, string currency)
    {
        this.amount = amount;
        this.currency = currency;
    }

    // Other methods...
}
// Note: real-world code should avoid binary floating point for money;
// we keep plain numbers here to focus on the creation patterns.
class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  // Other methods...
}

Initial Simplicity
#

At first, this class works well when you have the amount and currency separately:

Money price = new Money(new BigDecimal("19.99"), Currency.getInstance("PLN"));
price = Money(Decimal("19.99"), "PLN")
var price = new Money(19.99m, "PLN");
const price = new Money(19.99, "PLN");

Growing Complexity
#

However, monetary values might come in different formats:

  • As a single string like "19.99 PLN" from a CSV import.
  • As minor units like 1999 (cents/grosze), the way most payment provider APIs represent amounts.
  • As a map/dictionary containing "amount" and "currency" from a deserialized JSON payload.

To handle these, you might start cramming all of them into the construction logic:

public class Money {
    private BigDecimal amount;
    private Currency currency;

    // Constructor from amount and currency
    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // Constructor from string (e.g., "19.99 PLN")
    public Money(String moneyString) {
        String[] parts = moneyString.split(" ");
        this.amount = new BigDecimal(parts[0]);
        this.currency = Currency.getInstance(parts[1]);
    }

    // Constructor from minor units (e.g., 1999 = 19.99 PLN)
    public Money(long minorUnits, Currency currency) {
        this.currency = currency;
        this.amount = BigDecimal.valueOf(minorUnits)
                .movePointLeft(currency.getDefaultFractionDigits());
    }

    // Constructor from Map
    public Money(Map<String, Object> moneyData) {
        this.amount = new BigDecimal((String) moneyData.get("amount"));
        this.currency = Currency.getInstance((String) moneyData.get("currency"));
    }

    // Other methods...
}
from decimal import Decimal

class Money:
    # Python has no constructor overloading, so a single __init__
    # ends up dispatching on argument types...
    def __init__(self, value, currency=None):
        if isinstance(value, Decimal) and currency is not None:
            self.amount = value
            self.currency = currency
        elif isinstance(value, str):
            # From string (e.g., "19.99 PLN")
            amount, code = value.split(" ")
            self.amount = Decimal(amount)
            self.currency = code
        elif isinstance(value, int) and currency is not None:
            # From minor units (e.g., 1999 = 19.99 PLN)
            # Simplified: assumes a 2-decimal currency
            self.amount = Decimal(value).scaleb(-2)
            self.currency = currency
        elif isinstance(value, dict):
            # From dict (deserialized JSON)
            self.amount = Decimal(value["amount"])
            self.currency = value["currency"]
        else:
            raise TypeError("Unsupported arguments for Money()")

    # Other methods...
public class Money
{
    private readonly decimal amount;
    private readonly string currency;

    // Constructor from amount and currency
    public Money(decimal amount, string currency)
    {
        this.amount = amount;
        this.currency = currency;
    }

    // Constructor from string (e.g., "19.99 PLN")
    public Money(string moneyString)
    {
        var parts = moneyString.Split(' ');
        amount = decimal.Parse(parts[0], CultureInfo.InvariantCulture);
        currency = parts[1];
    }

    // Constructor from minor units (e.g., 1999 = 19.99 PLN)
    public Money(long minorUnits, string currency)
    {
        this.currency = currency;
        // Simplified: assumes a 2-decimal currency
        amount = minorUnits / 100m;
    }

    // Constructor from dictionary
    public Money(IDictionary<string, string> moneyData)
    {
        amount = decimal.Parse(moneyData["amount"], CultureInfo.InvariantCulture);
        currency = moneyData["currency"];
    }

    // Other methods...
}
class Money {
  // JavaScript has neither overloading nor types, so we end up with
  // runtime checks and even an extra flag parameter...
  constructor(value, currency, isMinorUnits = false) {
    if (typeof value === "string" && currency === undefined) {
      // From string (e.g., "19.99 PLN")
      const [amount, code] = value.split(" ");
      this.amount = Number(amount);
      this.currency = code;
    } else if (typeof value === "object" && value !== null) {
      // From object (deserialized JSON)
      this.amount = Number(value.amount);
      this.currency = value.currency;
    } else if (isMinorUnits) {
      // From minor units (e.g., 1999 = 19.99 PLN)
      // Simplified: assumes a 2-decimal currency
      this.amount = value / 100;
      this.currency = currency;
    } else {
      this.amount = value;
      this.currency = currency;
    }
  }

  // Other methods...
}

The Messiness Unveiled
#

Now, the Money class has:

  • Multiple Construction Paths: Each for a different input type.
  • Ambiguous Usage: It’s unclear which variant to use.
  • Complex Logic in Constructors: Parsing and conversion code within constructors.

Usage Becomes Confusing
#

Creating a Money instance now depends on picking the right variant:

// From amount and currency
Money price1 = new Money(new BigDecimal("19.99"), Currency.getInstance("PLN"));

// From string
Money price2 = new Money("19.99 PLN");

// From minor units
Money price3 = new Money(1999, Currency.getInstance("PLN"));

// From Map
Map<String, Object> moneyData = new HashMap<>();
moneyData.put("amount", "19.99");
moneyData.put("currency", "PLN");
Money price4 = new Money(moneyData);
# From amount and currency
price1 = Money(Decimal("19.99"), "PLN")

# From string
price2 = Money("19.99 PLN")

# From minor units, the same call shape as major units!
price3 = Money(1999, "PLN")

# From dict
price4 = Money({"amount": "19.99", "currency": "PLN"})
// From amount and currency
var price1 = new Money(19.99m, "PLN");

// From string
var price2 = new Money("19.99 PLN");

// From minor units
var price3 = new Money(1999L, "PLN");

// From dictionary
var moneyData = new Dictionary<string, string>
{
    ["amount"] = "19.99",
    ["currency"] = "PLN"
};
var price4 = new Money(moneyData);
// From amount and currency
const price1 = new Money(19.99, "PLN");

// From string
const price2 = new Money("19.99 PLN");

// From minor units, only a boolean flag away from a 100x bug
const price3 = new Money(1999, "PLN", true);

// From object
const price4 = new Money({ amount: "19.99", currency: "PLN" });

Issues Arise
#

  • Dangerously Similar Signatures: Money(1999, pln) (minor units) and Money(19.99, pln) (major units) look almost identical at the call site but differ by a factor of 100. This is exactly the kind of bug that silently corrupts financial data.
  • Error-Prone: Easy to use the wrong variant, causing runtime errors, or worse, wrong amounts that pass silently.
  • Maintenance Nightmare: Adding new ways to create Money requires touching the construction logic again, complicating the class.

The Hidden Costs
#

  • Hard to Extend: The class becomes bloated and difficult to maintain.
  • Poor Readability: It’s not clear at a glance what each construction path is for.
  • Violation of Single Responsibility: The class handles both data storage and parsing logic.

Creation Methods: Enhancing Clarity and Intent
#

To address these issues, we can use Creation Methods that provide meaningful names and encapsulate the instantiation logic.

Refactoring with Creation Methods
#

Let’s refactor the Money class:

public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    // Private constructor to prevent direct instantiation
    private Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public static Money of(BigDecimal amount, Currency currency) {
        return new Money(amount, currency);
    }

    public static Money parse(String moneyString) throws IllegalArgumentException {
        String[] parts = moneyString.split(" ");
        if (parts.length != 2) {
            throw new IllegalArgumentException("Invalid money format, expected '<amount> <currency>'");
        }
        BigDecimal amount = new BigDecimal(parts[0]);
        Currency currency = Currency.getInstance(parts[1]);
        return new Money(amount, currency);
    }

    public static Money ofMinorUnits(long minorUnits, Currency currency) {
        BigDecimal amount = BigDecimal.valueOf(minorUnits)
                .movePointLeft(currency.getDefaultFractionDigits());
        return new Money(amount, currency);
    }

    public static Money fromMap(Map<String, Object> moneyData) {
        BigDecimal amount = new BigDecimal((String) moneyData.get("amount"));
        Currency currency = Currency.getInstance((String) moneyData.get("currency"));
        return new Money(amount, currency);
    }

    // Other methods...
}
from decimal import Decimal

class Money:
    def __init__(self, amount: Decimal, currency: str):
        self.amount = amount
        self.currency = currency

    @classmethod
    def of(cls, amount: Decimal, currency: str) -> "Money":
        return cls(amount, currency)

    @classmethod
    def parse(cls, money_string: str) -> "Money":
        parts = money_string.split(" ")
        if len(parts) != 2:
            raise ValueError("Invalid money format, expected '<amount> <currency>'")
        return cls(Decimal(parts[0]), parts[1])

    @classmethod
    def of_minor_units(cls, minor_units: int, currency: str) -> "Money":
        # Simplified: assumes a 2-decimal currency
        return cls(Decimal(minor_units).scaleb(-2), currency)

    @classmethod
    def from_dict(cls, data: dict) -> "Money":
        return cls(Decimal(data["amount"]), data["currency"])

    # Other methods...
public class Money
{
    private readonly decimal amount;
    private readonly string currency;

    // Private constructor to prevent direct instantiation
    private Money(decimal amount, string currency)
    {
        this.amount = amount;
        this.currency = currency;
    }

    public static Money Of(decimal amount, string currency) =>
        new Money(amount, currency);

    public static Money Parse(string moneyString)
    {
        var parts = moneyString.Split(' ');
        if (parts.Length != 2)
            throw new ArgumentException("Invalid money format, expected '<amount> <currency>'");
        return new Money(decimal.Parse(parts[0], CultureInfo.InvariantCulture), parts[1]);
    }

    // Simplified: assumes a 2-decimal currency
    public static Money OfMinorUnits(long minorUnits, string currency) =>
        new Money(minorUnits / 100m, currency);

    public static Money FromDictionary(IDictionary<string, string> moneyData) =>
        new Money(decimal.Parse(moneyData["amount"], CultureInfo.InvariantCulture), moneyData["currency"]);

    // Other methods...
}
class Money {
  // JavaScript has no private constructors. By convention consumers
  // use the static creation methods below.
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  static of(amount, currency) {
    return new Money(amount, currency);
  }

  static parse(moneyString) {
    const parts = moneyString.split(" ");
    if (parts.length !== 2) {
      throw new Error("Invalid money format, expected '<amount> <currency>'");
    }
    return new Money(Number(parts[0]), parts[1]);
  }

  // Simplified: assumes a 2-decimal currency
  static ofMinorUnits(minorUnits, currency) {
    return new Money(minorUnits / 100, currency);
  }

  static fromObject({ amount, currency }) {
    return new Money(Number(amount), currency);
  }

  // Other methods...
}

Improved Usage
#

Now, creating Money instances becomes clearer:

// From amount and currency
Money price1 = Money.of(new BigDecimal("19.99"), Currency.getInstance("PLN"));

// From string
Money price2 = Money.parse("19.99 PLN");

// From minor units
Money price3 = Money.ofMinorUnits(1999, Currency.getInstance("PLN"));

// From Map
Map<String, Object> moneyData = new HashMap<>();
moneyData.put("amount", "19.99");
moneyData.put("currency", "PLN");
Money price4 = Money.fromMap(moneyData);
# From amount and currency
price1 = Money.of(Decimal("19.99"), "PLN")

# From string
price2 = Money.parse("19.99 PLN")

# From minor units
price3 = Money.of_minor_units(1999, "PLN")

# From dict
price4 = Money.from_dict({"amount": "19.99", "currency": "PLN"})
// From amount and currency
var price1 = Money.Of(19.99m, "PLN");

// From string
var price2 = Money.Parse("19.99 PLN");

// From minor units
var price3 = Money.OfMinorUnits(1999, "PLN");

// From dictionary
var price4 = Money.FromDictionary(new Dictionary<string, string>
{
    ["amount"] = "19.99",
    ["currency"] = "PLN"
});
// From amount and currency
const price1 = Money.of(19.99, "PLN");

// From string
const price2 = Money.parse("19.99 PLN");

// From minor units
const price3 = Money.ofMinorUnits(1999, "PLN");

// From object
const price4 = Money.fromObject({ amount: "19.99", currency: "PLN" });

Benefits Highlighted
#

  • Expressiveness: Method names clearly indicate how the Money is being created. ofMinorUnits(1999, ...) leaves no doubt about the factor-of-100 question.
  • Encapsulation: Parsing and conversion logic is encapsulated within the creation methods.
  • Ease of Maintenance: Adding new creation methods doesn’t clutter the constructors.

Factory Pattern: Simplifying Object Creation and Enhancing Flexibility
#

In real-world development, we often encounter situations where we need to create objects based on dynamic conditions or configurations. Without a structured approach, object creation logic can become scattered and complex, making the codebase hard to maintain and extend. The Factory Pattern offers a solution by centralising object creation and promoting flexibility.

Scenario: Order Processing Actions
#

Imagine you’re developing a feature that adds configurable actions to an order processing pipeline. Actions are executed after an order transitions to a new status (e.g., from paid to shipped), such as:

  • SendConfirmationAction: Sends a confirmation email to the customer.
  • ReserveStockAction: Reserves items in a specific warehouse.
  • GenerateInvoiceAction: Generates an invoice of a given type.
  • NotifyCarrierAction: Notifies the shipping carrier to schedule a pickup.

Each action requires different parameters and setup. Administrators can configure which actions to include and their settings.

The Messy Approach
#

Without the Factory Pattern, you might instantiate actions directly in your client code, leading to something like this:

public void configureTransitionActions(Transition transition, List<ActionConfig> configs) {
    for (ActionConfig config : configs) {
        String type = config.getType();
        Map<String, String> params = config.getParams();

        if ("sendConfirmation".equals(type)) {
            String template = params.get("template");
            SendConfirmationAction action = new SendConfirmationAction(template);
            transition.addAction(action);
        } else if ("reserveStock".equals(type)) {
            String warehouse = params.get("warehouse");
            ReserveStockAction action = new ReserveStockAction(warehouse);
            transition.addAction(action);
        } else if ("generateInvoice".equals(type)) {
            String invoiceType = params.get("invoiceType");
            String currency = params.get("currency");
            GenerateInvoiceAction action = new GenerateInvoiceAction(invoiceType, currency);
            transition.addAction(action);
        } else if ("notifyCarrier".equals(type)) {
            String carrier = params.get("carrier");
            NotifyCarrierAction action = new NotifyCarrierAction(carrier);
            transition.addAction(action);
        } else {
            throw new IllegalArgumentException("Unknown action type: " + type);
        }
    }
}
def configure_transition_actions(transition, configs):
    for config in configs:
        action_type = config.type
        params = config.params

        if action_type == "sendConfirmation":
            template = params["template"]
            action = SendConfirmationAction(template)
            transition.add_action(action)
        elif action_type == "reserveStock":
            warehouse = params["warehouse"]
            action = ReserveStockAction(warehouse)
            transition.add_action(action)
        elif action_type == "generateInvoice":
            invoice_type = params["invoiceType"]
            currency = params["currency"]
            action = GenerateInvoiceAction(invoice_type, currency)
            transition.add_action(action)
        elif action_type == "notifyCarrier":
            carrier = params["carrier"]
            action = NotifyCarrierAction(carrier)
            transition.add_action(action)
        else:
            raise ValueError(f"Unknown action type: {action_type}")
public void ConfigureTransitionActions(Transition transition, List<ActionConfig> configs)
{
    foreach (var config in configs)
    {
        var type = config.Type;
        var parameters = config.Params;

        if (type == "sendConfirmation")
        {
            var template = parameters["template"];
            var action = new SendConfirmationAction(template);
            transition.AddAction(action);
        }
        else if (type == "reserveStock")
        {
            var warehouse = parameters["warehouse"];
            var action = new ReserveStockAction(warehouse);
            transition.AddAction(action);
        }
        else if (type == "generateInvoice")
        {
            var invoiceType = parameters["invoiceType"];
            var currency = parameters["currency"];
            var action = new GenerateInvoiceAction(invoiceType, currency);
            transition.AddAction(action);
        }
        else if (type == "notifyCarrier")
        {
            var carrier = parameters["carrier"];
            var action = new NotifyCarrierAction(carrier);
            transition.AddAction(action);
        }
        else
        {
            throw new ArgumentException($"Unknown action type: {type}");
        }
    }
}
function configureTransitionActions(transition, configs) {
  for (const config of configs) {
    const { type, params } = config;

    if (type === "sendConfirmation") {
      const action = new SendConfirmationAction(params.template);
      transition.addAction(action);
    } else if (type === "reserveStock") {
      const action = new ReserveStockAction(params.warehouse);
      transition.addAction(action);
    } else if (type === "generateInvoice") {
      const action = new GenerateInvoiceAction(params.invoiceType, params.currency);
      transition.addAction(action);
    } else if (type === "notifyCarrier") {
      const action = new NotifyCarrierAction(params.carrier);
      transition.addAction(action);
    } else {
      throw new Error(`Unknown action type: ${type}`);
    }
  }
}

Issues Arise
#

  • Creation Logic in Client Code: The client code is responsible for instantiating the concrete action classes. This means the client needs to know about all the specific implementations, which violates the principle of encapsulation. The client shouldn’t be concerned with the details of object creation.
  • Scattered Instantiation Logic: The method is cluttered with creation logic for various actions.
  • Violation of the Open/Closed Principle: Adding a new action type requires modifying this method.
  • Tight Coupling: The method depends on concrete classes of actions.
  • Maintenance Challenges: As more actions are added, the method becomes longer and harder to manage.

Refactoring with the Factory Pattern
#

To clean up the code and adhere to solid design principles, we can introduce a TransitionActionFactory. By doing so, we encapsulate the creation logic within the factory, and the client code no longer needs to know about the specific classes or how to instantiate them.

A note on naming: what we’re about to build is technically a Simple Factory, a single object with a method that decides which class to instantiate. The GoF book describes two related but distinct patterns: Factory Method (a creation method that subclasses override) and Abstract Factory (one interface producing whole families of related objects). Simple Factory is the everyday workhorse and covers most real-world needs. The GoF variants earn their keep when you need polymorphic creation across class hierarchies.

Define a TransitionAction Interface

public interface TransitionAction {
    void execute(Order order, Transition transition);
}
from abc import ABC, abstractmethod

class TransitionAction(ABC):
    @abstractmethod
    def execute(self, order, transition): ...
public interface ITransitionAction
{
    void Execute(Order order, Transition transition);
}
// JavaScript has no interfaces. A common convention is a base class
// that documents the expected shape.
class TransitionAction {
  execute(order, transition) {
    throw new Error("Not implemented");
  }
}

Implement Specific Action Classes

public class SendConfirmationAction implements TransitionAction {
    private String template;

    public SendConfirmationAction(String template) {
        this.template = template;
    }

    @Override
    public void execute(Order order, Transition transition) {
        // Send confirmation email using the template
    }
}

// Similar implementations for ReserveStockAction, GenerateInvoiceAction, NotifyCarrierAction
class SendConfirmationAction(TransitionAction):
    def __init__(self, template: str):
        self.template = template

    def execute(self, order, transition):
        # Send confirmation email using the template
        ...

# Similar implementations for ReserveStockAction, GenerateInvoiceAction, NotifyCarrierAction
public class SendConfirmationAction : ITransitionAction
{
    private readonly string template;

    public SendConfirmationAction(string template)
    {
        this.template = template;
    }

    public void Execute(Order order, Transition transition)
    {
        // Send confirmation email using the template
    }
}

// Similar implementations for ReserveStockAction, GenerateInvoiceAction, NotifyCarrierAction
class SendConfirmationAction extends TransitionAction {
  constructor(template) {
    super();
    this.template = template;
  }

  execute(order, transition) {
    // Send confirmation email using the template
  }
}

// Similar implementations for ReserveStockAction, GenerateInvoiceAction, NotifyCarrierAction

Create the TransitionActionFactory

public class TransitionActionFactory {
    public TransitionAction createAction(String type, Map<String, String> params) {
        switch (type) {
            case "sendConfirmation":
                return new SendConfirmationAction(params.get("template"));
            case "reserveStock":
                return new ReserveStockAction(params.get("warehouse"));
            case "generateInvoice":
                return new GenerateInvoiceAction(params.get("invoiceType"), params.get("currency"));
            case "notifyCarrier":
                return new NotifyCarrierAction(params.get("carrier"));
            default:
                throw new IllegalArgumentException("Unknown action type: " + type);
        }
    }
}
class TransitionActionFactory:
    def create_action(self, action_type: str, params: dict) -> TransitionAction:
        match action_type:
            case "sendConfirmation":
                return SendConfirmationAction(params["template"])
            case "reserveStock":
                return ReserveStockAction(params["warehouse"])
            case "generateInvoice":
                return GenerateInvoiceAction(params["invoiceType"], params["currency"])
            case "notifyCarrier":
                return NotifyCarrierAction(params["carrier"])
            case _:
                raise ValueError(f"Unknown action type: {action_type}")
public interface ITransitionActionFactory
{
    ITransitionAction CreateAction(string type, IDictionary<string, string> parameters);
}

public class TransitionActionFactory : ITransitionActionFactory
{
    public ITransitionAction CreateAction(string type, IDictionary<string, string> parameters) =>
        type switch
        {
            "sendConfirmation" => new SendConfirmationAction(parameters["template"]),
            "reserveStock" => new ReserveStockAction(parameters["warehouse"]),
            "generateInvoice" => new GenerateInvoiceAction(parameters["invoiceType"], parameters["currency"]),
            "notifyCarrier" => new NotifyCarrierAction(parameters["carrier"]),
            _ => throw new ArgumentException($"Unknown action type: {type}")
        };
}
class TransitionActionFactory {
  createAction(type, params) {
    switch (type) {
      case "sendConfirmation":
        return new SendConfirmationAction(params.template);
      case "reserveStock":
        return new ReserveStockAction(params.warehouse);
      case "generateInvoice":
        return new GenerateInvoiceAction(params.invoiceType, params.currency);
      case "notifyCarrier":
        return new NotifyCarrierAction(params.carrier);
      default:
        throw new Error(`Unknown action type: ${type}`);
    }
  }
}

Simplify the Configuration Method

private final TransitionActionFactory actionFactory;

public void configureTransitionActions(Transition transition, List<ActionConfig> configs) {
    for (ActionConfig config : configs) {
        TransitionAction action = actionFactory.createAction(config.getType(), config.getParams());
        transition.addAction(action);
    }
}
def configure_transition_actions(transition, configs, action_factory: TransitionActionFactory):
    for config in configs:
        action = action_factory.create_action(config.type, config.params)
        transition.add_action(action)
private readonly ITransitionActionFactory actionFactory;

public void ConfigureTransitionActions(Transition transition, List<ActionConfig> configs)
{
    foreach (var config in configs)
    {
        var action = actionFactory.CreateAction(config.Type, config.Params);
        transition.AddAction(action);
    }
}
function configureTransitionActions(transition, configs, actionFactory) {
  for (const config of configs) {
    const action = actionFactory.createAction(config.type, config.params);
    transition.addAction(action);
  }
}

Extending the Factory
#

Suppose we need to add a new action, LogOrderAction, which logs order details to an external system.

Implement the New Action

public class LogOrderAction implements TransitionAction {
    private String logLevel;

    public LogOrderAction(String logLevel) {
        this.logLevel = logLevel;
    }

    @Override
    public void execute(Order order, Transition transition) {
        // Logic to log order details
    }
}
class LogOrderAction(TransitionAction):
    def __init__(self, log_level: str):
        self.log_level = log_level

    def execute(self, order, transition):
        # Logic to log order details
        ...
public class LogOrderAction : ITransitionAction
{
    private readonly string logLevel;

    public LogOrderAction(string logLevel)
    {
        this.logLevel = logLevel;
    }

    public void Execute(Order order, Transition transition)
    {
        // Logic to log order details
    }
}
class LogOrderAction extends TransitionAction {
  constructor(logLevel) {
    super();
    this.logLevel = logLevel;
  }

  execute(order, transition) {
    // Logic to log order details
  }
}

Update the Factory

public class TransitionActionFactory {
    public TransitionAction createAction(String type, Map<String, String> params) {
        switch (type) {
            case "sendConfirmation":
                return new SendConfirmationAction(params.get("template"));
            case "reserveStock":
                return new ReserveStockAction(params.get("warehouse"));
            case "generateInvoice":
                return new GenerateInvoiceAction(params.get("invoiceType"), params.get("currency"));
            case "notifyCarrier":
                return new NotifyCarrierAction(params.get("carrier"));
            case "logOrder":
                return new LogOrderAction(params.get("logLevel"));
            default:
                throw new IllegalArgumentException("Unknown action type: " + type);
        }
    }
}
class TransitionActionFactory:
    def create_action(self, action_type: str, params: dict) -> TransitionAction:
        match action_type:
            case "sendConfirmation":
                return SendConfirmationAction(params["template"])
            case "reserveStock":
                return ReserveStockAction(params["warehouse"])
            case "generateInvoice":
                return GenerateInvoiceAction(params["invoiceType"], params["currency"])
            case "notifyCarrier":
                return NotifyCarrierAction(params["carrier"])
            case "logOrder":
                return LogOrderAction(params["logLevel"])
            case _:
                raise ValueError(f"Unknown action type: {action_type}")
public class TransitionActionFactory : ITransitionActionFactory
{
    public ITransitionAction CreateAction(string type, IDictionary<string, string> parameters) =>
        type switch
        {
            "sendConfirmation" => new SendConfirmationAction(parameters["template"]),
            "reserveStock" => new ReserveStockAction(parameters["warehouse"]),
            "generateInvoice" => new GenerateInvoiceAction(parameters["invoiceType"], parameters["currency"]),
            "notifyCarrier" => new NotifyCarrierAction(parameters["carrier"]),
            "logOrder" => new LogOrderAction(parameters["logLevel"]),
            _ => throw new ArgumentException($"Unknown action type: {type}")
        };
}
class TransitionActionFactory {
  createAction(type, params) {
    switch (type) {
      case "sendConfirmation":
        return new SendConfirmationAction(params.template);
      case "reserveStock":
        return new ReserveStockAction(params.warehouse);
      case "generateInvoice":
        return new GenerateInvoiceAction(params.invoiceType, params.currency);
      case "notifyCarrier":
        return new NotifyCarrierAction(params.carrier);
      case "logOrder":
        return new LogOrderAction(params.logLevel);
      default:
        throw new Error(`Unknown action type: ${type}`);
    }
  }
}

Note: The client code in configureTransitionActions remains unchanged. The client doesn’t need to know about the new LogOrderAction class or how to instantiate it. This demonstrates how the Factory Pattern allows us to extend functionality without modifying client code.

Why the Factory Pattern Is So Much Better
#

  • Encapsulation: By moving the creation logic into the factory, we hide the instantiation details from the client code. The client doesn’t need to know which classes are being instantiated or how they are constructed.
  • Scalability: Easily add new actions without touching the configuration logic in the client code.
  • Maintainability: Centralised creation logic simplifies updates and bug fixes.
  • Readability: The client code is cleaner and more focused on its primary task.
  • Testability: Factories can be mocked or stubbed during unit tests, allowing for isolated testing of components.
  • Flexibility: Supports dynamic creation of objects based on runtime information.

Consequences of Not Using the Factory Pattern
#

Without the factory:

  • Client Code Knows Too Much: The client code is burdened with the details of object creation and needs to know about all the concrete classes.
  • Code Duplication: Object creation logic may be repeated in multiple places if not centralised.
  • Brittle Code: Changes in object creation (e.g., constructor parameters) would require changes in all client code that creates those objects.
  • Poor Compliance with SOLID Principles: The code violates the Open/Closed and Single Responsibility principles, leading to a less robust design.

Conclusion
#

By applying the Factory Pattern, we move the creation logic out of the client code and into a centralised factory. This encapsulation ensures that the client code remains clean and unaware of the specific classes and instantiation processes. It allows us to add new types or change the creation process without impacting the client code, resulting in a more maintainable and flexible codebase.

In contexts like this, the Factory Pattern is invaluable. It not only simplifies the code but also prepares your code for future growth and complexity.

Builder Pattern: Managing Complex Object Construction with Ease
#

In the fast-paced world of software development, we often need to create objects that have numerous optional parameters or configurations. Using traditional constructors or setters can lead to code that’s hard to read, maintain, and extend. The Builder Pattern offers a solution by providing a flexible and readable way to construct complex objects step by step.

Scenario: Configuring an HTTP Client
#

Imagine you’re writing a small library that wraps HTTP calls to external services. The HttpClientConfig class needs to handle a variety of optional settings:

  • Base URL: The root address of the target service.
  • Timeouts: Separate connect and read timeouts.
  • Retries: Maximum number of retries and backoff between attempts.
  • Proxy: Optional corporate proxy host and port.
  • Redirects: Whether to follow HTTP redirects.
  • Default Headers: Headers attached to every request (e.g., User-Agent, API keys).

Most callers will only care about one or two of these. Everything else should fall back to sensible defaults.

The Cumbersome Constructor Approach
#

Attempting to handle all these parameters with constructors leads to a bloated and confusing class:

public class HttpClientConfig {
    private String baseUrl;
    private Duration connectTimeout;
    private Duration readTimeout;
    private int maxRetries;
    private Duration retryBackoff;
    private String proxyHost;
    private Integer proxyPort;
    private boolean followRedirects;
    private Map<String, String> defaultHeaders;

    // Constructor with all possible parameters
    public HttpClientConfig(String baseUrl, Duration connectTimeout, Duration readTimeout,
                            int maxRetries, Duration retryBackoff, String proxyHost,
                            Integer proxyPort, boolean followRedirects,
                            Map<String, String> defaultHeaders) {
        this.baseUrl = baseUrl;
        this.connectTimeout = connectTimeout;
        this.readTimeout = readTimeout;
        this.maxRetries = maxRetries;
        this.retryBackoff = retryBackoff;
        this.proxyHost = proxyHost;
        this.proxyPort = proxyPort;
        this.followRedirects = followRedirects;
        this.defaultHeaders = defaultHeaders;
    }

    // Other methods...
}
from datetime import timedelta

class HttpClientConfig:
    # Constructor with all possible parameters
    def __init__(self, base_url, connect_timeout, read_timeout,
                 max_retries, retry_backoff, proxy_host,
                 proxy_port, follow_redirects, default_headers):
        self.base_url = base_url
        self.connect_timeout = connect_timeout
        self.read_timeout = read_timeout
        self.max_retries = max_retries
        self.retry_backoff = retry_backoff
        self.proxy_host = proxy_host
        self.proxy_port = proxy_port
        self.follow_redirects = follow_redirects
        self.default_headers = default_headers

    # Other methods...
public class HttpClientConfig
{
    private string baseUrl;
    private TimeSpan connectTimeout;
    private TimeSpan readTimeout;
    private int maxRetries;
    private TimeSpan retryBackoff;
    private string proxyHost;
    private int? proxyPort;
    private bool followRedirects;
    private Dictionary<string, string> defaultHeaders;

    // Constructor with all possible parameters
    public HttpClientConfig(string baseUrl, TimeSpan connectTimeout, TimeSpan readTimeout,
                            int maxRetries, TimeSpan retryBackoff, string proxyHost,
                            int? proxyPort, bool followRedirects,
                            Dictionary<string, string> defaultHeaders)
    {
        this.baseUrl = baseUrl;
        this.connectTimeout = connectTimeout;
        this.readTimeout = readTimeout;
        this.maxRetries = maxRetries;
        this.retryBackoff = retryBackoff;
        this.proxyHost = proxyHost;
        this.proxyPort = proxyPort;
        this.followRedirects = followRedirects;
        this.defaultHeaders = defaultHeaders;
    }

    // Other methods...
}
class HttpClientConfig {
  // Constructor with all possible parameters
  constructor(baseUrl, connectTimeoutMs, readTimeoutMs,
              maxRetries, retryBackoffMs, proxyHost,
              proxyPort, followRedirects, defaultHeaders) {
    this.baseUrl = baseUrl;
    this.connectTimeoutMs = connectTimeoutMs;
    this.readTimeoutMs = readTimeoutMs;
    this.maxRetries = maxRetries;
    this.retryBackoffMs = retryBackoffMs;
    this.proxyHost = proxyHost;
    this.proxyPort = proxyPort;
    this.followRedirects = followRedirects;
    this.defaultHeaders = defaultHeaders;
  }

  // Other methods...
}

Problems with This Approach
#

  • Poor Readability: It’s difficult to match arguments to parameters when calling the constructor, especially with two adjacent timeout parameters that are trivially easy to swap.
  • All-or-Nothing Call Sites: Every setting must be supplied at once, in one expression, optional ones included. In some languages that literally means passing null or magic values.
  • Constructor Overload Explosion: Creating multiple constructors for different parameter combinations leads to a maintenance nightmare.
  • Immutability Issues: Using setters to adjust parameters can compromise thread safety and object integrity.

Usage Becomes Messy
#

Creating an HttpClientConfig instance is cumbersome:

HttpClientConfig config = new HttpClientConfig(
    "https://api.example.com",     // baseUrl
    Duration.ofSeconds(5),         // connectTimeout... or was it readTimeout?
    Duration.ofSeconds(30),        // readTimeout
    3,                             // maxRetries
    null,                          // retryBackoff
    null,                          // proxyHost
    null,                          // proxyPort
    true,                          // followRedirects
    null                           // defaultHeaders
);
config = HttpClientConfig(
    "https://api.example.com",     # base_url
    timedelta(seconds=5),          # connect_timeout... or was it read_timeout?
    timedelta(seconds=30),         # read_timeout
    3,                             # max_retries
    None,                          # retry_backoff
    None,                          # proxy_host
    None,                          # proxy_port
    True,                          # follow_redirects
    None,                          # default_headers
)
var config = new HttpClientConfig(
    "https://api.example.com",     // baseUrl
    TimeSpan.FromSeconds(5),       // connectTimeout... or was it readTimeout?
    TimeSpan.FromSeconds(30),      // readTimeout
    3,                             // maxRetries
    default,                       // retryBackoff
    null,                          // proxyHost
    null,                          // proxyPort
    true,                          // followRedirects
    null                           // defaultHeaders
);
const config = new HttpClientConfig(
  "https://api.example.com",     // baseUrl
  5000,                          // connectTimeoutMs... or was it readTimeoutMs?
  30000,                         // readTimeoutMs
  3,                             // maxRetries
  null,                          // retryBackoffMs
  null,                          // proxyHost
  null,                          // proxyPort
  true,                          // followRedirects
  null                           // defaultHeaders
);
  • Hard to Read: It’s unclear what each null or value represents without consulting the constructor’s signature.
  • Error-Prone: Easy to misplace or forget parameters. Swapping the two timeouts compiles cleanly and only shows up in production as mysterious latency behaviour.

Refactoring with the Builder Pattern
#

The Builder Pattern allows us to construct HttpClientConfig objects step by step, providing clarity and flexibility.

Implementing the Builder

public class HttpClientConfig {
    private final String baseUrl;
    private final Duration connectTimeout;
    private final Duration readTimeout;
    private final int maxRetries;
    private final Duration retryBackoff;
    private final String proxyHost;
    private final Integer proxyPort;
    private final boolean followRedirects;
    private final Map<String, String> defaultHeaders;

    private HttpClientConfig(Builder builder) {
        this.baseUrl = builder.baseUrl;
        this.connectTimeout = builder.connectTimeout;
        this.readTimeout = builder.readTimeout;
        this.maxRetries = builder.maxRetries;
        this.retryBackoff = builder.retryBackoff;
        this.proxyHost = builder.proxyHost;
        this.proxyPort = builder.proxyPort;
        this.followRedirects = builder.followRedirects;
        this.defaultHeaders = Map.copyOf(builder.defaultHeaders);
    }

    // Other methods...

    public static class Builder {
        private final String baseUrl;
        private Duration connectTimeout = Duration.ofSeconds(10);
        private Duration readTimeout = Duration.ofSeconds(60);
        private int maxRetries = 0;
        private Duration retryBackoff = Duration.ofMillis(500);
        private String proxyHost;
        private Integer proxyPort;
        private boolean followRedirects = true;
        private Map<String, String> defaultHeaders = new HashMap<>();

        // Required parameter goes into the builder's constructor
        public Builder(String baseUrl) {
            this.baseUrl = baseUrl;
        }

        public Builder connectTimeout(Duration connectTimeout) {
            this.connectTimeout = connectTimeout;
            return this;
        }

        public Builder readTimeout(Duration readTimeout) {
            this.readTimeout = readTimeout;
            return this;
        }

        public Builder retries(int maxRetries, Duration backoff) {
            this.maxRetries = maxRetries;
            this.retryBackoff = backoff;
            return this;
        }

        public Builder proxy(String host, int port) {
            this.proxyHost = host;
            this.proxyPort = port;
            return this;
        }

        public Builder followRedirects(boolean followRedirects) {
            this.followRedirects = followRedirects;
            return this;
        }

        public Builder header(String name, String value) {
            this.defaultHeaders.put(name, value);
            return this;
        }

        public HttpClientConfig build() {
            return new HttpClientConfig(this);
        }
    }
}
from dataclasses import dataclass
from datetime import timedelta

@dataclass(frozen=True)
class HttpClientConfig:
    base_url: str
    connect_timeout: timedelta
    read_timeout: timedelta
    max_retries: int
    retry_backoff: timedelta
    proxy_host: str | None
    proxy_port: int | None
    follow_redirects: bool
    default_headers: dict

    # Other methods...

    class Builder:
        # Required parameter goes into the builder's constructor
        def __init__(self, base_url: str):
            self.base_url = base_url
            self.connect_timeout = timedelta(seconds=10)
            self.read_timeout = timedelta(seconds=60)
            self.max_retries = 0
            self.retry_backoff = timedelta(milliseconds=500)
            self.proxy_host = None
            self.proxy_port = None
            self.follow_redirects = True
            self.default_headers = {}

        def with_connect_timeout(self, value: timedelta) -> "HttpClientConfig.Builder":
            self.connect_timeout = value
            return self

        def with_read_timeout(self, value: timedelta) -> "HttpClientConfig.Builder":
            self.read_timeout = value
            return self

        def with_retries(self, max_retries: int, backoff: timedelta) -> "HttpClientConfig.Builder":
            self.max_retries = max_retries
            self.retry_backoff = backoff
            return self

        def with_proxy(self, host: str, port: int) -> "HttpClientConfig.Builder":
            self.proxy_host = host
            self.proxy_port = port
            return self

        def with_follow_redirects(self, value: bool) -> "HttpClientConfig.Builder":
            self.follow_redirects = value
            return self

        def with_header(self, name: str, value: str) -> "HttpClientConfig.Builder":
            self.default_headers[name] = value
            return self

        def build(self) -> "HttpClientConfig":
            return HttpClientConfig(
                base_url=self.base_url,
                connect_timeout=self.connect_timeout,
                read_timeout=self.read_timeout,
                max_retries=self.max_retries,
                retry_backoff=self.retry_backoff,
                proxy_host=self.proxy_host,
                proxy_port=self.proxy_port,
                follow_redirects=self.follow_redirects,
                default_headers=dict(self.default_headers),
            )
public class HttpClientConfig
{
    public string BaseUrl { get; }
    public TimeSpan ConnectTimeout { get; }
    public TimeSpan ReadTimeout { get; }
    public int MaxRetries { get; }
    public TimeSpan RetryBackoff { get; }
    public string ProxyHost { get; }
    public int? ProxyPort { get; }
    public bool FollowRedirects { get; }
    public IReadOnlyDictionary<string, string> DefaultHeaders { get; }

    private HttpClientConfig(Builder builder)
    {
        BaseUrl = builder.BaseUrl;
        ConnectTimeout = builder.ConnectTimeout;
        ReadTimeout = builder.ReadTimeout;
        MaxRetries = builder.MaxRetries;
        RetryBackoff = builder.RetryBackoff;
        ProxyHost = builder.ProxyHost;
        ProxyPort = builder.ProxyPort;
        FollowRedirects = builder.FollowRedirects;
        DefaultHeaders = new Dictionary<string, string>(builder.DefaultHeaders);
    }

    // Other methods...

    public class Builder
    {
        internal string BaseUrl { get; }
        internal TimeSpan ConnectTimeout { get; private set; } = TimeSpan.FromSeconds(10);
        internal TimeSpan ReadTimeout { get; private set; } = TimeSpan.FromSeconds(60);
        internal int MaxRetries { get; private set; } = 0;
        internal TimeSpan RetryBackoff { get; private set; } = TimeSpan.FromMilliseconds(500);
        internal string ProxyHost { get; private set; }
        internal int? ProxyPort { get; private set; }
        internal bool FollowRedirects { get; private set; } = true;
        internal Dictionary<string, string> DefaultHeaders { get; } = new();

        // Required parameter goes into the builder's constructor
        public Builder(string baseUrl)
        {
            BaseUrl = baseUrl;
        }

        public Builder WithConnectTimeout(TimeSpan value)
        {
            ConnectTimeout = value;
            return this;
        }

        public Builder WithReadTimeout(TimeSpan value)
        {
            ReadTimeout = value;
            return this;
        }

        public Builder WithRetries(int maxRetries, TimeSpan backoff)
        {
            MaxRetries = maxRetries;
            RetryBackoff = backoff;
            return this;
        }

        public Builder WithProxy(string host, int port)
        {
            ProxyHost = host;
            ProxyPort = port;
            return this;
        }

        public Builder WithFollowRedirects(bool value)
        {
            FollowRedirects = value;
            return this;
        }

        public Builder WithHeader(string name, string value)
        {
            DefaultHeaders[name] = value;
            return this;
        }

        public HttpClientConfig Build() => new HttpClientConfig(this);
    }
}
class HttpClientConfig {
  constructor(builder) {
    this.baseUrl = builder.baseUrl;
    this.connectTimeoutMs = builder.connectTimeoutMs;
    this.readTimeoutMs = builder.readTimeoutMs;
    this.maxRetries = builder.maxRetries;
    this.retryBackoffMs = builder.retryBackoffMs;
    this.proxyHost = builder.proxyHost;
    this.proxyPort = builder.proxyPort;
    this.followRedirects = builder.followRedirects;
    this.defaultHeaders = { ...builder.defaultHeaders };
    Object.freeze(this);
  }

  // Other methods...

  static Builder = class {
    // Required parameter goes into the builder's constructor
    constructor(baseUrl) {
      this.baseUrl = baseUrl;
      this.connectTimeoutMs = 10000;
      this.readTimeoutMs = 60000;
      this.maxRetries = 0;
      this.retryBackoffMs = 500;
      this.proxyHost = null;
      this.proxyPort = null;
      this.followRedirects = true;
      this.defaultHeaders = {};
    }

    withConnectTimeout(ms) {
      this.connectTimeoutMs = ms;
      return this;
    }

    withReadTimeout(ms) {
      this.readTimeoutMs = ms;
      return this;
    }

    withRetries(maxRetries, backoffMs) {
      this.maxRetries = maxRetries;
      this.retryBackoffMs = backoffMs;
      return this;
    }

    withProxy(host, port) {
      this.proxyHost = host;
      this.proxyPort = port;
      return this;
    }

    withFollowRedirects(value) {
      this.followRedirects = value;
      return this;
    }

    withHeader(name, value) {
      this.defaultHeaders[name] = value;
      return this;
    }

    build() {
      return new HttpClientConfig(this);
    }
  };
}

Simplified and Expressive Usage
#

Using the builder to create an HttpClientConfig is clean and readable:

HttpClientConfig config = new HttpClientConfig.Builder("https://api.example.com")
    .connectTimeout(Duration.ofSeconds(5))
    .readTimeout(Duration.ofSeconds(30))
    .retries(3, Duration.ofSeconds(1))
    .header("User-Agent", "my-service/1.0")
    .build();
config = (
    HttpClientConfig.Builder("https://api.example.com")
    .with_connect_timeout(timedelta(seconds=5))
    .with_read_timeout(timedelta(seconds=30))
    .with_retries(3, timedelta(seconds=1))
    .with_header("User-Agent", "my-service/1.0")
    .build()
)
var config = new HttpClientConfig.Builder("https://api.example.com")
    .WithConnectTimeout(TimeSpan.FromSeconds(5))
    .WithReadTimeout(TimeSpan.FromSeconds(30))
    .WithRetries(3, TimeSpan.FromSeconds(1))
    .WithHeader("User-Agent", "my-service/1.0")
    .Build();
const config = new HttpClientConfig.Builder("https://api.example.com")
  .withConnectTimeout(5000)
  .withReadTimeout(30000)
  .withRetries(3, 1000)
  .withHeader("User-Agent", "my-service/1.0")
  .build();

Callers who are happy with the defaults simply don’t mention them. No nulls, no magic values.

“But My Language Has Named Parameters…”
#

Fair objection. In Python you’d write HttpClientConfig(base_url=..., connect_timeout=...), in JavaScript you’d pass an options object, and C# offers named arguments and object initializers. That fixes the readability of the call site, and it is exactly why the constructor pain is felt most acutely in Java. But three problems survive named parameters in every language:

1. Multi-step, conditional assembly. Named parameters require all values to be known in one expression, at one call site. Real configuration is usually assembled gradually: a base of defaults, a block added by an if, another contributed by a different module. A builder carries that partial state legally:

HttpClientConfig.Builder builder = new HttpClientConfig.Builder(serviceUrl);
if (settings.isBehindProxy()) {
    builder.proxy(settings.getProxyHost(), settings.getProxyPort());
}
if (env == Environment.PRODUCTION) {
    builder.retries(3, Duration.ofSeconds(1));
}
HttpClientConfig config = builder.build();
builder = HttpClientConfig.Builder(service_url)
if settings.behind_proxy:
    builder.with_proxy(settings.proxy_host, settings.proxy_port)
if env == "production":
    builder.with_retries(3, timedelta(seconds=1))
config = builder.build()
var builder = new HttpClientConfig.Builder(serviceUrl);
if (settings.BehindProxy)
{
    builder.WithProxy(settings.ProxyHost, settings.ProxyPort);
}
if (env == Env.Production)
{
    builder.WithRetries(3, TimeSpan.FromSeconds(1));
}
var config = builder.Build();
const builder = new HttpClientConfig.Builder(serviceUrl);
if (settings.behindProxy) {
  builder.withProxy(settings.proxyHost, settings.proxyPort);
}
if (env === "production") {
  builder.withRetries(3, 1000);
}
const config = builder.build();

Without a builder you end up accumulating a dictionary of parameters and splatting it into the constructor, which is a hand-rolled builder with no name and no contract.

2. Coupled parameters. retries(3, backoff) and proxy(host, port) make related settings travel together. Named parameters happily accept inconsistent combinations, like max_retries=3 with no backoff or a proxy port without a host, and push the burden onto validation code and documentation.

3. Mutable assembly, immutable product. While building, the state may be temporarily incomplete. build() is the single gate where invariants are checked once, and what comes out is complete and frozen. Named parameters give you immutability, but not the separation between “under construction” and “done”.

That’s why fluent builders thrive even in languages with rich call-site syntax: java.net.http.HttpClient and OkHttp in Java, SparkSession.builder in Python, ConfigurationBuilder and HostBuilder in .NET, query builders like Knex in JavaScript.

Advantages of the Builder Pattern
#

1. Improved Readability

The builder pattern transforms the construction of complex objects into a series of clear, readable steps. .connectTimeout(...) and .readTimeout(...) cannot be swapped by accident.

2. Avoids Constructor Overload

Without the builder, accommodating various combinations of optional parameters would require multiple constructors, leading to:

  • Constructor Explosion: An unwieldy number of constructors to cover all parameter combinations.
  • Confusion: Difficulty in selecting the correct constructor.

3. Enhances Maintainability

  • Ease of Updates: Adding a new parameter involves adding a method to the builder, not modifying existing constructors.
  • Backward Compatibility: Existing code remains unaffected by changes to the builder.
  • Sensible Defaults: Defaults live in one place, the builder, instead of being scattered across call sites.

4. Promotes Immutability

  • Immutable Objects: The final object can be made immutable, improving thread safety.
  • Controlled Construction: The builder fully initialises the object before it’s returned.

Consequences of Not Using the Builder Pattern
#

Complex and Error-Prone Code

  • Hard to Read: Long parameter lists in constructors make code difficult to read and understand.
  • Increased Bugs: Higher chance of passing parameters in the wrong order or to the wrong fields.

Rigid and Inflexible Design

  • Difficult Extensions: Adding new parameters requires changes to constructors and possibly affects all calling code.
  • Lack of Optional Parameters: Cannot easily skip optional parameters without overloading constructors, nulls, or ad-hoc parameter dictionaries.

Example Without Builder

Attempting to create flexible construction without the builder might lead to the use of setters:

HttpClientConfig config = new HttpClientConfig();
config.setBaseUrl("https://api.example.com");
config.setConnectTimeout(Duration.ofSeconds(5));
config.setReadTimeout(Duration.ofSeconds(30));
config.setMaxRetries(3);
config.setFollowRedirects(true);
config = HttpClientConfig()
config.base_url = "https://api.example.com"
config.connect_timeout = timedelta(seconds=5)
config.read_timeout = timedelta(seconds=30)
config.max_retries = 3
config.follow_redirects = True
var config = new HttpClientConfig();
config.BaseUrl = "https://api.example.com";
config.ConnectTimeout = TimeSpan.FromSeconds(5);
config.ReadTimeout = TimeSpan.FromSeconds(30);
config.MaxRetries = 3;
config.FollowRedirects = true;
const config = new HttpClientConfig();
config.baseUrl = "https://api.example.com";
config.connectTimeoutMs = 5000;
config.readTimeoutMs = 30000;
config.maxRetries = 3;
config.followRedirects = true;

Problems with This Approach

  • Mutable Objects: The object is mutable, which can lead to inconsistent states and thread safety issues. That’s particularly bad for configuration shared between threads.
  • Incomplete Objects: There’s a risk of using the object before all necessary fields are set.
  • Verbose Code: Requires multiple lines and doesn’t encourage concise, fluent usage.

Embracing Patterns Pragmatically
#

Design patterns are powerful tools that, when used appropriately, can greatly enhance your codebase. However, it’s important to use them properly.

Guidelines for Using Patterns
#

  • Assess Complexity: Use patterns when the object’s creation logic is non-trivial.
  • Aim for Clarity: Patterns should make the code easier to understand, not more complicated.
  • Avoid Over-Engineering: Don’t introduce patterns for simple problems.

Recognising When Not to Use Them
#

  • Simple Objects: If an object has a straightforward constructor with few parameters, patterns may add unnecessary complexity.
  • One-Off Instances: For objects created infrequently or in a single place, the overhead of a pattern might not be justified.

Being Pragmatic
#

  • Refactor When Necessary: Start simple, and refactor to patterns as requirements evolve.
  • Balance: Strive for a balance between over-simplification and over-engineering.
  • Continuous Learning: Stay open to adopting patterns that solve real problems in your code.

Conclusion: Crafting Maintainable and Adaptable Code
#

As developers, our code doesn’t just need to work, it needs to endure. By refactoring to design patterns we create code that’s easier to read, maintain, and extend.

Remember, patterns are not about rigidly following a set of rules. They’re about applying proven solutions to common problems in a way that makes sense for your specific context. Always be pragmatic. Use patterns when they’re needed, not everywhere.