# 💉 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 ```python # ❌ 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 ``` ```python # ❌ 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 ```python # ✅ 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 ```python 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é ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python 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 ```python # 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 ```python # 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 ```python # ✅ 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 ```python # ✅ 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 ```python # ✅ 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 ```python # ✅ 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 ```python 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 ```python # 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 ```python 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** ! 💪