27  App Core

┌─────────────────────────────────────────────────┐
│          APPLICATION CORE TYPES                 │
│                                                 │
│  1. Entities                                    │
│     • Business objects with identity            │
│     • Patient, CTScan, StrokeResult             │
│                                                 │
│  2. Aggregates                                  │
│     • Groups of entities                        │
│     • StrokeAssessment (root + children)        │
│                                                 │
│  3. Interfaces                                  │
│     • Contracts for infrastructure              │
│     • IScanRepository, IModelService            │
│                                                 │
│  4. Domain Services                             │
│     • Business logic across entities            │
│     • AspectsCalculator, EligibilityChecker     │
│                                                 │
│  5. Specifications                              │
│     • Reusable query criteria                   │
│     • BrainScanSpec, CompletedScanSpec          │
│                                                 │
│  6. Exceptions & Guards                         │
│     • Domain-specific errors                    │
│     • InvalidAspectsScore, Guard clauses        │
│                                                 │
│  7. Domain Events                               │
│     • Things that happened                      │
│     • StrokeDetectedEvent                       │
│                                                 │
│  8. DTOs                                        │
│     • Data transfer objects                     │
│     • StrokeAnalysisResult                      │
│                                                 │
│  ALL are pure business logic!                   │
│  NO infrastructure dependencies!                │
└─────────────────────────────────────────────────┘

27.1 Overview: What Goes in Application Core?

The Application Core is the heart of your business logic. It contains all the types that represent your domain model and business rules.

Application Core Contents:

┌───────────────────────────────────────────────┐
│         APPLICATION CORE                      │
│                                               │
│  ┌──────────────┐      ┌──────────────┐       │
│  │  Entities    │      │ Aggregates   │       │
│  │  (Data +     │      │ (Entity      │       │
│  │  Behavior)   │      │  Groups)     │       │
│  └──────────────┘      └──────────────┘       │
│                                               │
│  ┌──────────────┐      ┌──────────────┐       │
│  │ Interfaces   │      │   Domain     │       │
│  │ (Contracts)  │      │  Services    │       │
│  └──────────────┘      └──────────────┘       │
│                                               │
│  ┌──────────────┐      ┌──────────────┐       │
│  │Specifications│      │   Custom     │       │
│  │ (Query       │      │  Exceptions  │       │
│  │  Logic)      │      │              │       │
│  └──────────────┘      └──────────────┘       │
│                                               │
│  ┌──────────────┐      ┌──────────────┐       │
│  │    DTOs      │      │   Domain     │       │
│  │ (Data        │      │   Events     │       │
│  │  Transfer)   │      │              │       │
│  └──────────────┘      └──────────────┘       │
│                                               │
└───────────────────────────────────────────────┘

All types are PURE business logic
NO infrastructure dependencies!

27.2 Type 1: Entities

27.2.1 What Are Entities?

Entities are business model classes that represent things in your domain that have: - Identity: Can be distinguished from other objects (usually by ID) - Lifecycle: Created, modified, persisted to database - Behavior: Contain business logic related to themselves

27.2.2 Key Characteristics

Entity = Data + Behavior + Identity

✅ Has unique identifier (ID)
✅ Contains domain logic
✅ Can be persisted to database
✅ Mutable (can change over time)
❌ NO database annotations
❌ NO infrastructure dependencies

27.2.3 Example 1: Patient Entity

# core/entities/patient.py
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional

@dataclass
class Patient:
    """
    Entity: Represents a patient in the radiology system
    Has identity (id), lifecycle (can be saved), and behavior
    """
    id: int
    hospital_number: str
    first_name: str
    last_name: str
    date_of_birth: date
    gender: str
    created_at: datetime
    updated_at: Optional[datetime] = None
    
    # Business Logic (Behavior)
    def get_age(self) -> int:
        """Calculate patient's age - Pure business logic"""
        today = date.today()
        age = today.year - self.date_of_birth.year
        
        # Adjust if birthday hasn't occurred this year
        if (today.month, today.day) < (self.date_of_birth.month, 
                                       self.date_of_birth.day):
            age -= 1
        return age
    
    def get_full_name(self) -> str:
        """Business rule: How to display patient name"""
        return f"{self.first_name} {self.last_name}"
    
    def is_pediatric(self) -> bool:
        """Business rule: Pediatric patients are under 18"""
        return self.get_age() < 18
    
    def is_eligible_for_contrast(self, creatinine: float) -> bool:
        """Business rule: Contrast eligibility based on kidney function"""
        # Simplified rule: creatinine < 1.5 mg/dL is safe
        return creatinine < 1.5
    
    def __eq__(self, other) -> bool:
        """Entities are equal if they have the same ID"""
        if not isinstance(other, Patient):
            return False
        return self.id == other.id

27.2.4 Example 2: CTScan Entity

# core/entities/ct_scan.py
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from enum import Enum

class ScanStatus(Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    FAILED = "failed"

@dataclass
class CTScan:
    """
    Entity: Represents a CT scan examination
    """
    id: int
    patient_id: int
    study_instance_uid: str
    acquisition_date: datetime
    modality: str
    body_part: str
    slice_thickness: float
    kvp: int  # Kilovoltage peak
    mas: int  # Milliampere-seconds
    status: ScanStatus
    
    # Relationships (other entities)
    patient: Optional['Patient'] = None
    
    # Business Logic
    def is_brain_scan(self) -> bool:
        """Business rule: Identify brain scans"""
        brain_parts = ['BRAIN', 'HEAD', 'SKULL']
        return self.body_part.upper() in brain_parts
    
    def is_high_resolution(self) -> bool:
        """Business rule: High-res scans have thin slices"""
        return self.slice_thickness <= 1.0
    
    def is_suitable_for_ai_analysis(self) -> bool:
        """Business rule: Criteria for AI processing"""
        return (
            self.is_brain_scan() and
            self.slice_thickness <= 5.0 and
            self.status == ScanStatus.COMPLETED
        )
    
    def calculate_radiation_dose(self) -> float:
        """Business rule: Estimate radiation exposure"""
        # Simplified calculation
        dose = (self.kvp / 100) * (self.mas / 100)
        return dose
    
    def mark_completed(self) -> None:
        """Business operation: Change scan status"""
        if self.status != ScanStatus.FAILED:
            self.status = ScanStatus.COMPLETED

27.3 Type 2: Aggregates

27.3.1 What Are Aggregates?

Aggregates are clusters of related entities treated as a single unit, with: - One root entity (Aggregate Root) - Consistency boundary: Changes happen through the root - Transaction boundary: Save/load as a unit

27.3.2 Concept Diagram

Aggregate = Root Entity + Child Entities

┌────────────────────────────────────────────┐
│         Stroke Assessment Aggregate        │
│              (Aggregate Root)              │
│                                            │
│  ┌───────────────────────────────┐         │
│  │   StrokeAssessment (Root)     │         │
│  │   • id                        │         │
│  │   • patient_id                │         │
│  │   • assessment_date           │         │
│  └───────────┬───────────────────┘         │
│              │                             │
│              │ owns/manages                │
│              │                             │
│      ┌───────┴────────┬──────────┐         │
│      ▼                ▼          ▼         │
│  ┌────────┐     ┌──────────┐  ┌─────┐      │
│  │ ASPECTS│     │ Clinical │  │NIHSS│      │
│  │ Score  │     │   Data   │  │Score│      │
│  └────────┘     └──────────┘  └─────┘      │
│                                            │
│  All modifications go through root!        │
└────────────────────────────────────────────┘

External code can ONLY access the root
Cannot directly modify child entities

27.3.3 Example: Stroke Assessment Aggregate

# core/aggregates/stroke_assessment.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from core.entities.patient import Patient
from core.value_objects import AspectsScore, NihssScore

@dataclass
class RegionScore:
    """Child entity: Cannot be accessed directly from outside"""
    region_name: str
    is_affected: bool
    confidence: float

@dataclass
class ClinicalData:
    """Child entity: Part of the aggregate"""
    symptom_onset: datetime
    blood_pressure_systolic: int
    blood_pressure_diastolic: int
    glucose_level: float

@dataclass
class StrokeAssessment:
    """
    Aggregate Root: Controls access to all child entities
    Maintains consistency of the entire assessment
    """
    # Identity
    id: int
    patient_id: int
    scan_id: int
    assessment_date: datetime
    
    # Child entities (managed by this root)
    _region_scores: List[RegionScore] = field(default_factory=list)
    _clinical_data: Optional[ClinicalData] = None
    _aspects_score: Optional[int] = None
    _nihss_score: Optional[int] = None
    
    # Relationships
    patient: Optional[Patient] = None
    
    # ============================================
    # Public API: All changes go through the root
    # ============================================
    
    def add_region_score(self, region: str, affected: bool, confidence: float):
        """Control how regions are added"""
        # Business rule: Validate region name
        valid_regions = ['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 
                        'Insula', 'Caudate', 'Lentiform', 'IC']
        
        if region not in valid_regions:
            raise ValueError(f"Invalid region: {region}")
        
        # Business rule: Confidence must be 0-1
        if not 0 <= confidence <= 1:
            raise ValueError("Confidence must be between 0 and 1")
        
        self._region_scores.append(
            RegionScore(region, affected, confidence)
        )
        
        # Automatically recalculate ASPECTS when regions change
        self._recalculate_aspects()
    
    def set_clinical_data(self, 
                         symptom_onset: datetime,
                         bp_systolic: int,
                         bp_diastolic: int,
                         glucose: float):
        """Control how clinical data is set"""
        # Business rule: Validate blood pressure
        if not (80 <= bp_systolic <= 250):
            raise ValueError("Invalid systolic BP")
        if not (40 <= bp_diastolic <= 150):
            raise ValueError("Invalid diastolic BP")
        
        self._clinical_data = ClinicalData(
            symptom_onset, bp_systolic, bp_diastolic, glucose
        )
    
    def get_aspects_score(self) -> int:
        """Public read access to score"""
        return self._aspects_score or 0
    
    def get_region_scores(self) -> List[RegionScore]:
        """Return copy to prevent external modification"""
        return self._region_scores.copy()
    
    # ============================================
    # Business Rules (Private methods)
    # ============================================
    
    def _recalculate_aspects(self):
        """Business logic: Calculate ASPECTS score from regions"""
        score = 10  # Start with 10
        for region in self._region_scores:
            if region.is_affected and region.confidence > 0.7:
                score -= 1
        self._aspects_score = max(0, score)
    
    def is_thrombolysis_candidate(self) -> bool:
        """Complex business rule involving multiple entities"""
        if not self._clinical_data or not self._aspects_score:
            return False
        
        # Multiple criteria
        aspects_ok = self._aspects_score >= 6
        time_window_ok = self._within_time_window()
        bp_ok = self._blood_pressure_acceptable()
        glucose_ok = 50 <= self._clinical_data.glucose_level <= 400
        
        return aspects_ok and time_window_ok and bp_ok and glucose_ok
    
    def _within_time_window(self) -> bool:
        """Business rule: Time from symptom onset"""
        if not self._clinical_data:
            return False
        
        time_diff = self.assessment_date - self._clinical_data.symptom_onset
        hours = time_diff.total_seconds() / 3600
        return hours <= 4.5  # Standard thrombolysis window
    
    def _blood_pressure_acceptable(self) -> bool:
        """Business rule: BP criteria for thrombolysis"""
        if not self._clinical_data:
            return False
        
        return (self._clinical_data.blood_pressure_systolic < 185 and
                self._clinical_data.blood_pressure_diastolic < 110)

Why Use Aggregates?

Without Aggregate:                With Aggregate:

❌ Anyone can modify regions      ✅ Only root can modify
❌ Score becomes inconsistent     ✅ Score auto-updates
❌ Validation scattered           ✅ Validation centralized
❌ Hard to maintain rules         ✅ Business rules in one place

External Code:                    External Code:
region.is_affected = True  ❌     assessment.add_region_score()  ✅
(bypasses validation)             (goes through validation)

27.4 Type 3: Interfaces

27.4.1 What Are Interfaces?

Interfaces define contracts for operations that the Application Core needs but will be implemented by Infrastructure.

27.4.2 Purpose

Application Core:
"I need to save scans, but I don't care HOW"
        │
        │ defines interface
        ▼
    interface IScanRepository {
        save_scan()
        get_scan()
    }
        ▲
        │ implements
        │
Infrastructure:
"I'll implement it using SQL/PACS/Files"

27.4.3 Example: Repository Interfaces

# core/interfaces/iscan_repository.py
from abc import ABC, abstractmethod
from typing import List, Optional
from core.entities.ct_scan import CTScan

class IScanRepository(ABC):
    """
    Interface: Contract for scan storage
    Core defines WHAT it needs
    Infrastructure decides HOW to implement
    """
    
    @abstractmethod
    async def get_by_id(self, scan_id: int) -> Optional[CTScan]:
        """Retrieve scan by ID"""
        pass
    
    @abstractmethod
    async def get_by_patient(self, patient_id: int) -> List[CTScan]:
        """Get all scans for a patient"""
        pass
    
    @abstractmethod
    async def get_pending_for_ai(self) -> List[CTScan]:
        """Get scans waiting for AI analysis"""
        pass
    
    @abstractmethod
    async def add(self, scan: CTScan) -> CTScan:
        """Save new scan"""
        pass
    
    @abstractmethod
    async def update(self, scan: CTScan) -> None:
        """Update existing scan"""
        pass
    
    @abstractmethod
    async def delete(self, scan_id: int) -> None:
        """Remove scan"""
        pass
# core/interfaces/imodel_service.py
from abc import ABC, abstractmethod
import numpy as np
from typing import Dict

class IModelService(ABC):
    """
    Interface: Contract for AI model operations
    """
    
    @abstractmethod
    async def predict_stroke(self, image_data: np.ndarray) -> Dict[str, float]:
        """
        Run stroke detection model
        Returns: Dict with region names and probabilities
        """
        pass
    
    @abstractmethod
    async def load_model(self, model_path: str) -> None:
        """Load AI model from path"""
        pass
    
    @abstractmethod
    def is_model_loaded(self) -> bool:
        """Check if model is ready"""
        pass
# core/interfaces/inotification_service.py
from abc import ABC, abstractmethod
from typing import List

class INotificationService(ABC):
    """
    Interface: Contract for sending notifications
    Could be email, SMS, Slack, etc.
    """
    
    @abstractmethod
    async def notify_critical_finding(
        self,
        recipients: List[str],
        patient_id: int,
        finding: str
    ) -> None:
        """Send urgent notification about critical finding"""
        pass
    
    @abstractmethod
    async def notify_report_ready(
        self,
        recipient: str,
        report_id: int
    ) -> None:
        """Notify that a report is ready"""
        pass

27.5 Type 4: Domain Services

27.5.1 What Are Domain Services?

Domain Services contain business logic that doesn’t naturally fit in a single entity. They: - Operate on multiple entities - Contain complex calculations - Implement business processes - Are stateless

27.5.2 When to Use Domain Services

Use Entity Method When:          Use Domain Service When:
• Logic involves ONE entity      • Logic spans MULTIPLE entities
• Simple calculation             • Complex algorithm
• Data about the entity          • Coordination/orchestration

Entity:                          Domain Service:
patient.get_age() ✅             StrokeEligibilityChecker
                                 .check(patient, scan, clinical) ✅

27.5.3 Example 1: ASPECTS Calculator Service

# core/services/aspects_calculator.py
from typing import Dict, List
import numpy as np
from core.entities.ct_scan import CTScan
from core.value_objects import RegionAnalysis

class AspectsCalculator:
    """
    Domain Service: Calculate ASPECTS score
    
    Business logic that involves complex calculation
    on CT scan data - doesn't naturally belong to CTScan entity
    """
    
    # Business rule: ASPECTS regions
    ASPECTS_REGIONS = [
        'M1', 'M2', 'M3', 'M4', 'M5', 'M6',
        'Insula', 'Caudate', 'Lentiform', 'Internal_Capsule'
    ]
    
    def calculate_score(
        self, 
        region_analyses: Dict[str, RegionAnalysis]
    ) -> int:
        """
        Calculate ASPECTS score (0-10)
        
        Business Rule: Start at 10, subtract 1 for each affected region
        """
        score = 10
        
        for region_name in self.ASPECTS_REGIONS:
            if region_name in region_analyses:
                analysis = region_analyses[region_name]
                
                # Business rule: Region is affected if probability > 70%
                if analysis.infarct_probability > 0.7:
                    score -= 1
        
        return max(0, min(10, score))  # Clamp between 0-10
    
    def categorize_score(self, score: int) -> str:
        """Business rule: Score interpretation"""
        if score >= 8:
            return "Minimal early ischemic changes"
        elif score >= 6:
            return "Moderate early ischemic changes"
        elif score >= 4:
            return "Extensive early ischemic changes"
        else:
            return "Severe early ischemic changes"
    
    def calculate_infarct_volume(
        self,
        region_analyses: Dict[str, RegionAnalysis]
    ) -> float:
        """
        Estimate infarct volume in ml
        Business rule: Each ASPECTS region ≈ 10ml
        """
        affected_regions = sum(
            1 for analysis in region_analyses.values()
            if analysis.infarct_probability > 0.7
        )
        return affected_regions * 10.0  # ml per region

27.5.4 Example 2: Thrombolysis Eligibility Service

# core/services/thrombolysis_checker.py
from datetime import datetime, timedelta
from typing import Tuple
from core.entities.patient import Patient
from core.entities.ct_scan import CTScan
from core.aggregates.stroke_assessment import StrokeAssessment

class ThrombolysisEligibilityChecker:
    """
    Domain Service: Determine thrombolysis eligibility
    
    Complex business logic involving multiple entities:
    - Patient demographics
    - CT scan findings
    - Clinical data
    - Timing constraints
    """
    
    def check_eligibility(
        self,
        patient: Patient,
        scan: CTScan,
        assessment: StrokeAssessment
    ) -> Tuple[bool, List[str]]:
        """
        Check if patient is eligible for thrombolysis
        
        Returns:
            (is_eligible, list_of_reasons)
        """
        reasons = []
        
        # Check all criteria
        age_ok = self._check_age(patient, reasons)
        time_ok = self._check_time_window(assessment, reasons)
        aspects_ok = self._check_aspects_score(assessment, reasons)
        bp_ok = self._check_blood_pressure(assessment, reasons)
        contraindications_ok = self._check_contraindications(
            patient, assessment, reasons
        )
        
        is_eligible = all([
            age_ok, time_ok, aspects_ok, bp_ok, contraindications_ok
        ])
        
        return is_eligible, reasons
    
    def _check_age(self, patient: Patient, reasons: List[str]) -> bool:
        """Business rule: Age 18-80"""
        age = patient.get_age()
        if age < 18:
            reasons.append("Patient too young (< 18)")
            return False
        if age > 80:
            reasons.append("Patient too old (> 80)")
            return False
        return True
    
    def _check_time_window(
        self,
        assessment: StrokeAssessment,
        reasons: List[str]
    ) -> bool:
        """Business rule: Within 4.5 hours of symptom onset"""
        clinical = assessment._clinical_data
        if not clinical:
            reasons.append("No clinical data available")
            return False
        
        time_diff = assessment.assessment_date - clinical.symptom_onset
        hours = time_diff.total_seconds() / 3600
        
        if hours > 4.5:
            reasons.append(f"Too late: {hours:.1f} hours since onset")
            return False
        
        return True
    
    def _check_aspects_score(
        self,
        assessment: StrokeAssessment,
        reasons: List[str]
    ) -> bool:
        """Business rule: ASPECTS >= 6"""
        score = assessment.get_aspects_score()
        
        if score < 6:
            reasons.append(
                f"ASPECTS score too low: {score} (need >= 6)"
            )
            return False
        
        return True
    
    def _check_blood_pressure(
        self,
        assessment: StrokeAssessment,
        reasons: List[str]
    ) -> bool:
        """Business rule: BP < 185/110"""
        clinical = assessment._clinical_data
        if not clinical:
            return False
        
        if clinical.blood_pressure_systolic >= 185:
            reasons.append("Systolic BP too high (>= 185)")
            return False
        
        if clinical.blood_pressure_diastolic >= 110:
            reasons.append("Diastolic BP too high (>= 110)")
            return False
        
        return True
    
    def _check_contraindications(
        self,
        patient: Patient,
        assessment: StrokeAssessment,
        reasons: List[str]
    ) -> bool:
        """Business rule: Check for contraindications"""
        clinical = assessment._clinical_data
        if not clinical:
            return False
        
        # Glucose out of range
        if clinical.glucose_level < 50:
            reasons.append("Hypoglycemia (glucose < 50)")
            return False
        if clinical.glucose_level > 400:
            reasons.append("Hyperglycemia (glucose > 400)")
            return False
        
        return True

27.6 Type 5: Specifications

27.6.1 What Are Specifications?

Specifications encapsulate query logic as reusable objects. They define criteria for selecting entities.

27.6.2 Purpose

Without Specification:              With Specification:

Repository method:                  Repository method:
get_brain_scans_ready_for_ai()     get_by_spec(spec)
get_pending_stroke_scans()    
get_high_priority_scans()           Specifications:
... (20 more methods!)              BrainScanSpec()
                                    PendingStrokeSpec()
❌ Repository explodes              HighPrioritySpec()
❌ Hard to combine criteria   
                                    ✅ Reusable
                                    ✅ Composable
                                    ✅ Testable

27.6.3 Example: Scan Specifications

# core/specifications/scan_specifications.py
from abc import ABC, abstractmethod
from typing import List
from core.entities.ct_scan import CTScan, ScanStatus

class Specification(ABC):
    """Base specification interface"""
    
    @abstractmethod
    def is_satisfied_by(self, entity: CTScan) -> bool:
        """Check if entity meets criteria"""
        pass
    
    def and_(self, other: 'Specification') -> 'AndSpecification':
        """Combine with AND logic"""
        return AndSpecification(self, other)
    
    def or_(self, other: 'Specification') -> 'OrSpecification':
        """Combine with OR logic"""
        return OrSpecification(self, other)


class BrainScanSpecification(Specification):
    """Specification: Scan must be a brain CT"""
    
    def is_satisfied_by(self, scan: CTScan) -> bool:
        return scan.is_brain_scan()


class CompletedScanSpecification(Specification):
    """Specification: Scan must be completed"""
    
    def is_satisfied_by(self, scan: CTScan) -> bool:
        return scan.status == ScanStatus.COMPLETED


class HighResolutionSpecification(Specification):
    """Specification: Scan must be high resolution"""
    
    def is_satisfied_by(self, scan: CTScan) -> bool:
        return scan.is_high_resolution()


class SuitableForAISpecification(Specification):
    """Specification: Scan meets AI processing criteria"""
    
    def is_satisfied_by(self, scan: CTScan) -> bool:
        return scan.is_suitable_for_ai_analysis()


class RecentScanSpecification(Specification):
    """Specification: Scan acquired within last N hours"""
    
    def __init__(self, hours: int):
        self.hours = hours
    
    def is_satisfied_by(self, scan: CTScan) -> bool:
        from datetime import datetime, timedelta
        cutoff = datetime.now() - timedelta(hours=self.hours)
        return scan.acquisition_date >= cutoff


# Composite Specifications (Combine multiple)

class AndSpecification(Specification):
    """Combine specifications with AND"""
    
    def __init__(self, left: Specification, right: Specification):
        self.left = left
        self.right = right
    
    def is_satisfied_by(self, entity: CTScan) -> bool:
        return (self.left.is_satisfied_by(entity) and
                self.right.is_satisfied_by(entity))


class OrSpecification(Specification):
    """Combine specifications with OR"""
    
    def __init__(self, left: Specification, right: Specification):
        self.left = left
        self.right = right
    
    def is_satisfied_by(self, entity: CTScan) -> bool:
        return (self.left.is_satisfied_by(entity) or
                self.right.is_satisfied_by(entity))

27.6.4 Usage Example

# Usage in Domain Service or Application Service
from core.specifications.scan_specifications import (
    BrainScanSpecification,
    CompletedScanSpecification,
    RecentScanSpecification
)

class ScanFilterService:
    def get_scans_for_stroke_ai(self, all_scans: List[CTScan]) -> List[CTScan]:
        """
        Find scans suitable for stroke AI analysis
        Using composed specifications!
        """
        # Build complex specification by combining simple ones
        spec = (
            BrainScanSpecification()
            .and_(CompletedScanSpecification())
            .and_(RecentScanSpecification(hours=24))
        )
        
        # Filter scans using specification
        return [scan for scan in all_scans if spec.is_satisfied_by(scan)]
    
    def get_urgent_review_scans(self, all_scans: List[CTScan]) -> List[CTScan]:
        """Different combination for urgent reviews"""
        spec = (
            BrainScanSpecification()
            .and_(RecentScanSpecification(hours=1))
        )
        
        return [scan for scan in all_scans if spec.is_satisfied_by(scan)]

Benefits:

✅ Reusable: Use same specs in different contexts
✅ Testable: Test each spec in isolation
✅ Composable: Combine with AND/OR
✅ Readable: Brain spec AND Completed spec
✅ Single Responsibility: Each spec does one thing

27.7 Type 6: Custom Exceptions and Guard Clauses

27.7.1 Custom Exceptions

Domain-specific exceptions that represent business rule violations.

# core/exceptions/domain_exceptions.py

class DomainException(Exception):
    """Base exception for all domain errors"""
    pass

class InvalidAspectsScoreException(DomainException):
    """Raised when ASPECTS score is invalid"""
    def __init__(self, score: int):
        self.score = score
        super().__init__(f"Invalid ASPECTS score: {score}. Must be 0-10")

class PatientTooYoungException(DomainException):
    """Raised when patient doesn't meet age requirement"""
    def __init__(self, age: int, minimum: int):
        super().__init__(
            f"Patient age {age} is below minimum {minimum}"
        )

class StrokeTimeWindowExpiredException(DomainException):
    """Raised when treatment window has passed"""
    def __init__(self, hours_elapsed: float):
        super().__init__(
            f"Stroke occurred {hours_elapsed:.1f} hours ago. "
            f"Treatment window (4.5 hours) has expired"
        )

class ScanNotSuitableForAIException(DomainException):
    """Raised when scan doesn't meet AI processing criteria"""
    def __init__(self, reason: str):
        super().__init__(f"Scan not suitable for AI: {reason}")

class BloodPressureTooHighException(DomainException):
    """Raised when BP exceeds thrombolysis threshold"""
    def __init__(self, systolic: int, diastolic: int):
        super().__init__(
            f"BP {systolic}/{diastolic} exceeds threshold for thrombolysis"
        )

27.7.2 Guard Clauses

Guard clauses enforce business rules and prevent invalid states.

# core/guards/guard.py
from typing import Any, Optional
from core.exceptions.domain_exceptions import DomainException

class Guard:
    """
    Helper class for enforcing business rules
    (Guard Clauses pattern)
    """
    
    @staticmethod
    def against_null(value: Any, parameter_name: str):
        """Ensure value is not None"""
        if value is None:
            raise DomainException(f"{parameter_name} cannot be null")
    
    @staticmethod
    def against_empty_string(value: str, parameter_name: str):
        """Ensure string is not empty"""
        if not value or value.strip() == "":
            raise DomainException(f"{parameter_name} cannot be empty")
    
    @staticmethod
    def against_out_of_range(
        value: float,
        min_value: float,
        max_value: float,
        parameter_name: str
    ):
        """Ensure value is within range"""
        if value < min_value or value > max_value:
            raise DomainException(
                f"{parameter_name} must be between {min_value} and {max_value}"
            )
    
    @staticmethod
    def against_invalid_aspects(score: int):
        """Ensure ASPECTS score is valid"""
        if score < 0 or score > 10:
            from core.exceptions.domain_exceptions import (
                InvalidAspectsScoreException
            )
            raise InvalidAspectsScoreException(score)

27.7.3 Usage in Entities

# core/entities/stroke_result.py
from dataclasses import dataclass
from datetime import datetime
from core.guards.guard import Guard
from core.exceptions.domain_exceptions import InvalidAspectsScoreException

@dataclass
class StrokeResult:
    """Entity with guard clauses"""
    id: int
    assessment_id: int
    aspects_score: int
    detected_at: datetime
    
    def __post_init__(self):
        """Validate on creation"""
        self._validate()
    
    def _validate(self):
        """Business rule validation"""
        Guard.against_null(self.assessment_id, "assessment_id")
        Guard.against_invalid_aspects(self.aspects_score)
    
    def update_score(self, new_score: int):
        """Business operation with validation"""
        # Guard clause
        if new_score < 0 or new_score > 10:
            raise InvalidAspectsScoreException(new_score)
        
        self.aspects_score = new_score

27.8 Type 7: Domain Events and Handlers

27.8.1 What Are Domain Events?

Domain Events represent something that happened in the domain that other parts of the system should know about.

27.8.2 Concept

Something Important Happens:
"Stroke detected with low ASPECTS score!"
         │
         │ raises
         ▼
    Domain Event
  (StrokeDetectedEvent)
         │
         │ notifies
         ▼
    ┌────────────┬────────────┬────────────┐
    │            │            │            │
    ▼            ▼            ▼            ▼
Handler 1   Handler 2   Handler 3   Handler 4
Send Email  Log Event  Update Stats Notify PACS

Multiple handlers can react to one event!

27.8.3 Example: Domain Events

# core/events/domain_events.py
from dataclasses import dataclass
from datetime import datetime
from typing import List

@dataclass
class DomainEvent:
    """Base class for all domain events"""
    occurred_at: datetime
    event_id: str
    
    def __post_init__(self):
        if not self.occurred_at:
            self.occurred_at = datetime.now()


@dataclass
class StrokeDetectedEvent(DomainEvent):
    """
    Event: Stroke has been detected in a scan
    """
    patient_id: int
    scan_id: int
    aspects_score: int
    assessment_id: int
    severity: str  # "mild", "moderate", "severe"


@dataclass
class CriticalFindingEvent(DomainEvent):
    """
    Event: Critical finding requiring immediate attention
    """
    patient_id: int
    scan_id: int
    finding_type: str
    severity: str
    requires_immediate_action: bool


@dataclass
class ThrombolysisEligibilityDeterminedEvent(DomainEvent):
    """
    Event: Thrombolysis eligibility has been determined
    """
    patient_id: int
    assessment_id: int
    is_eligible: bool
    reasons: List[str]


@dataclass
class AssessmentCompletedEvent(DomainEvent):
    """
    Event: Stroke assessment has been completed
    """
    assessment_id: int
    patient_id: int
    scan_id: int
    aspects_score: int
    completion_time: datetime

27.8.4 Example: Event Handlers

# core/events/handlers.py
from abc import ABC, abstractmethod
from core.events.domain_events import DomainEvent, StrokeDetectedEvent

class EventHandler(ABC):
    """Base interface for event handlers"""
    
    @abstractmethod
    async def handle(self, event: DomainEvent) -> None:
        """Handle the domain event"""
        pass


class NotifyStrokeTeamHandler(EventHandler):
    """Handler: Send notification when stroke detected"""
    
    def __init__(self, notification_service):
        self.notification_service = notification_service
    
    async def handle(self, event: StrokeDetectedEvent) -> None:
        """Send urgent notification for low ASPECTS"""
        if event.aspects_score <= 5:
            await self.notification_service.notify_critical_finding(
                recipients=["stroke-team@hospital.com"],
                patient_id=event.patient_id,
                finding=f"Critical stroke detected: ASPECTS {event.aspects_score}"
            )


class LogStrokeDetectionHandler(EventHandler):
    """Handler: Log stroke detection for audit trail"""
    
    def __init__(self, logger):
        self.logger = logger
    
    async def handle(self, event: StrokeDetectedEvent) -> None:
        """Log the detection"""
        self.logger.info(
            f"Stroke detected - Patient: {event.patient_id}, "
            f"ASPECTS: {event.aspects_score}, "
            f"Severity: {event.severity}"
        )


class UpdateStatisticsHandler(EventHandler):
    """Handler: Update real-time statistics"""
    
    def __init__(self, stats_service):
        self.stats_service = stats_service
    
    async def handle(self, event: StrokeDetectedEvent) -> None:
        """Update detection statistics"""
        await self.stats_service.increment_stroke_count()
        await self.stats_service.record_aspects_score(event.aspects_score)

27.8.5 Event Dispatcher

# core/events/event_dispatcher.py
from typing import Dict, List, Type
from core.events.domain_events import DomainEvent
from core.events.handlers import EventHandler

class EventDispatcher:
    """
    Coordinates between events and their handlers
    """
    
    def __init__(self):
        self._handlers: Dict[Type[DomainEvent], List[EventHandler]] = {}
    
    def register(
        self,
        event_type: Type[DomainEvent],
        handler: EventHandler
    ):
        """Register a handler for an event type"""
        if event_type not in self._handlers:
            self._handlers[event_type] = []
        self._handlers[event_type].append(handler)
    
    async def dispatch(self, event: DomainEvent):
        """Dispatch event to all registered handlers"""
        event_type = type(event)
        
        if event_type in self._handlers:
            for handler in self._handlers[event_type]:
                await handler.handle(event)

27.8.6 Usage in Domain Service

# core/services/stroke_detection_service.py
from core.services.aspects_calculator import AspectsCalculator
from core.events.domain_events import StrokeDetectedEvent
from core.events.event_dispatcher import EventDispatcher

class StrokeDetectionService:
    """Domain service that raises events"""
    
    def __init__(
        self,
        calculator: AspectsCalculator,
        event_dispatcher: EventDispatcher
    ):
        self.calculator = calculator
        self.dispatcher = event_dispatcher
    
    async def detect_stroke(self, scan, region_data) -> int:
        """Detect stroke and raise event"""
        
        # Calculate ASPECTS
        aspects_score = self.calculator.calculate_score(region_data)
        
        # Determine severity
        severity = self._determine_severity(aspects_score)
        
        # Raise domain event
        event = StrokeDetectedEvent(
            occurred_at=datetime.now(),
            event_id=str(uuid.uuid4()),
            patient_id=scan.patient_id,
            scan_id=scan.id,
            aspects_score=aspects_score,
            assessment_id=None,  # Set if available
            severity=severity
        )
        
        # Dispatch to all handlers
        await self.dispatcher.dispatch(event)
        
        return aspects_score
    
    def _determine_severity(self, score: int) -> str:
        """Business rule: Categorize severity"""
        if score >= 8:
            return "mild"
        elif score >= 6:
            return "moderate"
        else:
            return "severe"

27.9 Type 8: DTOs (Data Transfer Objects)

27.9.1 What Are DTOs?

DTOs are simple data containers for transferring data between layers, with no business logic.

# core/dtos/stroke_analysis_dto.py
from dataclasses import dataclass
from datetime import datetime
from typing import List, Dict

@dataclass
class StrokeAnalysisRequest:
    """DTO: Request to analyze a scan"""
    scan_id: int
    patient_id: int
    urgent: bool = False


@dataclass
class RegionAnalysisResult:
    """DTO: Result for one brain region"""
    region_name: str
    is_affected: bool
    infarct_probability: float
    confidence_score: float


@dataclass
class StrokeAnalysisResult:
    """DTO: Complete analysis result"""
    scan_id: int
    patient_id: int
    aspects_score: int
    severity: str
    region_results: List[RegionAnalysisResult]
    analyzed_at: datetime
    analysis_duration_ms: int
    
    def to_dict(self) -> Dict:
        """Convert to dictionary for API response"""
        return {
            "scan_id": self.scan_id,
            "patient_id": self.patient_id,
            "aspects_score": self.aspects_score,
            "severity": self.severity,
            "regions": [
                {
                    "name": r.region_name,
                    "affected": r.is_affected,
                    "probability": r.infarct_probability
                }
                for r in self.region_results
            ],
            "analyzed_at": self.analyzed_at.isoformat(),
            "duration_ms": self.analysis_duration_ms
        }