{"id":49165532,"url":"https://github.com/pysilver/django-styleguide","last_synced_at":"2026-04-22T15:02:24.603Z","repository":{"id":352311913,"uuid":"1214678750","full_name":"pySilver/django-styleguide","owner":"pySilver","description":null,"archived":false,"fork":false,"pushed_at":"2026-04-18T22:40:28.000Z","size":29,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-19T00:37:28.160Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pySilver.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-18T22:39:13.000Z","updated_at":"2026-04-18T23:07:47.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pySilver/django-styleguide","commit_stats":null,"previous_names":["pysilver/django-styleguide"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/pySilver/django-styleguide","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pySilver%2Fdjango-styleguide","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pySilver%2Fdjango-styleguide/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pySilver%2Fdjango-styleguide/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pySilver%2Fdjango-styleguide/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pySilver","download_url":"https://codeload.github.com/pySilver/django-styleguide/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pySilver%2Fdjango-styleguide/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32141485,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-22T14:31:12.705Z","status":"ssl_error","status_checked_at":"2026-04-22T14:27:43.037Z","response_time":58,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-22T15:02:20.680Z","updated_at":"2026-04-22T15:02:24.592Z","avatar_url":"https://github.com/pySilver.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# Django Styleguide\n\n**Table of contents:**\n\n\u003c!-- toc --\u003e\n\n- [Introduction](#introduction)\n- [Overview](#overview)\n- [Architectural Rationale](#architectural-rationale)\n- [Models](#models)\n    - [Base model](#base-model)\n    - [Validation - `clean` and `full_clean`](#validation---clean-and-full_clean)\n    - [Validation - constraints](#validation---constraints)\n    - [Database Triggers with pgtrigger](#database-triggers-with-pgtrigger)\n    - [Async-Compatible pgtrigger_ignore](#async-compatible-pgtrigger_ignore)\n    - [Combining Triggers](#combining-triggers)\n    - [Properties](#properties)\n    - [Methods](#methods)\n    - [Testing](#testing)\n- [Services](#services)\n    - [Example - function-based service](#example---function-based-service)\n    - [Example - class-based service](#example---class-based-service)\n    - [Naming convention](#naming-convention)\n    - [Modules](#modules)\n    - [Selectors](#selectors)\n    - [Testing](#testing-1)\n- [APIs \u0026 Schemas](#apis--schemas)\n    - [Naming convention](#naming-convention-1)\n    - [List APIs](#list-apis)\n        - [Plain](#plain)\n        - [Filters + Pagination](#filters--pagination)\n    - [Detail API](#detail-api)\n    - [Create API](#create-api)\n    - [Update API](#update-api)\n    - [Fetching objects](#fetching-objects)\n    - [Nested schemas](#nested-schemas)\n    - [Advanced serialization](#advanced-serialization)\n- [Urls](#urls)\n    - [API URLs with django-ninja](#api-urls-with-django-ninja)\n    - [Regular Django Views](#regular-django-views)\n    - [Organizing by Domain](#organizing-by-domain)\n- [Settings](#settings)\n    - [Typed Settings Classes](#typed-settings-classes)\n    - [Environment-Specific Composition](#environment-specific-composition)\n    - [YAML Configuration](#yaml-configuration)\n    - [Benefits](#benefits)\n    - [Integrations](#integrations)\n    - [Local Overrides](#local-overrides)\n- [Errors \u0026 Exception Handling](#errors--exception-handling)\n    - [Django-ninja Error Handling](#django-ninja-error-handling)\n    - [Input Validation with Pydantic v2](#input-validation-with-pydantic-v2)\n    - [Handling Django Exceptions](#handling-django-exceptions)\n    - [Service Layer Errors](#service-layer-errors)\n- [Testing](#testing-2)\n    - [Overview](#overview-1)\n    - [Naming conventions](#naming-conventions)\n    - [Factories](#factories)\n- [TaskIQ](#taskiq)\n    - [The basics](#the-basics)\n    - [Error handling](#error-handling)\n    - [Configuration](#configuration)\n    - [Structure](#structure)\n    - [Periodic Tasks](#periodic-tasks)\n    - [Beyond](#beyond)\n- [Cookbook](#cookbook)\n    - [Handling updates with a service](#handling-updates-with-a-service)\n- [DX (Developer Experience)](#dx-developer-experience)\n    - [Type Checking](#type-checking)\n    - [Code Quality Tools](#code-quality-tools)\n\n\u003c!-- tocstop --\u003e\n\n## Introduction\n\nThis Django Styleguide establishes coding standards and architectural patterns for\nDjango applications. Originally based\non [HackSoft's Django Styleguide](https://github.com/HackSoftware/Django-Styleguide), it\nhas been refined through\nproduction experience.\n\nFor practical examples, refer to the [\n`Django-Styleguide-Example`](https://github.com/HackSoftware/Django-Styleguide-Example)\nrepository.\n\n## Overview\n\nThis styleguide enforces a clear separation of concerns in Django applications:\n\n**Business logic placement:**\n\n✅ **Must reside in:**\n\n- Services - Functions that handle data writes and orchestrate business operations\n- Selectors - Functions that handle data retrieval and queries\n- Model properties - For simple, non-relational derived values\n- Model `clean` methods - For multi-field validation within a single model\n\n❌ **Must not reside in:**\n\n- APIs and Views - These are interfaces, not business logic containers\n- Serializers and Forms - These handle data transformation, not business rules\n- Form tags - These are presentation layer components\n- Model `save` methods - These should remain simple persistence operations\n- Custom managers or querysets - These provide query interfaces, not business logic\n- Signals - Reserved for decoupled event handling and cache invalidation\n\n**Decision criteria for properties vs selectors:**\n\nUse selectors when:\n\n- The property spans multiple relations\n- The property risks causing N+1 query problems\n- Complex calculations are involved\n\nUse properties when:\n\n- Deriving simple values from non-relational fields\n- No additional database queries are required\n\n## Architectural Rationale\n\n### Why avoid business logic in APIs/Views/Serializers/Forms?\n\nPlacing business logic in these layers creates two critical problems:\n\n1. **Fragmentation** - Business logic becomes scattered across multiple locations,\n   making the data flow impossible to\n   trace.\n2. **Hidden complexity** - Generic abstractions obscure implementation details,\n   requiring deep framework knowledge for\n   simple changes.\n\nWhile generic APIs and views work well for basic CRUD operations, real-world\napplications rarely stay within these\nboundaries. Once you deviate from the simple path, the code becomes unmaintainable.\n\n**Solution:** This styleguide provides clear architectural boundaries that:\n\n- Establish explicit locations for different types of logic\n- Enable teams to develop their own patterns within these boundaries\n- Maintain separation between core business logic and interface layers\n\nThe fundamental principle: Business logic (the \"core\") must remain independent from its\ninterfaces (APIs, CLI, admin).\n\n### Why avoid business logic in custom managers/querysets?\n\nCustom managers and querysets should provide better query interfaces for your models.\nHowever, they're inappropriate for\nbusiness logic because:\n\n1. **Domain mismatch** - Business logic operates on concepts that don't map directly to\n   database models\n2. **Cross-model operations** - Business operations typically span multiple models,\n   creating ambiguity about placement\n3. **External dependencies** - Third-party integrations don't belong in database query\n   interfaces\n\n**Solution:** Use a service layer that:\n\n- Keeps domain logic separate from data models and APIs\n- Can be implemented as functions, classes, or modules based on your needs\n- Leverages custom managers/querysets for their intended purpose: better query\n  interfaces\n\n### Why avoid business logic in signals?\n\nSignals are the most dangerous location for business logic:\n\n**Appropriate signal uses:**\n\n- Connecting decoupled components that shouldn't know about each other\n- Cache invalidation outside the business layer\n- System-wide event notifications\n\n**Why signals fail for business logic:**\n\n- Implicit connections make data flow impossible to trace\n- Hidden dependencies create debugging nightmares\n- Tight coupling disguised as loose coupling\n\n**Verdict:** Reserve signals for specific infrastructure concerns, never for\ndomain/business logic.\n\n## Models\n\nModels strictly handle data persistence and basic validation. Business logic must reside\nin the service layer.\n\n### Base Model\n\nConsider using established abstract models from `django-model-utils` package for common\nfunctionality:\n\n- **TimeStampedModel**: Adds `created` and `modified` fields\n- **TimeFramedModel**: Adds `start` and `end` fields for time-bound records\n- **SoftDeletableModel**: Adds `is_removed` field for soft deletion\n\n**Implementation:**\n\n  ```python\nfrom model_utils.models import TimeStampedModel, SoftDeletableModel\n\n\nclass Product(TimeStampedModel):\n    # Automatically includes created and modified fields\n    name = models.CharField(max_length=255)\n\n\nclass ArchivableProduct(TimeStampedModel, SoftDeletableModel):\n    # Includes created, modified, and is_removed fields\n    name = models.CharField(max_length=255)\n```\n\n### Internationalization Best Practices\n\n**Essential for international projects:**\n\n1. **Field Definition Pattern:**\n\n   ```python\n    from django.utils.translation import gettext_lazy as _\n\n    class Product(TimeStampedModel):\n\n    # Always use verbose_name with the lowercase\n\n    name = models.CharField(_(\"product name\"), max_length=None)\n    price = models.DecimalField(_(\"price\"), decimal_places=2)\n    is_available = models.BooleanField(_(\"is available\"), default=True)\n\n        # Help text should be clear and translatable\n        logo = models.ImageField(\n            _(\"logo\"),\n            help_text=_(\"Square logo (1:1 ratio). Requirements: 500x500 pixels, max 5MB\")\n        )\n    ```\n\n2. Model Meta Configuration:\n    ```python\n    class Meta:\n        verbose_name = _(\"product\")  # Lowercase singular\n        verbose_name_plural = _(\"products\")  # Lowercase plural\n    ```\n\n3. Validation Error Messages:\n\n    ```python\n    def clean(self):\n        # Use concise, action-oriented messages\n        if self.start_date \u003e= self.end_date:\n            raise ValidationError(_(\"End date must be after start date\"))\n\n        # For conditional requirements, be specific but brief\n        if self.requires_approval and not self.approver:\n            raise ValidationError(_(\"Approver required when approval is enabled\"))\n    ```\n\nGuidelines:\n\n- Lowercase for all field verbose names (\"customer email,\" not \"Customer Email\")\n- Lowercase for model verbose names in Meta\n- Concise errors that explain what's wrong and how to fix it\n- Import gettext_lazy as _ for all translatable strings\n- Avoid first-person language in help texts\n\n### Validation - `clean` and `full_clean`\n\n**Preferred Approach: Service-layer validation with Pydantic schemas**\n\nPydantic schemas should be your **single source of truth** for all business logic\nvalidation. Model `clean()` methods are acceptable only for simple, self-contained field\nvalidation.\n\n**Service-first validation pattern:**\n\n```python\nfrom pydantic import BaseModel, field_validator\nfrom datetime import date\n\n\nclass CourseSchemaIn(BaseModel):\n    name: str\n    start_date: date\n    end_date: date\n\n    @field_validator('end_date')\n    @classmethod\n    def validate_date_range(cls, v: date, info) -\u003e date:\n        \"\"\"Business logic validation happens in the schema.\"\"\"\n        if 'start_date' in info.data and v \u003c= info.data['start_date']:\n            raise ValueError(\"End date must be after start date\")\n        return v\n\n\ndef course_create(*, schema: CourseSchemaIn) -\u003e Course:\n    \"\"\"Service uses validated schema - no duplication.\"\"\"\n    obj = Course(\n        name=schema.name,\n        start_date=schema.start_date,\n        end_date=schema.end_date\n    )\n\n    obj.full_clean()  # Triggers database constraints only\n    obj.save()\n\n    return obj\n```\n\n**When to use model `clean()` - rare cases only:**\n\n```python\nfrom django.db import models\nfrom django.core.exceptions import ValidationError\nfrom django.db.models import Q, F\nfrom django.utils.translation import gettext_lazy as _\n\n\nclass Course(models.Model):\n    CATEGORY_CHOICES = [\n        ('programming', _('Programming')),\n        ('design', _('Design')),\n        ('business', _('Business')),\n    ]\n\n    name = models.CharField(unique=True, max_length=255)\n    start_date = models.DateField()\n    end_date = models.DateField()\n    category = models.CharField(max_length=50, choices=CATEGORY_CHOICES)\n    programming_languages = models.JSONField(default=list, blank=True)\n\n    def clean(self):\n        \"\"\"Business logic validation - conditional requirements.\"\"\"\n        if self.category == 'programming' and not self.programming_languages:\n            raise ValidationError(\n                _(\"Programming courses must specify at least one programming language\")\n            )\n\n    class Meta:\n        constraints = [\n            # Structural validation - dates must be valid\n            models.CheckConstraint(\n                condition=Q(start_date__lt=F(\"end_date\")),\n                name=\"valid_course_date_range\",\n                violation_error_message=_(\"End date must be after start date\"),\n            ),\n        ]\n```\n\n**Rules for choosing validation location:**\n\n✅ **Use Pydantic schemas (PREFERRED):**\n\n- All business logic validation\n- Multi-field validation\n- Conditional validation based on other fields\n- Type coercion and transformation\n- Complex business rules\n\n✅ **Use database constraints:**\n\n- Structural data integrity (immutable rules)\n- Examples: foreign keys, unique constraints, check constraints for data format\n- See \"Validation - Constraints\" section below\n\n❌ **Use model `clean()` sparingly:**\n\n- Only when Pydantic schema isn't practical\n- Simple, self-contained field validation\n- Django admin compatibility requirements\n\n**Key principle:** Avoid duplicating validation logic across layers. Choose one location\nand stick to it.\n\n### Validation - Constraints\n\n**Database constraints enforce structural data integrity - use them for immutable rules\nonly.**\n\n[Django's constraints](https://docs.djangoproject.com/en/dev/ref/models/constraints/)\nprovide database-level validation\nthat works regardless of how data is inserted.\n\n**When to Use Database Constraints:**\n\nDatabase constraints are for **structural correctness** - rules about data format that\nwill never change:\n\n✅ **Use constraints for:**\n\n- Temporal correctness (start \u003c end dates)\n- Structural completeness (composite fields must be complete or null)\n- Referential integrity (foreign keys)\n- Uniqueness requirements\n- Data format validation that's immutable\n\n❌ **Don't use constraints for:**\n\n- Business logic that may evolve\n- Conditional validation based on user input\n- Rules that depend on external state\n- Complex multi-model validations\n\n**Example: Temporal Constraints**\n\n```python\nfrom django.db import models\nfrom django.db.models import Q, F\n\n\nclass Promotion(models.Model):\n    effective_start_time = models.DateTimeField(null=True, blank=True)\n    effective_end_time = models.DateTimeField(null=True, blank=True)\n\n    class Meta:\n        constraints = [\n            # Both null OR both set with start \u003c end\n            models.CheckConstraint(\n                condition=(\n                        Q(effective_start_time__isnull=True,\n                          effective_end_time__isnull=True)\n                        | Q(\n                    effective_start_time__isnull=False,\n                    effective_end_time__isnull=False,\n                    effective_start_time__lt=F(\"effective_end_time\"),\n                )\n                ),\n                name=\"valid_effective_time_period\",\n                violation_error_message=\"Effective end time must be after start time\",\n            ),\n        ]\n```\n\n**Example: Structural Completeness Constraints**\n\nFrom your `PriceField` implementation - both amount and currency must be set together:\n\n```python\nfrom django.db.models import CheckConstraint, Q\n\n\nclass PriceConstraint(CheckConstraint):\n    \"\"\"Ensures price amount and currency are both set or both null.\"\"\"\n\n    def __init__(self, *, field_name: str, **kwargs) -\u003e None:\n        condition = Q(\n            **{\n                f\"{field_name}_amount_micros__isnull\": True,\n                f\"{field_name}_currency_code__isnull\": True,\n            },\n        ) | Q(\n            **{\n                f\"{field_name}_amount_micros__isnull\": False,\n                f\"{field_name}_currency_code__isnull\": False,\n            },\n        )\n\n        super().__init__(\n            condition=condition,\n            name=f\"{field_name}_complete_or_null\",\n            violation_error_message=\"Amount and currency must be both set or null\",\n        )\n\n\n# Usage in model:\nclass Product(models.Model):\n    price = PriceField(null=True, blank=True)\n\n    class Meta:\n        constraints = [\n            PriceConstraint(field_name='price'),\n        ]\n```\n\n**Constraint vs Pydantic Validation Decision Matrix:**\n\n| Rule Type              | Example                                | Use             |\n|------------------------|----------------------------------------|-----------------|\n| Structural correctness | Price has both amount + currency       | DB Constraint   |\n| Temporal validity      | Start date \u003c End date                  | DB Constraint   |\n| Uniqueness             | One promotion per merchant ID          | DB Constraint   |\n| Business conditional   | Coupon type determines required fields | Pydantic Schema |\n| External validation    | Valid country code from API            | Pydantic Schema |\n| Context-dependent      | Admin can skip required fields         | Pydantic Schema |\n\n**Key Principle:** Database constraints protect data structure; Pydantic schemas enforce\nbusiness policy.\n\nDatabase constraints raise `ValidationError` on both `model.save()` and\n`Model.objects.create(...)`.\nReference: \u003chttps://docs.djangoproject.com/en/dev/ref/models/instances/#validating-objects\u003e\n\n### Database Triggers with pgtrigger\n\n[django-pgtrigger](https://django-pgtrigger.readthedocs.io/) provides PostgreSQL\ntriggers\nfor enforcing data integrity at the database level. Use triggers for rules that must be\nenforced regardless of how data is modified.\n\n**Three core patterns:**\n\n#### 1. Official Interface Pattern (pgtrigger.Protect)\n\nFor critical models where all modifications must go through the service layer, use\n`pgtrigger.Protect` to block direct ORM access:\n\n```python\nimport pgtrigger\n\nclass DataSource(TimeStampedModel):\n    display_name = models.CharField(max_length=50)\n    merchant = models.ForeignKey(Merchant, on_delete=CASCADE)\n\n    class Meta:\n        triggers = [\n            pgtrigger.Protect(\n                name=\"protect_inserts\",\n                operation=pgtrigger.Insert,\n            ),\n            pgtrigger.Protect(\n                name=\"protect_updates\",\n                operation=pgtrigger.Update,\n            ),\n            pgtrigger.Protect(\n                name=\"protect_deletes\",\n                operation=pgtrigger.Delete,\n            ),\n        ]\n```\n\n**What this prevents:**\n\n```python\n# All of these FAIL with pgtrigger.Error\ndata_source.save()  # PostgreSQL trigger RAISES EXCEPTION\ndata_source.delete()  # PostgreSQL trigger RAISES EXCEPTION\nDataSource.objects.create()  # PostgreSQL trigger RAISES EXCEPTION\n```\n\n**Service layer bypass with pgtrigger_ignore:**\n\n```python\nfrom project.core.pgtrigger import pgtrigger_ignore\n\nclass DataSourceService:\n    @transaction.atomic\n    @pgtrigger_ignore(\n        \"data_sources.DataSource:protect_inserts\",\n        \"data_sources.FileInput:protect_inserts\",\n    )\n    def create(self, *, schema: DataSourceSchemaIn) -\u003e DataSource:\n        data_source = DataSource(\n            display_name=schema.display_name,\n            merchant=self.merchant,\n        )\n        data_source.full_clean()\n        data_source.save()  # Allowed - trigger bypassed\n        return data_source\n```\n\n**When to use Official Interface:**\n\n- Models with complex business logic in service layer\n- Models with strict validation requirements\n- Models where direct manipulation could break invariants\n- Audit-critical models requiring service-layer logging\n\n#### 2. Immutable Fields (pgtrigger.ReadOnly)\n\nPrevent modification of fields that must never change after creation:\n\n```python\nclass DataSource(TimeStampedModel):\n    merchant = models.ForeignKey(Merchant, on_delete=CASCADE)\n    input = EnumField(Input)\n    source_type = EnumField(SourceType)\n\n    class Meta:\n        triggers = [\n            pgtrigger.ReadOnly(\n                name=\"immutable_fields\",\n                fields=[\"merchant\", \"input\", \"source_type\"],\n            ),\n            # ... Protect triggers\n        ]\n```\n\n**What this prevents:**\n\n```python\n# This FAILS - merchant is immutable\ndata_source.merchant = another_merchant\ndata_source.save()  # PostgreSQL trigger blocks\n\n# This SUCCEEDS - display_name is mutable\ndata_source.display_name = \"New Name\"\ndata_source.save()  # Allowed\n```\n\n**Alternative: exclude specific fields:**\n\n```python\npgtrigger.ReadOnly(\n    name=\"immutable_fields\",\n    exclude=[\"countries\"],  # Only countries can be updated\n)\n```\n\n#### 3. Finite State Machine (pgtrigger.FSM)\n\nEnforce valid state transitions at the database level:\n\n```python\nclass Brand(TimeStampedModel):\n    status = EnumField(BrandStatus)\n\n    class Meta:\n        triggers = [\n            pgtrigger.FSM(\n                name=\"status_fsm\",\n                field=\"status\",\n                transitions=[\n                    # Initial approval flow\n                    (BrandStatus.PENDING, BrandStatus.APPROVED),\n                    (BrandStatus.PENDING, BrandStatus.REJECTED),\n                    (BrandStatus.PENDING, BrandStatus.MISSPELLED),\n                    # Discover misspellings after approval\n                    (BrandStatus.APPROVED, BrandStatus.MISSPELLED),\n                    (BrandStatus.APPROVED, BrandStatus.REJECTED),\n                    # Reconsideration paths\n                    (BrandStatus.REJECTED, BrandStatus.PENDING),\n                    (BrandStatus.REJECTED, BrandStatus.APPROVED),\n                    # Fix incorrect misspelling classification\n                    (BrandStatus.MISSPELLED, BrandStatus.APPROVED),\n                    (BrandStatus.MISSPELLED, BrandStatus.REJECTED),\n                ],\n            ),\n        ]\n```\n\n**What this enforces:**\n\n```python\n# SUCCEEDS - valid transition\nfetch.status = FetchStatus.IN_PROGRESS\nfetch.status = FetchStatus.COMPLETED\nfetch.save()  # PostgreSQL validates transition\n\n# FAILS - invalid transition\nfetch.status = FetchStatus.COMPLETED\nfetch.status = FetchStatus.IN_PROGRESS  # Going backwards\nfetch.save()  # PostgreSQL trigger raises exception\n```\n\n### Async-Compatible pgtrigger_ignore\n\nThe standard `pgtrigger.ignore()` uses thread-local storage, which doesn't work with\nasync\ncode. Use the custom `pgtrigger_ignore` wrapper from `core/pgtrigger.py`:\n\n```python\nfrom project.core.pgtrigger import pgtrigger_ignore\n\n# As async context manager\nasync with pgtrigger_ignore(\"app.Model:protect_inserts\"):\n    await instance.asave()\n\n# As sync context manager\nwith pgtrigger_ignore(\"app.Model:protect_inserts\"):\n    instance.save()\n\n# As decorator on sync function (with @transaction.atomic)\n@sync_to_async\n@transaction.atomic\n@pgtrigger_ignore(\"app.Model:protect_inserts\")\ndef create(self, *, schema: SchemaIn) -\u003e Model:\n    instance.save()\n\n# As decorator on async function (native async)\n@pgtrigger_ignore(\"app.Model:protect_updates\")\nasync def update(self, *, instance: Model) -\u003e Model:\n    await instance.asave()\n```\n\n**Transaction safety notes:**\n\npgtrigger flushes a temporary Postgres variable when exiting the context manager. If a\ndatabase error occurs inside the ignore block while in a transaction, the transaction\nenters an errored state and the flush fails.\n\n```python\n# ❌ WRONG - transaction in error state when ignore context exits\nwith transaction.atomic():\n    with pgtrigger.ignore(\"app.Model:protect_inserts\"):\n        try:\n            Model.objects.create(unique_key=\"duplicate\")\n        except IntegrityError:\n            pass  # Flush will fail!\n\n# ✅ CORRECT - session flush happens outside the transaction\nwith pgtrigger.ignore.session(\"app.Model:protect_inserts\"):\n    with transaction.atomic():\n        try:\n            Model.objects.create(unique_key=\"duplicate\")\n        except IntegrityError:\n            pass  # Transaction rolled back, session flush succeeds\n```\n\n### Combining Triggers\n\nModels often combine multiple trigger types:\n\n```python\nclass Brand(TimeStampedModel):\n    name = models.CharField(unique=True, max_length=70)\n    slug = models.SlugField(unique=True)\n    status = EnumField(BrandStatus)\n\n    class Meta:\n        triggers = [\n            # FSM for status transitions\n            pgtrigger.FSM(\n                name=\"status_fsm\",\n                field=\"status\",\n                transitions=[...],\n            ),\n            # Immutable identifiers\n            pgtrigger.ReadOnly(\n                name=\"immutable_identifiers\",\n                fields=[\"name\", \"slug\"],\n            ),\n            # Official Interface - all operations through service\n            pgtrigger.Protect(name=\"protect_inserts\", operation=pgtrigger.Insert),\n            pgtrigger.Protect(name=\"protect_updates\", operation=pgtrigger.Update),\n            pgtrigger.Protect(name=\"protect_deletes\", operation=pgtrigger.Delete),\n        ]\n```\n\n**Benefits of this approach:**\n\n1. **Database-level enforcement** - Rules enforced regardless of how data is modified\n2. **Race condition prevention** - State transitions validated atomically\n3. **Service layer guarantee** - All business logic executes through designated paths\n4. **Audit trail** - Modifications only happen through controlled service methods\n\n### Properties\n\nModel properties provide efficient access to derived values.\n\n**Example implementation:**\n\n```python\nfrom django.db import models\nfrom django.db.models import Q, F\nfrom django.utils import timezone\nfrom django.core.exceptions import ValidationError\nfrom django.utils.translation import gettext_lazy as _\n\n\nclass Course(models.Model):\n    CATEGORY_CHOICES = [\n        ('programming', _('Programming')),\n        ('design', _('Design')),\n        ('business', _('Business')),\n    ]\n\n    name = models.CharField(unique=True, max_length=255)\n    start_date = models.DateField()\n    end_date = models.DateField()\n    category = models.CharField(max_length=50, choices=CATEGORY_CHOICES)\n    programming_languages = models.JSONField(default=list, blank=True)\n\n    def clean(self):\n        \"\"\"Business logic validation - conditional requirements.\"\"\"\n        if self.category == 'programming' and not self.programming_languages:\n            raise ValidationError(\n                _(\"Programming courses must specify at least one programming language\")\n            )\n\n    @property\n    def has_started(self) -\u003e bool:\n        now = timezone.now()\n        return self.start_date \u003c= now.date()\n\n    @property\n    def has_finished(self) -\u003e bool:\n        now = timezone.now()\n        return self.end_date \u003c= now.date()\n\n    class Meta:\n        constraints = [\n            models.CheckConstraint(\n                condition=Q(start_date__lt=F(\"end_date\")),\n                name=\"valid_course_date_range\",\n                violation_error_message=_(\"End date must be after start date\"),\n            ),\n        ]\n```\n\nProperties enable direct access in serializers and templates.\n\n**Rules for model properties:**\n\n✅ **Use properties when:**\n\n- Deriving values from non-relational fields only\n- Calculations are simple and performant\n\n❌ **Use services/selectors when:**\n\n- Spanning multiple relations or fetching additional data\n- Complex calculations that impact performance\n\n**Decision criteria:** Consider query performance and N+1 implications.\n\n### Methods\n\nModel methods extend property functionality with parameterized logic.\n\n**Example with parameters:**\n\n```python\nfrom django.db import models\nfrom django.db.models import Q, F\nfrom django.core.exceptions import ValidationError\nfrom django.utils import timezone\nfrom django.utils.translation import gettext_lazy as _\nfrom datetime import date\n\n\nclass Course(models.Model):\n    CATEGORY_CHOICES = [\n        ('programming', _('Programming')),\n        ('design', _('Design')),\n        ('business', _('Business')),\n    ]\n\n    name = models.CharField(unique=True, max_length=255)\n    start_date = models.DateField()\n    end_date = models.DateField()\n    category = models.CharField(max_length=50, choices=CATEGORY_CHOICES)\n    programming_languages = models.JSONField(default=list, blank=True)\n\n    def clean(self):\n        \"\"\"Business logic validation - conditional requirements.\"\"\"\n        if self.category == 'programming' and not self.programming_languages:\n            raise ValidationError(\n                _(\"Programming courses must specify at least one programming language\")\n            )\n\n    @property\n    def has_started(self) -\u003e bool:\n        now = timezone.now()\n        return self.start_date \u003c= now.date()\n\n    @property\n    def has_finished(self) -\u003e bool:\n        now = timezone.now()\n        return self.end_date \u003c= now.date()\n\n    def is_within(self, x: date) -\u003e bool:\n        return self.start_date \u003c= x \u003c= self.end_date\n\n    class Meta:\n        constraints = [\n            models.CheckConstraint(\n                condition=Q(start_date__lt=F(\"end_date\")),\n                name=\"valid_course_date_range\",\n                violation_error_message=_(\"End date must be after start date\"),\n            ),\n        ]\n```\n\nMethods requiring arguments cannot be properties.\n\n**Attribute synchronization pattern:**\n\nUse methods when setting one attribute requires updating related attributes:\n\n```python\nfrom django.utils.crypto import get_random_string\nfrom django.conf import settings\nfrom django.utils import timezone\n\n\nclass Token(models.Model):\n    secret = models.CharField(max_length=255, unique=True)\n    expiry = models.DateTimeField(blank=True, null=True)\n\n    def set_new_secret(self):\n        now = timezone.now()\n\n        self.secret = get_random_string(255)\n        self.expiry = now + settings.TOKEN_EXPIRY_TIMEDELTA\n\n        return self\n```\n\nThe `set_new_secret` method ensures both `secret` and `expiry` are updated atomically.\n\n**Rules for model methods:**\n\n✅ **Use methods when:**\n\n- Simple derived values require arguments\n- Operating on non-relational fields only\n- Synchronizing multiple attribute updates\n\n❌ **Move to services/selectors when:**\n\n- Spanning multiple relations or fetching additional data\n- Complex business logic is involved\n\n### Testing\n\n**Test models only when they contain custom logic:** validation, properties, or methods.\n\n**Implementation:**\n\n```python\nfrom datetime import timedelta\n\nimport pytest\nfrom django.core.exceptions import ValidationError\nfrom django.utils import timezone\n\nfrom project.some_app.models import Course\n\npytestmark = pytest.mark.django_db(transaction=True)\n\n\nclass TestCourseValidation:\n    \"\"\"Tests for Course model validation.\"\"\"\n\n    def test_course_end_date_cannot_be_before_start_date(self) -\u003e None:\n        start_date = timezone.now()\n        end_date = timezone.now() - timedelta(days=1)\n\n        course = Course(start_date=start_date, end_date=end_date)\n\n        with pytest.raises(ValidationError):\n            course.full_clean()\n```\n\n**Key principles:**\n\n1. Assert validation errors through `full_clean`\n2. Use `pytest.raises()` for exception testing\n3. Avoid database hits when testing pure validation logic\n\n## Services\n\n**Services contain all business logic.**\n\nThe service layer implements domain-specific operations, manages database transactions,\nand orchestrates system\ninteractions.\n\n**Architecture position:**\n\n![Service layer](https://user-images.githubusercontent.com/387867/134778130-be168592-b953-4b74-8588-a3dbaa0b6871.png)\n\n**Service implementation forms:**\n\n- Simple functions (most common)\n- Classes (for stateful operations)\n- Modules (for complex domains)\n\n**Service function requirements:**\n\n- Location: `\u003cyour_app\u003e/services.py`\n- Arguments: Keyword-only (except for single or no arguments)\n- Type annotations: Required for all parameters and returns\n- Scope: Database operations, external services, business logic\n\n### Example - Function-Based Service\n\n**Service with Pydantic schema (preferred approach):**\n\n```python\nfrom pydantic import BaseModel\n\n\nclass UserSchemaIn(BaseModel):\n    \"\"\"Input schema for creating a User.\"\"\"\n    email: str\n    name: str\n\n\ndef user_create(\n        *,\n        schema: UserSchemaIn\n) -\u003e User:\n    user = User(email=schema.email)\n    user.full_clean()\n    user.save()\n\n    profile_create(user=user, name=schema.name)\n    confirmation_email_send(user=user)\n\n    return user\n```\n\nThis service orchestrates the complete user creation flow, calling related services in\nsequence.\n\n### Pydantic Schemas as Default for Service Input\n\n**Pydantic schemas are the preferred default for validating service input:**\n\n```python\nfrom pydantic import BaseModel, HttpUrl, Field\nfrom pydantic_extra_types.country import CountryAlpha2\nfrom pydantic_extra_types.currency_code import Currency\nfrom decimal import Decimal\n\n\nclass ReturnPolicySchemaIn(BaseModel):\n    \"\"\"Input schema for creating a OnlineReturnPolicy.\"\"\"\n\n    country_codes: list[CountryAlpha2] = Field(min_length=1)\n    policy_url: HttpUrl\n    currency: Currency\n    return_methods: list[ReturnMethod] = Field(min_length=1, max_length=3)\n\n    # Optional fields with defaults\n    return_eligibility: ReturnEligibility | None = None\n    accepts_exchanges: bool | None = None\n    return_label_cost_amount: Decimal | None = Field(default=None, ge=0)\n    # ... other fields\n\n\ndef return_policy_create(\n        *,\n        merchant: Merchant,\n        schema: ReturnPolicySchemaIn,  # Schema as parameter\n) -\u003e OnlineReturnPolicy:\n    \"\"\"\n    Service accepts schema for input validation.\n    This approach keeps signatures clean and provides type safety.\n    \"\"\"\n    # Extract and transform data from schema\n    policy_data = schema.model_dump(exclude={\"country_codes\"}, exclude_none=True)\n    policy_data[\"policy_url\"] = str(policy_data[\"policy_url\"])\n\n    # Business logic implementation\n    policy = OnlineReturnPolicy(merchant=merchant, **policy_data)\n    policy.full_clean()\n    policy.save()\n\n    return policy\n```\n\n**Benefits of schema-first approach:**\n\n1. **Type safety** - Leverage Pydantic's rich type system (HttpUrl, Currency,\n   CountryAlpha2, etc.)\n2. **Validation** - Centralized input validation with clear error messages\n3. **API reusability** - Same schema works directly with django-ninja endpoints:\n   ```python\n   @api.post(\"/return-policies\")\n   def create_return_policy_api(request, data: ReturnPolicySchemaIn):\n       merchant = get_object_or_404(Merchant, id=request.user.merchant_id)\n       policy = return_policy_create(merchant=merchant, schema=data)\n       return {\"id\": policy.id}\n   ```\n4. **Documentation** - Schema serves as clear documentation of expected input\n5. **Consistency** - Uniform validation approach across services\n\n**Exception: Simple services can skip schemas:**\n\n```python\n# Simple lookups with basic types\ndef user_get(*, user_id: int) -\u003e User:\n    return get_object_or_404(User, id=user_id)\n\n\n# Few parameters of basic types (str, int, bool)\ndef user_deactivate(*, user: User, reason: str) -\u003e User:\n    user.is_active = False\n    user.deactivation_reason = reason\n    user.full_clean()\n    user.save()\n    return user\n```\n\n**When to skip Pydantic schemas:**\n\n- Simple services with very few parameters (1-3) of basic input types\n- Internal utilities never exposed via API\n- When all parameters are already validated domain objects\n\n### Example - Class-Based Service\n\n**Class-based services encapsulate related operations under a namespace.**\n\nExample\nfrom [Django Styleguide Example](https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/services.py#L22):\n\n```python\n# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/services.py\n\n\nclass FileStandardUploadService:\n    \"\"\"\n    This also serves as an example of a service class,\n    which encapsulates 2 different behaviors (create \u0026 update) under a namespace.\n\n    Meaning, we use the class here for:\n\n    1. The namespace\n    2. The ability to reuse `_infer_file_name_and_type` (which can also be an util)\n    \"\"\"\n\n    def __init__(self, user: BaseUser, file_obj):\n        self.user = user\n        self.file_obj = file_obj\n\n    def _infer_file_name_and_type(self, file_name: str = \"\", file_type: str = \"\") -\u003e\n\n        Tuple[str, str]:\n\n    file_name = file_name or self.file_obj.name\n\n    if not file_type:\n        guessed_file_type, encoding = mimetypes.guess_type(file_name)\n        file_type = guessed_file_type or \"\"\n\n    return file_name, file_type\n\n\ndef create(self, file_name: str = \"\", file_type: str = \"\") -\u003e File:\n    _validate_file_size(self.file_obj)\n\n    file_name, file_type = self._infer_file_name_and_type(file_name, file_type)\n\n    obj = File(\n        file=self.file_obj,\n        original_file_name=file_name,\n        file_name=file_generate_name(file_name),\n        file_type=file_type,\n        uploaded_by=self.user,\n        upload_finished_at=timezone.now()\n    )\n\n    obj.full_clean()\n    obj.save()\n\n    return obj\n\n\ndef update(self, file: File, file_name: str = \"\", file_type: str = \"\") -\u003e File:\n    _validate_file_size(self.file_obj)\n\n    file_name, file_type = self._infer_file_name_and_type(file_name, file_type)\n\n    file.file = self.file_obj\n    file.original_file_name = file_name\n    file.file_name = file_generate_name(file_name)\n    file.file_type = file_type\n    file.uploaded_by = self.user\n    file.upload_finished_at = timezone.now()\n\n    file.full_clean()\n    file.save()\n\n    return file\n```\n\n**Benefits of class-based services:**\n\n1. **Namespace** - Groups related operations (create/update)\n2. **Code reuse** - Shared logic via private methods\n\n**Usage pattern:**\n\n```python\n# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/apis.py\n\nclass FileDirectUploadApi(ApiAuthMixin, APIView):\n    def post(self, request):\n        service = FileDirectUploadService(\n            user=request.user,\n            file_obj=request.FILES[\"file\"]\n        )\n        file = service.create()\n\n        return Response(data={\"id\": file.id}, status=status.HTTP_201_CREATED)\n```\n\nAnd\n\n```python\n@admin.register(File)\nclass FileAdmin(admin.ModelAdmin):\n    # ... other code here ...\n    # https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/admin.py\n\n    def save_model(self, request, obj, form, change):\n        try:\n            cleaned_data = form.cleaned_data\n\n            service = FileDirectUploadService(\n                file_obj=cleaned_data[\"file\"],\n                user=cleaned_data[\"uploaded_by\"]\n            )\n\n            if change:\n                service.update(file=obj)\n            else:\n                service.create()\n        except ValidationError as exc:\n            self.message_user(request, str(exc), messages.ERROR)\n```\n\n**Use class-based services for multi-step workflows.**\n\nExample of a direct file upload flow:\n\n```python\n# https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/files/services.py\n\n\nclass FileDirectUploadService:\n    \"\"\"\n    This also serves as an example of a service class,\n    which encapsulates a flow (start \u0026 finish) + one-off action (upload_local) into a namespace.\n\n    Meaning, we use the class here for:\n\n    1. The namespace\n    \"\"\"\n\n    def __init__(self, user: BaseUser):\n        self.user = user\n\n    @transaction.atomic\n    def start(self, *, file_name: str, file_type: str) -\u003e Dict[str, Any]:\n        file = File(\n            original_file_name=file_name,\n            file_name=file_generate_name(file_name),\n            file_type=file_type,\n            uploaded_by=self.user,\n            file=None\n        )\n        file.full_clean()\n        file.save()\n\n        upload_path = file_generate_upload_path(file, file.file_name)\n\n        \"\"\"\n        We are doing this in order to have an associated file for the field.\n        \"\"\"\n        file.file = file.file.field.attr_class(file, file.file.field, upload_path)\n        file.save()\n\n        presigned_data: Dict[str, Any] = {}\n\n        if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3:\n            presigned_data = s3_generate_presigned_post(\n                file_path=upload_path, file_type=file.file_type\n            )\n\n        else:\n            presigned_data = {\n                \"url\": file_generate_local_upload_url(file_id=str(file.id)),\n            }\n\n        return {\"id\": file.id, **presigned_data}\n\n    def finish(self, *, file: File) -\u003e File:\n        # Potentially, check against user\n        file.upload_finished_at = timezone.now()\n        file.full_clean()\n        file.save()\n\n        return file\n```\n\n### Naming Convention\n\n**Required pattern:** `\u003centity\u003e_\u003caction\u003e`\n\nExample: `user_create`, `user_update`, `user_deactivate`\n\n**Benefits:**\n\n- **Namespacing** - All user operations start with `user_`\n- **Searchability** - Easy to find all operations for an entity\n- **Consistency** - Predictable naming across the codebase\n\n### Modules\n\n**Start with a single `services.py` file.**\n\nWhen complexity grows, split into domain-specific modules.\n\n**Example structure for an authentication app:**\n\n```\nservices\n├── __init__.py\n├── jwt.py\n└── oauth.py\n```\n\n**Organization options:**\n\n- Export from `services/__init__.py` for clean imports\n- Use folder-modules like `jwt/__init__.py` for complex domains\n- Refactor when the current structure becomes unwieldy\n\n### Selectors\n\n**Separation of concerns:**\n\n- **Services** - Write operations (push data)\n- **Selectors** - Read operations (pull data)\n\nSelectors are a specialized sub-layer for data fetching.\n\n**Rules:** Selectors follow the same conventions as services.\n\n**Implementation in `\u003cyour_app\u003e/selectors.py`:**\n\n```python\ndef user_list(*, fetched_by: User) -\u003e Iterable[User]:\n    user_ids = user_get_visible_for(user=fetched_by)\n\n    query = Q(id__in=user_ids)\n\n    return User.objects.filter(query)\n```\n\nNote: `user_get_visible_for` is another selector being composed.\n\n**Return types:** QuerySets, lists, or any appropriate data structure.\n\n**Selectors must provide value beyond simple QuerySet wrappers:**\n\nSelectors should encapsulate business logic (visibility rules, access control, complex\nfiltering) or query optimizations (select_related, prefetch_related). Don't create\nselector methods that simply wrap Django ORM calls without adding logic - consumers can\ncall Django directly for trivial queries like `Model.objects.filter(id=x).exists()`.\n\n### Testing\n\n**Services must be thoroughly tested as they contain business logic.**\n\n**Testing requirements:**\n\n1. **Exhaustive coverage** - Test all business logic paths\n2. **Database interaction** - Create and read real database records\n3. **External mocking** - Mock async tasks and external services\n\n**Test data creation methods:**\n\n- [`faker`](https://github.com/joke2k/faker) - For generating fake data\n- Services - Use existing services to create test state\n- [`factory_boy`](https://factoryboy.readthedocs.io/en/latest/orms.html) - For model\n  factories\n- Direct `Model.objects.create()` - When factories aren't available\n\n**Example service under test:**\n\n```python\nfrom django.contrib.auth.models import User\nfrom django.core.exceptions import ValidationError\nfrom django.db import transaction\n\nfrom project.payments.selectors import items_get_for_user\nfrom project.payments.models import Item, Payment\nfrom project.payments.tasks import payment_charge\n\n\n@transaction.atomic\ndef item_buy(\n        *,\n        item: Item,\n        user: User,\n) -\u003e Payment:\n    if item in items_get_for_user(user=user):\n        raise ValidationError(f'Item {item} already in {user} items.')\n\n    payment = Payment(\n        item=item,\n        user=user,\n        successful=False\n    )\n    payment.full_clean()\n    payment.save()\n\n    # Run the task once the transaction has commited,\n    # guaranteeing the object has been created.\n    transaction.on_commit(\n        lambda: payment_charge.delay(payment_id=payment.id)\n    )\n\n    return payment\n```\n\n**Service operations:**\n\n- Selector validation\n- Object creation\n- Task scheduling\n\n**Test implementation:**\n\n```python\nimport pytest\nfrom unittest.mock import patch, Mock\n\nfrom django.contrib.auth.models import User\nfrom django.core.exceptions import ValidationError\n\nfrom django_styleguide.payments.services import item_buy\nfrom django_styleguide.payments.models import Payment, Item\n\npytestmark = pytest.mark.django_db(transaction=True)\n\n\nclass TestItemBuy:\n    \"\"\"Tests for item_buy service.\"\"\"\n\n    @patch('project.payments.services.items_get_for_user')\n    async def test_buying_item_that_is_already_bought_fails(\n            self, items_get_for_user_mock: Mock\n    ) -\u003e None:\n        \"\"\"\n        Since we already have tests for `items_get_for_user`,\n        we can safely mock it here and give it a proper return value.\n        \"\"\"\n        user = User(username='Test User')\n        item = Item(\n            name='Test Item',\n            description='Test Item description',\n            price=10.15\n        )\n\n        items_get_for_user_mock.return_value = [item]\n\n        with pytest.raises(ValidationError):\n            await item_buy(user=user, item=item)\n\n    @patch('project.payments.services.payment_charge.kiq')\n    async def test_buying_item_creates_a_payment_and_calls_charge_task(\n            self,\n            payment_charge_mock: Mock\n    ) -\u003e None:\n        # How we prepare our tests is a topic for a different discussion\n        user = await given_a_user(username=\"Test user\")\n        item = await given_a_item(\n            name='Test Item',\n            description='Test Item description',\n            price=10.15\n        )\n\n        assert await Payment.objects.acount() == 0\n\n        payment = await item_buy(user=user, item=item)\n\n        assert await Payment.objects.acount() == 1\n        assert payment == await Payment.objects.afirst()\n\n        assert not payment.successful\n\n        payment_charge_mock.assert_called_once()\n```\n\n## APIs \u0026 Schemas\n\n**Framework:** [django-ninja](https://django-ninja.dev/) - Fast, type-safe, and aligned\nwith our service layer.\n\n**Extensions for class-based APIs:**\n\n- [django-ninja-extra](https://eadwincode.github.io/django-ninja-extra/)\n- [django-ninja-crud](https://github.com/hbakri/django-ninja-crud)\n\n### API Design Rules\n\n**Structure:**\n\n- One endpoint per operation (4 endpoints for CRUD)\n- No business logic in endpoints\n- Endpoints are thin interfaces to services\n\n**Permitted in endpoints:**\n\n- Object fetching\n- Data transformation\n- Service delegation\n\n### Schema Requirements\n\n**Mandatory separation:**\n\n- **Input schemas** - Validate incoming data (Pydantic models)\n- **Output schemas** - Define response structure (Pydantic models)\n\n**Schema conventions:**\n\n- Define schemas near endpoints\n- Name as `InputSchema` or `OutputSchema`\n- Minimize schema reuse to avoid coupling\n- Use inline definitions for nested structures\n\n### Pydantic Schema Naming Conventions\n\n**To avoid namespace collisions with Django models, Pydantic schemas use descriptive\nsuffixes:**\n\n**Three suffix types:**\n\n1. **`SchemaIn`** - For API write operations (create, update requests)\n   ```python\n   class DataSourceSchemaIn(BaseModel):\n       \"\"\"Input schema for creating/updating a DataSource.\"\"\"\n       display_name: str\n       input_type: Input\n       file_input: FileInputSchema | None = None\n   ```\n\n2. **`SchemaOut`** - For API read operations (responses)\n   ```python\n   class DataSourceSchemaOut(BaseModel):\n       \"\"\"Output schema for DataSource responses.\"\"\"\n       id: int\n       display_name: str\n       input_type: Input\n       created: datetime\n       file_input: FileInputSchema | None = None\n   ```\n\n3. **`Schema`** - For intermediate/compositional schemas\n   ```python\n   class FetchSettingsSchema(BaseModel):\n       \"\"\"Shared schema for fetch settings validation.\"\"\"\n       enabled: bool = True\n       frequency: Frequency\n       time_of_day: TimeOfDay | None = None\n\n   class DestinationSchema(BaseModel):\n       \"\"\"Compositional schema used in multiple contexts.\"\"\"\n       destination: DestinationEnum\n       state: State\n   ```\n\n**Django models keep clean, natural names:**\n\n```python\n# models.py - Django models without suffixes\nclass DataSource(models.Model):\n    display_name = models.CharField(max_length=50)\n    input = EnumField(Input)\n    # ...\n\nclass FetchSettings(models.Model):\n    enabled = models.BooleanField(default=True)\n    frequency = EnumField(Frequency)\n    # ...\n\n# schemas.py - Pydantic schemas with suffixes\nclass DataSourceSchemaIn(BaseModel):\n    display_name: str\n    input: Input\n    # ...\n\nclass FetchSettingsSchema(BaseModel):\n    enabled: bool = True\n    frequency: Frequency\n    # ...\n```\n\n**When to use each suffix:**\n\n- **Use `SchemaIn`/`SchemaOut`** when:\n    - Schemas differ for read vs write operations\n    - You need different fields or validation for input vs output\n    - Working with top-level API request/response schemas\n    - Example: `UserSchemaIn` has password field, `UserSchemaOut` doesn't\n\n- **Use `Schema`** when:\n    - Schema is identical for both read and write\n    - Schema is used for composition (nested in other schemas)\n    - Schema is purely for validation, not directly exposed via API\n    - Example: `PriceSchema`, `AddressSchema` used in multiple parent schemas\n\n**Benefits of this convention:**\n\n1. **No namespace collisions** - Import both model and schema without conflicts:\n   ```python\n   from project.data_sources.models import DataSource\n   from project.data_sources.schemas import DataSourceSchemaIn, DataSourceSchemaOut\n   # No ambiguity about which is which\n   ```\n\n2. **Clear intent** - Suffix immediately signals the purpose:\n    - `Schema` = validation/serialization layer\n    - No suffix = Django ORM model\n\n3. **Consistency with django-ninja** - Aligns with common patterns in the ecosystem\n\n4. **Avoids duplication** - Use plain `Schema` for shared validation logic instead of\n   creating duplicate `SchemaIn`/`SchemaOut` pairs\n\n**Anti-pattern to avoid:**\n\n```python\n# DON'T create duplicate In/Out for everything\nclass PriceSchemaIn(BaseModel):  # ❌ Unnecessary duplication\n    amount: Decimal\n    currency: str\n\nclass PriceSchemaOut(BaseModel):  # ❌ Identical to In\n    amount: Decimal\n    currency: str\n\n# DO use single Schema for shared validation\nclass PriceSchema(BaseModel):  # ✅ One schema, multiple uses\n    amount: Decimal\n    currency: str\n```\n\n### Naming Convention\n\n**Required pattern:** `\u003centity\u003e_\u003caction\u003e_api`\n\nExamples:\n\n- `user_create_api`\n- `user_send_reset_password_api`\n- `user_deactivate_api`\n\nThis pattern mirrors service naming for consistency.\n\n### List APIs\n\n#### Plain\n\n**Basic list endpoint:**\n\n```python\nfrom ninja import NinjaAPI, Schema\nfrom pydantic import BaseModel\nfrom typing import List\n\nfrom styleguide_example.users.selectors import user_list\n\napi = NinjaAPI()\n\n\nclass UserOutputSchema(Schema):\n    id: str\n    email: str\n\n\n@api.get(\"/users\", response=List[UserOutputSchema])\ndef list_users(request):\n    users = user_list()\n    return users\n```\n\n**Note:** Authentication must be explicitly configured.\n\n#### Filters + Pagination\n\n**Implementation with query parameters:**\n\n```python\nfrom ninja import NinjaAPI, Query, Schema\nfrom pydantic import BaseModel, Field\nfrom typing import List, Optional\n\nfrom ninja.pagination import paginate, PageNumberPagination\n\nfrom styleguide_example.users.selectors import user_list\n\napi = NinjaAPI()\n\n\nclass UserFiltersSchema(Schema):\n    id: Optional[int] = None\n    is_admin: Optional[bool] = None\n    email: Optional[str] = None\n\n\nclass UserOutputSchema(Schema):\n    id: str\n    email: str\n    is_admin: bool\n\n\n@api.get(\"/users\", response=List[UserOutputSchema])\n@paginate(PageNumberPagination)\ndef list_users(\n        request,\n        filters: UserFiltersSchema = Query(...)\n):\n    users = user_list(filters=filters.get_filter_expression())\n    return users\n```\n\nThe selector remains the same:\n\n```python\nfrom styleguide_example.users.models import BaseUser\n\n\ndef user_list(*, filters=None):\n    filters = filters or {}\n\n    qs = BaseUser.objects.all()\n\n    return qs.filter(filters)\n```\n\n**Separation of concerns:**\n\n- Django-ninja: Parameter validation\n- Selector: Filter application\n\n### Detail API\n\n**Implementation:**\n\n```python\nfrom ninja import NinjaAPI, Schema\nfrom datetime import date\n\nfrom styleguide_example.courses.selectors import course_get\n\napi = NinjaAPI()\n\n\nclass CourseOutputSchema(Schema):\n    id: str\n    name: str\n    start_date: date\n    end_date: date\n\n\n@api.get(\"/courses/{course_id}\", response=CourseOutputSchema)\ndef get_course(request, course_id: int):\n    course = course_get(id=course_id)\n    return course\n```\n\n### Create API\n\n**Implementation:**\n\n```python\nfrom ninja import NinjaAPI, Schema\nfrom datetime import date\n\nfrom styleguide_example.courses.services import course_create\n\napi = NinjaAPI()\n\n\nclass CourseInputSchema(Schema):\n    name: str\n    start_date: date\n    end_date: date\n\n\n@api.post(\"/courses\")\ndef create_course(request, data: CourseInputSchema):\n    course = course_create(**data.model_dump())\n    return {\"id\": course.id}\n```\n\n### Update API\n\n**Implementation:**\n\n```python\nfrom ninja import NinjaAPI, Schema\nfrom typing import Optional\nfrom datetime import date\n\nfrom styleguide_example.courses.services import course_update\n\napi = NinjaAPI()\n\n\nclass CourseUpdateSchema(Schema):\n    name: Optional[str] = None\n    start_date: Optional[date] = None\n    end_date: Optional[date] = None\n\n\n@api.patch(\"/courses/{course_id}\")\ndef update_course(request, course_id: int, data: CourseUpdateSchema):\n    course = course_update(course_id=course_id, **data.model_dump(exclude_none=True))\n    return {\"success\": True}\n```\n\n### Fetching Objects\n\n**Object fetching must occur at the API layer.**\n\n**Standard approach:** Use Django's `get_object_or_404` in endpoints:\n\n```python\nfrom django.shortcuts import get_object_or_404\nfrom ninja import NinjaAPI\n\napi = NinjaAPI()\n\n\n@api.get(\"/courses/{course_id}\")\ndef get_course(request, course_id: int):\n    course = get_object_or_404(Course, id=course_id)\n    # Use the course object...\n```\n\nDjango-ninja automatically handles 404 responses.\n\n### Nested Schemas\n\n**Define nested structures as separate Pydantic models:**\n\n```python\nfrom ninja import Schema\nfrom typing import List\nfrom datetime import date\n\n\n# Define the nested schema\nclass WeekSchema(Schema):\n    id: int\n    number: int\n    topic: str\n\n\n# Use it in the parent schema\nclass CourseDetailSchema(Schema):\n    id: int\n    name: str\n    start_date: date\n    end_date: date\n    weeks: List[WeekSchema]  # Nested schema\n\n\n# Or define inline for simple cases\nclass CourseWithInlineWeeksSchema(Schema):\n    id: int\n    name: str\n\n    class WeekInfo(Schema):\n        number: int\n        topic: str\n\n    weeks: List[WeekInfo]  # Inline nested schema\n```\n\n### Advanced Serialization\n\n**Complex responses require dedicated serialization services:**\n\n```python\nfrom ninja import NinjaAPI\nfrom typing import List, Any\n\napi = NinjaAPI()\n\n\n@api.get(\"/feed\")\ndef get_feed(request):\n    feed = some_feed_get(user=request.user)\n    data = some_feed_serialize(feed)\n    return data\n```\n\n**Serialization service implementation:**\n\n```python\nfrom ninja import Schema\nfrom pydantic import ConfigDict\nfrom typing import List\nfrom your_app.models import FeedItem  # Add the missing import\n\n\nclass FeedItemSchema(Schema):\n    id: int\n    title: str\n    content: str\n    calculated_field: int = 0  # Provide default for computed field\n\n    # Pydantic v2 syntax\n    model_config = ConfigDict(from_attributes=True)\n\n\ndef some_feed_serialize(feed_items: List[FeedItem]) -\u003e List[dict]:\n    feed_ids = [feed_item.id for feed_item in feed_items]\n\n    # Refetch items with optimizations\n    objects = FeedItem.objects.select_related(\n        # ... as complex as you want ...\n    ).prefetch_related(\n        # ... as complex as you want ...\n    ).filter(\n        id__in=feed_ids\n    ).order_by(\n        \"-some_timestamp\"\n    )\n\n    some_cache = get_some_cache(feed_ids)\n\n    result = []\n    for feed_item in objects:\n        # Convert to dict first, then add computed fields\n        item_data = FeedItemSchema.model_validate(feed_item).model_dump()\n        item_data['calculated_field'] = some_cache.get(feed_item.id, 0)\n        result.append(item_data)\n\n    return result\n\n```\n\n**Serialization strategy:**\n\n1. Refetch with optimized queries (joins/prefetches)\n2. Build in-memory caches for computed values\n3. Return API-ready data structures\n\n**Location:** `serializers.py` module in the Django app.\n\n## URLs\n\n**django-ninja handles routing via decorators, but domain organization remains critical.\n**\n\n### API URLs with django-ninja\n\n**Define API instances per domain:**\n\n```python\n# project/education/apis.py\nfrom ninja import NinjaAPI\n\napi = NinjaAPI(urls_namespace='education')\n\n\n@api.get(\"/courses\")\ndef list_courses(request):\n    # Implementation\n    pass\n\n\n@api.get(\"/courses/{course_id}\")\ndef get_course(request, course_id: int):\n    # Implementation\n    pass\n```\n\n**Main URL configuration:**\n\n```python\n# project/urls.py\nfrom django.urls import path\n\nfrom project.education.apis import api as education_api\nfrom project.users.apis import api as users_api\n\nurlpatterns = [\n    path('api/education/', education_api.urls),\n    path('api/users/', users_api.urls),\n]\n```\n\n### Regular Django Views\n\n**Organize non-API views by domain using standard Django patterns:**\n\n```python\n# project/education/urls.py\nfrom django.urls import path, include\n\nfrom project.education import views\n\napp_name = 'education'\n\n# Nested URL structure for logical grouping\nurlpatterns = [\n    path('courses/', include([\n        path('', views.course_list, name='list'),\n        path('\u003cint:course_id\u003e/', views.course_detail, name='detail'),\n        path('\u003cint:course_id\u003e/enroll/', views.course_enroll, name='enroll'),\n        path('\u003cint:course_id\u003e/materials/', include([\n            path('', views.materials_list, name='materials-list'),\n            path('\u003cint:material_id\u003e/', views.material_detail, name='material-detail'),\n        ])),\n    ])),\n]\n```\n\n### Organizing by Domain\n\n**Domain-based URL organization is mandatory.**\n\n**Benefits:**\n\n1. **Namespace separation** - Each domain owns its URL namespace\n2. **Atomic refactoring** - Move entire domains as units\n3. **Parallel development** - Teams work independently\n4. **Intuitive discovery** - URLs mirror domain structure\n\n**Recommended structure for large projects:**\n\n```\nproject/\n├── urls.py                    # Main URL configuration\n├── api/\n│   ├── v1/\n│   │   └── __init__.py       # Combines all v1 API routers\n│   └── v2/\n│       └── __init__.py       # Combines all v2 API routers\n├── education/\n│   ├── apis.py               # Education API endpoints (NinjaAPI)\n│   └── urls.py               # Education regular views\n├── users/\n│   ├── apis.py               # User API endpoints (NinjaAPI)\n│   └── urls.py               # User regular views\n└── payments/\n    ├── apis.py               # Payment API endpoints (NinjaAPI)\n    └── urls.py               # Payment regular views\n```\n\nThis structure maintains locality of behavior by keeping URL configuration adjacent to\nimplementation.\n\n## Settings\n\n**Requirements:**\n\n- Typed settings using **pydantic** `BaseSettings`\n- YAML configuration files for values\n- Full type annotations\n- Startup validation\n\n**Directory structure:**\n\n```\nconfig/\n├── settings/\n│   ├── base.py      # All settings classes and composition\n│   ├── local.py     # Local environment composition\n│   ├── production.py # Production environment composition\n│   └── test.py      # Test environment composition\n├── urls.py\n├── wsgi.py\n└── asgi.py\n\n.envs/\n├── .local.yaml      # Local environment values\n├── .production.yaml # Production environment values\n├── .test.yaml       # Test environment values\n└── .override.yaml   # Local overrides (gitignored)\n```\n\n### Typed Settings Classes\n\n**Define domain-specific settings classes in `config/settings/base.py`:**\n\n```python\nfrom pydantic import SecretStr, HttpUrl\nfrom pydantic_settings import BaseSettings, YamlConfigSettingsSource\n\n\nclass GeneralSettings(BaseSettings):\n    DEBUG: bool = False\n    SECRET_KEY: SecretStr\n    ALLOWED_HOSTS: list[str]\n\n\nclass DatabasesSettings(BaseSettings):\n    DATABASES: dict[str, PostgreSQLSettings]\n\n\nclass NatsSettings(BaseSettings):\n    NATS_URL: NatsDsn\n\n# ... more settings classes for each domain\n```\n\n### Environment-Specific Composition\n\n**Compose settings per environment in `config/settings/{env}.py`:**\n\n```python\nfrom pydantic_settings import SettingsConfigDict\n\n\nclass DjangoSettings(\n    GeneralSettings,\n    DatabasesSettings,\n    NatsSettings,\n    # ... all other settings classes\n    BaseDjangoSettings,  # Must be last for proper MRO\n):\n    model_config = SettingsConfigDict(\n        yaml_file=[\n            BASE_DIR / \".envs/.local.yaml\",\n            BASE_DIR / \".envs/.override.yaml\",  # Local overrides\n        ],\n        extra=\"ignore\",\n        validate_default=True,\n    )\n\n\n# Create instance and inject into Django\ndjango_settings = DjangoSettings()\nto_django(django_settings)  # Converts pydantic settings to Django globals\n```\n\n### YAML Configuration\n\n**Store configuration values in YAML for maintainability:**\n\n```yaml\n# .envs/.local.yaml\nDEBUG: true\nSECRET_KEY: your-secret-key-here\nALLOWED_HOSTS:\n  - localhost\n  - 127.0.0.1\n\nDATABASES:\n  default:\n    ENGINE: django.db.backends.postgresql\n    NAME: project\n    HOST: localhost\n    PORT: 5432\n```\n\n### Benefits\n\n✅ **Type safety** - Validated at startup\n✅ **IDE support** - Full autocomplete with pyright\n✅ **Clear structure** - Domain-based organization\n✅ **Local overrides** - Via `.override.yaml`\n✅ **Environment parity** - Consistent structure\n✅ **Early validation** - Before Django initialization\n\n### Integrations\n\n**Pattern for optional integrations:**\n\n```python\nclass SentrySettings(BaseSettings):\n    SENTRY_DSN: str = \"\"  # Empty string disables Sentry\n    SENTRY_ENVIRONMENT: str = \"development\"\n    SENTRY_TRACES_SAMPLE_RATE: float = 0.1\n\n    def configure(self) -\u003e None:\n        \"\"\"Configure Sentry if DSN is provided.\"\"\"\n        if self.SENTRY_DSN:\n            import sentry_sdk\n            from sentry_sdk.integrations.django import DjangoIntegration\n\n            sentry_sdk.init(\n                dsn=self.SENTRY_DSN,\n                environment=self.SENTRY_ENVIRONMENT,\n                integrations=[DjangoIntegration()],\n                traces_sample_rate=self.SENTRY_TRACES_SAMPLE_RATE,\n            )\n```\n\n**Integration benefits:**\n\n- Grouped, typed configuration\n- Explicit enable/disable via empty values\n- Startup validation\n- Full IDE support\n\n### Local Overrides\n\n**Use `.envs/.override.yaml` for local development settings:**\n\n```yaml\n# .envs/.override.yaml\nDEBUG: true\nDATABASES:\n  default:\n    HOST: my-local-postgres\n    PASSWORD: my-local-password\n```\n\n**Override rules:**\n\n- Loaded last with highest precedence\n- Never commit (contains credentials)\n- Provide example files for team onboarding\n\n## Errors \u0026 Exception Handling\n\n**Core principles:**\n\n1. Use Django's built-in exceptions\n2. Leverage Pydantic's automatic 422 validation errors\n3. Maintain consistent error formats\n4. Never silence unexpected errors (let them surface as 500s)\n\n**Standard:** Follow RFC7807 (\u003chttps://datatracker.ietf.org/doc/html/rfc7807\u003e) for error\nresponses.\n\n### Django-ninja Error Handling\n\n**Direct error raising pattern:**\n\n```python\nfrom ninja import NinjaAPI\nfrom ninja.errors import HttpError\n\napi = NinjaAPI()\n\n\n@api.get(\"/items/{item_id}\")\ndef get_item(request, item_id: int):\n    # Raise HTTP errors directly\n    if not request.user.is_authenticated:\n        raise HttpError(401, \"Authentication required\")\n\n    item = get_item_by_id(item_id)\n    if not item:\n        raise HttpError(404, \"Item not found\")\n\n    return item\n```\n\n### Input Validation with Pydantic v2\n\n**Automatic 422 validation errors:**\n\n```python\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\nfrom pydantic_extra_types.country import CountryAlpha2\nfrom typing import Optional\n\n\nclass UserCreateSchema(BaseModel):\n    model_config = ConfigDict(str_strip_whitespace=True)\n\n    email: str = Field(min_length=3, max_length=255)\n    age: int = Field(gt=0, le=120)\n    country: CountryAlpha2\n    name: str\n\n    @field_validator('email', mode='after')\n    @classmethod\n    def validate_email(cls, v: str) -\u003e str:\n        if '@' not in v:\n            raise ValueError('Invalid email format')\n        return v.lower()\n\n\n@api.post(\"/users\")\ndef create_user(request, data: UserCreateSchema):\n    # Validation happens automatically before this point\n    # If validation fails, returns 422 with:\n    # {\n    #   \"detail\": [\n    #     {\"type\": \"value_error\", \"loc\": [\"body\", \"email\"],\n    #      \"msg\": \"Invalid email format\"}\n    #   ]\n    # }\n    return user_service_create(**data.model_dump())\n```\n\n### Handling Django Exceptions\n\n**Register handlers for service layer exceptions:**\n\n```python\nfrom django.core.exceptions import (\n    ValidationError as DjangoValidationError,\n    PermissionDenied,\n    ObjectDoesNotExist\n)\nfrom ninja import NinjaAPI\n\napi = NinjaAPI()\n\n\n# Handle Django's ValidationError from model.full_clean()\n@api.exception_handler(DjangoValidationError)\ndef handle_django_validation_error(request, exc):\n    # Extract error messages\n    if hasattr(exc, 'message_dict'):\n        errors = exc.message_dict\n    elif hasattr(exc, 'messages'):\n        errors = {'non_field_errors': exc.messages}\n    else:\n        errors = {'non_field_errors': [str(exc)]}\n\n    return api.create_response(\n        request,\n        {\"message\": \"Validation failed\", \"errors\": errors},\n        status=400\n    )\n\n\n# Handle Django's PermissionDenied\n@api.exception_handler(PermissionDenied)\ndef handle_permission_denied(request, exc):\n    return api.create_response(\n        request,\n        {\"message\": str(exc) or \"Permission denied\"},\n        status=403\n    )\n\n\n# Handle Django's ObjectDoesNotExist\n@api.exception_handler(ObjectDoesNotExist)\ndef handle_not_found(request, exc):\n    return api.create_response(\n        request,\n        {\"message\": \"Object not found\"},\n        status=404\n    )\n```\n\n### Service Layer Errors\n\n**Domain exception hierarchy:**\n\n```python\n# project/core/exceptions.py\nclass ApplicationError(Exception):\n    \"\"\"Base for all service layer exceptions.\"\"\"\n    pass\n\n\n# project/users/services.py\nfrom project.core.exceptions import ApplicationError\n\n\nclass UserAlreadyExistsError(ApplicationError):\n    pass\n\n\ndef user_create(*, email: str, password: str) -\u003e User:\n    \"\"\"Create a new user.\"\"\"\n    if User.objects.filter(email=email).exists():\n        raise UserAlreadyExistsError(f\"User with email {email} already exists\")\n\n    user = User(email=email)\n    user.set_password(password)\n    user.full_clean()  # Still use Django's validation\n    user.save()\n    return user\n```\n\n**Unified application error handler:**\n\n```python\n# project/api/v1/__init__.py\n@api.exception_handler(ApplicationError)\ndef handle_application_error(request, exc):\n    # Map domain exceptions to HTTP status codes\n    if isinstance(exc, UserAlreadyExistsError):\n        status = 409  # Conflict\n    else:\n        status = 400  # Bad Request\n\n    return api.create_response(\n        request,\n        {\"message\": str(exc)},\n        status=status\n    )\n```\n\n## Testing\n\n### Overview\n\n**Framework:** Use **pytest** with **pytest-django** and **pytest-asyncio** for all\ntests.\n\n**Reference:\n** [Quality Assurance in Django - Testing what matters](https://www.youtube.com/watch?v=PChaEAIsQls) -\nDjangoCon Europe 2022\n\n**Test organization by layer:**\n\n- Models\n- Services\n- Selectors\n- APIs/Views\n\n**Required directory structure:**\n\n```\nproject_name\n├── app_name\n│   ├── __init__.py\n│   └── tests\n│       ├── __init__.py\n│       ├── conftest.py              # Fixtures\n│       ├── factories.py             # factory_boy factories\n│       ├── test_services.py         # Service tests\n│       ├── test_selectors.py        # Selector tests\n│       └── test_models.py           # Model tests (if needed)\n└── __init__.py\n```\n\n### Required Test Setup\n\nEvery test file must include:\n\n```python\nimport pytest\n\npytestmark = pytest.mark.django_db(transaction=True)\n```\n\n### Naming Conventions\n\n**Required patterns:**\n\n- File: `test_\u003cname_of_tested_component\u003e.py`\n- Class: `Test\u003cNameOfTestedComponent\u003e\u003cMethod\u003e` (e.g., `TestBrandSelectorList`)\n- Method: `test_\u003cbehavior_description\u003e` with `async def` for async code\n\n**Example mapping:**\n\n```python\ndef a_very_neat_service(*args, **kwargs):\n    pass\n```\n\n**File name:**\n\n```\nproject_name/app_name/tests/services/test_a_very_neat_service.py\n```\n\n**Test class:**\n\n```python\nclass TestAVeryNeatService:\n    \"\"\"Tests for a_very_neat_service.\"\"\"\n\n    async def test_does_something(self) -\u003e None:\n        pass\n```\n\n**Utility function tests mirror module structure:**\n\n- Module: `project_name/common/utils.py`\n- Test: `project_name/common/tests/test_utils.py`\n\n**Submodule example:**\n\n- Module: `project_name/common/utils/files.py`\n- Test: `project_name/common/tests/utils/test_files.py`\n\n**Principle:** Test structure must match code structure.\n\n### Fixtures\n\nDefine fixtures in `conftest.py`:\n\n```python\nimport pytest\nfrom project.brands.tests.factories import BrandFactory, ApprovedBrandFactory\n\n@pytest.fixture\nasync def brand() -\u003e Brand:\n    return await BrandFactory.acreate()\n\n@pytest.fixture\nasync def approved_brand() -\u003e Brand:\n    return await ApprovedBrandFactory.acreate()\n```\n\n### Factories\n\n**Use factories for test data generation.**\n\n**Resources:**\n\n- [Improve your Django tests with fakes and factories](https://www.hacksoft.io/blog/improve-your-tests-django-fakes-and-factories)\n- [Advanced factory usage](https://www.hacksoft.io/blog/improve-your-tests-django-fakes-and-factories-advanced-usage)\n- [factory_boy: testing like a pro - DjangoCon 2022](https://www.youtube.com/watch?v=-C-XNHAJF-c)\n\n### Async Testing Patterns\n\n```python\n# Async iteration over QuerySet\nbrands = [b async for b in selector.list()]\n\n# Async count\nassert await queryset.acount() == 2\n\n# Async first\nbrand = await queryset.afirst()\n```\n\n## TaskIQ\n\n**[TaskIQ](https://taskiq-python.github.io/) is required for:**\n\n- Third-party service communication (emails, notifications)\n- Heavy computation outside HTTP cycles\n- Periodic task scheduling\n- Async event processing\n\n**Key advantage:** Async-first architecture with superior performance.\n\n### The Basics\n\n**Core principle:** TaskIQ is an interface layer - business logic stays in services.\n\n**Important:** TaskIQ is async-only, aligning with Django's async capabilities.\n\n**Email service example:**\n\n```python\nfrom django.db import transaction\nfrom django.core.mail import EmailMultiAlternatives\n\nfrom styleguide_example.core.exceptions import ApplicationError\nfrom styleguide_example.common.services import model_update\nfrom styleguide_example.emails.models import Email\n\n\nasync def email_send(email: Email) -\u003e Email:\n    if email.status != Email.Status.SENDING:\n        raise ApplicationError(\n            f\"Cannot send non-ready emails. Current status is {email.status}\")\n\n    subject = email.subject\n    from_email = \"styleguide-example@hacksoft.io\"\n    to = email.to\n\n    html = email.html\n    plain_text = email.plain_text\n\n    msg = EmailMultiAlternatives(subject, plain_text, from_email, [to])\n    msg.attach_alternative(html, \"text/html\")\n\n    # Use async email sending if available, or sync_to_async wrapper\n    await sync_to_async(msg.send)()\n\n    email, _ = await model_update(\n        instance=email,\n        fields=[\"status\", \"sent_at\"],\n        data={\n            \"status\": Email.Status.SENT,\n            \"sent_at\": timezone.now()\n        }\n    )\n    return email\n```\n\n**Task wrapper for the service:**\n\n```python\nfrom taskiq import TaskiqDepends\nfrom config.taskiq_app import broker\n\nfrom styleguide_example.emails.models import Email\n\n\n@broker.task()\nasync def email_send(email_id: int) -\u003e None:\n    email = await Email.objects.aget(id=email_id)\n\n    from styleguide_example.emails.services import email_send\n    await email_send(email)\n```\n\n**Task pattern:**\n\n1. Fetch required data\n2. Delegate to service\n\n**Service triggering the task:**\n\n```python\nfrom django.db import transaction\nfrom asgiref.sync import sync_to_async\n\n# ... more imports here ...\n\nfrom styleguide_example.emails.tasks import email_send as email_send_task\n\n\n@sync_to_async\n@transaction.atomic\ndef user_complete_onboarding(user: User) -\u003e User:\n    # ... some code here\n\n    email = email_get_onboarding_template(user=user)\n\n    # TaskIQ uses .kiq() instead of .delay()\n    transaction.on_commit(\n        lambda: async_to_sync(email_send_task.kiq)(email.id),\n    )\n\n    return user\n```\n\n**Key conventions:**\n\n1. Import tasks with `_task` suffix to distinguish from services\n2. Use `.kiq()` method for task execution\n\n**TaskIQ workflow:**\n\n1. Tasks call services\n2. Import service inside task function body\n3. Import task at module level with `_task` suffix\n4. Execute tasks on transaction commit\n\n**Benefit:** This pattern prevents circular imports.\n\n### Error Handling\n\n**Use TaskIQ retry middlewares\nwith [tenacity](https://tenacity.readthedocs.io/en/latest/) for complex retry logic.**\n\n**Implementation with error handling:**\n\n```python\nimport logging\nfrom taskiq import TaskiqDepends\nfrom taskiq_aiohttp import TaskiqMiddleware\nfrom tenacity import retry, stop_after_attempt, wait_exponential\n\nfrom config.taskiq_app import broker\nfrom styleguide_example.emails.models import Email\n\nlogger = logging.getLogger(__name__)\n\n\n@broker.task(\n    # Use TaskIQ's SimpleRetryMiddleware or SmartRetryMiddleware\n    retry_on_error=True,\n    max_retries=3,\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10)\n)\nasync def email_send(email_id: int) -\u003e None:\n    email = await Email.objects.aget(id=email_id)\n\n    from styleguide_example.emails.services import email_send\n\n    try:\n        await email_send(email)\n    except Exception as exc:\n        logger.warning(f\"Exception occurred while sending email: {exc}\")\n\n        # Check if this is the last retry attempt\n        if email_send.retry.statistics.get(\"attempt_number\", 0) \u003e= 3:\n            # Handle final failure\n            from styleguide_example.emails.services import email_failed\n            await email_failed(email)\n\n        raise  # Re-raise to trigger retry\n```\n\n**Failed retry strategies:**\n\n- Use tenacity's `retry_error_callback`\n- Implement wrapper functions for final exceptions\n- Track failures with TaskIQ's result backend\n\n### Configuration\n\n**Required location:** `config/taskiq_app.py`\n\n```python\nfrom taskiq import InMemoryBroker\nfrom taskiq_nats import NatsBroker\nfrom taskiq.schedule_sources import LabelScheduleSource\nfrom taskiq.middlewares import SimpleRetryMiddleware\n\nfrom django.conf import settings\n\n# Use NATS as the recommended backend for production\nif settings.ENVIRONMENT == \"production\":\n    broker = NatsBroker(\n        servers=[settings.NATS_URL],\n        queue=\"taskiq\",\n    )\nelse:\n    broker = InMemoryBroker()\n\n# Add retry middleware\nbroker.add_middlewares(\n    SimpleRetryMiddleware(\n        default_retry_count=3,\n        exponential_backoff=True,\n    )\n)\n\n# Configure scheduler for periodic tasks\nscheduler = broker.scheduler(\n    schedule_source=LabelScheduleSource(broker),\n)\n```\n\nTaskIQ auto-discovers tasks from `tasks.py` files via import paths.\n\n### Structure\n\n**Task location:** `tasks.py` modules per app\n\n**Auto-import via CLI:**\n\n```bash\n# Start worker with auto-discovery\ntaskiq worker config.taskiq_app:broker -fsd -tp \"project/**/tasks.py\"\n```\n\n**Scaling pattern:**\n\n- Start with single `tasks.py`\n- Split by domain when complexity grows: `tasks/domain_a.py`, `tasks/domain_b.py`\n- Ensure proper imports\n\n### Periodic Tasks\n\n**Use [TaskIQ scheduler](https://taskiq-python.github.io/guide/scheduling-tasks.html)\nwith decorator-based\nconfiguration:**\n\n```python\nfrom taskiq import TaskiqScheduler\nfrom taskiq.schedule_sources import LabelScheduleSource\nfrom config.taskiq_app import broker\n\nfrom styleguide_example.reports.services import generate_daily_report\n\n\n@broker.task(\n    schedule=[\n        {\n            \"cron\": \"0 2 * * *\",  # Daily at 2 AM\n            # https://crontab.guru/#0_2_*_*_*\n            \"args\": [],\n            \"kwargs\": {},\n        }\n    ]\n)\nasync def generate_daily_report_task():\n    \"\"\"Generate daily reports at 2 AM.\"\"\"\n    await generate_daily_report()\n\n\n@broker.task(\n    schedule=[\n        {\n            \"cron\": \"*/15 * * * *\",  # Every 15 minutes\n            # https://crontab.guru/#*/15_*_*_*_*\n            \"args\": [],\n            \"kwargs\": {},\n        }\n    ]\n)\nasync def health_check_task():\n    \"\"\"Run health checks every 15 minutes.\"\"\"\n    from styleguide_example.monitoring.services import run_health_checks\n    await run_health_checks()\n```\n\n**Scheduler command:**\n\n```bash\ntaskiq scheduler config.taskiq_app:scheduler -fsd -tp \"project/**/tasks.py\"\n```\n\n**Advantages over Celery Beat:**\n\n- Decorator-based schedules\n- No database models required\n- Simplified deployment\n- **Requirement:** Include crontab.guru links for all cron expressions\n\n### Beyond\n\n**Advanced patterns:**\n\n1. **Dependency injection** - FastAPI-style dependencies\n2. **NATS streaming** - JetStream for event streaming\n3. **FastStream integration** - Complex event-driven architectures\n\n```python\nfrom taskiq import TaskiqDepends, Context\n\n\n@broker.task()\nasync def complex_workflow(\n        data: dict,\n        context: Context = TaskiqDepends(),\n) -\u003e None:\n    \"\"\"Example of a task with dependencies.\"\"\"\n    task_id = context.message.task_id\n\n    # Your complex workflow logic here\n    # Can spawn sub-tasks, handle streams, etc.\n```\n\n**Core principles remain:**\n\n- Business logic in services\n- Tasks as thin interfaces\n- Clear separation of concerns\n\n**Critical:** TaskIQ is async-only. Use `sync_to_async` and `async_to_sync` from\n`asgiref.sync` for Django integration.\n\n## Cookbook\n\n### Handling Updates with a Service\n\n**Use `model_update` / `amodel_update` by default for all single-instance updates.**\n\n```python\ndef user_update(*, user: User, data) -\u003e User:\n    non_side_effect_fields = ['first_name', 'last_name']\n\n    user, has_updated = model_update(\n        instance=user,\n        fields=non_side_effect_fields,\n        data=data\n    )\n\n    # Side-effect fields update here (e.g. username is generated based on first \u0026 last name)\n\n    # ... some additional tasks with the user ...\n\n    return user\n```\n\n**Benefits over manual save():**\n\n- Auto-calls `full_clean()` before save\n- Auto-includes `modified` in `update_fields`\n- Dirty checking: only saves if values actually changed\n- Returns `has_updated` flag for conditional logic\n- Optimized UPDATE query with only changed fields\n\n**Reference implementations:**\n\n- [\n  `model_update`](https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/services.py)\n- [\n  `user_update`](https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/users/services.py)\n- [Tests](https://github.com/HackSoftware/Django-Styleguide-Example/blob/master/styleguide_example/common/tests/services/test_model_update.py)\n\n**Important:** Include tests when adopting this pattern.\n\n### When to Use Direct Modification\n\n**Use direct modification only when:**\n\n- Read-modify-write pattern (new value depends on old value)\n- Bulk update preparation with `prepare_instance_for_bulk_update`\n- Explicitly skipping validation (rare, document why)\n\n| Scenario | Approach | Example |\n|----------|----------|---------|\n| **Single-instance update** | `model_update` helper | Any update where you set fields to known values |\n| **Read-modify-write** | Direct modification | `counter += 1`, `balance -= amount` where new depends on old |\n\n**Default: use helper**\n\n```python\ndef pause(self, *, data_source: DataSource) -\u003e tuple[FetchSettings, bool]:\n    \"\"\"Use helper - handles full_clean, modified, update_fields automatically.\"\"\"\n    return model_update(\n        instance=data_source.file_input.fetch_settings,\n        fields=[\"enabled\"],\n        data={\"enabled\": False},\n    )\n```\n\n**Direct modification: only for read-modify-write**\n\n```python\ndef increment_counter(self, *, instance: Model) -\u003e Model:\n    \"\"\"Direct modification - new value depends on old value.\"\"\"\n    instance.counter += 1\n    instance.full_clean()\n    instance.save(update_fields=[\"counter\", \"modified\"])\n    return instance\n```\n\n## DX (Developer Experience)\n\n### Type Checking\n\n**Required:** [`pyright`](https://github.com/microsoft/pyright) for static type checking\n\n**Standard:** Full type annotations for all code\n\n**Validation command:**\n\n```bash\nbasedpyright\n```\n\nRun pyright before committing to ensure type safety. All parameters and return types\nmust be annotated.\n\n### Code Quality Tools\n\n**Required tools:**\n\n- **[`ruff`](https://docs.astral.sh/ruff/)** - Fast Python linter and formatter (\n  replaces flake8, black, isort)\n- **[`uv`](https://docs.astral.sh/uv/)** - Modern package manager (10-100x faster than\n  pip)\n\n**Validation workflow:**\n\n```bash\n# Format code first\nruff format .\n\n# Then lint and auto-fix\nruff check . --fix\n\n# Type check\npyright\n```\n\n**Important:** Always run `ruff format` before `ruff check` - formatting may affect\nlinting results.\n\n**Pre-commit checklist:**\n\n1. `ruff format .` - Ensure consistent formatting\n2. `ruff check . --fix` - Fix linting issues\n3. `pyright` - Verify type safety\n4. `pytest` - Run tests\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpysilver%2Fdjango-styleguide","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpysilver%2Fdjango-styleguide","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpysilver%2Fdjango-styleguide/lists"}