Skip to Content
Course content

369: The Builder Pattern

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a pull request for a teammate who was building a complex PDF report generator. He had a Report class with a constructor that looked like a phone number: Report(title, author, footer, header, orientation, margin_top, margin_bottom, font_size, show_page_numbers, allow_links, ...). By the time he actually instantiated the object, the code looked like this: Report("Annual Audit", "Finance Team", None, True, "Portrait", 10, 10, 12, False, True).

I remember staring at that line for five minutes, trying to figure out what that False in the ninth position actually controlled. We've all been there—the "telescoping constructor" nightmare. It's fragile, impossible to read, and if you want to add one more optional parameter, you have to update every single instantiation across your entire codebase. That's where the Builder pattern saves your sanity.

The Problem with Overloaded Constructors

In Python, we have named arguments, which help a bit, but when an object requires a complex, multi-step assembly process, even kwargs can feel messy. The Builder pattern is designed to separate the construction of a complex object from its representation. Instead of one giant __init__ call, you use a separate "Builder" object that lets you configure the product piece by piece.

The real magic happens when you implement a "fluent interface." This is just a fancy way of saying that each method in your builder returns self. This allows you to chain calls together, making the code read almost like a sentence. It turns a confusing list of arguments into a clear, intentional sequence of configuration steps.

class Query:
    def __init__(self):
        self.table = None
        self.columns = []
        self.where_clause = None

    def __str__(self):
        cols = ", ".join(self.columns) if self.columns else "*"
        query = f"SELECT {cols} FROM {self.table}"
        if self.where_clause:
            query += f" WHERE {self.where_clause}"
        return query

class QueryBuilder:
    def __init__(self):
        self.query = Query()

    def select(self, *columns):
        self.query.columns.extend(columns)
        return self  # This is the key to chaining

    def from_table(self, table):
        self.query.table = table
        return self

    def where(self, condition):
        self.query.where_clause = condition
        return self

    def build(self):
        # Here you can add validation to ensure the query is valid
        if not self.query.table:
            raise ValueError("A table must be specified.")
        return self.query

# Now, look how much cleaner this is:
builder = QueryBuilder()
sql = builder.select("id", "username", "email")\
             .from_table("users")\
             .where("active = 1")\
             .build()

print(sql)  # SELECT id, username, email FROM users WHERE active = 1

Decoupling Assembly from the Result

You might be wondering: "Why not just put these methods directly on the Query class?" In simple cases, you can. But as you scale, you'll find that the Query object should ideally be an immutable "Value Object"—something that represents a result, not something that knows how to build itself. By keeping the QueryBuilder separate, you ensure that the Query object is only ever created in a valid state via the .build() method.

I've found this particularly useful when the "build" step involves expensive operations or validation. For example, the .build() method can check if the requested columns actually exist in the table before returning the final object. If you just used a constructor, you'd be doing that validation inside __init__, which often makes the class harder to test and subclass.

When to Skip the Builder

I'll be honest: don't over-engineer this. If your class only has three or four parameters, a Builder is overkill. Python's optional arguments and keyword arguments (def __init__(self, name, age=None, city=None):) handle simple cases perfectly. Only reach for the Builder pattern when the construction process feels like a chore, or when you find yourself passing None or False into a constructor just to reach the tenth argument. If the "assembly" of the object has a logical sequence, that's your cue to build a Builder.




📋 Practical Task

Building a Custom HTTP Request Composer

You are tasked with creating a system that constructs complex HTTP requests. Some requests are simple, but others require specific headers, a JSON body, query parameters, and a specific HTTP method (GET, POST, PUT, etc.).

Your goal: Implement an HttpRequest class and an HttpRequestBuilder class. The builder should support the following chained methods:

  • set_method(method): Sets the HTTP method (e.g., "POST").
  • set_url(url): Sets the destination URL.
  • add_header(key, value): Adds a header to a dictionary of headers.
  • set_body(body): Sets the request body.
  • build(): Returns a finalized HttpRequest object.

Requirements:

  • The HttpRequest class should be a simple data holder with a __str__ method that prints the request details in a readable format.
  • The build() method must raise a ValueError if the url or method has not been set.
  • Ensure your builder methods return self to allow for method chaining.

Test your implementation with this scenario: Create a POST request to "https://api.example.com/v1/login" with the header "Content-Type": "application/json" and a body of '{"username": "admin", "password": "123"}' using a single chained statement.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.