Factory Pattern with Enums in Python

The factory pattern is one of my favorites when working with applications that require dynamically instantiated classes based on varying conditions, things can get out of hand as time passes and new classes are added and new parameters pop up. The factory pattern often allows to have a solution to these issues with a simple implementation that can stand longer the test of time and reduce the quantity of spaghetti-code you will generate.

Our example will be based on the code needed by a mothership to generate and deploy UAPs to a planet. We will have different possible types of UAP that can be built and deployed, and we want a simple way to dynamically generate them and provide them the parameters they need.

Classes with something in common

In our example we have 2 classes, each one for a different type of UAP, cigar shaped and tictac shaped. They have the same methods and attributes.

  class CigarUAP:
      warp_speed: bool

      def call_mothership(self):
          ...

      def turn_90_degrees(self, mach: int = 14):
          ...

  class TicTacUAP:
      warp_speed: bool

      def call_mothership(self):
          ...

      def turn_90_degrees(self, mach: int = 12):
          ...

Since they share the same attributes and methods, we can create an abstract class to describe the same data, and a protocol class to describe the same behavior. Meaning the same attributes, and the same method signatures.

  from typing import Protocol
  
  class ABCUAP:
      warp_speed: bool

  class UAPProtocol(Protocol):
      def call_mothership(self)->None:
          ...

      def turn_90_degrees(self, mach: int) -> None:
          ...

Factory Function, Enum types and Mappings

Now, if we want to make a factory for our UAP classes, we can use a function for that. We do so in order to decouple the class definitions from their instantiation and the parts of your program that need to use them. In this way we can add, remove and even change drastically the classes without risking to affect the main program using them.

In our case, we can define an enum for the UAP types we have, this enum will help us make the factory function stricter in what it can accepts and use, both by the logic within the function, and by using a static type checker like mypy.

  from enum import Enum
  class UAPType(Enum):
      TICTAC = "tictac"
      CIGAR = "cigar"

  def deploy_uap(uap_type: UAPType) -> ABCUAP:
      if uap_type == UAPType.TICTAC:
          return TicTacUAP()
      elif uap_type == UAPType.CIGAR:
          return CigarUAP()
      else:
          raise ValueError("The UAPType passed is not yet available on your planet.")

Here is one way to instantiate a UAP class:

  uap_type_I_want = UAPType("tictac")
  new_uap: ABCUAP = deploy_uap(uap_type_I_want)

However, the logic within the factory function is not very dynamic, if we add a new UAP class, for example, we will have to add a new elif statement, which can be daunting. One way I like to deal with this is by having a map of enums to class types as in the following:

  UAP_TYPES_MAP = {UAPType.TICTAC: TicTacUAP, UAPType.CIGAR: CigarUAP}

  def deploy_uap(uap_type: UAPType) -> ABCUAP:
      uap_obj = UAP_TYPES_MAP.get(uap_type)
      if not uap_obj:
          raise ValueError("The UAPType passed is not yet available on your planet.")
      return uap_obj

In this way, we can retrieve the class corresponding to the enum passed from the map, This approach simplifies adding or removing classes without requiring changes to the factory function's logic.

Different attributes, options and acorns

So far we have instantiated the UAP classes without worrying about the attributes. However, if we want to pass different values to our factory function we may incur in some challenges if we were just to add warp_speed to the parameters of the deploy_uap function.

As a way of example, let's add now a new class of UAP:

  class AcornUAP(ABCUAP):
    warp_speed: bool
    irf_cloak: bool

    def call_mothership(self):
        ...

    def turn_90_degrees(self, mach: int = 12):
        ...

We now have a new attribute. It could also become the case that new UAP classes lack an attribute, in our current case only the AcornUAP has a irf_cloak attribute, so passing these parameters to the deploy_uap function directly may not be feasible if we keep adding new classes with different uneven attributes. What we can do, then, is define a dataclass which will collect all the possible attributes of a UAP.

  @dataclass
  class UAPOptions:
    warp_speed: bool
    irf_cloak: bool

The __init__ dunder method of each class will be responsible of extracting from it the values it needs for its attributes. We also need to update our abstract class.

  class ABCUAP:
    options: UAPOptions
        
  class AcornUAP(ABCUAP):
    options: UAPOptions

    def __init__(self, options: UAPOptions):
        self.warp_speed = options.warp_speed
        self.irf_cloak = options.irf_cloak

    def call_mothership(self):
        ...

    def turn_90_degrees(self, mach: int = 12):
        ...

We also need to update our previous UAP cigar and tictac classes.

Great. Now that we have an option object to pass the parameters to any UAP class, we can just add that as a parameter to the factory function, without having to worry about edge cases in the parameters needed by any UAP class.

  def deploy_uap(uap_type: UAPType, options: UAPOptions) -> UAPProtocol:
    uap_obj = UAP_TYPES_MAP.get(uap_type)
    if not uap_obj:
        raise ValueError("The UAPType passed is not yet available on your planet.")
    return uap_obj(options)

  new_acorn_uap: UAPProtocol = deploy_uap(UAPType.ACORN, UAPOptions(True, True))

It is also an "option" to make different option classes that may match with specific or subsets of UAP classes, however that may be beyond of the scope of this article, since it's mostly about the factory function. As a note, you may incur in issues with your type checker, this is because you may want to specify a specific class in the variable you store the instantiated class into, but that can be tricky with these factory functions, so I generally prefer to just use the protocol or abstract class if I have to add type hinting to the instantiated object variable.

A final example where we deploy different types of UAP's would look like this:

  earth_uap = deploy_uap(UAPType.TICTAC, UAPOptions(True, False))
  moon_uap = deploy_uap(UAPType.CIGAR, UAPOptions(False, False))
  mars_uap = deploy_uap(UAPType.ACORN, UAPOptions(True, True))

Conclusion

This was a short and dirty explanation of how to generalize your classes, attributes and parameters so that you can have simple factory functions that can be easy to use/implement/maintain and allow for easy extension. The factory pattern is one of the most common patterns, and although there are OOB ways of implementing it, in python a single function can get the job done in most cases given that the premises (generalized classes and parameters) are met.