12 KiB
Django Styleguide
Django styleguide used in HackSoft projects.
Expect often updates as we discuss & decide upon different things.
Table of contents:
Examples
Most of the examples are taken from HackSoft's Learning Management System - Odin - https://github.com/HackSoftware/Odin
Overview
In Django, business logic should live in:
- Model properties (with some exceptions).
- Model
clean
method for additional validations (with some exceptions). - Services - functions, that take care of code written to the database.
- Selectors - functions, that take care of code taken from the database.
In Django, business logic should not live in:
- APIs and Views.
- Serializers and Forms.
- Form tags.
- Model
save
method.
Model properties vs selectors:
- If the model property spans multiple relations, it should better be a selector.
- If a model property, added to some list API, will cause
N + 1
problem that cannot be easily solved withselect_related
, it should better be a selector.
Cookie Cutter
We recommend starting every new project with cookiecutter-django
Once this is done, depending on the context, remove everything that's not needed.
The usual list is:
allauth
- templates
- Settings for things that are not yet required (always add settings when necessary)
Models
Lets take a look at an example model:
class Course(models.Model):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
attendable = models.BooleanField(default=True)
students = models.ManyToManyField(
Student,
through='CourseAssignment',
through_fields=('course', 'student')
)
teachers = models.ManyToManyField(
Teacher,
through='CourseAssignment',
through_fields=('course', 'teacher')
)
slug_url = models.SlugField(unique=True)
repository = models.URLField(blank=True)
video_channel = models.URLField(blank=True, null=True)
facebook_group = models.URLField(blank=True, null=True)
logo = models.ImageField(blank=True, null=True)
public = models.BooleanField(default=True)
generate_certificates_delta = models.DurationField(default=timedelta(days=15))
objects = CourseManager()
def clean(self):
if self.start_date > self.end_date:
raise ValidationError("End date cannot be before start date!")
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
@property
def visible_teachers(self):
return self.teachers.filter(course_assignments__hidden=False).select_related('profile')
@property
def duration_in_weeks(self):
weeks = rrule.rrule(
rrule.WEEKLY,
dtstart=self.start_date,
until=self.end_date
)
return weeks.count()
@property
def has_started(self):
now = get_now()
return self.start_date <= now.date()
@property
def has_finished(self):
now = get_now()
return self.end_date <= now.date()
@property
def can_generate_certificates(self):
now = get_now()
return now.date() <= self.end_date + self.generate_certificates_delta
def __str__(self) -> str:
return self.name
Few things to spot here.
Custom validation:
- There's a custom model validation, defined in
clean
. This validation uses only model fields and no relations. - This requires someone to call
full_clean()
on the model instance. The best place to do that is in thesave()
method of the model. Otherwise people can forget to callfull_clean()
in the respective service.
Properties:
- All properties, expect
visible_teachers
work directly on model fields. visible_teachers
is a great candidate for a selector.
We have few general rules for custom validations & model properties / methods:
Custom validation
- If the custom validation depends only on the non-relational model fields, define it in
clean
and callfull_clean
insave
. - If the custom validation is more complex & spans relationships, do it in the service that creates the model.
- It's OK to combine both
clean
and additional validation in theservice
.
Properties
- If your model properties use only non-relational model fields, they are OK to stay as properties.
- If a property, such as
visible_teachers
starts spanning relationships, it's better to define a selector for that.
Methods
- If you need a method that updates several fields at once (for example -
created_at
andcreated_by
when something happens), you can create a model method that does the job. - Every model method should be wrapped in a service. There should be no model method calling outside a service.
Testing
Models need to be tested only if there's something additional to them - like custom validation or properties.
If we are strict & don't do custom validation / properties, then we can test the models without actually writing anything to the database => we are going to get quicker tests.
For example, if we want to test the custom validation, here's how a test could look like:
from datetime import timedelta
from django.test import TestCase
from django.core.exceptions import ValidationError
from odin.common.utils import get_now
from odin.education.factories import CourseFactory
from odin.education.models import Course
class CourseTests(TestCase):
def test_course_end_date_cannot_be_before_start_date(self):
start_date = get_now()
end_date = get_now() - timedelta(days=1)
course_data = CourseFactory.build()
course_data['start_date'] = start_date
course_data['end_date'] = end_date
course = Course(**course_data)
with self.assertRaises(ValidationError):
course.full_clean()
There's a lot going on in this test:
get_now()
returns a timezone aware datetime.CourseFactory.build()
will return a dictionary with all required fields for a course to exist.- We replace the values for
start_date
andend_date
. - We assert that a validation error is going to be raised if we call
full_clean
. - We are not hitting the database at all, since there's no need for that.
Here's how CourseFactory
looks like:
class CourseFactory(factory.DjangoModelFactory):
name = factory.Sequence(lambda n: f'{n}{faker.word()}')
start_date = factory.LazyAttribute(
lambda _: get_now()
)
end_date = factory.LazyAttribute(
lambda _: get_now() + timedelta(days=30)
)
slug_url = factory.Sequence(lambda n: f'{n}{faker.slug()}')
repository = factory.LazyAttribute(lambda _: faker.url())
video_channel = factory.LazyAttribute(lambda _: faker.url())
facebook_group = factory.LazyAttribute(lambda _: faker.url())
class Meta:
model = Course
@classmethod
def _build(cls, model_class, *args, **kwargs):
return kwargs
@classmethod
def _create(cls, model_class, *args, **kwargs):
return create_course(**kwargs)
Services
A service is a simple function that:
- Lives in
your_app/services.py
module - Takes keyword-only arguments
- Is type-annotated (even if you are not using
mypy
at the moment) - Works mostly with models & other services and selectors
- Does business logic - from simple model creation to complex cross-cutting concerns, to calling external services & tasks.
An example service that creates an user:
def create_user(
*,
email: str,
name: str
) -> User:
user = User(email=email)
user.full_clean()
user.save()
create_profile(user=user, name=name)
send_confirmation_email(user=user)
return user
As you can see, this service calls 2 other services - create_profile
and send_confirmation_email
Selectors
A selector is a simple function that:
- Lives in
your_app/selectors.py
module - Takes keyword-only arguments
- Is type-annotated (even if you are not using
mypy
at the moment) - Works mostly with models & other services and selectors
- Does business logic around fetching data from your database
An example selector that list users from the database:
def get_users(*, fetched_by: User) -> Iterable[User]:
user_ids = get_visible_users_for(user=fetched_by)
query = Q(id__in=user_ids)
return User.objects.filter(query)
As you can see, get_visible_users_for
is another selector.
APIs & Serializers
When using services & selectors, all of your APIs should look simple & the same.
General rules for an API is:
- Do 1 API per operation. For CRUD on a model, this means 4 APIs.
- Use the most simple
APIView
orGenericAPIView
- Use services / selectors & don't do business logic in your API.
- Use serializers for fetching objects from params - passed either via
GET
orPOST
- Serializer should be nested in the API and be named either
InputSerializer
orOutputSerializer
OutputSerializer
can subclassModelSerializer
, if needed.InputSerializer
should always be a plainSerializer
- Reuse serializers as little as possible
- If you need a nested serializer, use the
inline_serializer
util
An example list API
class CourseListApi(SomeAuthenticationMixin, APIView):
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = Course
fields = ('id', 'name', 'start_date', 'end_date')
def get(self, request):
courses = get_courses()
data = self.OutputSerializer(courses, many=True)
return Response(data)
An example detail API
class CourseDetailApi(SomeAuthenticationMixin, APIView):
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = Course
fields = ('id', 'name', 'start_date', 'end_date')
def get(self, request, course_id):
course = get_course(id=course_id)
data = self.OutputSerializer(course)
return Response(data)
An example create API
class CourseCreateApi(SomeAuthenticationMixin, APIView):
class InputSerializer(serializers.Serializer):
name = serializers.CharField()
start_date = serializers.DateField()
end_date = serializers.DateField()
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
create_course(**serializer.validated_data)
return Response(status=status.HTTP_201_CREATED)
An example update API
class CourseUpdateApi(SomeAuthenticationMixin, APIView):
class InputSerializer(serializers.Serializer):
name = serializers.CharField(required=False)
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
def post(self, request, course_id):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
update_course(course_id=course_id, **serializer.validated_data)
return Response(status=status.HTTP_200_OK)
Nested serializers
In case you need to use a nested serializer, you can do the following thing:
class Serializer(serializers.Serializer):
weeks = inline_serializer(many=True, fields={
'id': serializers.IntegerField(),
'number': serializers.IntegerField(),
})
The implementation of inline_serializer
can be found in utils.py
in this repo.
Inspiration
The way we do Django is inspired by the following things:
- The general idea for separation of concerns
- Boundaries by Gary Bernhardt
- Rails service objects