DEV Community

Rasul
Rasul

Posted on • Edited on

Understanding Builder Design Pattern

The Builder is a pattern that belongs to the creational design patterns family. What makes it special is its ability to provide abstraction to the client by encapsulating different types of the same object with various combinations during the creation process. This creates reusable and safe access points. Another key feature of this design pattern is its capability to handle the creation of objects that are not within the responsibility of the object itself, thereby adhering to the Single Responsibility Principle (SRP). This feature supports modularity and code maintainability.

Problem

The complexity that arises in the creation of instances or initialization of instances of classes. In other words, the complexity due to combinations and dependencies during the creation of a family of objects leads to complex constructors and tightly coupled code.

Context

The goal of the Builder design pattern is not to manage the increasing number of constructors due to additional features. The main idea is to facilitate the dynamic creation of different types/representations/combinations of objects of a class.

By different types, we mean enabling the client to manage the creation process step by step. We provide the client with the flexibility to create the desired combination of the object.

Another main goal of this pattern is to address the complexity of creating objects with different combinations using a single solution. For example, if an object can be created in 3 or 4 different ways, the aim is to ensure that any new combinations in the future can be handled by adding properties rather than changing the creation mechanism, thus maintaining reusability and maintainability.

In other words, it abstracts the complexity of creating object families by encapsulating the step-by-step creation process in a single place.

Image description
When to Use It?

  • When creating an object involves many complex steps.
  • When there are different combinations for creating an object.
  • When the object needs to be created according to specific access types.
  • When future architectural changes might introduce new object combinations.

In these situations, the Builder design pattern can be used to solve the problem.

However, one of the biggest mistakes with design patterns is treating them as DOGMAS. This is a flawed approach because it can lead us towards anti-patterns.

The DOGMA approach can lead to Curved Architecture in design patterns!
The word COMBINATION (representation) implies that each object is created in different ways and represented in different types!
CLIENT: The side which using builder!

GoF Definition

Image description
“Separate the construction of a complex object from its representation so that the same construction process can create different representations.“

By abstracting the complexity of nested class constructors and delegating responsibilities to different objects, the Builder pattern allows the same creation algorithm to be applied to every member of an object family.

For those not very familiar with OOP/OOAD and paradigm techniques, this might seem complex. However, it’s not very different from what we expressed in the first part of the article — just a bit more technical.

The Builder design pattern is based on algorithmic dependencies. It should allow for changes in the algorithms associated with any object family. Therefore, changing algorithmic structures should create a separate class hierarchy (potentially changing algorithms should be isolated).

Image description
Responsibilities

  • Builder: An abstract interface that defines the process for creating parts of a product.
  • ConcreteBuilder: A concrete implementation of the Builder class that constructs the parts of the product.
  • Director: A class that uses the Builder interface to create complex and varied types of objects according to specific rules. It manages the order of method calls and which methods are called, encapsulating the complexity of creating the object with certain rules.
  • Product: The object that is being created.

Real Case Example

The Builder design pattern has different implementations depending on the programming language. In our example, we will implement a more complex structure: the SQLite Query Generator. We will use a combination of the Builder pattern and the State pattern.

Before we dive into the example, let’s take a look at our class diagram.

Image description
The role of the Director is played by DeclarativeSelectQueryGenerator (we will not delve into this part to keep the article concise; for details, click here). Interfaces are used for contract encapsulation to manage compile-time states, and the builder classes responsible for generating queries are present in our diagram.

Let’s continue with the example from a TDD perspective, noting that we will not examine all the classes in detail.

Our main goal is to set up a declarative SQLite query generation structure. To do this, let’s create our example usage scenario (test-first approach).

[TestMethod]
public void Build_WhenCalledSelectAndWhereCondition_ShouldReturnCorrectQuery()
{
    const string expectedQuery = @"SELECT * FROM Users WHERE Users.Id > 5 OR Users.Name = 'Rasul' ";
    string generatedQuery = new SQLiteSelectQueryBuilder()
                                .Select()
                                .From("Users")
                                .Where("Id")
                                .GreaterThan(5)
                                .Or("Name")
                                .EqualTo("Rasul")
                                .Build()
    Assert.AreEqual(expectedQuery, generatedQuery);
}
Enter fullscreen mode Exit fullscreen mode

Our example class shows that within SQLite, query structures are provided through methods with different stages (fluent/method chaining). To design this, we plan to apply the fluent API concept. However, an important detail is that after the From method is called, it should no longer appear in IntelliSense, meaning a state change is involved.

To design this, we need to manage the state using the State pattern. However, in our case, we need to solve this in compile time. Therefore, we can achieve this using the well-known interface segregation and information-hiding principles.

First, let’s take a look at the Select example.

public class SQLiteSelectQueryBuilder : ChainQueryBuilder,
                                            ISelectQueryBuilder
{
    const string SELECT = "SELECT";
    const string ALL = "*";
    private IEnumerable<string> _columns;
    private bool HasColumns => _columns != null && _columns.Any();
    public SQLiteSelectQueryBuilder(params string[] columns) : base(null) => _columns = columns;
    public ISelectQueryBuilder Select(params string[] columns)
    {
        _columns = columns;
        return this;
    }
    public ISelectQueryBuilder Select(IEnumerable<string> columns)
    {
        _columns = columns;
        return this;
    }
    public IFromQueryBuilder From(string table)
        => new SQLiteFromQueryBuilder(table, this);
    protected override string Build(QueryBuilderContext? context = null)
    {
        if (HasColumns)
        {
            string aggregatedColumn = string.Empty;
            if (context.HasValue)
            {
                IEnumerable<string> columns = _columns.Select(x => $"{context.Value.Alias}.{x}");
                aggregatedColumn = string.Join(",", columns);
            }
            else
            {
                aggregatedColumn = string.Join(",", _columns);
            }
            return $"{SELECT} {aggregatedColumn} ";
        }

        return $"{SELECT} {ALL} ";
    }
    public override string Build() => Build(null);
}
Enter fullscreen mode Exit fullscreen mode

As seen in the code structure, each method for the Select query returns its own state object. Examining the builder and state approaches used in this class, we see that because there are more than two management options for constructing a query, it supports a step-by-step approach (which is where the Builder pattern comes into play). Additionally, to enable step-by-step construction of other queries (building), transitions between interfaces are used (e.g., the From example).

Let’s continue with the From example.

public class SQLiteFromQueryBuilder : ChainQueryBuilder,
                                        IFromQueryBuilder
{
    const string FROM = "FROM";
    const string AS = "AS";
    private readonly string _tableName;
    private string _alias;
    private bool HasAlias => !string.IsNullOrWhiteSpace(_alias);
    private string DefaultAlias => HasAlias ? _alias : _tableName;
    public SQLiteFromQueryBuilder(string tableName, ChainQueryBuilder innerQueryBuilder) : base(innerQueryBuilder)
        => _tableName = tableName ?? throw new ArgumentNullException(nameof(tableName));
    public IFromQueryBuilder Alias(string alias)
    {
        _alias = alias;
        return this;
    }
    public IWhereQueryBuilder Where(string column)
        => new SQLiteWhereQueryBuilder(DefaultAlias, column, this);
    public IOrderByQueryBuilder OrderBy()
        => new SQLiteOrderByQueryBuilder(this);
    protected override string Build(QueryBuilderContext? context = null)
    {
        StringBuilder builder = new StringBuilder();
        context = new QueryBuilderContext(DefaultAlias);
        BuildChain(builder, context);
        if (HasAlias)
        {
            builder.Append($"{FROM} {_tableName} {AS} {_alias} ");
        }
        else
        {
            builder.Append($"{FROM} {_tableName} ");
        }
        return builder.ToString();
    }
    public override string Build() => Build(null);
}
Enter fullscreen mode Exit fullscreen mode

The same approach is used here as well. Within the class, the From structure can be set up using two different types of methods. This flexibility is provided by the Builder design pattern.

Let’s take a look at the Where example.

public class SQLiteWhereQueryBuilder : ChainQueryBuilder,
                                        IWhereQueryBuilder,
                                        IConditionQueryBuilder<IWhereQueryBuilder>
{
    const string WHERE = "WHERE";
    readonly Dictionary<string, string> Symbols = new Dictionary<string, string>()
    {
        {nameof(IWhereQueryBuilder.EqualTo), "="},
        {nameof(IWhereQueryBuilder.NotEqualTo), "!="},
        {nameof(IWhereQueryBuilder.GreaterThan), ">"},
        {nameof(IWhereQueryBuilder.LessThan), "<"},
        {nameof(IWhereQueryBuilder.In), "IN"},
        {nameof(IConditionQueryBuilder<IWhereQueryBuilder>.And), "AND"},
        {nameof(IConditionQueryBuilder<IWhereQueryBuilder>.Or), "OR"},
    };
    private readonly string _firstColumn;
    private readonly string _alias;
    private readonly List<string> _conditions;
    public SQLiteWhereQueryBuilder(string alias, string firstColumn, ChainQueryBuilder innerQueryBuilder) : base(innerQueryBuilder)
    {
        _firstColumn = firstColumn ?? throw new ArgumentNullException(nameof(firstColumn));
        _alias = alias ?? throw new ArgumentNullException(nameof(alias));

        _conditions = new List<string>
        {
            $"{WHERE} {GetColumnName(_firstColumn)} "
        };
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> EqualTo(string value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.EqualTo)]} '{value}' ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> EqualTo(double value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.EqualTo)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> EqualTo(int value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.EqualTo)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> NotEqualTo(string value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.NotEqualTo)]} '{value}' ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> NotEqualTo(double value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.NotEqualTo)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> NotEqualTo(int value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.NotEqualTo)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> In(params string[] values)
    {
        IEnumerable<string> convertedStrings = values.Select(x => $"'{x}'");
        string joinedValues = string.Join(",", convertedStrings);

        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.In)]} ({joinedValues}) ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> In(params double[] values)
    {
        string joinedValues = string.Join(",", values);

        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.In)]} ({joinedValues}) ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> In(params int[] values)
    {
        string joinedValues = string.Join(",", values);

        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.In)]} ({joinedValues}) ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> GreaterThan(int value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.GreaterThan)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> GreaterThan(double value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.GreaterThan)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> LessThan(int value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.LessThan)]} {value} ");
        return this;
    }
    public IConditionQueryBuilder<IWhereQueryBuilder> LessThan(decimal value)
    {
        _conditions.Add($"{Symbols[nameof(IWhereQueryBuilder.LessThan)]} {value} ");
        return this;
    }
    public IOrderByQueryBuilder OrderBy()
      => new SQLiteOrderByQueryBuilder(this);

    public IWhereQueryBuilder And(string column)
    {
        _conditions.Add($"{Symbols[nameof(IConditionQueryBuilder<IWhereQueryBuilder>.And)]} {GetColumnName(column)} ");
        return this;
    }
    public IWhereQueryBuilder Or(string column)
    {
        _conditions.Add($"{Symbols[nameof(IConditionQueryBuilder<IWhereQueryBuilder>.Or)]} {GetColumnName(column)} ");
        return this;
    }
    protected override string Build(QueryBuilderContext? context = null)
    {
        StringBuilder builder = new StringBuilder();
        BuildChain(builder, context);
        if (_conditions.Any())
        {
            foreach (string condition in _conditions)
            {
                builder.Append(condition);
            }
        }
        return builder.ToString();
    }
    public override string Build() => Build(null);
    private string GetColumnName(string column)
        => $"{_alias}.{column}";
}
Enter fullscreen mode Exit fullscreen mode

Although it might seem a bit complex, the Where clause structure allows us to create logical blocks in many different types and should be flexible. Therefore, it includes all the functions within itself. In the shown code, all conditions are collected and then combined into a single query using the builder and chaining methods. This approach is applied similarly to all subqueries using the Builder pattern.

The code is lengthy, so I won’t dwell on it too much, but for those interested, you can view the complete code here.

Conclusion

The Builder design pattern is an important pattern that provides flexibility and control in software development. This pattern simplifies the creation of complex objects and enables writing more readable and maintainable code.

It is particularly useful in systems that need to handle multiple requests simultaneously. Most importantly, this pattern allows detailed and step-by-step control of the object creation process. As a result, client code becomes independent of how the object is created, enhancing code reusability. Additionally, it makes it easy to create objects in different configurations.

Stay Tuned!

Top comments (0)