Those who know how to break all the rules and work around specific guidelines reach the very top.
Chess GM Evgeny Bareev
Chess is a game which takes years to master. Beginners are usually taught to stick to the basics, among other things: rapid development, positional openings, control of open lines, etc. However, once a certain level of proficiency has been reached, breaking the rules in a truly preposterous manner might be a splendid way to surprise an unprepared opponent and catch them off guard. The same may sometimes apply to programming. When you are stuck with a specific technology (e.g. project requirements presented by the client force you to use it), you might decide not to follow some principal points in order to prepare the application for imminent success.
Why modular monolith
Old-fashioned monolith application assumes no modularity. It’s often called a “single deployment unit” with highly-coupled parts. Sadly, poorly modeled (mostly due to bad communication, incomplete knowledge transfer, or tight budget) logic inside the monolith usually leads to a so-called Big Ball of Mud (hereinafter — BBoM) where every logical component is coupled with one another on a very low level. In such a system, any change requires extra hours for refactoring, decoupling, and debugging. Obviously, it does not mean that we suddenly should stop developing monolith type applications and move to complex microservices. Monolith apps with modularity in place and loosely coupled logic can be a great way to start a project and keep building more complex solutions on it.
This is where modularity comes to play. What we want to achieve is to stay monolithically physically wise (single deployment unit) and split the logic to corresponding modules. Some say that modular monolith takes the worst cons from both monolith and microservices, but usually, it is a good starting point to pave the way for switching to microservices when the application grows and is experiencing performance issues. There are three key features of modular monolith which we have to take into consideration:
- Loosely coupled, strong cohesion modules. If modules are highly coupled, it is often a good time to re-think the system architecture and merge both modules.
- Modules should implement a public interface which allows other components to communicate with one another and encapsulates the business logic.
- Modules should implement everything required for functionality they provide. For instance, databases should be private for each module which needs the database and should not be directly accessed by other modules (only by public interface).
These three major features will increase our chance that the monolith will not become BBoM in the future. Furthermore, the business logic will be split between modules and we will be able to test it separately. Hence, moving into microservices will not be an uphill struggle from the code standpoint. However, using this architecture in favor of old-fashioned monoliths is much more time-consuming. It might require elements such as read models or queues in order to pass information from one database to another and allow other components to read them efficiently without performance issues.
Base assumptions for Django application
We have to create several principal assumptions before we try to follow Modular Monolith architecture using the Django Framework. Therefore, these assumptions must be based on the Modular Monolith basics and sometimes they might not be coherent with Django policies, especially on the database level and the models layer.
Each new module will be added as a standard Django app to the
Models & relations
In order to follow basic assumptions of modular monolith, the database should be private to the module. To achieve this, we need to make sure that there are no DB relations (Foreign Keys, etc.) between models from distinct modules. In other words, DB relations are fine as long as models stay in the same module. Instead of relations, we will just use the IDs. In order to encapsulate the models, it might be reasonable to use dataclasses to transfer data between the web and use case layers.
Use cases & services
Each module will consist of business logic (if needed) and database integrations that will determine the module’s public API. This will give us the green light to test only business logic without accessing Django views.
Views & templates
We will store views and templates in a separate module (for instance,
views). Each view should not use Django models directly but rather call necessary public interface functions to obtain or save the data.
Modular monolith issues with Django
The biggest issue with this approach, which day-to-day Django programmers will notice, is lack of FK relations,
related querysets, or
ON_DELETE clauses. In our system, the modules should be independent. If you want to remove something from module B as a signal to remove a record from module A, you will require additional time to write a use case and the framework will not work in our favor. Furthermore, there might be an n + 1 queries problem when reading from multiple databases from multiple modules. When that happens, it might be a good idea to re-think the architecture and perhaps merge the modules together. Another way is to pass information to the read model and read combined information from there. Unfortunately, it also requires time to create a new model, use case to read from that model, etc.
Another hard to overcome issue is how to define a public interface. Usually defining the public interface of the module is done by the
__all__ list inside the
__init__.py file. Unfortunately, Django won’t allow this due to Django apps being not ready when trying to access the models. Sadly, there is no better way to overcome this problem than to strictly check what is imported in which place.
Very basic example of modular monolith in Django
For the purpose of this example, we will create two simple modules that will just store some data about the books and authors. (The example was created for Django==3.2)
Creating the modules
First we have to create two Django apps. One called
authors and the other
books, each with
__init__.py file. Then add both to the INSTALLED_APPS.
Creating the models and dataclasses
Then we have to add model and dataclass for Author:
Next are the
Book model and the
Book dataclass. Notice that I’m not using the FK to the Author model since models should stay private to the module:
Use cases to add book and the author
Now we need to add a use case which will add the Author to the database. An important detail is that I’m passing the dataclass as the argument instead of the model:
Use case to add the author:
A very similar book use case is presented below.
Use case to add the book:
Now we need to call our use cases in the views.
Currently, our implementation has one flaw, i.e. it allows us to add a book with an author that might not exist in our system. How should we fix that? What we definitely should not do is:
As you can see, the above example uses the Author model directly, which is a violation of the public interface of the authors module. Instead, it would definitely be advantageous to add another use case like in the following code:
Modular monolith is a great architectural pattern. However, technologies like Django, which rely on database integration and ORM, might struggle. Time is the most problematic issue here. With the purpose of making independent modules, with a public interface, and private databases, we have to consume more time than by simply calling
User.objects.all(). Yet, this time might pay off in the future when it comes to adding new changes to the system and its design. It comes as no surprise that there is no straight answer whether you should use this architecture or not, it always ought to be an informed decision of the involved team. Indeed, if the project is small or the budget is not big enough, modular monolith architecture might not be a good idea due to time constraints and more complexity than in a monolith system. Albeit, you don’t have to always follow every assumption of the modular monolith. Sometimes it may be preferable to keep all models in one module and just split the business logic. Sometimes it might be easier to merge things together to provide features faster using the Django ORM. In the end, you always have to look for the opportunities and choose the optimal solution. Sadly, suffice it to say that there is no simple formula for this. Nevertheless, a modular monolith is not a remedy for every issue you might encounter, it’s only a small part of architecture design. With bad communication and deficiency of knowledge exchange in your team, you still might find yourself in the BBoM, but the modular monolith might be a good start to introduce a bigger change.