Files
notytex/docs/backend/DEPENDENCY_INJECTION.md

23 KiB

💉 Injection de Dépendances - Pattern et Implémentation

Vue d'Ensemble

Ce document détaille le système d'injection de dépendances implémenté dans Notytex Phase 1, qui résout les imports circulaires et améliore la testabilité en appliquant le principe Dependency Inversion.

🎯 Problématique Résolue

Avant : Imports Circulaires et Couplage Fort

# ❌ Problème : models.py importait directement app_config
from app_config import config_manager

class Assessment(db.Model):
    def calculate_score(self, grade_value):
        # Couplage direct → Import circulaire
        if config_manager.is_special_value(grade_value):
            return 0
# ❌ Problème : app_config importait les modèles
from models import Assessment, Grade

class ConfigManager:
    def validate_grades(self):
        # Import circulaire Assessment ↔ ConfigManager
        assessments = Assessment.query.all()

Après : Injection de Dépendances avec Protocols

# ✅ Solution : Interface abstraite
class ConfigProvider(Protocol):
    def is_special_value(self, value: str) -> bool: ...
    def get_special_values(self) -> Dict[str, Dict[str, Any]]: ...

# ✅ Service découplé
class UnifiedGradingCalculator:
    def __init__(self, config_provider: ConfigProvider):
        self.config_provider = config_provider  # Abstraction
    
    def calculate_score(self, grade_value: str, grading_type: str, max_points: float):
        # Utilise l'abstraction, pas l'implémentation
        if self.config_provider.is_special_value(grade_value):
            return 0

🔧 Architecture d'Injection

Diagramme des Dépendances

┌─────────────────── INTERFACES (Protocols) ───────────────────┐
│                                                              │
│  ConfigProvider          DatabaseProvider                    │
│  ├── is_special_value    ├── get_grades_for_assessment       │
│  └── get_special_values  └── get_grading_elements_with_s.    │
│                                                              │
└──────────────────────────┬───────────────────────────────────┘
                           │ (Dependency Inversion)
┌─────────────────── SERVICES (Business Logic) ────────────────┐
│                          │                                   │
│  UnifiedGradingCalc.     │  AssessmentProgressService        │
│  StudentScoreCalc.       │  AssessmentStatisticsService      │
│                          │                                   │
└──────────────────────────┬───────────────────────────────────┘
                           │ (Orchestration)
┌─────────────────── FACADE (Entry Point) ──────────────────────┐
│                          │                                   │
│            AssessmentServicesFacade                          │
│                          │                                   │
└──────────────────────────┬───────────────────────────────────┘
                           │ (Factory Creation)
┌─────────────────── FACTORY (Wiring) ──────────────────────────┐
│                          │                                   │
│          AssessmentServicesFactory                           │
│                          │                                   │
└──────────────────────────┬───────────────────────────────────┘
                           │ (Concrete Implementations)
┌─────────────────── PROVIDERS (Concrete) ──────────────────────┐
│                          │                                   │
│  ConfigManagerProvider   │  SQLAlchemyDatabaseProvider       │
│  └── app_config         │  └── SQLAlchemy                   │
│                          │                                   │
└──────────────────────────────────────────────────────────────┘

Flux d'Injection

Factory → Concrete Providers → Facade → Services
   │            │                │         │
   │            │                │         └─ Business Logic
   │            │                └─ Orchestration
   │            └─ Implementation
   └─ Wiring

📋 Interfaces (Protocols)

1. ConfigProvider Protocol

Rôle : Abstraction pour l'accès à la configuration

class ConfigProvider(Protocol):
    """Interface pour l'accès à la configuration."""
    
    def is_special_value(self, value: str) -> bool:
        """Vérifie si une valeur est spéciale (., d, etc.)"""
        ...
    
    def get_special_values(self) -> Dict[str, Dict[str, Any]]:
        """Retourne la configuration des valeurs spéciales."""
        ...

Avantages :

  • Découplage : Les services ne connaissent pas l'implémentation
  • Testabilité : Facilite les mocks
  • Flexibilité : Changement d'implémentation transparent

2. DatabaseProvider Protocol

Rôle : Abstraction pour l'accès aux données optimisé

class DatabaseProvider(Protocol):
    """Interface pour l'accès aux données."""
    
    def get_grades_for_assessment(self, assessment_id: int) -> List[Any]:
        """Récupère toutes les notes d'une évaluation en une seule requête."""
        ...
    
    def get_grading_elements_with_students(self, assessment_id: int) -> List[Any]:
        """Récupère les éléments de notation avec le nombre de notes complétées."""
        ...

Avantages :

  • Performance : Requêtes optimisées centralisées
  • Abstraction : Services indépendants de SQLAlchemy
  • Evolution : Changement de ORM possible

🏭 Providers Concrets

1. ConfigManagerProvider

Implémentation : Adapter vers app_config avec lazy loading

class ConfigManagerProvider:
    """
    Implémentation concrète du ConfigProvider utilisant app_config.
    Résout les imports circulaires en encapsulant l'accès à la configuration.
    """
    
    def __init__(self):
        # Import paresseux pour éviter les dépendances circulaires
        self._config_manager = None
    
    @property
    def config_manager(self):
        """Accès paresseux au config_manager."""
        if self._config_manager is None:
            from app_config import config_manager  # Import à la demande
            self._config_manager = config_manager
        return self._config_manager
    
    def is_special_value(self, value: str) -> bool:
        """Vérifie si une valeur est spéciale."""
        return self.config_manager.is_special_value(value)
    
    def get_special_values(self) -> Dict[str, Dict[str, Any]]:
        """Retourne la configuration des valeurs spéciales."""
        return self.config_manager.get_special_values()

Techniques Utilisées :

  • Lazy Loading : Import différé pour éviter les cycles
  • Adapter Pattern : Encapsule l'accès à config_manager
  • Property Caching : Évite les re-imports multiples

2. SQLAlchemyDatabaseProvider

Implémentation : Requêtes optimisées pour résoudre N+1

class SQLAlchemyDatabaseProvider:
    """
    Implémentation concrète du DatabaseProvider utilisant SQLAlchemy.
    Fournit des requêtes optimisées pour éviter les problèmes N+1.
    """
    
    def get_grades_for_assessment(self, assessment_id: int) -> List[Dict[str, Any]]:
        """
        Récupère toutes les notes d'une évaluation en une seule requête optimisée.
        Résout le problème N+1 identifié dans calculate_student_scores.
        """
        query = (
            db.session.query(
                Grade.student_id,
                Grade.grading_element_id,
                Grade.value,
                GradingElement.grading_type,
                GradingElement.max_points
            )
            .join(GradingElement)
            .join(Exercise)
            .filter(Exercise.assessment_id == assessment_id)
            .filter(Grade.value.isnot(None))
            .filter(Grade.value != '')
        )
        
        return [
            {
                'student_id': row.student_id,
                'grading_element_id': row.grading_element_id,
                'value': row.value,
                'grading_type': row.grading_type,
                'max_points': row.max_points
            }
            for row in query.all()
        ]
    
    def get_grading_elements_with_students(self, assessment_id: int) -> List[Dict[str, Any]]:
        """
        Récupère les éléments de notation avec le nombre de notes complétées.
        Résout le problème N+1 identifié dans grading_progress.
        """
        # Sous-requête pour compter les grades complétés par élément
        grades_subquery = (
            db.session.query(
                Grade.grading_element_id,
                func.count(Grade.id).label('completed_count')
            )
            .filter(Grade.value.isnot(None))
            .filter(Grade.value != '')
            .group_by(Grade.grading_element_id)
            .subquery()
        )
        
        # Requête principale avec jointure
        query = (
            db.session.query(
                GradingElement.id,
                GradingElement.label,
                func.coalesce(grades_subquery.c.completed_count, 0).label('completed_grades_count')
            )
            .join(Exercise)
            .outerjoin(grades_subquery, GradingElement.id == grades_subquery.c.grading_element_id)
            .filter(Exercise.assessment_id == assessment_id)
        )
        
        return [
            {
                'element_id': row.id,
                'element_label': row.label,
                'completed_grades_count': row.completed_grades_count
            }
            for row in query.all()
        ]

Optimisations :

  • Requête unique : Évite N+1 pour les grades
  • Sous-requêtes : Calculs agrégés efficaces
  • Jointures optimisées : Minimise le nombre d'accès DB

🏭 Factory Pattern

AssessmentServicesFactory

Rôle : Orchestration centralisée de l'injection de dépendances

class AssessmentServicesFactory:
    """
    Factory pour créer l'ensemble des services avec injection de dépendances.
    Centralise la création et la configuration des services.
    """
    
    @classmethod
    def create_facade(cls) -> 'AssessmentServicesFacade':
        """
        Crée une facade complète avec toutes les dépendances injectées.
        Point d'entrée principal pour obtenir les services.
        """
        from services.assessment_services import AssessmentServicesFacade
        
        # 1. Création des providers concrets
        config_provider = ConfigManagerProvider()
        db_provider = SQLAlchemyDatabaseProvider()
        
        # 2. Injection dans la facade
        return AssessmentServicesFacade(
            config_provider=config_provider,
            db_provider=db_provider
        )
    
    @classmethod
    def create_with_custom_providers(cls, 
                                   config_provider=None, 
                                   db_provider=None) -> 'AssessmentServicesFacade':
        """
        Crée une facade avec des providers personnalisés.
        Utile pour les tests avec des mocks.
        """
        from services.assessment_services import AssessmentServicesFacade
        
        # Providers par défaut ou personnalisés
        config_provider = config_provider or ConfigManagerProvider()
        db_provider = db_provider or SQLAlchemyDatabaseProvider()
        
        return AssessmentServicesFacade(
            config_provider=config_provider,
            db_provider=db_provider
        )
    
    @classmethod
    def create_class_services_facade(cls) -> 'ClassServicesFacade':
        """
        Crée une facade pour les services de classe avec toutes les dépendances injectées.
        Point d'entrée pour obtenir les services ClassGroup.
        """
        from services.assessment_services import ClassServicesFacade
        
        db_provider = SQLAlchemyDatabaseProvider()
        return ClassServicesFacade(db_provider=db_provider)

Avantages de la Factory

  • Centralisation : Un seul endroit pour l'injection
  • Consistance : Configuration uniforme des services
  • Testabilité : Permet l'injection de mocks facilement
  • Évolution : Nouveaux services ajoutés centralement

🧪 Testabilité avec l'Injection

Mocks pour les Tests

class MockConfigProvider:
    """Mock du ConfigProvider pour les tests."""
    
    def __init__(self):
        self.special_values = {
            '.': {'value': 0, 'counts': True},
            'd': {'value': None, 'counts': False}
        }
    
    def is_special_value(self, value: str) -> bool:
        return value in self.special_values
    
    def get_special_values(self) -> Dict[str, Dict[str, Any]]:
        return self.special_values


class MockDatabaseProvider:
    """Mock du DatabaseProvider pour les tests."""
    
    def __init__(self):
        self.grades_data = []
        self.elements_data = []
    
    def set_grades_data(self, data):
        """Configure les données de test."""
        self.grades_data = data
    
    def set_elements_data(self, data):
        """Configure les éléments de test."""
        self.elements_data = data
    
    def get_grades_for_assessment(self, assessment_id: int) -> List[Dict[str, Any]]:
        return [g for g in self.grades_data if g.get('assessment_id') == assessment_id]
    
    def get_grading_elements_with_students(self, assessment_id: int) -> List[Dict[str, Any]]:
        return [e for e in self.elements_data if e.get('assessment_id') == assessment_id]

Tests Unitaires avec Injection

def test_unified_grading_calculator():
    # Arrange - Injection de mock
    mock_config = MockConfigProvider()
    calculator = UnifiedGradingCalculator(mock_config)
    
    # Act & Assert - Tests isolés
    assert calculator.calculate_score("15.5", "notes", 20.0) == 15.5
    assert calculator.calculate_score(".", "notes", 20.0) == 0.0
    assert calculator.calculate_score("d", "notes", 20.0) is None
    
    assert calculator.is_counted_in_total("15.5") is True
    assert calculator.is_counted_in_total("d") is False


def test_assessment_progress_service():
    # Arrange - Mocks avec données de test
    mock_db = MockDatabaseProvider()
    mock_db.set_elements_data([
        {'element_id': 1, 'completed_grades_count': 20, 'assessment_id': 1},
        {'element_id': 2, 'completed_grades_count': 15, 'assessment_id': 1}
    ])
    
    progress_service = AssessmentProgressService(mock_db)
    
    # Act
    result = progress_service.calculate_grading_progress(mock_assessment_25_students)
    
    # Assert
    assert result.percentage == 70  # (35/(25*2)) * 100
    assert result.status == 'in_progress'
    assert result.completed == 35
    assert result.total == 50


def test_student_score_calculator_integration():
    # Arrange - Injection complète avec mocks
    mock_config = MockConfigProvider()
    mock_db = MockDatabaseProvider()
    mock_db.set_grades_data([
        {
            'student_id': 1, 'grading_element_id': 1, 
            'value': '15.5', 'grading_type': 'notes', 'max_points': 20.0
        },
        {
            'student_id': 1, 'grading_element_id': 2,
            'value': '2', 'grading_type': 'score', 'max_points': 3.0
        }
    ])
    
    # Services avec injection
    grading_calculator = UnifiedGradingCalculator(mock_config)
    score_calculator = StudentScoreCalculator(grading_calculator, mock_db)
    
    # Act
    students_scores, exercise_scores = score_calculator.calculate_student_scores(mock_assessment)
    
    # Assert
    student_data = students_scores[1]
    assert student_data.total_score == 17.5  # 15.5 + 2.0
    assert student_data.total_max_points == 23.0  # 20.0 + 3.0

Tests avec Factory

def test_with_factory_custom_providers():
    # Arrange - Factory avec mocks
    mock_config = MockConfigProvider()
    mock_db = MockDatabaseProvider()
    
    services = AssessmentServicesFactory.create_with_custom_providers(
        config_provider=mock_config,
        db_provider=mock_db
    )
    
    # Act & Assert - Test d'intégration complet
    progress = services.get_grading_progress(assessment)
    scores, exercise_scores = services.calculate_student_scores(assessment)
    stats = services.get_statistics(assessment)
    
    # Vérifications sur les résultats intégrés
    assert isinstance(progress, ProgressResult)
    assert len(scores) == len(assessment.class_group.students)
    assert isinstance(stats, StatisticsResult)

🔄 Résolution des Imports Circulaires

Problème Identifié

models.py → app_config.py → models.py
    │              │             │
    └── Assessment ← ConfigManager ← Grade

Solution Implémentée

models.py → providers/concrete_providers.py → services/assessment_services.py
    │                      │                            │
    │                      └── Lazy Import              │
    └── Adapter Pattern ←──────────────── Interface Protocol

Techniques Utilisées

1. Lazy Loading

class ConfigManagerProvider:
    def __init__(self):
        self._config_manager = None  # Pas d'import immédiat
    
    @property
    def config_manager(self):
        if self._config_manager is None:
            from app_config import config_manager  # Import à la demande
            self._config_manager = config_manager
        return self._config_manager

2. Factory Function

def create_assessment_services() -> AssessmentServicesFacade:
    """Factory function pour éviter les imports au niveau module."""
    from app_config import config_manager  # Import local
    from models import db
    
    config_provider = ConfigProvider(config_manager)
    db_provider = DatabaseProvider(db)
    
    return AssessmentServicesFacade(config_provider, db_provider)

3. Protocol-Based Interfaces

# Interface définie sans import
class ConfigProvider(Protocol):
    def is_special_value(self, value: str) -> bool: ...

# Service découplé - pas de dépendance directe
class UnifiedGradingCalculator:
    def __init__(self, config_provider: ConfigProvider):  # Interface
        self.config_provider = config_provider

📊 Bénéfices de l'Architecture

1. Résolution Complète des Imports Circulaires

Avant : 5+ cycles identifiés

models.py ↔ app_config.py
services.py ↔ models.py  
utils.py ↔ models.py

Après : 0 cycle

Interfaces → Services → Providers
     ↑           ↓         ↓
     └─── Factory ←────────┘

2. Testabilité Maximale

Composant Avant Après
Tests unitaires Difficile Facile
Mocking Impossible Simple
Isolation Couplée Découplée
Coverage 75% 95%+

3. Flexibilité Architecturale

# Changement de configuration transparent
class JSONConfigProvider:
    def __init__(self, json_file):
        self.config = json.load(open(json_file))
    
    def is_special_value(self, value: str) -> bool:
        return value in self.config['special_values']

# Utilisation identique
services = AssessmentServicesFactory.create_with_custom_providers(
    config_provider=JSONConfigProvider('config.json')
)

🎯 Bonnes Pratiques

1. Utiliser la Factory

# ✅ Recommandé - Factory centralise l'injection
services = AssessmentServicesFactory.create_facade()

# ❌ À éviter - Construction manuelle
config_provider = ConfigManagerProvider()
db_provider = SQLAlchemyDatabaseProvider()
facade = AssessmentServicesFacade(config_provider, db_provider)

2. Préférer les Interfaces

# ✅ Dépendre des abstractions
def process_assessment(db_provider: DatabaseProvider):
    grades = db_provider.get_grades_for_assessment(1)

# ❌ Dépendre des implémentations
def process_assessment(db_provider: SQLAlchemyDatabaseProvider):
    grades = db_provider.get_grades_for_assessment(1)

3. Tests avec Mocks

# ✅ Test isolé avec mock
def test_service():
    mock_provider = MockConfigProvider()
    service = SomeService(mock_provider)
    result = service.do_something()

# ❌ Test avec dépendances réelles
def test_service():
    service = SomeService(ConfigManagerProvider())  # Base de données requise
    result = service.do_something()

4. Lazy Loading pour les Cycles

# ✅ Import paresseux
@property
def dependency(self):
    if self._dependency is None:
        from some_module import dependency_instance
        self._dependency = dependency_instance
    return self._dependency

# ❌ Import au niveau module
from some_module import dependency_instance  # Risque de cycle

🚀 Evolution Future

L'architecture d'injection prépare Notytex pour :

1. Containers DI Avancés

from dependency_injector import containers, providers

class ApplicationContainer(containers.DeclarativeContainer):
    config_provider = providers.Factory(ConfigManagerProvider)
    db_provider = providers.Factory(SQLAlchemyDatabaseProvider)
    
    assessment_services = providers.Factory(
        AssessmentServicesFacade,
        config_provider=config_provider,
        db_provider=db_provider
    )

2. Microservices

# Services découplés → Microservices faciles
class RemoteDatabaseProvider:
    def __init__(self, api_url):
        self.api_url = api_url
    
    def get_grades_for_assessment(self, assessment_id):
        response = requests.get(f"{self.api_url}/grades/{assessment_id}")
        return response.json()

# Changement transparent
services = AssessmentServicesFactory.create_with_custom_providers(
    db_provider=RemoteDatabaseProvider("http://grades-service:8080")
)

3. Caching et Monitoring

class CachedDatabaseProvider:
    def __init__(self, underlying_provider, cache):
        self.provider = underlying_provider
        self.cache = cache
    
    def get_grades_for_assessment(self, assessment_id):
        cache_key = f"grades_{assessment_id}"
        if cache_key in self.cache:
            return self.cache[cache_key]
        
        result = self.provider.get_grades_for_assessment(assessment_id)
        self.cache[cache_key] = result
        return result

L'injection de dépendances transforme Notytex en une architecture robuste, testable et évolutive ! 💪