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
Metaclass 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 withField(description="...") - Output schemas (
*Out) --- response serialization, inherit fromninja.Schema - Use
@staticmethod def resolve_<field>()for custom serialization (e.g.,MoneyFieldtoDecimal,ContentTypeFK 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¶
- Architecture Overview --- High-level system design
- Async Patterns --- How the async stack works across layers
- Provider Pattern --- The provider/plugin layer in detail
- Project Structure --- Full directory layout