The Factory Method Design Pattern: A Guide for Network Automation Engineers

The Factory Method Design Pattern: A Guide for Network Automation Engineers


Tech Gauge:

🟩🟩🟩🟩⬜ (4/5)

  • This article is quite technical and suitable for readers with a solid understanding of OOP in Python and the SOLID Principles.

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


Making clothes is a pretty old thing. Humans have mastered different ways of doing it for millennia, and yet, I bet the last time you did it yourself was… well, never! Right?

This introduction, however cheesy, should be enough to help start drawing a picture in your head of what Factory Design Patterns do for us: they intend to separate object creation from object use.

And yes, it’s plural: there are at least two of them: Factory Method Pattern and Abstract Factory Pattern, but in this article we will focus on the Factory Method Pattern. A third “something that looks like a pattern but probably isn’t” also exists: the Simple Factory. We will briefly discuss it here too.

If you really want to see an article about the Abstract Factory Pattern, let me know in the comments.

Disambiguation

Before going any further, I remember having a hard time with terminology when I was first formally learning about the factory patterns. Hence, I want to clarify two terms that are often used when we talk about Factory Patterns, but are not synonym with them:

Factory

  • A broad term that just refers to anything that is expected to create something else in a program.

  • A factory could be any of the OOP fundamental building blocks: a function, a method, a class...

  • What the factory creates is often an object, but it doesn’t always have to be one. It could be a file, a record, an output in the screen, a network connection, a sound wave... it doesn't really matter.

Factory method:

  • Also referred to as "creation method" in some sources.

  • The same definitions of "Factory" above applies here too, but now it's obviously narrowed down to the level of a method inside a class.

  • I want to emphasize: it is still NOT the Factory Method Pattern that we are talking about here! It's just a method that behaves like a factory (creates something)

Hopefully you will be able to understand that code implementing any factory pattern (including the one we will discuss here) also implements both these concepts above, but the other way around isn't necessarily true.

Defining The Scenario

Imagine you are responsible for writing python code that will deal with different kinds of network connections.

You know your network and right now you have to cover for three connection types: SSH, HTTP and gRCP. It’s safe to assume, however, appliances from different vendors will be introduced in the future, meaning it is best if the code is not tightly coupled with only these three protocols.

The Simple Factory “Pattern”

Here, all we have is a class with a factory method (mind the definition given before) responsible for creating objects.

Let’s see how the code would look like in this case, with one class for each of the network connections types we need to cover, and a separate class with a simple factory method.

class SSHConnection:
    def connect(self):
        # some super efficient code to create and return a SSH connection
        # it could include several parameters, use third-party libs, ...
        return "Connecting via SSH"

class HTTPConnection:
    def connect(self):
         # some super efficient code to create and return a HTTP(S) connection
         # it could include several parameters, use third-party libs, ...
        return "Connecting via HTTP"

class GRPCConnection:
    def connect(self):
        # some super efficient code to create and return a gRPC connection
        # it could include several parameters, use third-party libs, ...
        return "Connecting via gRPC"

class NetworkConnectionFactory:
    @staticmethod
    def create_connection(connection_type):
        # potentially several other args would be accepted here
        # and passed down to each class initializer as well.
        if connection_type == 'ssh':
            return SSHConnection()
        elif connection_type == 'http':
            return HTTPConnection()
        elif connection_type == 'grpc':
            return GRPCConnection()
        else:
            raise ValueError("Unknown session type")

# Usage
connection = NetworkConnectionFactory.create_connection('ssh')
print(connection.connect())  # Output: Connecting via SSH

# the same idea to create other connection types.

Note: as it’s usually the case with these type of examples, focus on the concept rather than the code. Just pretend the classes and methods have all they need to actually create the connections we are talking about.

As you can see, the factory method in this case typically uses conditional logic to decide which class, or which flavours of a class, to instantiate based on input parameter(s).

As more and more conditionals are added to the method, a responsible developer will consider abstracting the concept through the use of more advanced techniques, and suddenly it all starts to become more like one of the other (real) factory patterns.

As simple as it is, we can still highlight some advantages of using this:

  • Encapsulation: The simple factory encapsulates the object creation logic, making it easier to change the instantiation process without affecting the client code.

  • Code Reusability: By centralizing the creation logic, the simple factory promotes code reusability and reduces duplication.

  • Adherence to SRP: The factory class has a Single Responsibility: to create objects. This separation of concerns can make the code more maintainable.

But simplicity never comes free of cost. You will pay the price with the following disadvantages:

  • Breaks the OCP: When the time comes to support new types of connections, to keep up with your simple factory, you will need to modify the NetworkConnectionFactory class. Maybe it is not that big of a deal to you, but it is a fact that would be in clear violation of the Open-Closed Principle.

  • Limited Flexibility & Coupling: As the number of classes to instantiate grows, the factory method may become large and difficult to maintain. This means your factory class right now is somehow coupled with the network connection classes.

  • Not a True Design Pattern: Some consider the simple factory to be more of a programming idiom or a stepping stone to more complex patterns like the Factory Method or Abstract Factory.

The Factory Method Pattern

The simple factory works, but it comes with a few code smells that can be addressed by the real Factory Method Pattern.

Because Python is such a flexible language, in reality there’s a number of distinct ways that this pattern could be implemented. To illustrate that, I want to explore two different approaches to the Factory Method Pattern: a more “traditional way” and what I like to call “the pythonic way”.

A Traditional Implementation

Historically, the Factory Method Pattern is oriented towards the use of Interfaces, both to the products (the objects that are created) and to the factories (the classes responsible to handle object creation).

Here’s how we could do it:

from abc import ABC, abstractmethod

# Connections Interface (the product)
class NetworkConnection(ABC):
    @abstractmethod
    def connect(self):
        pass

# Concrete classes for different types of network connections
class SSHConnection(NetworkConnection):
    def connect(self):
        return "Connecting via SSH"

class HTTPConnection(NetworkConnection):
    def connect(self):
        return "Connecting via HTTPS"

class GRPCConnection(NetworkConnection):
    def connect(self):
        return "Connecting via gRPC"


# Factories Interface (the factory)
class NetworkConnectionFactory(ABC):
    @abstractmethod
    def create_connection(self):
        pass

# Concrete factory classes for each connection type
class SSHConnectionFactory(NetworkConnectionFactory):
    def create_connection(self):
        return SSHConnection()

class HTTPSConnectionFactory(NetworkConnectionFactory):
    def create_connection(self):
        return HTTPSConnection()

class GRPCConnectionFactory(NetworkConnectionFactory):
    def create_connection(self):
        return GRPCConnection()

# Usage
factory = SSHConnectionFactory() # explicitly create a SSHConnectionFactory
connection = factory.create_connection()
print(connection.connect())  # Output: Connecting via SSH

factory = GRPCConnectionFactory() # explicitly create a GRPCConnectionFactory
connection = factory.create_connection()
print(connection.connect())  # Output: Connecting via gRPC

NOTE: do not take the fact that this implementation uses abstract classes (interfaces) as an indication of this being the Abstract Factory Pattern. One thing has nothing to do with the other.

The Factory Method Pattern relies heavily on inheritance and that can make the code harder to understand and maintain, when used excessively. But by assessing our gains, I think they far outweigh that issue:

  • First, we still get everything good we already had with the simple factory: Encapsulation, Code Reusability, Adherence to SRP.

  • Flexibility: As soon as you need to support a new connection type, you only have to create its respective connection and factory classes.

  • Adheres to OCP: We no longer violate the Open-Closed Principle as with the half-baked simple factory.

  • Adherence to ISP: Each class implements only the interface that is specific for its use case, keeping strictly in line with the Interface Segregation Principle.

The one thing that really troubles me about this traditional approach is the the amount of code! Along with the factory interface class, we need one concrete factory class for each concrete product class. Which means each new connection type that we need to support requires two new classes here.

Another undesired side effect of this traditional implementation is that to actually get a NetworkConnection object (of whatever specific type), developers need to instantiate its respective factory object first. It’s like you having to know a little bit of the manufacturing process that goes on inside a clothing Factory before you can order a suit.

Well, here comes python to our rescue… 🐍

The Pythonic Implementation

Just to make it clear: I’ve never seen this been officially called “the pythonic way”, or “the pythonic implementation” by some well-known and renowned pythonista. I came up with this term for myself and I’m just going with it…

The main problem with the traditional approach we just saw is the sheer amount of classes that we need to create, plus the fact that the clients of our code will have keep track of such classes. If only there was a way to treat the factory as single actionable entity to “just create object of type X”.

Well, we can achieve exactly that thanks to the wonders of python. Let’s see how:

# we'll need the built-in 'inspect' module for some instrospection.
import inspect

# No changes required in NetworkConnection interface 
# and its concrete implementations

# The factory class is now a standard class, not an Interface.
class NetworkConnectionFactory(): 
    def __init__(self):
        # keys will be connection names
        # values will be referecens to the NetworkConnection concrete subclasses.
        self._connection_classes = {}

        # populates self._connection_classes
        self._set_connection_classes()

    def _set_connection_classes(self):
        """
        Detects and registers all connection classes 
        that are concrete subclasses of NetworkConnection.
        """
        for name, obj in globals().items():
            if inspect.isclass(obj) and not inspect.isabstract(obj) and issubclass(obj, NetworkConnection):
                self._connection_classes[name.lower().replace('connection', '')] = obj

    def create_connection(self, connection_type):
        if connection_type in self._connection_classes:
            return self._connection_classes[connection_type]()
        else:
            raise ValueError(f"Unknown connection type: {connection_type}")

# Usage
factory = NetworkConnectionFactory() # a single factory object that can be reused

ssh_conn = factory.create_connection('ssh')
print(ssh_conn.connect())  # Output: Connecting via SSH
http_conn = factory.create_connection('http')
print(http_conn.connect())  # Output: Connecting via HTTPS
grpc_conn = factory.create_connection('grpc')
print(grpc_conn.connect())  # Output: Connecting via gRPC

Let’s summarize what’s happened here:

  • First, we don’t really need to change anything about the product classes: NetworkConnection and all of its concrete subclasses can stay exactly the same.

  • We ditched the factory Interface, and instead made NetworkConnectionFactory a standard class.

  • NetworkConnectionFactory contains an instance attribute, _connection_classes, that is in the core of our ‘pythonic implementation’: it’s a dictionary that links connection types given as strings (‘ssh’, ‘grcp’, ‘http’, …) with their respective NetworkConnection concrete implementations.

  • As an instance of NetworkConnectionFactory is created, we populate _connection_classes attribute with _set_connection_classes(): for each concrete subclass of NetworkConnection, it takes the class name as a lower case string, and removes “connection” out of that to create the keys in our dict. The respective value, evidently, will be a reference to that class itself. This is done thanks to Python’s powerful introspection capabilities.

  • By the way, the leading _ in _connection_classes and _set_connection_classes() is just a convention amongst python developers to signal: “this attribute/method should be treated as a private”.

  • create_connection() is now an instance method, since it has to access the instance attribute to check if the parameter passed to connection_type maps to a supported connection type.

Some may call that a hack. I won’t disagree, but I’ll also call it a win: the whole process is abstracted in a elegant way and a single factory instance can be used and reused to produce NetworkConnection objects of all types. The only thing clients of this code need to know is what string they should use for connection_type, which quite frankly should be obvious anyways.

Wrapping it up

The Factory Method Design Pattern offers developers an elegant way to manage object creation, promoting flexibility and adherence to SOLID principles.

By separating the creation of network connection objects from their usage, engineers can ensure their code is more maintainable, scalable, and adaptable to future changes.

The traditional implementation provides a structured approach using interfaces, while the pythonic implementation presented here leverages python's dynamic capabilities to reduce complexity and enhance usability. Both methods highlight the importance of design patterns in creating efficient and effective solutions that only true network automation ninjas can ship.