Fat Models Design
Design philosophy and patterns for model-centric business logic in crelio-app
Fat Models Design
This document explains the "fat models" design philosophy as implemented in the crelio-app codebase, with patterns extracted from actual code.
Design Philosophy
The crelio-app follows the Fat Model, Thin View pattern:
- Business logic lives in models - validation, calculations, state transitions, side effects
- Views are thin orchestrators - handle HTTP, extract session, delegate to models
- No signals.py files - all post-save logic is in model methods
- Lifecycle hooks - consistent pattern via
BaseModel
[!IMPORTANT] The absence of Django signals is intentional. All side effects are explicit in model methods, making the codebase easier to trace and debug.
BaseModel Foundation
All domain models extend BaseModel from core/models/base.py:
class BaseModel(models.Model, ActivityLogBase):
"""Base model with lifecycle hooks and utilities"""
# Feature flags
allow_individual_instance_caching = False
webhook_enabled = False
es_sync_enabled = False
should_log_activity = False
should_send_notifications = False
class Meta:
abstract = True
def validate(self, *args, **kwargs):
"""Validation hook - called before save"""
pass
def before_save(self, *args, **kwargs):
"""Pre-save hook - prepare data, set defaults"""
pass
def after_save(self, *args, **kwargs):
"""Post-save hook - ES sync, webhooks, notifications"""
pass
def save(self, *args, **kwargs):
self.is_new_instance = self.is_new
self.validate(*args, **kwargs)
self.before_save(*args, **kwargs)
super().save(*args, **save_kwargs)
self.after_save(*args, **kwargs)
return self # Enable method chainingLifecycle Hook Purpose
| Hook | Purpose | Example Usage |
|---|---|---|
validate() | Input validation, permission checks | UserDetails.validate() - age, contact, national ID |
before_save() | Set defaults, calculate derived fields | Generate patient ID, format DOB |
after_save() | Side effects, external syncs | ES update, webhook triggers, notifications |
Common Patterns
Pattern 1: Fat Model with Business Methods
Models contain domain-specific business logic as methods:
# patient/models/user_details.py
class UserDetails(BaseModel):
@classmethod
def register_patient(cls, payload, is_collection_center, session):
"""Entry point for patient registration workflow"""
patient = cls(**payload)
patient.save(session=session, payload=payload)
return patient
def validate(self, *args, **kwargs):
"""Comprehensive validation"""
self.validate_patient_action(*args, **kwargs)
self.validate_strict_check(*args, **kwargs)
# ... 20+ validation methods
def before_save(self, *args, **kwargs):
"""Prepare context before save"""
self.prepare_context(*args, **kwargs)
self.set_default_values(*args, **kwargs)
self.generate_lab_wise_sequential_patient_id()
def after_save(self, *args, **kwargs):
"""Post-save side effects"""
self.update_es_record()
self.trigger_patient_webhooks()
self.process_dependent_patients()
# ... more side effectsEvidence: user_details.py - 84 methods, 3342 lines
Pattern 2: Proxy Models for Domain Behaviors
Proxy models add domain-specific behavior without database changes:
# patient/proxies/patient_report.py
class PatientReport(LabReportRelation):
"""Proxy for patient-facing report operations"""
class Meta:
proxy = True
@classmethod
def get_reports(cls, report_type, lab_ids, patient_ids, ...):
"""Fetch reports with patient-specific filtering"""
...
def view_report(self, lab_ids, patient_ids):
"""Return report with format for patient portal"""
...
def download_report(self):
"""Generate PDF for download"""
...Evidence: patient_report.py - 991 lines
Pattern 3: Class Methods for CRUD Operations
Complex operations are exposed as @classmethod:
# report/models/reflex_test_config.py
class ReflexTestConfiguration(BaseModel):
@classmethod
def save_reflex_test_config(cls, payload: dict, session):
"""Create new reflex test with validation"""
cls.validate_payload(payload, lab_id)
instance = cls.objects.create(**validated_data)
cls.create_parameter_rules(instance, rules)
instance.add_activity_log("created", session=session)
@classmethod
def update_reflex_test_config(cls, payload, reflex_test_id, session):
"""Update with atomic transaction"""
with transaction.atomic():
instance = cls.objects.get(id=reflex_test_id)
# ... update logicEvidence: reflex_test_config.py
Pattern 4: Mixin Classes for Cross-Cutting Concerns
Mixins add reusable behavior:
# core/models/activity_log_base.py
class ActivityLogBase:
"""Mixin for activity logging to Elasticsearch"""
def add_activity_log(self, action, message="", session={}, ...):
"""Log activity with model-specific payload"""
payload = self.prepare_activity_log_payload(action, session)
ActivityLog(**payload).save()
@classmethod
def get_activities(cls, instance_id, start_date, end_date, ...):
"""Retrieve activity logs from ES"""
...
# communication/base.py
class CommunicationBase:
"""Mixin for SMS/WhatsApp/Email notifications"""
def send(self, lab_id, is_report=0, ...):
"""Orchestrate multi-channel communication"""
self.validate_triggers()
self.prepare_user_meta()
self.prepare_lab_meta()
# ... send via Fusion workerEvidence:
Pattern 5: Custom Managers (Rare)
Some models use custom managers, though this is less common:
# patient/managers/patient_overview_manager.py
class PatientOverviewManager:
"""Manager for patient overview queries"""
def get_patient_overview(self, patient_id, lab_id):
"""Complex query with prefetch optimization"""
return self.select_related(...).prefetch_related(...)Evidence: patient_overview_manager.py
State Machines and Enums
Status Transitions
Many models track status with integer/string fields and methods:
# report/models/lab_report_relation.py
class LabReportRelation(BaseModel):
isSigned = models.IntegerField(default=0)
isPartialSigned = models.IntegerField(default=0)
isApproved = models.IntegerField(default=0)
syncStatus = models.IntegerField(default=0)
# Status transitions are handled in methods, not a formal state machineEnum Patterns
# report/models/lab_report_relation.py
class DepartmentType(IntEnum):
ASSIGNED_DEPARTMENT = 0
USER_DEPARTMENTS = 1
EMERGENCY = 2
class UserRole(IntEnum):
LAB_USER = 0
CC_USER = 1
ORG_LOGIN = 2
MARKETING_USER = 3
BRANCH_USER = 4Anti-Patterns Found
[!CAUTION] The following patterns should be avoided when extending the codebase.
Anti-Pattern 1: Massive Model Classes
Some models have grown too large:
| Model | Lines | Issue |
|---|---|---|
UserDetails | 3342 | Too many responsibilities |
DeviceResultsValidation | 3338 | Complex device + report logic |
SmartReport | 1787 | Contains matplotlib rendering |
Recommendation: Consider extracting to service classes or domain-specific proxies.
Anti-Pattern 2: Network Calls in save()
Some after_save() methods make synchronous network calls:
# Example from user_details.py
def after_save(self, *args, **kwargs):
self.update_es_record() # ES call
self.trigger_patient_webhooks() # HTTP callsRecommendation: Queue to Fusion worker for async processing.
Anti-Pattern 3: Cross-App Model Imports
High coupling through direct imports:
# patient/models/user_details.py imports:
from finance.models.billing import Billing
from finance.models.invoice import Invoice
from report.models.lab_report_relation import LabReportRelation
# ... 20+ cross-app importsRecommendation: Use dependency injection or domain events.
How to Implement New Workflow
Step 1: Identify the Domain
Determine which app owns the new workflow:
- Patient-related →
patient/ - Billing/financial →
finance/ - Lab results →
report/orinterfacing/
Step 2: Choose the Pattern
| Scenario | Pattern | Location |
|---|---|---|
| New CRUD operation | Add @classmethod to model | models/{entity}.py |
| Domain-specific behavior | Create proxy model | proxies/{proxy}.py |
| Complex query | Use manager method | managers/{manager}.py |
| Cross-cutting concern | Add to existing mixin | core/models/ or extend |
Step 3: Implement Model Logic
class MyModel(BaseModel):
# 1. Override validate() for input validation
def validate(self, *args, **kwargs):
if not self.required_field:
raise ValidationError("required_field is missing")
# 2. Override before_save() for computed fields
def before_save(self, *args, **kwargs):
if self.is_new:
self.generated_id = self.generate_unique_id()
# 3. Override after_save() for side effects
def after_save(self, *args, **kwargs):
if self.should_log_activity:
self.add_activity_log("created", session=kwargs.get("session"))
# 4. Add business methods
@classmethod
def process_workflow(cls, payload, session):
"""Main entry point for the workflow"""
instance = cls()
instance.set_values(payload)
instance.save(session=session)
return instanceStep 4: Create Thin View
# views/my_view.py
class MyView(GenericView):
@transaction.atomic
def post(self, request, *args, **kwargs):
lab_id = self.get_lab_id_from_session(request.session)
payload = request.data
# Delegate to model
result = MyModel.process_workflow(payload, request.session)
return JsonResponse({"status": "success", "id": result.pk})Step 5: Add to URL Routing
# urls.py
path("my-endpoint/", MyView.as_view(), name="my-endpoint"),File Evidence
Core Patterns
- base.py - BaseModel
- activity_log_base.py - ActivityLogBase
- base.py - CommunicationBase
Fat Model Examples
- user_details.py - 3342 lines
- billing.py - 1119 lines
- smart_report.py - 1787 lines
- reflex_test_config.py - 1279 lines
Proxy Examples
- patient_report.py - 991 lines
- lab_patient.py
Thin View Example
- registration.py - Delegates to
UserDetails.register_patient()