A few practical examples of software design patterns we implemented
Here at Landbot, we love software design patterns. We love (building) software. We love design. And you can bet we love patterns.
We’d like to share a few software patterns we successfully implemented in our codebase. They may also fit in yours. Or, like us, you may just feel the satisfaction nice software solutions generate 😊.
Let’s start with a basic: separate the way we construct components from the way we use them, to decrease coupling.
Yes, we’re talking about the well-known design pattern Dependency Injection
Dependency injection
In several parts of our code, we reuse instances of useful components, like repositories (we will go with that later) or clients to connect with 3rd parties. And we wanted to split the abovementioned responsibilities:
- Create the components
- Use them where needed
And instead of coding this by ourselves, we reused the already existent and battle-tested Inject Python package.
There are 3 critical things we had to consider to work with Inject:
Configure injector
We use Django as our base framework. So, we think the right way to configure our injector is at Django app config time:
Let’s zoom in on configure_injector
The configure_once function receives a callable with an argument of type Binder. And this is how it’s used:
There are some subtleties with this binding step. You may need to create a new instance each time the component is needed, instead of using a single shared instance. That’s .bind_to_provider is for. But we don’t want to go deeper into these details now.
So, you need a Redis client, cool. Here, you define the binding between the component (Redis client) and its implementation (a single shared instance). This is the instance that will be injected where needed. And where is it needed?
Injecting autoparams
The component may be needed in a class implementation, to be available for all class members. In this situation, we use inject.autoparams decorator
Now you can instantiate the class like this
_filter = ChannelUUIDFilter()
and the 2 required components, channel_repository and template_repository, will be automatically injected by the framework. Which also created them by following the defined strategy (.bind vs .bind_to_provider)
Injecting instances
The component may also be needed in a specific function or method. In those situations, we use inject.instance like this:
We don’t care about who created TrackingSender, and how they did it. We can just use it here now.
Repository pattern
A repository is a component that encapsulates the logic to access a data source. Here in Landbot, we mainly use them to encapsulate ORM calls, as part of our general architecture strategy for layers separation (we don’t want to expose Django-specific calls).
We just make sure a repository implements a defined contract interface, which we define as an abstract Python class like this one:
And we implement it using Django-specific ORM queries, like these:
We implement the same pattern to access external data sources. In that situation, we may need to inject other components into our repository implementation. We do it in the constructor.
Use Cases
If you’re worried about how to encapsulate your functional requirements, separating them from pure technical ones, you know the use case concept for sure.
A use case allows a role (an external actor) to interact with our system to achieve a goal, by encapsulating a list of actions.
In Landbot, external actors (like our bot builder) interact with our system via REST API. We respond to that interaction by implementing a Django Rest Framework view that follows these 3 steps:
- Deserialize the input data
- Pass the deserialized data (plus other optionally needed data) to a use case
- Send the use case’s response back to the actor
Here we can see the 3 steps. The operation is to create a channel, but just forget about it and see the structure:
And the internal structure of a use case for us may be as simple as this one:
As you can see, we inject the needed components at build creation time, using inject.autoparams, as explained above. And the use case just follows a list of actions (fetch data from a repository, create the requested object, publish some events and return the object created).
Oh, yes, there's that other part about… param validation?
Let’s go with this now.
Domain Specific Data Validation
We’re implicitly validating data via Django Rest Framework serializer, as we could see in the view code above when calling _serializer.is_valid.
But this is not a domain validation. It’s just data validation.
In our Django Rest Framework view, we ensure the input data is valid from a strictly typing-oriented point of view: we receive valid strings, valid numbers, valid lists, etc.
In our use case, we need to make sure the input data is valid from our domain point of view: we receive valid channel IDs we receive valid brand IDs, etc.
You don’t know what a channel or a brand is? Well, that’s the whole point :-) Just the components working inside of our domain should know that. Use cases are that kinds of components. Django Rest Framework views are not.
To implement this domain-specific validation, we use the Pydantic library.
So, going back to our previous example, this is what the ApiChatChannelCreator use case expects as input data:
A brand, a channel name, and a list of URLs.
As we saw in a prior post on our blog, these domain-specific types (domain objects, in our architecture’s terms) may be as simple as a string:
Or a more complex object, like a URL that must be declared as valid for our domain (a specific type of str, in plain Python terms):
You can be as specific as desired when talking about domain validation. For example, one of our use cases requires a very specific type of dictionary (Mapping type, really), containing date and time-related elements. So, we define it in Pydantic like this:
Of course, DateStr, DateRange, TimeStr, and TimeRange are also domain-defined Pydantic types. You get the idea, right?
What do you think about these software patterns/abstractions we use here at Landbot? Do let us know in the comments!