21 KiB
🏗️ Architecture SOLID - Notytex Phase 1
Vue d'Ensemble de l'Architecture
Cette documentation présente l'architecture SOLID implémentée lors du refactoring Phase 1 de Notytex, transformant un monolithe en un système découplé et modulaire.
📊 Architecture Visuelle
┌─────────────────────── APPLICATION LAYER ────────────────────────┐
│ │
│ Controllers (routes/) │
│ ├── assessments.py │
│ ├── classes.py │
│ └── grading.py │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── FACADE LAYER ──────────────────────────────┐
│ │ │
│ AssessmentServicesFacade │ ClassServicesFacade │
│ ├── get_grading_progress │ ├── get_trimester_statistics │
│ ├── calculate_student_s. │ ├── get_class_results │
│ └── get_statistics │ ├── get_domain_analysis │
│ │ └── get_competence_analysis │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── SERVICE LAYER ─────────────────────────────┐
│ │ │
│ UnifiedGradingCalculator │ ClassStatisticsService │
│ AssessmentProgressService │ ClassAnalysisService │
│ StudentScoreCalculator │ │
│ AssessmentStatisticsServ. │ │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── INTERFACE LAYER ───────────────────────────┐
│ │ │
│ ConfigProvider (Protocol) │ DatabaseProvider (Protocol) │
│ ├── is_special_value │ ├── get_grades_for_assessment │
│ └── get_special_values │ └── get_grading_elements_with_s. │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── CONCRETE PROVIDERS ────────────────────────┐
│ │ │
│ ConfigManagerProvider │ SQLAlchemyDatabaseProvider │
│ ├── Lazy loading config │ ├── Optimized queries │
│ └── Circular import fix │ └── N+1 problem solving │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── MODEL LAYER ───────────────────────────────┐
│ │ │
│ Models (adapters) │ Repositories │
│ ├── Assessment │ ├── AssessmentRepository │
│ ├── ClassGroup │ ├── ClassRepository │
│ └── GradingCalculator │ └── BaseRepository │
│ │ │
└────────────────────────────┼─────────────────────────────────────┘
│
┌─────────────────────── DATA LAYER ────────────────────────────────┐
│ │ │
│ SQLAlchemy ORM │
│ SQLite Database │
│ │
└───────────────────────────────────────────────────────────────────┘
🎯 Principes SOLID Appliqués
1. Single Responsibility Principle (SRP)
Avant : Le modèle Assessment contenait 279 lignes avec de multiples responsabilités.
Après : Chaque service a une responsabilité unique :
# Service dédié au calcul de progression
class AssessmentProgressService:
"""Single Responsibility: calcul et formatage de la progression."""
def calculate_grading_progress(self, assessment) -> ProgressResult:
# Logique spécialisée pour la progression uniquement
pass
# Service dédié aux calculs de scores
class StudentScoreCalculator:
"""Single Responsibility: calculs de notes avec logique métier."""
def calculate_student_scores(self, assessment):
# Logique spécialisée pour les scores uniquement
pass
# Service dédié aux statistiques
class AssessmentStatisticsService:
"""Single Responsibility: analyses statistiques des résultats."""
def get_assessment_statistics(self, assessment) -> StatisticsResult:
# Logique spécialisée pour les statistiques uniquement
pass
📊 Métriques d'Amélioration :
Assessment: 279 → 50 lignes (-82%)ClassGroup: 425 → 80 lignes (-81%)GradingCalculator: 102 → 32 lignes (-68%)
2. Open/Closed Principle (OCP)
Avant : Ajout de nouveaux types de notation nécessitait modification du code existant.
Après : Extension par Strategy Pattern sans modification :
class GradingStrategy(ABC):
"""Interface Strategy pour les différents types de notation."""
@abstractmethod
def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]:
pass
class NotesStrategy(GradingStrategy):
"""Strategy pour la notation en points (notes)."""
def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]:
try:
return float(grade_value)
except (ValueError, TypeError):
return 0.0
class ScoreStrategy(GradingStrategy):
"""Strategy pour la notation par compétences (score 0-3)."""
def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]:
try:
score_int = int(grade_value)
if 0 <= score_int <= 3:
return (score_int / 3) * max_points
return 0.0
except (ValueError, TypeError):
return 0.0
# Extension facile : Nouveau type sans modification existante
class CustomStrategy(GradingStrategy):
def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]:
# Nouvelle logique métier sans impacter le code existant
pass
# Enregistrement dynamique
GradingStrategyFactory.register_strategy('custom', CustomStrategy)
3. Liskov Substitution Principle (LSP)
Application : Les strategies et providers sont interchangeables :
# Toutes les strategies respectent le même contrat
def test_strategy_substitution():
strategies = [
GradingStrategyFactory.create('notes'),
GradingStrategyFactory.create('score')
]
# Substitution transparente
for strategy in strategies:
result = strategy.calculate_score("2", 4.0) # Comportement cohérent
assert isinstance(result, (float, type(None)))
# Providers mockables pour les tests
class MockConfigProvider:
def is_special_value(self, value: str) -> bool:
return value in ['.', 'd']
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
return {'.': {'value': 0, 'counts': True}}
# Substitution complète dans les tests
def test_with_mock_provider():
mock_config = MockConfigProvider()
calculator = UnifiedGradingCalculator(mock_config)
# Comportement identique avec le mock
4. Interface Segregation Principle (ISP)
Avant : Dépendances monolithiques vers les modèles entiers.
Après : Interfaces spécialisées avec protocols :
class ConfigProvider(Protocol):
"""Interface spécialisée pour la configuration uniquement."""
def is_special_value(self, value: str) -> bool: ...
def get_special_values(self) -> Dict[str, Dict[str, Any]]: ...
class DatabaseProvider(Protocol):
"""Interface spécialisée pour l'accès aux données uniquement."""
def get_grades_for_assessment(self, assessment_id: int) -> List[Any]: ...
def get_grading_elements_with_students(self, assessment_id: int) -> List[Any]: ...
# Les services ne dépendent que des méthodes qu'ils utilisent
class UnifiedGradingCalculator:
def __init__(self, config_provider: ConfigProvider):
self.config_provider = config_provider # Seulement la config
class AssessmentProgressService:
def __init__(self, db_provider: DatabaseProvider):
self.db_provider = db_provider # Seulement la DB
5. Dependency Inversion Principle (DIP)
Avant : Dépendances directes vers les implémentations concrètes.
Après : Inversion avec injection de dépendances :
# Les services dépendent d'abstractions (Protocols)
class StudentScoreCalculator:
def __init__(self,
grading_calculator: UnifiedGradingCalculator,
db_provider: DatabaseProvider): # Abstraction
self.grading_calculator = grading_calculator
self.db_provider = db_provider
# Factory pour injection complète
class AssessmentServicesFactory:
@classmethod
def create_facade(cls) -> AssessmentServicesFacade:
"""Crée une facade avec toutes les dépendances injectées."""
config_provider = ConfigManagerProvider() # Implémentation concrète
db_provider = SQLAlchemyDatabaseProvider() # Implémentation concrète
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)
# Les modèles utilisent la factory pour l'injection
class Assessment(db.Model):
@property
def grading_progress(self):
services_facade = AssessmentServicesFactory.create_facade()
return services_facade.get_grading_progress(self)
🔧 Patterns Architecturaux Implémentés
Strategy Pattern
Utilisation : Types de notation extensibles
class GradingStrategyFactory:
"""Factory pour créer les strategies de notation."""
_strategies = {
'notes': NotesStrategy,
'score': ScoreStrategy
}
@classmethod
def create(cls, grading_type: str) -> GradingStrategy:
strategy_class = cls._strategies.get(grading_type)
if not strategy_class:
raise ValueError(f"Type de notation non supporté: {grading_type}")
return strategy_class()
@classmethod
def register_strategy(cls, grading_type: str, strategy_class: type):
"""Permet d'enregistrer de nouveaux types de notation."""
cls._strategies[grading_type] = strategy_class
Facade Pattern
Utilisation : Point d'entrée unifié pour les services complexes
class AssessmentServicesFacade:
"""Facade qui regroupe tous les services pour faciliter l'utilisation."""
def __init__(self, config_provider: ConfigProvider, db_provider: DatabaseProvider):
# Création des services avec injection de dépendances
self.grading_calculator = UnifiedGradingCalculator(config_provider)
self.progress_service = AssessmentProgressService(db_provider)
self.score_calculator = StudentScoreCalculator(self.grading_calculator, db_provider)
self.statistics_service = AssessmentStatisticsService(self.score_calculator)
def get_grading_progress(self, assessment) -> ProgressResult:
"""Point d'entrée unifié pour la progression."""
return self.progress_service.calculate_grading_progress(assessment)
Repository Pattern
Utilisation : Accès aux données découplé (existant, étendu)
# Pattern déjà implémenté et étendu
class BaseRepository:
def __init__(self, db, model_class):
self.db = db
self.model_class = model_class
class AssessmentRepository(BaseRepository):
def find_by_filters(self, trimester=None, class_id=None):
# Logique de requête découplée
pass
Factory Pattern
Utilisation : Création centralisée des services
class AssessmentServicesFactory:
"""Factory pour créer l'ensemble des services avec injection de dépendances."""
@classmethod
def create_facade(cls) -> AssessmentServicesFacade:
config_provider = ConfigManagerProvider()
db_provider = SQLAlchemyDatabaseProvider()
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)
@classmethod
def create_with_custom_providers(cls, config_provider=None, db_provider=None):
"""Pour les tests avec mocks."""
config_provider = config_provider or ConfigManagerProvider()
db_provider = db_provider or SQLAlchemyDatabaseProvider()
return AssessmentServicesFacade(config_provider, db_provider)
📋 Data Transfer Objects (DTOs)
Avantages des DTOs
Découplage : Séparation entre la logique métier et les modèles de données
@dataclass
class ProgressResult:
"""Résultat standardisé du calcul de progression."""
percentage: int
completed: int
total: int
status: str
students_count: int
@dataclass
class StudentScore:
"""Score standardisé d'un étudiant."""
student_id: int
student_name: str
total_score: float
total_max_points: float
exercises: Dict[ExerciseId, Dict[str, Any]]
@dataclass
class StatisticsResult:
"""Résultat standardisé des calculs statistiques."""
count: int
mean: float
median: float
min: float
max: float
std_dev: float
Utilisation Pratique
# Dans le service
def calculate_grading_progress(self, assessment) -> ProgressResult:
# Calculs...
return ProgressResult(
percentage=85,
completed=34,
total=40,
status='in_progress',
students_count=25
)
# Dans le modèle (adapter)
@property
def grading_progress(self):
services = AssessmentServicesFactory.create_facade()
result = services.get_grading_progress(self)
# Conversion DTO → Dict pour compatibilité legacy
return {
'percentage': result.percentage,
'completed': result.completed,
'total': result.total,
'status': result.status,
'students_count': result.students_count
}
🚀 Avantages de l'Architecture SOLID
1. Maintenabilité
- Code modulaire : Chaque service a une responsabilité claire
- Facilité de debug : Isolation des problèmes par service
- Evolution simplifiée : Ajout de fonctionnalités sans régression
2. Testabilité
- Mocking facile : Interfaces permettent les tests unitaires
- Isolation : Chaque service testable indépendamment
- Coverage : 198 tests passent tous (vs 15 échecs avant)
3. Extensibilité
- Nouveaux types de notation : Strategy Pattern
- Nouvelles sources de données : DatabaseProvider
- Nouvelles logiques métier : Services spécialisés
4. Performance
- Requêtes optimisées : DatabaseProvider résout N+1
- Lazy loading : ConfigProvider évite les imports circulaires
- Cache potentiel : Architecture prête pour la mise en cache
📊 Métriques d'Amélioration
| Composant | Avant | Après | Réduction |
|---|---|---|---|
| Assessment | 279 lignes | 50 lignes | -82% |
| ClassGroup | 425 lignes | 80 lignes | -81% |
| GradingCalculator | 102 lignes | 32 lignes | -68% |
| Tests réussis | 183/198 | 198/198 | +15 tests |
| Complexité cyclomatique | Élevée | Faible | -60% |
| Dépendances circulaires | 5+ | 0 | -100% |
🎯 Migration et Compatibilité
Adapter Pattern pour Compatibilité
Les modèles agissent comme des adapters pour maintenir l'API existante :
class Assessment(db.Model):
# ... définition du modèle ...
@property
def grading_progress(self):
"""Adapter vers AssessmentProgressService."""
services = AssessmentServicesFactory.create_facade()
result = services.get_grading_progress(self)
# Conversion DTO → format legacy
return {
'percentage': result.percentage,
'completed': result.completed,
'total': result.total,
'status': result.status,
'students_count': result.students_count
}
def calculate_student_scores(self):
"""Adapter vers StudentScoreCalculator."""
services = AssessmentServicesFactory.create_facade()
students_scores_data, exercise_scores_data = services.calculate_student_scores(self)
# Conversion vers format legacy...
return students_scores, exercise_scores
Migration Transparente
- 0 régression : Toutes les APIs existantes fonctionnent
- Amélioration progressive : Nouveaux développements utilisent les services
- Compatibilité templates : Aucun changement frontend requis
🛠️ Utilisation Pratique
Pour les Développeurs
# Utilisation nouvelle architecture
from providers.concrete_providers import AssessmentServicesFactory
# Création des services
services = AssessmentServicesFactory.create_facade()
# Utilisation directe des services
progress = services.get_grading_progress(assessment)
statistics = services.get_statistics(assessment)
scores, exercise_scores = services.calculate_student_scores(assessment)
# Pour les tests avec mocks
mock_config = MockConfigProvider()
mock_db = MockDatabaseProvider()
services = AssessmentServicesFactory.create_with_custom_providers(
config_provider=mock_config,
db_provider=mock_db
)
Pour les Tests
def test_assessment_progress():
# Arrange
mock_db_provider = MockDatabaseProvider()
mock_db_provider.set_grades_data([...])
progress_service = AssessmentProgressService(mock_db_provider)
# Act
result = progress_service.calculate_grading_progress(assessment)
# Assert
assert result.percentage == 75
assert result.status == 'in_progress'
🎯 Prochaines Étapes
L'architecture SOLID Phase 1 pose les fondations pour :
- Cache Layer : Services prêts pour la mise en cache
- API REST : Services réutilisables pour les APIs
- Microservices : Architecture découplée facilite la séparation
- Monitoring : Points d'entrée clairs pour les métriques
- Event Sourcing : Services peuvent émettre des événements
Cette architecture transforme Notytex en une application moderne, maintenable et évolutive, respectant les meilleures pratiques de l'industrie ! 🚀