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 chaining

Lifecycle Hook Purpose

HookPurposeExample Usage
validate()Input validation, permission checksUserDetails.validate() - age, contact, national ID
before_save()Set defaults, calculate derived fieldsGenerate patient ID, format DOB
after_save()Side effects, external syncsES 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 effects

Evidence: 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 logic

Evidence: 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 worker

Evidence:

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 machine

Enum 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 = 4

Anti-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:

ModelLinesIssue
UserDetails3342Too many responsibilities
DeviceResultsValidation3338Complex device + report logic
SmartReport1787Contains 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 calls

Recommendation: 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 imports

Recommendation: 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/ or interfacing/

Step 2: Choose the Pattern

ScenarioPatternLocation
New CRUD operationAdd @classmethod to modelmodels/{entity}.py
Domain-specific behaviorCreate proxy modelproxies/{proxy}.py
Complex queryUse manager methodmanagers/{manager}.py
Cross-cutting concernAdd to existing mixincore/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 instance

Step 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

Fat Model Examples

Proxy Examples

Thin View Example

On this page