hacksoftware Django Styleguide

repository·master·Indexed 27 days ago

https://github.com/hacksoftware/django-styleguide

A pragmatic and opinionated guide for structuring Django applications to ensure scalability and maintainability. It focuses on separating business logic from the interface layer by utilizing a dedicated service layer for writing data and a selector layer for fetching data, while providing specific rules for the use of model properties, methods, and constraints.

Tokens
14K
Snippets
30
Records
42
Agent score
42%

What's inside django-styleguide

  1. Overview of Django Styleguide Core Principles

    master

    The Django Styleguide provides a pragmatic, opinionated structure for building scalable Django applications by separating concerns between the 'core' (business logic) and the 'interface' (APIs, CLI, etc.).

    Where to put business logic:

    • Services: Functions that primarily handle writing data to the database.
    • Selectors: Functions that primarily handle fetching data from the database.
    • Model properties: For non-trivial, simple logic (with exceptions).
    • Model clean method: For additional validations (with exceptions).

    Where NOT to put business logic:

    • APIs and Views
    • Serializers and Forms
    • Form tags
    • Model save method
    • Custom managers or querysets (use them for exposing model interfaces, not domain logic)
    • Signals (use only for decoupling unrelated components or cache invalidation)

    Decision Rule: Model Properties vs. Selectors

    • Use a Selector if the property spans multiple relations.
    • Use a Selector if the property is non-trivial and could cause N + 1 query problems when serialized.
  2. Test the Service layer

    master

    Since services contain business logic, they should be tested exhaustively.

    Testing Rules of Thumb:

    1. Cover business logic: Test all edge cases and logical branches.
    2. Hit the database: Tests should perform actual create/read operations.
    3. Mock external dependencies: Mock async task calls (e.g., Celery .delay()) and anything outside the project boundaries.

    Recommended Tools:

    • faker for generating fake data.
    • factory_boy for creating model instances.
    • unittest.mock.patch for mocking selectors or external tasks.

    Example Test Pattern:

    @patch('project.payments.services.payment_charge.delay')
    def test_buying_item_creates_a_payment_and_calls_charge_task(self, payment_charge_mock):
        # ... setup ...
        payment = item_buy(user=user, item=item)
    
        self.assertEqual(1, Payment.objects.count())
        payment_charge_mock.assert_called_once()
    from unittest.mock import patch, Mock
    from django.test import TestCase
    from django.contrib.auth.models import User
    from django.core.exceptions import ValidationError
    
    from django_styleguide.payments.services import item_buy
    from django_styleguide.payments.models import Payment, Item
    
    class ItemBuyTests(TestCase):
        @patch('project.payments.services.items_get_for_user')
        def test_buying_item_that_is_already_bought_fails(
            self, items_get_for_user_mock: Mock
        ):
            user = User(username='Test User')
            item = Item(
                name='Test Item',
                description='Test Item description',
                price=10.15
            )
    
            items_get_for_user_mock.return_value = [item]
    
            with self.assertRaises(ValidationError):
                item_buy(user=user, item=item)
    
        @patch('project.payments.services.payment_charge.delay')
        def test_buying_item_creates_a_payment_and_calls_charge_task(
            self, payment_charge_mock
        ):
            user = User(username='Test user')
            item = Item(
                name='Test Item',
                description='Test Item description',
                price=10.15
            )
    
            self.assertEqual(0, Payment.objects.count())
    
            payment = item_buy(user=user, item=item)
    
            self.assertEqual(1, Payment.objects.count())
            self.assertEqual(payment, Payment.objects.first())
    
            self.assertFalse(payment.successful)
    
            payment_charge_mock.assert_called_once()
  3. Handle Celery Task Errors and Retries

    master

    Error handling and retry logic should reside within the task layer, not the service layer.

    To implement retries and failure handling:

    1. Use bind=True in the @shared_task decorator to access the task instance (self).
    2. Wrap the service call in a try/except block and use self.retry() to handle transient failures.
    3. Use the on_failure parameter in the decorator to specify a callback function for permanent failures.
    4. Follow the naming pattern _{task_name}_failure for the callback function. This callback should call a service to handle the final failure state (e.g., marking a record as 'failed').
    @shared_task(bind=True, on_failure=_email_send_failure)
    def email_send(self, email_id):
        email = Email.objects.get(id=email_id)
        from styleguide_example.emails.services import email_send
    
        try:
            email_send(email)
        except Exception as exc:
            logger.warning(f"Exception occurred: {exc}")
            self.retry(exc=exc, countdown=5)
    
    def _email_send_failure(self, exc, task_id, args, kwargs, einfo):
        email_id = args[0]
        email = Email.objects.get(id=email_id)
        from styleguide_example.emails.services import email_failed
        email_failed(email)
  4. Implement a BaseModel for common fields

    master

    To avoid repetition, define an abstract BaseModel containing common fields like created_at and updated_at. All other models should inherit from this BaseModel.

    from django.db import models
    from django.utils import timezone
    
    
    class BaseModel(models.Model):
        created_at = models.DateTimeField(db_index=True, default=timezone.now)
        updated_at = models.DateTimeField(auto_now=True)
    
        class Meta:
            abstract = True
    
    
    class SomeModel(BaseModel):
        pass
  5. Configure integration settings with feature flags

    master

    To prevent integration errors in local development, use a boolean flag (e.g., USE_SOME_INTEGRATION) to conditionally load integration settings. Place the integration logic in config/settings/<integration_name>.py.

    Pattern:

    1. Define a setting that reads from the environment (e.g., SENTRY_DSN).
    2. Check if that setting exists or use a dedicated boolean flag.
    3. Only perform imports and configuration if the flag is active.
    from config.env import env
    
    SENTRY_DSN = env('SENTRY_DSN', default='')
    
    if SENTRY_DSN:
        import sentry_sdk
        from sentry_sdk.integrations.django import DjangoIntegration
        from sentry_sdk.integrations.celery import CeleryIntegration
        # ... configuration logic ...
  6. Implement a List API with Filters and Pagination

    master

    To implement a list API that supports filtering and pagination while using a plain APIView, follow this pattern:

    1. Filter Serialization: Use a FilterSerializer within the API to validate query parameters.
    2. Selector Filtering: Pass the validated filters to a selector (e.g., using django-filter).
    3. Pagination: Use a utility like get_paginated_response to handle the response structure.

    API Implementation:

    class UserListApi(ApiErrorsMixin, APIView):
        class Pagination(LimitOffsetPagination):
            default_limit = 1
    
        class FilterSerializer(serializers.Serializer):
            id = serializers.IntegerField(required=False)
            is_admin = serializers.NullBooleanField(required=False)
            email = serializers.EmailField(required=False)
    
        class OutputSerializer(serializers.Serializer):
            id = serializers.CharField()
            email = serializers.CharField()
            is_admin = serializers.BooleanField()
    
        def get(self, request):
            filters_serializer = self.FilterSerializer(data=request.query_params)
            filters_serializer.is_valid(raise_exception=True)
    
            users = user_list(filters=filters_serializer.validated_data)
    
            return get_paginated_response(
                pagination_class=self.Pagination,
                serializer_class=self.OutputSerializer,
                queryset=users,
                request=request,
                view=self
            )

    Selector Implementation:

    def user_list(*, filters=None):
        filters = filters or {}
        qs = BaseUser.objects.all()
        return BaseUserFilter(filters, qs).qs
    class UserListApi(ApiErrorsMixin, APIView):
        class Pagination(LimitOffsetPagination):
            default_limit = 1
    
        class FilterSerializer(serializers.Serializer):
            id = serializers.IntegerField(required=False)
            is_admin = serializers.NullBooleanField(required=False)
            email = serializers.EmailField(required=False)
    
        class OutputSerializer(serializers.Serializer):
            id = serializers.CharField()
            email = serializers.CharField()
            is_admin = serializers.BooleanField()
    
        def get(self, request):
            # Make sure the filters are valid, if passed
            filters_serializer = self.FilterSerializer(data=request.query_params)
            filters_serializer.is_valid(raise_exception=True)
    
            users = user_list(filters=filters_serializer.validated_data)
    
            return get_paginated_response(
                pagination_class=self.Pagination,
                serializer_class=self.OutputSerializer,
                queryset=users,
                request=request,
                view=self
            )
    
    # Selector
    def user_list(*, filters=None):
        filters = filters or {}
    
        qs = BaseUser.objects.all()
    
        return BaseUserFilter(filters, qs).qs
  7. Test models with validation, properties, or methods

    master

    Only test models if they contain additional logic like validation, properties, or methods. When testing validation, assert that full_clean() raises a ValidationError. You can often perform these tests without hitting the database to increase test speed.

    class CourseTests(TestCase):
        def test_course_end_date_cannot_be_before_start_date(self):
            start_date = timezone.now()
            end_date = timezone.now() - timedelta(days=1)
    
            course = Course(start_date=start_date, end_date=end_date)
    
            with self.assertRaises(ValidationError):
                course.full_clean()
  8. Organize URLs in a tree-like structure

    master

    Alternatively, you can define a visible tree-like structure for your URLs by nesting include() calls directly within urlpatterns. This makes the entire URL hierarchy immediately apparent in a single file.

    from django.urls import path, include
    
    from styleguide_example.files.apis import (
        FileDirectUploadApi,
        FilePassThruUploadStartApi,
        FilePassThruUploadFinishApi,
        FilePassThruUploadLocalApi,
    )
    
    urlpatterns = [
        path(
            "upload/",
            include((
                [ 
                    path(
                        "direct/",
                        FileDirectUploadApi.as_view(),
                        name="direct"
                    ),
                    path(
                        "pass-thru/",
                        include((
                            [
                                path(
                                    "start/",
                                    FilePassThruUploadStartApi.as_view(),
                                    name="start"
                                ),
                                path(
                                    "finish/",
                                    FilePassThruUploadFinishApi.as_view(),
                                    name="finish"
                                ),
                                path(
                                    "local/<str:file_id>/",
                                    FilePassThruUploadLocalApi.as_view(),
                                    name="local"
                                )
                            ], "pass-thru")
                        ))
                    )
                ], "upload")
            ))
        )
    ]
  9. Organize URLs using domain patterns

    master

    To avoid merge conflicts in large projects and improve modularity, organize URLs by splitting different domains into their own domain_patterns lists. These lists are then included in the main urlpatterns using include(). This approach allows you to move domain-specific patterns to separate modules easily.

    from django.urls import path, include
    
    from project.education.apis import (
        CourseCreateApi,
        CourseUpdateApi,
        CourseListApi,
        CourseDetailApi,
        CourseSpecificActionApi,
    )
    
    course_patterns = [
        path('', CourseListApi.as_view(), name='list'),
        path('<int:course_id>/', CourseDetailApi.as_view(), name='detail'),
        path('create/', CourseCreateApi.as_view(), name='create'),
        path('<int:course_id>/update/', CourseUpdateApi.as_view(), name='update'),
        path(
            '<int:course_id>/specific-action/',
            CourseSpecificActionApi.as_view(),
            name='specific-action'
        ),
    ]
    
    urlpatterns = [
        path('courses/', include((course_patterns, 'courses'))),
    ]
  10. Load settings from a `.env` file

    master

    Use django-environ to load local environment variables from a .env file located in your project root. This should be done at the beginning of your base.py file.

    Implementation in base.py:

    import os
    from config.env import env, environ
    
    # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 
    BASE_DIR = environ.Path(__file__) - 3
    
    env.read_env(os.path.join(BASE_DIR, ".env"))

    Best Practices:

    • Never commit .env to source control.
    • Always commit an .env.example file with empty values to guide new developers.
    import os
    
    from config.env import env, environ
    
    # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 
    BASE_DIR = environ.Path(__file__) - 3
    
    env.read_env(os.path.join(BASE_DIR, ".env"))
  11. Follow testing naming conventions

    master

    To maintain consistency, follow these two naming conventions for test files and classes:

    1. File names: Use the pattern test_the_name_of_the_thing_that_is_tested.py.
    2. Test case classes: Use the pattern class TheNameOfTheThingThatIsTestedTests(TestCase):.

    For example, a service function named a_very_neat_service should have:

    • File path: project_name/app_name/tests/services/test_a_very_neat_service.py
    • Class name: class AVeryNeatServiceTests(TestCase):

    For utility modules, match the module structure. If common/utils/files.py exists, the test should be at common/tests/utils/test_files.py.

    def a_very_neat_service(*args, **kwargs):
        pass
    
    # File: project_name/app_name/tests/services/test_a_very_neat_service.py
    class AVeryNeatServiceTests(TestCase):
        pass