Builder Design Pattern: A Guide for Network Automation Engineers

Builder Design Pattern: A Guide for Network Automation Engineers

·

11 min read


Tech Gauge:

🟩🟩🟩⬜⬜ (3/5)

  • This article is moderately technical and suitable for readers with some background in Python and OOP.

  • Python is the language used in the examples here. The concept and theory behind it, however, is completely language-agnostic.


How do you instantiate a new object in your code?

"It couldn't be more trivial", I hear you thinking: classes have constructors and we just need to invoke them passing whatever arguments it needs. Most objects will require none, one, or maybe just a handful of parameters. And that’s that…

Well… what if the object you want to create is not like "most objects"?

What is the Builder Pattern?

If we have to define it in a single sentence: The Builder pattern intends to separate object creation from the object representation. In other words, the pattern organizes the object initialization in a series of optional steps that will be abstracted away, outside the actual class that represents the object.

Let’s devise a very high-level example: suppose you have a class that serves as a blueprint for objects with different customization options.

  • Instance 'alpha' of that class is defined by having features A, B and C.

  • Instance 'beta', on the other hand, might need features A, B, C, X, Y and Z.

  • You got it… let’s just assume that several other instances of the same class will require entirely different feature sets.

Instantiating an object of such class can be quite an elaborate process, as it allows several “flavors”. Without giving much thought about it, most developers would go with either of two approaches:

1 - create a single class with a bulky and complex __init__() method, with all the required logic to handle all possible cases; or

2 - (ab)using inheritance, create a main class with several subclasses covering each case.

Even though either option can work, they are far from optimal, specially if each flavor requires a large number of args for its initialization, leading to a massively complex __init__() method and/or the number of customizations are large enough to require an equally large number of subclasses.

For cases like this, it is helpful to use custom builders with the sole purpose of carrying out the instantiation of objects. This is where the Builder Pattern comes in handy, by allowing client code the flexibility to pick and choose what it really needs for the object it wants to build.

Defining The Example Scenario

Imagine you need objects to hold interface configuration data - not the configuration itself, just the relevant data that would later be used to render configuration. The challenge is that not all network devices work the same, and more than likely you will need different feature sets enabled on different interfaces of different devices.

To keep things very simple and the code snippets relatively short, let’s consider only two different device roles and limit ourselves to think about only two possible features that could be enabled on a interface level: VLANs and OSPF routing.

  • Access Switch: needs access VLAN interfaces, as well as trunks towards core switch interfaces. No routing required.

  • Core Switch: needs trunking on interfaces connecting to access switches and OSPF routing on VLAN interfaces (and potentially other interfaces connecting to other devices).

For VLANs, we need to keep track of access and trunk interfaces. We will do that with specific attributes:

  • access_interfaces: a list of tuples identifying an interface and its access VID. Each tuple should look like: (<interface-name>, <vlan_id>);

  • trunk_interfaces: a list of tuples identifying a trunk interface and its allowed VIDs. Each tuple should look like: (<interface-name>, [<vlan_id1, vlan_id2, …])

For OSPF, we need to track the routing process and area IDs to be assigned to an interface. We will do that with a specific attribute:

  • ospf_interfaces: a list of tuples identifying the interface name, OSPF routing process ID, and area ID. Each tuple should look lile: (<interface-name>, <ospf-process-id>, <ospf-area-id>)

Implementation #1: Building without a Builder

Let’s start with a naive implementation of our code, one that doesn’t attempt to use the builder pattern at all:

class InterfacesConfigData:
    """A class that holds data about configs to be applied to a device's interfaces"""

    def __init__(self, access_interfaces, trunk_interfaces, ospf_interfaces):
        # potentially a much more complex initialization process would be required here
        # Like checking if the device role matches with the data assigned to an interface, 
        # or if an interface isn't showing up in more than one of the attrs, etc...
        self.access_interfaces = access_interfaces
        self.trunk_interfaces = trunk_interfaces
        self.ospf_interfaces = ospf_interfaces

    # possibly 'getter' and 'setter' methods would be here as well.

    def _access_interfaces_str(self):
        result = ""
        for intf in self.access_interfaces:
            result += f"\t{intf[0]}: {intf[1]}\n"
        return result

    def _trunk_interfaces_str(self):
            result = ""
            for intf in self.trunk_interfaces:
                result += f"\t{intf[0]} trunking for: {', '.join(map(str, intf[1]))}\n"
            return result

    def _ospf_interfaces_str(self):
            result = ""
            for intf in self.ospf_interfaces:
                result += f"\t{intf[0]} is in OSPF-{intf[1]} area-{intf[2]}\n"
            return result

    def __str__(self):
        return (
            f"Access Interfaces:\n{self._access_interfaces_str()}" 
            f"Trunk Interfaces:\n{self._trunk_interfaces_str()}"
            f"OSPF Interfaces:\n{self._ospf_interfaces_str()}"
        )

Note: keep in mind that this example is intentionally simple. It will serve us well to show how the builder pattern can be implemented, but in reality it wouldn’t be strictly necessary from a ‘best practice’ point of view is this case.

The class has the three attributes we discussed, and it includes methods that will take care of producing a nice output for us whenever we invoke the __str()__ - for example by trying to print an instance of InterfacesConfigData.

If we were to instantiate objects of InterfacesConfigData, the only alternative at the moment is to use it’s included initializer. You know the drill…

sw1_access_intfs = [("Gi0/1", 10), ("Gi0/2", 20)]
sw1_trunk_intfs = [("Gi0/3", [10,20]), ("Gi0/4", [10,20])]
sw1_intfs_configs = InterfacesConfigData(
    access_interfaces=sw1_access_intfs,
    trunk_interfaces=sw1_trunk_intfs,
    ospf_interfaces=[]               # <<<<<<< we need to pass [] here
)

sw2_trunk_intfs = [("Gi0/1", [10,20]), ("Gi0/2", [10,20])]
sw2_ospf_intfs = [("Vlan10", 1, 0), ("Vlan20", 1, 0)]
sw2_intfs_configs = InterfacesConfigData(
    access_interfaces=[],             # <<<<<<< we need to pass [] here
    trunk_interfaces=sw2_trunk_intfs,
    ospf_interfaces=sw2_ospf_intfs
)


# and now we print the InterfacesConfigData objects, 
# which calls for the __str__() method
>>> print(sw1_intfs_configs)
Access Interfaces:
        Gi0/1: 10
        Gi0/2: 20
Trunk Interfaces:
        Gi0/3 trunking for: 10, 20
        Gi0/4 trunking for: 10, 20
OSPF Interfaces:

>>> print(sw2_intfs_configs)
Access Interfaces:
Trunk Interfaces:
        Gi0/1 trunking for: 10, 20
        Gi0/2 trunking for: 10, 20
OSPF Interfaces:
        Vlan10 is in OSPF-1 area-0
        Vlan20 is in OSPF-1 area-0

Surely it works, but because we had to cover for all possibilities in our initializer (let’s pretend we had to do that… 🙂), we ended up with a bulky, messy and hard to maintain code there. On top of that, the parameters we needed to pass to that initializer varied from sw1_intfs_configs to sw2_intfs_configs and we had to be careful including an empty list ([]) where appropriate.

The builder pattern now comes to our rescue!

Implementation #2: The Simple Builder

Let’s now finally look on how a trivial Builder for this class could look like:

class InterfacesConfigDataBuilder:
    def __init__(self):
        self._access_interfaces = []
        self._trunk_interfaces = []
        self._ospf_interfaces = []

    def set_access_interfaces(self, access_interfaces):
        # here comes all the validations and complex logic for this feature
        self._access_interfaces = access_interfaces

    def set_trunk_interfaces(self, trunk_interfaces):
        # here comes all the validations and complex logic for this feature
        self._trunk_interfaces = trunk_interfaces

    def set_ospf_interfaces(self, ospf_interfaces):
        # here comes all the validations and complex logic for this feature
        self._ospf_interfaces = ospf_interfaces

    def build(self):
        # this is the method we will need to call to get 
        # our InterfacesConfigData object
        return InterfacesConfigData(
            access_interfaces=self._access_interfaces,
            trunk_interfaces=self._trunk_interfaces,
            ospf_interfaces=self._ospf_interfaces
        )

Once again, use your imagination to pretend that the __init__() method in the InterfacesConfigData class is super big and complex, full of logic to cover all possible use cases. We should now remove all that from the InterfacesConfigData class, since it now lives in the InterfacesConfigDataBuilder class.

And here is how we can use our simple builder:

# Defining again the same data we will use to build the objects
sw1_access_intfs = [("Gi0/1", 10), ("Gi0/2", 20)]
sw1_trunk_intfs = [("Gi0/3", [10,20]), ("Gi0/4", [10,20])]
sw2_trunk_intfs = [("Gi0/1", [10,20]), ("Gi0/2", [10,20])]
sw2_ospf_intfs = [("Vlan10", 1, 0), ("Vlan20", 1, 0)]

# we now only interact with the builder to create the objects.
# we can cherry-pick the methods we want to call, without having 
# to think about the ones we don't need.
sw1_intfs_config_builder = InterfacesConfigDataBuilder()
sw1_intfs_config_builder.set_access_interfaces(sw1_access_intfs)
sw1_intfs_config_builder.set_trunk_interfaces(sw1_trunk_intfs)
sw1_intfs_configs = sw1_intfs_config_builder.build()
print(sw1_intfs_configs)    # <<<<< will produce the same output as before

sw2_intfs_config_builder = InterfacesConfigDataBuilder()
sw2_intfs_config_builder.set_trunk_interfaces(sw2_trunk_intfs)
sw2_intfs_config_builder.set_ospf_interfaces(sw2_ospf_intfs)
sw2_intfs_configs = sw2_intfs_config_builder.build()               
print(sw2_intfs_configs)   # <<<<< will produce the same output as before

Hopefully, the simplicity of our example doesn’t prevent you from seeing how much better this is already:

  • Readability and Maintainability: Now it is clearer which properties are being set and in what order, making the code easier to understand and modify.

  • Encapsulation: The builder encapsulates the construction process, hiding the details of how the object is constructed from the client code. This provides a clear and simple interface for object creation.

  • Separation of Concerns: We are adhering to the Single Responsibility Principle much better by having an object representation (the InterfacesConfigData class) that is concerned only with the object’s behaviours it must implement, and a separate builder that ins concerned only with creating instances of that object.

  • Flexibility: The builder pattern allows you to set only the parameters you need. You don't have to provide all parameters at once, and you can omit optional parameters without creating multiple constructors or using default values.

  • Immutability: By employing builders, it’s natural to move 'setter’ methods away from the main object class and into the builder class. In Python, this doesn’t exactly prevent anything, but certainly creates an indication (for responsible developers at least) that an object shouldn’t be changed after creation.

But we don’t have to stop here. Things can get even more interesting…

Implementation #3: The Fluent Builder

Our builder allows for clearly setting of features an object needs for its initialization, but we have to do that line by line. We can’t really chain methods to produce the same end result.

If we want to achieve that, we need to produce what is known as a “fluent builder”, i.e., one that offers an API that allows command chaining. Fortunately, this is something quite easy to do in Python and the only thing we need to change is our “setter” methods in the builder class to also return the builder object itself.

class InterfacesConfigDataBuilder:
    # the __init__() and build() methods don't change

    def set_access_interfaces(self, access_interfaces):
        self._access_interfaces = access_interfaces
        return self # <<<<<<<<< HERE!

    def set_trunk_interfaces(self, trunk_interfaces):
        self._trunk_interfaces = trunk_interfaces
        return self # <<<<<<<<< HERE!

    def set_ospf_interfaces(self, ospf_interfaces):
        self._ospf_interfaces = ospf_interfaces
        return self # <<<<<<<<< HERE!

Now, you have a fluent builder:

# assume we have the same data defined as before for SW1 and SW2

# we can now call the setter methos chained
sw1_intfs_config_builder = InterfacesConfigDataBuilder()
sw1_intfs_configs = (sw1_intfs_config_builder
                     .set_access_interfaces(sw1_access_intfs)
                     .set_trunk_interfaces(sw1_trunk_intfs)
                     .build())
print(sw1_intfs_configs)    # <<<<< will produce the same output as before

sw2_intfs_config_builder = InterfacesConfigDataBuilder()
sw2_intfs_configs = (sw2_intfs_config_builder
                     .set_trunk_interfaces(sw2_trunk_intfs)
                     .set_ospf_interfaces(sw2_ospf_intfs)
                     .build())          
print(sw2_intfs_configs)   # <<<<< will produce the same output as before

For better readability, I split the line in multiple lines, but the call for setters + build methods above could have been a one-liner.

Implementation #4: The Builder Director

Another interesting concept you should keep in mind while writing builders, is that of a Builder Director.

It is simply a separate class that encapsulates the logic needed to construct different variations of your target class. Essentially, the Director class uses the methods provided by the Builder class to streamline the creation process, allowing you to easily generate specific objects with a single action.

Here’s how it’s done:

class Director:
    def _get_new_builder(self):
        return InterfacesConfigDataBuilder()

    def build_access_switch(self, access_interfaces, trunk_interfaces):
        return (self._get_new_builder()
                .set_access_interfaces(access_interfaces)
                .set_trunk_interfaces(trunk_interfaces)
                .build())

    def build_core_switch(self, trunk_interfaces, ospf_interfaces):
        return (self._get_new_builder()
                .set_trunk_interfaces(trunk_interfaces)
                .set_ospf_interfaces(ospf_interfaces)
                .build())

Now, we have laser-focused methods that encapsulates all the required logic we need to create each object variation we require.

Also, notice _get_new_builder() method. It’s there for two reasons:

  • in our use case, we always need a brand new InterfacesConfigDataBuilder object for each InterfacesConfigData object we want to build

  • instead of requiring the client code to create that Builder object every time it wants to interface with the Director to have the desired object created, the _get_new_builder() silently takes care of that.

Now it becomes even simpler to create the InterfacesConfigData objects:

# assume we have the same data defined as before for SW1 and SW2
director = Director()

# Building an access switch by calling a single method from the director
sw1_intfs_configs = director.build_access_switch(sw1_access_intfs, sw1_trunk_intfs)
print(sw1_intfs_configs) # <<<<< will produce the same output as before

# Building a core switch by calling a single method from the director
sw2_intfs_configs = director.build_core_switch(sw2_trunk_intfs, sw2_ospf_intfs)
print(sw2_intfs_configs) # <<<<< will produce the same output as before

Wrapping it Up: Building Stuff Like a Ninja

In our line of work, it is so common to have to deal with complex objects (devices, interfaces, configurations, designs, etc…) that the Builder Design Pattern can be a valuable asset for the infra automation developer.

By separating the construction process from the representation, the Builder Pattern enhances code readability, maintainability, and flexibility. It adheres to the Single Responsibility Principle by clearly delineating the roles of object representation and object creation. The use of Fluent Builders and Builder Directors further streamlines the process, allowing for method chaining and encapsulating construction logic, respectively.

We definitely didn’t exhaust the topic with this article, but by adopting the techniques presented here, network automation engineers can efficiently write and maintain their code, building objects like a true ninja.