Architecture Overview¶
The LMS is a three-tier application with an async request path, schema-per-tenant database isolation, and a Celery-powered task queue for background processing.
High-Level Architecture¶
┌──────────────┐ ┌──────────────────────────────────────────┐ ┌────────────┐
│ │ │ Django (ASGI via gunicorn + uvicorn) │ │ │
│ React / │────▶│ │────▶│ PostgreSQL │
│ react-admin │◀────│ django-ninja-extra async controllers │◀────│ 16+ │
│ │ │ django-pgschemas (schema routing) │ │ │
└──────────────┘ │ django-allauth (session auth) │ └────────────┘
│ django-guardian (object permissions) │
┌──────────────┐ │ django-pghistory (audit triggers) │ ┌────────────┐
│ Borrower │────▶│ │────▶│ Redis │
│ Portal │◀────│ │◀────│ │
└──────────────┘ └────────────┬─────────────────────────────┘ └────────────┘
│ │
▼ │
┌──────────────────────────────────────────┐ │
│ Celery Workers │◀──────────┘
│ (interest accrual, delinquency, │
│ dunning, portfolio snapshots, etc.) │
└──────────────────────────────────────────┘
Components¶
- Admin Dashboard: React 18+ with react-admin, providing the administrative interface for loan management operations
- Borrower Portal: React 18+ with MUI and React Query, providing borrower self-service (payments, documents, support)
- Backend: Django 5.x running on ASGI with fully async controllers, services, and selectors
- Database: PostgreSQL 16+ with schema-per-tenant isolation via django-pgschemas
- Task queue: Celery + Redis for async workflows, daily batch jobs, and Canvas orchestration
- Audit: django-pghistory creates PostgreSQL triggers that automatically snapshot every tracked model change
Request Lifecycle¶
A typical API request flows through these layers:
- Domain routing ---
DomainRoutingMiddlewareresolves the tenant from the request domain and activates the correct PostgreSQL schema - Middleware stack --- Security, session, CSRF, authentication, tenant status, locale activation
- Controller --- Async django-ninja-extra controller handles HTTP concerns (validation, permissions, throttling)
- Service --- Async business logic layer handles transactions and orchestration
- Selector --- Async read-only layer constructs complex querysets
- Model --- Django ORM interacts with the tenant's PostgreSQL schema
Middleware Stack¶
The middleware chain processes every request in order:
| Order | Middleware | Purpose |
|---|---|---|
| 1 | DomainRoutingMiddleware |
Resolves tenant from domain, sets request.tenant |
| 2 | SecurityMiddleware |
Django security headers |
| 3 | SessionMiddleware |
Session handling |
| 4 | CsrfViewMiddleware |
CSRF protection |
| 5 | AuthenticationMiddleware |
User authentication |
| 6 | AccountMiddleware |
django-allauth session |
| 7 | TenantStatusMiddleware |
Checks tenant is active, returns 503 if suspended |
| 8 | TenantLocaleMiddleware |
Activates per-tenant locale, timezone, currency |
| 9 | MessageMiddleware |
Django messages framework |
Authentication and Authorization¶
Authentication¶
- django-allauth in headless mode provides session-based authentication
- Sessions use httpOnly, Secure cookies
- Auth endpoints at
/api/v1/auth/handle login, signup, password reset GET /api/v1/users/mereturns the current user with tenant role and RBAC permissions
Authorization (RBAC)¶
Five roles form an additive hierarchy:
| Role | Inherits | Adds |
|---|---|---|
viewer |
--- | Read-only access to all resources |
collector |
viewer | Create/edit collection actions, create promise-to-pay |
loan_officer |
collector | CRUD borrowers/loans/collateral; create payments/fees/documents; loan lifecycle actions; servicing operations |
admin |
loan_officer | CRUD config resources (programs, products, templates); financial actions; ledger management |
superadmin |
--- | Wildcard access ({"action": "*", "resource": "*"}) |
Object-Level Permissions¶
django-guardian provides fine-grained object-level permissions:
- Loan officers receive explicit permissions on their assigned loans and borrowers
- Permissions are granted/revoked when
assigned_officer_idchanges has_object_permission()filters querysets so officers only see their own assignments
Frontend RBAC¶
GET /api/v1/users/me returns permissions in react-admin's RBAC format:
{"action": "list", "resource": "borrowers"}
{"action": "*", "resource": "*"}
{"type": "deny", "action": "write", "resource": "borrowers.ssn_last_four"}
The admin frontend uses usePermissions() + hasPermission() to gate UI elements. The borrower portal uses IsBorrowerUser permission scoping all data to the authenticated borrower.
API Conventions¶
Async Controllers¶
All API endpoints use async class-based controllers via django-ninja-extra:
@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)
react-admin Compatibility¶
All CRUD endpoints conform to the react-admin REST data provider convention:
GET /{resource}--- list withsort,range,filterquery paramsGET /{resource}/{id}--- single resource withidfield in response- List responses include
Content-Range: items 0-24/100header - Business actions use
POST /{resource}/{id}/{action}(e.g.,/loans/{id}/approve) - Nested resources use
/{parent}/{id}/{child}(e.g.,/borrowers/{id}/addresses)
OpenAPI Documentation¶
Every endpoint includes operation_id, summary, and a docstring description. Every schema field includes Field(description="..."). OpenAPI docs are served at /api/v1/docs.
Validation Errors¶
Pydantic validation errors are converted to react-admin's server-side validation format:
Model Conventions¶
Base Mixins¶
All tenant models inherit from mixins in common/models.py:
| Mixin | Fields |
|---|---|
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) |
Field Conventions¶
| Data Type | Django Field |
|---|---|
| Money | MoneyField (django-money) --- never DecimalField for money |
| Phone | PhoneNumberField (django-phonenumber-field) |
| Rates/percentages | DecimalField stored as decimal (0.065 = 6.5%) |
| Extensible data | JSONField (JSONB) |
| Enums | Django TextChoices (string-based) |
| Generic relations | Django contenttypes (GenericForeignKey + GenericRelation) |
Audit History¶
django-pghistory tracks changes via PostgreSQL triggers. Tracked models automatically get *Event tables that store full row snapshots on every insert and update. Over 15 models are tracked, including Borrower, Loan, Payment, Fee, LoanModification, and JournalEntry.
Internationalization¶
Backend i18n¶
- Model fields, choices, and validators use
gettext_lazyfor deferred translation - Services and runtime error messages use
gettextwith%named substitution (never f-strings inside_()) - Message catalogs extracted via
makemessages, compiled viacompilemessages - English and Spanish translations included
Per-Tenant Localization¶
Tenant.locale,Tenant.timezone,Tenant.default_currencyfieldsTenantLocaleMiddlewareactivates locale per request (priority: Accept-Language header, tenant locale, default)- Babel-based formatters for currency, dates, numbers, and percentages
- Jinja2 filters registered on PDF and communication template environments
Frontend i18n¶
- Admin: react-admin
i18nProviderwithra-i18n-polyglot, English and Spanish - Portal: locale-aware formatting via browser
IntlAPIs, borrower language preference
See Also¶
- Multi-Tenancy --- Schema-per-tenant isolation model
- Layered Architecture --- The 5-layer app structure
- Provider Pattern --- Protocol-based plugin system
- Async Patterns --- Async ORM and transaction handling
- Cross-Module Communication --- Service calls, signals, and Celery
- Event Sourcing & Replay --- Loan state reconstruction