Skip to content

Layered Architecture

Each Django app follows a strict 5-layer separation of concerns. Dependencies flow downward only --- violations are treated as bugs.

Layer Diagram

API (api.py)            HTTP only. Async class-based controllers,
   │                    Pydantic schemas, permissions, throttling.
   ▼                    No ORM writes, no business logic.
Service (services.py)   Async business logic. Transactions,
   │                    orchestration, cross-module calls.
   ▼                    No HTTP awareness.
Selector (selectors.py) Async read-only queries. Complex querysets,
   │                    aggregations. No side effects.
Model (models.py)       Data structure. Fields, constraints,
   │                    properties. No business rules.
Provider (providers/)   External integrations. Protocol
                        implementations. Injected into services.

Layer Responsibilities

API Layer (api.py)

The API layer handles all HTTP concerns using async class-based controllers via django-ninja-extra:

  • Request/response serialization with Pydantic schemas
  • Permission checks and throttling
  • Pagination, sorting, and filtering (react-admin compatibility)
  • Calling services for business operations
  • Never contains business logic or direct ORM writes
@api_controller("/borrowers", tags=["Borrowers"], permissions=[IsViewerOrAbove])
class BorrowerController(ControllerBase, ReactAdminControllerMixin):
    @http_get("", response=list[BorrowerOut])
    async def list_borrowers(self, sort: str = "", range: str = "", filter: str = ""):
        qs = await selectors.borrower_list(filters=filter or None, sort=sort or None)
        return await self.paginated_list(qs, "borrowers", range or None)

    @http_post("", response={201: BorrowerOut}, permissions=[IsLoanOfficerOrAbove])
    async def create_borrower(self, payload: BorrowerIn):
        borrower = await services.create_borrower(**payload.dict())
        return 201, borrower

Service Layer (services.py)

The service layer contains all business logic:

  • Transaction management (with transaction.atomic():)
  • Cross-module orchestration (calling other apps' services)
  • Data validation beyond schema validation
  • Side effects (GL entries, webhook events, notifications)
  • No HTTP awareness --- services don't know about requests or responses
async def create_borrower(*, first_name: str, last_name: str, **kwargs) -> Borrower:
    borrower = await Borrower.objects.acreate(
        first_name=first_name,
        last_name=last_name,
        **kwargs,
    )
    return borrower

async def approve_loan(*, loan_id: UUID, approved_by: User) -> Loan:
    loan = await Loan.objects.aget(id=loan_id)
    if loan.status != LoanStatus.PENDING:
        raise ValueError(
            _("Cannot approve loan %(loan_number)s: status is '%(status)s'.")
            % {"loan_number": loan.loan_number, "status": loan.status}
        )
    # Run compliance checks, update status, create GL entries...
    return loan

Selector Layer (selectors.py)

The selector layer handles complex read-only queries:

  • Queryset construction with filters, annotations, and aggregations
  • No side effects --- selectors never modify data
  • Return querysets or computed values
async def borrower_list(
    *, filters: str | None = None, sort: str | None = None
) -> QuerySet[Borrower]:
    qs = Borrower.objects.filter(is_archived=False)
    if filters:
        parsed = json.loads(filters)
        if "q" in parsed:
            qs = qs.filter(
                Q(first_name__icontains=parsed["q"])
                | Q(last_name__icontains=parsed["q"])
            )
    return qs

Model Layer (models.py)

The model layer defines data structure with minimal logic:

  • Fields, constraints, and database indexes
  • Properties for computed values
  • Meta class for ordering, unique constraints, verbose names
  • No business rules --- business logic belongs in services
class Borrower(UUIDPrimaryKey, Timestamped, ExternalID, Archivable, models.Model):
    first_name = models.CharField(max_length=100, verbose_name=_("first name"))
    last_name = models.CharField(max_length=100, verbose_name=_("last name"))
    ssn_last_four = models.CharField(max_length=4, verbose_name=_("SSN last four"))
    do_not_contact = models.BooleanField(default=False, verbose_name=_("do not contact"))

    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

    class Meta:
        ordering: ClassVar[list[str]] = ["-created_at"]
        verbose_name = _("borrower")
        verbose_name_plural = _("borrowers")

Provider Layer (providers/)

The provider layer handles external integrations using Python's typing.Protocol:

  • Protocol definitions in providers/__init__.py
  • Implementations as subapps or stateless modules
  • Injected into services at runtime
  • See Provider Pattern for details

Dependency Rules

These rules are enforced by convention --- violations are treated as bugs:

From Can Call Cannot Call
API Services, Selectors Models (for writes)
Service Other Services, Selectors, Models API
Selector Models Services, API
Model Nothing Everything
Provider External APIs Services, API

Cross-Module Rules

  • Services may call other app services (cross-module). Dependencies must be explicit.
  • No circular dependencies: if app A calls app B's service, B must not call A's.
  • Use Django signals or Celery tasks to break circular dependencies.

App Module Convention

Every app follows this file layout:

apps/{module}/
├── __init__.py
├── apps.py              # Django AppConfig
├── models.py            # Data models
├── services.py          # Business logic (writes, orchestration)
├── selectors.py         # Read queries (complex querysets)
├── schemas.py           # Pydantic schemas (API input/output)
├── api.py               # django-ninja-extra controllers (HTTP layer)
├── permissions.py       # Custom permission classes (optional)
├── admin.py             # Django admin (optional)
├── providers/           # Protocol + implementations (optional)
│   ├── __init__.py      # Protocol definition + registry
│   └── ...              # Provider implementations
├── signals.py           # Django signals (optional)
├── tasks.py             # Celery tasks (optional)
├── baker_recipes.py     # model-bakery test fixture recipes (optional)
├── migrations/
└── tests/
    ├── test_services.py # Service layer tests (primary focus)
    ├── test_selectors.py
    └── test_api.py      # API integration tests

Schema Conventions

Pydantic schemas in schemas.py define API input/output types:

  • Input schemas (*In) --- request validation with Field(description="...")
  • Output schemas (*Out) --- response serialization, inherit from ninja.Schema
  • Use @staticmethod def resolve_<field>() for custom serialization (e.g., MoneyField to Decimal, ContentType FK to "app_label.model_name")

Note

ninja.Schema already sets from_attributes = True. Do not add class Config to Out schemas --- it triggers PydanticDeprecatedSince20 warnings.

Import Conventions

  • Relative imports for same-app references: from .models import Loan
  • Absolute imports for cross-app references: from apps.amortization.services import generate_schedule
  • Import sorting enforced by ruff ("I" rule)

See Also