Laboratoire pratique pour comprendre et démontrer l'intérêt des principes SOLID et CUPID en C# / .NET 10.
Ce projet propose, pour chaque famille de principes, deux implémentations de la même fonctionnalité :
| Dossier | Contenu | Objectif |
|---|---|---|
Without/ |
Code violant les principes (anti-patterns) | Montrer les problèmes concrets |
With/ |
Code respectant les principes | Montrer les solutions apportées |
graph LR
A["🏗️ Même fonctionnalité"] --> B["❌ Without<br/>Anti-patterns"]
A --> C["✅ With<br/>Bonnes pratiques"]
B --> D["🔴 Difficile à tester"]
B --> E["🔴 Couplage fort"]
B --> F["🔴 Risques de régression"]
C --> G["🟢 Testable unitairement"]
C --> H["🟢 Extensible"]
C --> I["🟢 Maintenable"]
CSharp/
├── SOLID/ 📐 Principes de conception orientée objet
│ ├── With/ ✅ SOLID respecté
│ └── Without/ ❌ SOLID violé
│
└── CUPID/ ☕ Propriétés de code de qualité
├── With/ ✅ SOLID + CUPID
└── Without/ ❌ SOLID ✓ mais CUPID ✗
Les 5 principes SOLID guident la structure du code orienté objet.
mindmap
root((🏛️ SOLID))
🇸 Single Responsibility
Une classe = une raison de changer
🇴 Open/Closed
Ouvert à l extension
Fermé à la modification
🇱 Liskov Substitution
Un sous-type remplace son type de base
Sans surprise
🇮 Interface Segregation
Interfaces petites et ciblées
Pas de méthodes inutiles
🇩 Dependency Inversion
Dépendre des abstractions
Pas des implémentations
| Principe | ❌ Without (anti-pattern) | ✅ With (bonne pratique) |
|---|---|---|
| S — SRP | OrderService — God class 4 responsabilités |
4 classes ciblées (Validator, Calculator, Repository, Notifier) |
| O — OCP | switch fermé — modifier pour étendre |
IDiscountStrategy — créer une classe pour étendre |
| L — LSP | Square : Rectangle avec effets de bord |
IShape + records immuables, substituables |
| I — ISP | IWorker impose Eat()/Sleep() au robot → NotSupportedException |
Interfaces ciblées, le robot n'implémente que IWorkable |
| D — DIP | new FileOrderRepository() en dur |
Injection de IOrderRepository — testable avec stubs |
📂 Détail de la structure SOLID
SOLID/
├── With/Sources/Solid.With/
│ ├── Models/Order.cs 📦 Modèles partagés
│ ├── Srp/ 1️⃣ OrderValidator, PriceCalculator, OrderRepository, NotificationService
│ ├── Ocp/ 2️⃣ IDiscountStrategy + PercentageDiscount, FixedAmountDiscount, NoDiscount
│ ├── Lsp/ 3️⃣ IShape + Rectangle, Square, Circle (records immuables)
│ ├── Isp/ 4️⃣ IWorkable, IFeedable, ISleepable, IMeetingAttendee
│ └── Dip/ 5️⃣ IOrderRepository, INotificationService + OrderProcessor
│
└── Without/Sources/Solid.Without/
├── Models/Order.cs 📦 Mêmes modèles
├── Srp/OrderService.cs 1️⃣ God class (valide + calcule + persiste + notifie)
├── Ocp/DiscountCalculator.cs 2️⃣ Switch fermé
├── Lsp/Shapes.cs 3️⃣ Rectangle/Square avec effet de bord
├── Isp/Workers.cs 4️⃣ Interface fourre-tout IWorker
└── Dip/OrderProcessor.cs 5️⃣ new FileOrderRepository(), new SmtpEmailService()
CUPID (par Daniel Terhorst-North) décrit les propriétés que devrait avoir un bon code. Là où SOLID guide la structure, CUPID guide la qualité ressentie du code.
graph TB
subgraph "📐 SOLID"
direction TB
S1["Structure du code"]
S2["Comment organiser<br/>classes et interfaces"]
S3["Règles de conception"]
end
subgraph "☕ CUPID"
direction TB
C1["Qualité du code"]
C2["Comment le code<br/>se lit et se compose"]
C3["Propriétés souhaitables"]
end
SOLID --> BOTH["✅ Code bien structuré<br/>ET agréable à utiliser"]
CUPID --> BOTH
⚠️ Point clé du projet : le dossierCUPID/Withoutcontient du code SOLID-compliant (interfaces, DI, SRP…) mais qui manque de qualités CUPID. Cela montre que SOLID seul ne suffit pas pour du code vraiment agréable à utiliser.
mindmap
root((☕ CUPID))
🧩 Composable
Se combine facilement
Petite surface d API
Utilisable seul ou avec d autres
🔧 Unix philosophy
Fait UNE chose bien
Simple et focalisé
Compréhensible en 10 secondes
🔮 Predictable
Fait ce qu on attend
Déterministe
Pas d état caché
🏠 Idiomatic
Naturel dans le langage
Suit les conventions C#
Utilise les features modernes
🗣️ Domain-based
Parle le langage métier
Ubiquitous Language
Types métier riches
Le code Without utilise un système de commandes de boissons implémenté avec SOLID (interfaces, DI, SRP) mais sans les qualités CUPID. Le code With implémente la même fonctionnalité en ajoutant les propriétés CUPID.
« Le code se combine facilement avec d'autres briques. Petite surface d'API, entrée → sortie. »
graph TB
subgraph "❌ Without — Contexte mutable partagé"
CTX["📋 OrderProcessingContext<br/>(mutable partagé)"]
S1["Step 1"] --> CTX
CTX --> S2["Step 2"]
S2 --> CTX
CTX --> S3["Step 3"]
CTX -. "🔒 Composants<br/>interdépendants" .-> LOCK["Inutilisable<br/>isolément"]
end
subgraph "✅ With — Pipeline composable"
P1["OrderPricing"] -->|"Money"| P2["DiscountCalculation"]
P2 -->|"Money"| P3["TaxCalculation"]
P3 -->|"Money"| RESULT["Total"]
P1 -. "🔓 Chaque brique<br/>fonctionne seule" .-> OK["Composable"]
end
| ❌ Without (SOLID ✓) | ✅ With (SOLID + CUPID) | |
|---|---|---|
| Communication | Contexte mutable partagé (OrderProcessingContext) |
Chaque brique prend un Money et renvoie un Money |
| Isolation | Impossible d'utiliser une étape sans le contexte complet | Chaque composant utilisable seul ou composé |
| Ordre | L'ordre des étapes change le résultat (notification avant calcul → total = 0) |
Pipeline naturel, chaque sortie = entrée suivante |
// ❌ Without — Les étapes mutent un contexte partagé
var context = new OrderProcessingContext { Order = order };
foreach (var step in steps)
step.Execute(context); // Chaque step mute context 😰
// ✅ With — Pipeline d'entrée/sortie, composable
var subTotal = pricing.CalculateSubTotal(order.Lines); // Money → Money
var discounted = discount.ApplyDiscount(subTotal); // Money → Money
var tax = taxCalc.CalculateTax(discounted); // Money → Money
var total = discounted + tax; // 🧩 Composable !« Fait UNE chose, bien, complètement. Compréhensible en 10 secondes. »
graph LR
subgraph "❌ Without — Framework générique"
ENGINE["🏭 ConfigurableValidationEngine<T><br/>──────────────<br/>AddRule(predicate, msg)<br/>WithStopOnFirstError(bool)<br/>WithErrorCallback(Action)<br/>Validate(T)"]
ENGINE -. "🤔 Sur-ingénierie<br/>Quel T ? Quelles règles ?" .-> COMPLEX["Complexe à<br/>comprendre"]
end
subgraph "✅ With — Outil focalisé"
VALID["✏️ OrderValidator<br/>──────────────<br/>Validate(CoffeeOrder)"]
VALID -. "✅ Compris en<br/>10 secondes" .-> SIMPLE["Simple et<br/>efficace"]
end
| ❌ Without (SOLID ✓) | ✅ With (SOLID + CUPID) | |
|---|---|---|
| Portée | ConfigurableValidationEngine<T> — valide n'importe quoi |
OrderValidator — valide des commandes de café |
| Clarté | Il faut lire la configuration pour comprendre | Le nom suffit |
| Usage | Configurer, ajouter des règles, puis valider | Appeler Validate(order) |
// ❌ Without — Framework à configurer avant usage
var engine = new ConfigurableValidationEngine<OrderDto>()
.AddRule(o => !string.IsNullOrWhiteSpace(o.GetCustomerEmail()), "Email requis")
.AddRule(o => o.GetItems().Count > 0, "Articles requis")
.WithStopOnFirstError(true);
var result = engine.Validate(order); // 🏭 Usine à gaz
// ✅ With — Fait UNE chose, bien
var result = validator.Validate(order); // 🔧 C'est tout !« Fait ce qu'on attend. Même entrée → même sortie. Pas d'état caché. »
graph TB
subgraph "❌ Without — État mutable caché"
ENGINE2["PriceCalculationEngine<br/>──────────────<br/>_runningTotal 💣<br/>_itemCount 💣<br/>_bulkDiscountApplied 💣"]
CALL1["Execute() #1"] --> ENGINE2
ENGINE2 --> R1["Total = 60"]
CALL2["Execute() #2"] --> ENGINE2
ENGINE2 --> R2["Total = 120 😱<br/>+ remise cachée !"]
end
subgraph "✅ With — Fonctions pures"
CALC["PriceCalculator<br/>──────────────<br/>Aucun état interne"]
CALLA["CalculateSubTotal()"] --> CALC --> RA["9.00 ✅"]
CALLB["CalculateSubTotal()"] --> CALC --> RB["9.00 ✅"]
end
| ❌ Without (SOLID ✓) | ✅ With (SOLID + CUPID) | |
|---|---|---|
| État | _runningTotal s'accumule entre les appels |
Aucun état interne |
| Déterminisme | Appeler 2 fois = résultat doublé 😱 | Appeler 100 fois = même résultat ✅ |
| Surprises | Remise volume cachée au-delà de 5 articles | Tous les paramètres sont explicites |
// ❌ Without — État caché, résultat imprévisible
engine.Execute(context); // SubTotal = 60
engine.Execute(context); // SubTotal = 120 😱 (accumulé !)
// ✅ With — Fonction pure, toujours le même résultat
calculator.CalculateSubTotal(lines); // 9.00
calculator.CalculateSubTotal(lines); // 9.00 ✅ (même entrée = même sortie)« Naturel dans le langage. Un développeur C# lit le code et se dit : "c'est comme ça que j'aurais fait." »
graph LR
subgraph "❌ Without — Style Java en C#"
JAVA["item.GetName()<br/>item.SetName(v)<br/>item.GetUnitPrice()<br/>item.SetUnitPrice(v)<br/>new OrderDtoBuilder()<br/> .WithCustomerName(...)<br/> .AddBeverage(...)<br/> .Build()"]
end
subgraph "✅ With — C# idiomatique"
CSHARP["new Coffee(name, size, price)<br/>new OrderLine(coffee, qty)<br/>new CoffeeOrder(name, email, lines)<br/>CoffeeMenu.Espresso()<br/>CoffeeMenu.Latte(Large)<br/>espresso with { Size = Large }"]
end
JAVA -. "😑 Verbeux,<br/>non naturel" .-> JAVA
CSHARP -. "😊 Concis,<br/>idiomatique" .-> CSHARP
| ❌ Without (SOLID ✓) | ✅ With (SOLID + CUPID) | |
|---|---|---|
| Modèles | Classes mutables, getters/setters Java | Records immuables, constructeurs primaires |
| Construction | Builder Java-style verbeux | Constructeur record + with expressions |
| API | item.GetName(), item.SetName(v) |
item.Name (propriété C#) |
| Opérateurs | Pas utilisés | price * qty, subtotal + tax (surcharge naturelle) |
// ❌ Without — Verbeux, style Java
var order = new OrderDtoBuilder()
.WithCustomerName("Alice")
.WithCustomerEmail("alice@coffee.com")
.AddBeverage("Latte", 4.00m, 2, "Medium")
.Build();
// ✅ With — Concis, idiomatique C#
var order = new CoffeeOrder("Alice", "alice@coffee.com",
[CoffeeMenu.OrderLine(CoffeeMenu.Latte(), 2)]);« Le code parle le langage du métier. Un expert du domaine reconnaît les concepts. »
graph TB
subgraph "❌ Without — Jargon technique"
T1["OrderDto"]
T2["BeverageItemDto"]
T3["EntityPersistenceManager"]
T4["NotificationDispatcher"]
T5["PersistEntity()"]
T6["DispatchPayload()"]
end
subgraph "✅ With — Langage du coffee shop"
D1["☕ CoffeeOrder"]
D2["☕ Coffee, Espresso, Latte"]
D3["💰 Money"]
D4["🏪 CoffeeShop"]
D5["📝 PlaceOrder()"]
D6["✉️ SendConfirmation()"]
end
| ❌ Without (SOLID ✓) | ✅ With (SOLID + CUPID) | |
|---|---|---|
| Modèle | OrderDto, BeverageItemDto |
CoffeeOrder, Coffee, OrderLine |
| Façade | OrderOrchestrator |
CoffeeShop |
| Actions | PersistEntity(), DispatchPayload() |
PlaceOrder(), SaveOrder(), SendConfirmation() |
| Types | decimal pour l'argent |
Money — Value Object métier |
| Vocabulaire | Technique (DTO, Entity, Payload, Endpoint) | Métier (Coffee, Latte, Espresso, Small, Large) |
// ❌ Without — Un barista ne comprend pas ce code
var manager = new EntityPersistenceManager();
manager.Execute(context); // "Entity persisted to data store"
// ✅ With — Un barista comprend ce code
var shop = new CoffeeShop(validator, pricing, tax, store, notifier);
var confirmation = shop.PlaceOrder(order); // ☕ Langage métier !graph LR
subgraph "☕ CUPID = code de qualité"
direction TB
C["🧩 Composable<br/>Se combine facilement"]
U["🔧 Unix<br/>Fait une chose bien"]
P["🔮 Predictable<br/>Pas de surprise"]
I["🏠 Idiomatic<br/>Naturel en C#"]
D["🗣️ Domain-based<br/>Parle le métier"]
end
C --> RESULT["✅ Facile à lire<br/>✅ Facile à tester<br/>✅ Facile à comprendre<br/>✅ Agréable à utiliser"]
U --> RESULT
P --> RESULT
I --> RESULT
D --> RESULT
📂 Détail de la structure CUPID
CUPID/
├── With/Sources/Cupid.With/ ✅ SOLID + CUPID
│ ├── Models/
│ │ ├── CoffeeSize.cs 🗣️ Enum métier
│ │ ├── Money.cs 🏠 Value Object idiomatique avec opérateurs
│ │ └── CoffeeOrder.cs 🗣️ Coffee, OrderLine, CoffeeOrder, OrderConfirmation
│ ├── Composable/Pricing.cs 🧩 OrderPricing, TaxCalculation, DiscountCalculation
│ ├── Unix/OrderValidator.cs 🔧 Fait UNE chose bien
│ ├── Predictable/PriceCalculator.cs 🔮 Fonctions pures, pas d'état
│ ├── Idiomatic/CoffeeMenu.cs 🏠 API naturelle C# avec pattern matching
│ └── Domain/CoffeeShop.cs 🗣️ Façade métier + IOrderStore, IConfirmationNotifier
│
└── Without/Sources/Cupid.Without/ ❌ SOLID ✓ mais CUPID ✗
├── Models/OrderDto.cs 🗣️✗ DTO anémique, accesseurs Java
├── Composable/OrderOrchestrator.cs 🧩✗ Contexte mutable partagé
├── Unix/ConfigurableValidationEngine.cs 🔧✗ Framework sur-ingénié
├── Predictable/PriceCalculationEngine.cs 🔮✗ État caché, non déterministe
├── Idiomatic/OrderDtoBuilder.cs 🏠✗ Builder Java-style
└── Domain/Services.cs 🗣️✗ EntityPersistenceManager, NotificationDispatcher
dotnet test111 tests au total — 54 SOLID + 57 CUPID — tous passent ✅
- .NET 10 / C# 14
- xUnit pour les tests
- Aucune dépendance externe — tout le code est autonome
Projet à vocation pédagogique — libre d'utilisation.