Skip to content

Code Conventions

Import patterns, async conventions, schema patterns, model conventions, and API documentation standards.

Import Conventions

Relative Imports (Same App)

from .models import Loan
from . import selectors, services
from .providers import payment_processor_registry

Absolute Imports (Cross-App)

from apps.amortization.services import generate_schedule
from apps.borrowers.models import Borrower
from common.permissions import IsLoanOfficerOrAbove

Import sorting is enforced by ruff ("I" rule / isort):

uv run ruff check --fix .   # Auto-sort imports

Section Order

  1. Future imports
  2. Standard library
  3. Third-party packages
  4. First-party (apps, common, config)
  5. Local (relative imports)

Async Patterns

All Layers Are Async

# Controller (api.py)
@http_get("")
async def list_loans(self, ...):
    return await selectors.loan_list()

# Service (services.py)
async def create_loan(**kwargs) -> Loan:
    loan = await Loan.objects.acreate(**kwargs)
    return loan

# Selector (selectors.py)
async def loan_list() -> QuerySet[Loan]:
    return Loan.objects.filter(is_archived=False)

Django Async ORM

Use async variants for all ORM operations:

Sync Async
Model.objects.create() Model.objects.acreate()
Model.objects.get() Model.objects.aget()
instance.save() instance.asave()
instance.delete() instance.adelete()
qs.count() qs.acount()
qs.exists() qs.aexists()

Transactions

Use sync with transaction.atomic(): — it works in async functions:

async def create_loan(**kwargs) -> Loan:
    with transaction.atomic():
        loan = await Loan.objects.acreate(**kwargs)
        await generate_schedule(loan)
        return loan

Warning

Do not use async with transaction.atomic(): — Django's Atomic class lacks __aenter__/__aexit__. Do not use the @transaction.atomic decorator on async functions.

sync_to_async

Only needed for third-party libraries without async support (e.g., django-guardian):

from asgiref.sync import sync_to_async

has_perm = await sync_to_async(user.has_perm)("view_loan", loan)

Model Conventions

Base Mixins

All tenant models inherit from mixins in common/models.py:

Mixin Fields Provided
UUIDPrimaryKey id: UUID (auto UUIDv4)
Timestamped created_at, updated_at (auto-set)
ExternalID external_id: str (nullable, unique per tenant)
Archivable is_archived: bool, archived_at: datetime

Do not add these fields to individual models — they come from the mixins.

Field Types

Data Field Type
Money MoneyField (django-money) — never DecimalField
Phone PhoneNumberField (django-phonenumber-field)
Rates/percentages DecimalField stored as decimal (0.065 not 6.5)
Extensible data JSONBField
Enums TextChoices (string-based, not integer)
FKs UUID fields referencing other models
Attachable relations GenericForeignKey + GenericRelation

ClassVar for Meta Attributes

class Loan(BaseModel):
    ordering: ClassVar[list[str]] = ["-created_at"]
    indexes: ClassVar[list[models.Index]] = [
        models.Index(fields=["status", "created_at"]),
    ]

Translation Markers on Models

from django.utils.translation import gettext_lazy as _

class Loan(BaseModel):
    class Meta:
        verbose_name = _("loan")              # Lowercase
        verbose_name_plural = _("loans")

    status = models.CharField(
        help_text=_("Current loan status."),   # gettext_lazy for help_text
    )

Pydantic Schema Conventions

Base Class

All schemas inherit from ninja.Schema which sets from_attributes = True. Do not add a Config class:

# CORRECT
class LoanOut(Schema):
    loan_number: str = Field(description="Unique loan identifier")

# WRONG — triggers deprecation warning
class LoanOut(Schema):
    class Config:
        from_attributes = True  # Already set by ninja.Schema

Field Documentation

Every field must have a description:

class LoanIn(Schema):
    loan_number: str = Field(description="Unique loan identifier")
    amount: PositiveDecimal = Field(description="Loan principal amount")
    term_months: int = Field(description="Loan term in months")

Custom Serialization

Use @staticmethod def resolve_<field>() for custom serialization:

class BorrowerOut(Schema):
    full_name: str

    @staticmethod
    def resolve_full_name(obj: Any) -> str:
        return f"{obj.first_name} {obj.last_name}"

GenericFK Serialization

GenericFKOutMixin serializes ContentType FK to "app_label.model_name":

class NoteOut(ArchivableModelOut, GenericFKOutMixin):
    body: str = Field(description="Note content")

API Documentation Standards

Endpoint Decorators

Every endpoint must include operation_id and summary:

@http_post(
    "/{uuid:loan_id}/approve",
    response={200: LoanOut, 404: ErrorOut, 409: ErrorOut},
    permissions=[IsLoanOfficerOrAbove],
    operation_id="approve_loan",
    summary="Approve a loan",
)
async def approve(self, loan_id: UUID, payload: LoanApproveIn) -> Any:
    """Approve a pending loan application.
    Runs compliance checks before approval."""
  • operation_id: {verb}_{resource} format, unique across all endpoints
  • summary: Short imperative phrase
  • Docstring: Multi-line for complex business actions

Ruff Configuration

[tool.ruff]
target-version = "py313"
line-length = 120
extend-exclude = ["*/migrations/*"]

[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B", "A", "DJ", "RUF"]

mypy Configuration

[tool.mypy]
python_version = "3.13"
plugins = ["mypy_django_plugin.main"]
strict = true
warn_unreachable = true

Tests relax strict typing (disallow_untyped_defs = false). Migrations are ignored entirely.

See Also