How to write better Django code
In this post I will be sharing some tips, which can be used for better code quality. It will also help you not to shoot yourself in the foot later in the process.
As many know, Django is an awesome web framework. It allows building webapps, prototypes etc. blazing fast. I have been using it for most of my projects, and I have never been disappointed. Django works great in small to medium codebases. The problems start to arise, when codebase start to grow to millions of LOC with hundreds of models.
Tip #1: Loose couple everything
One of Django's functionalities is creating bounded contexts, or in Django jargon, apps Each app needs to be registered in the INSTALLED_APPS
list. Each app can have its own models. All fine here.
The problem arises when developers start using models from app A in app B. With this, you have introduced tight coupling between apps A and B. This has many cons:
Harder to track side effects.
Harder to test operational flow.
Refactoring can be a PITA
Cyclic imports resulting in local importing.
How can this be avoided? Create services with public methods which can then be consumed in outside apps. In other words, create an API for each app. This way you can have a clear entry to you app.
Example:
BAD:
def some_method_in_app_A():
...
ModelFromB().save()
GOOD:
# App A:
def some_method_in_app_A():
...
appB.services.save_model_b()
# App B:
def save_model_b():
ModelFromB().save()
When you have separated the responsibilities to its own bounded context, it is easier to test/mock functions.
Tip #2: Strive for more unit than integration tests
Many of you know the difference between unit and integration tests. For those who don't => Unit tests test simple blocks of code without any outside services, such as database or cache.
Integration tests on the other hand, test the broader functionality of the system. Want to test some function that retrieves entities from database or saves data to database? It's an integration test.
While integration tests can be convenient to write, they can be significantly slower than unit tests, if database is used in the test. This can slow down your pipeline if the number of tests is large. Having more unit tests can also decrease your pipeline bill at the end of the month.
Now you are probably wondering, how can I then unit test some code which uses database?? Well it's simple. Extract code which makes calls to database to separate functions and mock them with unittest.mock
. You can check more about mocking in python Python mocking 101.
Tip #3: Use pytest instead of django's test runner
Pytest is a framework for testing python applications. It can also be used to test django code with the help of pytest-django. In my experience it is faster than django's test runner. It has many testing sugars which will enable you to write better tests with less code. Check it out.
Pytest-django, among others, introduces one handy decorator: @pytest.mark.django_db
. This one will enable you to use database in your test and will "explode" if database is used without the decorator.
With this you can know if you have some unexpected database hits that can be mocked.
Tip #4: Don't use models directly in views
Why not, you ask? Even the Django tutorial uses models directly in views!
Yes it's convenient, yes it is simple. But it's bad practice, because it tightly couples view logic with database and makes harder to write unit tests for views.
One solution here is to use 3-tier architecture.
Example of three tier architecture:
Presentation layer
Handles request/response cycle.
Handles authentication/authorization.
Validates input data from requests
Calls service layer
Service layer
Contains business and domain logic
Perform various validations
Calls persistence layer
Persistence layer
Handles communication with database.
If you think this is an overkill for your project, you can easily merge service and persistence layer into one. The important this is, that you can have interfaces which are easy to test and business logic outside of views.
This way, when you test views, you can easily mock the called service layer methods.
Tip #5: Don't use models directly in templates
Oh boy is this important. Yes, it's convenient and easy. But it tightly couples models with templates (presentation layer).
There are many reasons, why using models directly in templates is a horrible idea.
It is very hard to debug if something goes wrong. Stacktraces can be cryptic if some exception is raised in template rendering
N+1 issues can happen quite easy and it is hard to detect.
Django introduces some magic methods on the models. One example is the
ChoiceField
which addsget_<fieldname>_display
method on the model. This IMO is bad, as it can make code hard to maintain.
The solution to this is to convert model data into a simple DTO or dict. This DTO can then be passed to template context and you will know exactly why some data is being rendered in the html.
Tip #6: Use forms and serializers only for input validation
As you might know, Django has forms and DRF has Serializers for input validation. Both are quite handy at their job. But here I want to talk about ModelForm
and ModelSerializer
, which are subclasses of Form
and Serializer
classes. Both can create a direct connection to your model. This makes saving data to connected models an easy job.
While this is again convenient, you in return get a tight coupling between an input validation system and database. What if you want to save something else in the same serializer? You have to override the save()
method and place additional logic there. Sounds like a mess right? It can be a big mess, which is hard to maintain and test.
The solution to this, is to ditch ModelForm
and ModelSerializer
for Form
and Serializer
respectively. Let those two perform input validation and then pass validated data to service layer.
In case you need better performance, you can also use other libraries for validation. See this benchmark.
I like to use marshmallow which is framework/ORM agnostic. You can use it in any project, where input validation is required.
Tip #7: Download stubs for django
If you are using latest versions of python, it is a must that you use type annotations. Even if python is a dynamic language, declaring variable type hints makes code easier to read and understand. Your IDE will most likely provide better autocomplete features, if type hints are provided.
Django comes without type stubs by default. By downloading 3rd party stubs like https://github.com/typeddjango/django-stubs, you will get better type hints when using django modules. In the end, who doesn't want types in their code.
Conclusion
Some of these tips might seem quite radical to follow. You might need to invest some time to follow these tips. But in the end, it will be worth it. Especially if you start to move outside of MVP/POC region.