Dynamic Factory Design Pattern in Python
Published at Oct 12, 2023
The factory pattern might be one of the most common and well known design patterns, and for good reasons. It relies on abstractions while being able to serve clients with concrete implementations of the object they want.
Usually, the pattern relies on if
or switch/match
chains to determine the object to return, which can work when there are a limited number of objects, but it presents some problems when scaling, as forgetting to add the condition to the factory ends up creating unexpected side effects.
An alternative is just making a dynamic factory, which sacrificies readability for scalability, while also introducing some conditions in the way the project, modules and objects should be structure or named.
Itβs especially easy to implement in Python by leveraging the getattr
function and the importlib
module, as we can dynamically look for objects in any module we want.
The Example
We are going to implement music players in different formats: MP3, FLAC, WAV, and whatever comes in the future. Conceptually, these music players must only do one thing: play
music.
As such, an interface is defined for them
class MusicPlayer(Protocol):
"""Defines the Music Player interface, to be implemented
by objects that can play music in a format they want."""
def play(self) -> None:
"""Plays music"""
...
Before going further, lets have a quick word on interfaces. There are two traditional ways to do it:
- Explicitely, where an Abstract Base Class is defined, and subclasses are made with it, having to implement the abstract methods themselves.
- Implicitely, through Protocols, on which no inheritance is used, and the interface implementation is not enforced.
This is my preferred choice as it reduces the amount of boiler plate and imports, but it might be my Go
bias, as the interfaces there are also implicit. That said,as Python is a dynamic language, thereβs no real pre runtime check on the implementation conforming to the interface, so if the method is called and it is not implemented, it will break.
To make sure we instantiate the right player based on the type of files our music contains, we are going to use the factory pattern, and make it dynamic.
The folder structure for all the examples is the same, and it can be a bit tight, as we need to conform to some kind of norm for the pattern to work. In this case, all the music players will be housed in the players
module, and the submodule name will be the player name (think mp3
, flac
, etc.). Furthermore, the object will conform to the same conventions, with the name being uppercase and the Player
suffix (like MP3Player
, for example).
βββ README.md
βββ basic
βΒ Β βββ main.py
βΒ Β βββ players
βΒ Β βββ __init__.py
βΒ Β βββ flac.py
βΒ Β βββ mp3.py
βΒ Β βββ wav.py
βββ configurable
βΒ Β βββ main.py
βΒ Β βββ players
βΒ Β βββ __init__.py
βΒ Β βββ flac.py
βΒ Β βββ mp3.py
βΒ Β βββ wav.py
βββ functional
βββ main.py
βββ players
βββ __init__.py
βββ flac.py
βββ mp3.py
βββ wav.py
I prefer the interfaces defined on the client side, that is, wherever the objects will be called, rather than where the objects are defined, as this leads to a decoupled architecture.
We are going to explore three options:
- A basic one, where all the players are instantiated in the same way.
- A more advanced one, where players have different state, and require a different way of initialization.
- A functional alternative.
To see the full code for each case, head to the GitHub repo.
Basic
The basic pattern consists on just instantiating the objects in the same way. The classes require no initializationa rguments, and have no state.
Here, a typical player looks like this:
class RandomPlayer:
def play(self) -> None:
print("Playing Random player")
As such, the factory is quite simple in the way it can initialize any kind of player without arguments.
class MusicPlayerFactory:
"""Dynamic factory that creates MusicPlayers. It automatically looks for the
right MusicPlayer in the `players` module given the potential name of it."""
def __init__(self, name: str) -> None:
"""Initializes the class, parsing the given name.
Args:
name (str): service name."""
self.module_name = "players." + name.lower().strip()
player_name = name.upper()
self.player_name = player_name + "Player"
def get_music_player(self) -> MusicPlayer:
"""Finds the right Music Player to create.
Returns:
initialized music player (MusicPlayer)
Raises:
ModuleNotFoundError | AttributeError"""
try:
player = getattr(
importlib.import_module(self.module_name), self.player_name
)
except ModuleNotFoundError:
print(f"Module {self.module_name} does not exist.")
raise
except AttributeError:
print(f"Music Player {self.player_name} has not been implemented yet.")
raise
print(f"Initializing {self.player_name}")
return player()
The factory when intiialized parses the given name
to define the module name where the music player is housed, as well as the player name to be instantiated.
the get_music_player
method just looks for the player to spawn based on that, using the importlib
module. If all is right, the instantiated player will be returned.
In practice, we pass the factory something like mp3
, which in turn, by string parsing, will search for MP3Player
inside the players.mp3
submodule.
Configurable
In this case, the player have different way of initializing them, and they use their own attributes in the play
method as a way of configuration. A real world example of this could be some form of authentication or special business logic.
Delegating each playerβs own unique features to the init is a clean way of maintaining the interface implementation, as the play
method signature stays the same. That said, this pretty much locks you into the traditional OOP design pattern, unless youβd prefer to use partials
on a functional example, at the expense of readability.
class MP3Player:
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def play(self) -> None:
print("Using api_key to authenticate")
print("Playing MP3")
class FLACPlayer:
def __init__(self, api_key: str, secret_key: str) -> None:
self.api_key = api_key
self.secret_key = secret_key
def play(self) -> None:
print("Using api_key and secret_key to authenticate")
print("Playing FLAC")
class WAVPlayer:
def __init__(self, user: str, pwd: str) -> None:
self.user = user
self.pwd = pwd
def play(self) -> None:
print("Using user and pwd to authenticate")
print("Playing WAV")
With this, the factory is slightly more complex, as it needs to handle init arguments to properly instantiate the objects. In this case, we receive them as arguments on the get_music_player
method, and just unpack them into the player call.
The arguments, in a production environment, would be passed as some form of configuration file, CLI flags (chosen for this example), or the prefered tool of choice.
class MusicPlayerFactory:
"""Dynamic factory that creates MusicPlayers. It automatically looks for the
right MusicPlayer in the `players` module given the potential name of it."""
def __init__(self, name: str) -> None:
"""Initializes the class, parsing the given name.
Args:
name (str): service name."""
self.module_name = "players." + name.lower().strip()
player_name = name.upper()
self.player_name = player_name + "Player"
def get_music_player(self, init: dict[str, Any]) -> MusicPlayer:
"""Finds the right Music Player to create.
Returns:
initialized music player (MusicPlayer)
Raises:
ModuleNotFoundError | AttributeError"""
try:
player = getattr(
importlib.import_module(self.module_name), self.player_name
)
except ModuleNotFoundError:
print(f"Module {self.module_name} does not exist.")
raise
except AttributeError:
print(f"Music Player {self.player_name} has not been implemented yet.")
raise
print(f"Initializing {self.player_name}")
return player(**init)
Functional
The functional approach is slightly simpler, more readable in my opinion, and reduces boiler plate code; but, as mentioned, configuration is limited.
We start by defining a the interface with type
, recently released with Python 3.12, which is gonna represent the signature of the play
function, that is, a function that takes no arguments, and returns nothing.
type MusicPlayer = Callable[[], None]
The factory is way shorter, as we donβt need to do string manipulation to get the player name, as the player, in this case, will just be the play
function inside the specific submodule (mp3
, wav
, etc.). Furthermore, the name is passed directly as a parameter to the function.
def get_music_player(name: str) -> MusicPlayer:
"""Finds the right Music Player to create.
Returns:
music player (MusicPlayer)
Raises:
ModuleNotFoundError | AttributeError"""
module_name = "players." + name.lower().strip()
try:
play = getattr(importlib.import_module(module_name), "play")
except ModuleNotFoundError:
print(f"Module {module_name} does not exist.")
raise
print(f"Initializing {name.upper()} player")
return play
The implementations, as mentioned, will follow the same folder structure, with the difference that only a play
function will be defined, rather than a class implementing a Protocol. For example:
def play() -> None:
print("Playing MP3")
And, to wrap up, the call to the player will be slightly different, as the factory itself returns the uncalled play function, so we spawn it and then call it.
play = get_music_player(name)
play()