
Les principes SOLID en pratique : Transformer un monolithe en architecture propre
Les principes SOLID en pratique : Transformer un monolithe en architecture propre
Table of Contents
- Introduction
- Le projet : un e-commerce en détresse
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
- L'architecture finale
- Tests et validation
- Conclusion
Introduction
Les principes SOLID ne sont pas des règles abstraites de manuels académiques. Ce sont des garde-fous concrets qui séparent le code maintenable du code qui devient une dette technique en quelques mois. Dans cet article, nous allons les appliquer à un cas réel : une application e-commerce TypeScript qui croule sous les problèmes.
Plutôt que de présenter chaque principe isolément avec des exemples artificiels, nous allons transformer un vrai projet — du code monolithique, couplé et fragile vers une architecture propre, testable et extensible.
Prérequis : Bonne connaissance de TypeScript, familiarité avec Node.js et les bases de la programmation orientée objet.
Le projet : un e-commerce en détresse
Voici notre point de départ — un OrderService qui fait absolument tout. C'est le type de code qu'on retrouve dans beaucoup de startups après 18 mois de développement "rapide" :
// ❌ LE PROBLÈME : une classe qui fait TOUT
class OrderService {
private db: Database;
constructor() {
this.db = new PostgresDatabase(); // Couplage dur
}
async createOrder(userId: string, items: CartItem[]) {
// 1. Validation (responsabilité #1)
if (!items.length) throw new Error("Cart is empty");
for (const item of items) {
if (item.quantity <= 0) throw new Error("Invalid quantity");
if (item.price < 0) throw new Error("Invalid price");
}
// 2. Calcul des prix (responsabilité #2)
let subtotal = 0;
for (const item of items) {
subtotal += item.price * item.quantity;
}
let tax = subtotal * 0.2; // TVA France
let shipping = subtotal > 50 ? 0 : 5.99;
// Réductions spéciales...
if (items.length > 5) subtotal *= 0.95;
const total = subtotal + tax + shipping;
// 3. Persistance (responsabilité #3)
const order = await this.db.query(
INSERT INTO orders (user_id, total, status)
VALUES (, , 'pending') RETURNING *,
[userId, total]
);
// 4. Paiement (responsabilité #4)
const stripe = new Stripe(process.env.STRIPE_KEY!);
const payment = await stripe.charges.create({
amount: Math.round(total * 100),
currency: "eur",
source: "tok_visa",
});
// 5. Notification (responsabilité #5)
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
auth: { user: process.env.EMAIL, pass: process.env.EMAIL_PASS },
});
await transporter.sendMail({
to: userId,
subject: "Commande confirmée",
html: <h1>Merci !</h1><p>Total: €</p>,
});
// 6. Inventaire (responsabilité #6)
for (const item of items) {
await this.db.query(
"UPDATE products SET stock = stock - WHERE id = ",
[item.quantity, item.productId]
);
}
// 7. Analytics (responsabilité #7)
console.log([ANALYTICS] Order created: , total: );
await fetch("https://analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ event: "order_created", total }),
});
return order;
}
}
Les problèmes sont évidents :
- 7 responsabilités dans une seule classe
- Impossible à tester unitairement (dépendances concrètes)
- Changer le système de paiement = modifier
OrderService - Ajouter une notification SMS = modifier
OrderService - Le calcul de prix est dur à faire évoluer
Transformons ce cauchemar en code professionnel.
S — Single Responsibility Principle
"Une classe ne devrait avoir qu'une seule raison de changer."
Le diagnostic
Notre OrderService viole massivement le SRP : validation, pricing, persistance, paiement, notifications, inventaire et analytics — 7 responsabilités. Si le format d'email change, on modifie OrderService. Si le calcul de TVA évolue, on modifie OrderService. Chaque changement dans n'importe quel domaine métier impacte cette même classe.
L'extraction
Chaque responsabilité devient sa propre classe avec un périmètre clair :
// ✅ Responsabilité 1 : Validation des commandes
class OrderValidator {
validate(items: CartItem[]): ValidationResult {
const errors: string[] = [];
if (!items.length) {
errors.push("Le panier est vide");
}
for (const item of items) {
if (item.quantity <= 0) {
errors.push(Quantité invalide pour );
}
if (item.price < 0) {
errors.push(Prix invalide pour );
}
if (!item.productId) {
errors.push("Identifiant produit manquant");
}
}
return {
isValid: errors.length === 0,
errors,
};
}
}
// ✅ Responsabilité 2 : Calcul des prix
class PricingEngine {
calculate(items: CartItem[], discounts: Discount[] = []): PriceBreakdown {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountAmount = this.applyDiscounts(subtotal, items, discounts);
const discountedSubtotal = subtotal - discountAmount;
const tax = this.calculateTax(discountedSubtotal);
const shipping = this.calculateShipping(discountedSubtotal);
return {
subtotal,
discountAmount,
tax,
shipping,
total: discountedSubtotal + tax + shipping,
};
}
private calculateTax(amount: number): number {
return amount * 0.2; // TVA 20%
}
private calculateShipping(subtotal: number): number {
return subtotal > 50 ? 0 : 5.99;
}
private applyDiscounts(
subtotal: number,
items: CartItem[],
discounts: Discount[]
): number {
let total = 0;
for (const discount of discounts) {
if (discount.type === "percentage") {
total += subtotal * (discount.value / 100);
} else if (discount.type === "fixed") {
total += discount.value;
}
}
// Réduction volume automatique
if (items.length > 5) {
total += subtotal * 0.05;
}
return Math.min(total, subtotal); // Ne jamais dépasser le subtotal
}
}
// ✅ Responsabilité 3 : Gestion de l'inventaire
class InventoryManager {
constructor(private readonly repository: InventoryRepository) {}
async reserveStock(items: CartItem[]): Promise<StockReservation> {
const reservations: StockReservation[] = [];
for (const item of items) {
const available = await this.repository.getStock(item.productId);
if (available < item.quantity) {
// Annuler les réservations précédentes
await this.releaseReservations(reservations);
throw new InsufficientStockError(item.productId, available, item.quantity);
}
const reservation = await this.repository.reserve(
item.productId,
item.quantity
);
reservations.push(reservation);
}
return { items: reservations, expiresAt: Date.now() + 15 * 60 * 1000 };
}
private async releaseReservations(reservations: StockReservation[]) {
for (const r of reservations) {
await this.repository.release(r.productId, r.quantity);
}
}
}
Le résultat
Désormais, chaque classe a une seule raison de changer :
OrderValidatorchange quand les règles de validation évoluentPricingEnginechange quand la logique de prix est mise à jourInventoryManagerchange quand la gestion de stock est modifiée
Impact métrique : la complexité cyclomatique de OrderService passe de 23 à 4.
O — Open/Closed Principle
"Une entité doit être ouverte à l'extension mais fermée à la modification."
Le problème concret
Notre PricingEngine calcule la TVA avec un taux fixe de 20%. Mais l'entreprise se développe en Allemagne (19%), en Espagne (21%), et bientôt au Royaume-Uni (20% mais calcul différent post-Brexit). À chaque nouveau pays, il faudrait modifier PricingEngine.
La solution : le Strategy Pattern
// ✅ Interface de stratégie de taxation
interface TaxStrategy {
readonly countryCode: string;
calculate(amount: number, category?: ProductCategory): number;
}
// Chaque pays implémente sa propre stratégie
class FranceTaxStrategy implements TaxStrategy {
readonly countryCode = "FR";
calculate(amount: number, category?: ProductCategory): number {
// TVA réduite pour les livres en France
if (category === "books") return amount * 0.055;
// TVA réduite pour l'alimentaire
if (category === "food") return amount * 0.10;
return amount * 0.20;
}
}
class GermanyTaxStrategy implements TaxStrategy {
readonly countryCode = "DE";
calculate(amount: number, category?: ProductCategory): number {
if (category === "food" || category === "books") {
return amount * 0.07; // Taux réduit allemand
}
return amount * 0.19;
}
}
// Ajouter un pays = ajouter une classe. Zéro modification du code existant.
class SpainTaxStrategy implements TaxStrategy {
readonly countryCode = "ES";
calculate(amount: number): number {
return amount * 0.21;
}
}
// ✅ PricingEngine refactorisé — ouvert à l'extension
class PricingEngine {
private readonly discountStrategies: DiscountStrategy[] = [];
constructor(
private readonly taxStrategy: TaxStrategy,
private readonly shippingStrategy: ShippingStrategy
) {}
addDiscountStrategy(strategy: DiscountStrategy): void {
this.discountStrategies.push(strategy);
}
calculate(items: CartItem[]): PriceBreakdown {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountAmount = this.discountStrategies.reduce(
(total, strategy) => total + strategy.apply(subtotal, items),
0
);
const discountedSubtotal = subtotal - Math.min(discountAmount, subtotal);
const tax = this.taxStrategy.calculate(discountedSubtotal);
const shipping = this.shippingStrategy.calculate(discountedSubtotal, items);
return {
subtotal,
discountAmount,
tax,
shipping,
total: discountedSubtotal + tax + shipping,
};
}
}
Pourquoi c'est puissant
Pour supporter un nouveau pays, on crée une classe. Pour ajouter une règle de livraison, on crée une classe. Aucun code existant n'est modifié. Le risque de régression est nul.
// Composition au point d'entrée
const frenchPricing = new PricingEngine(
new FranceTaxStrategy(),
new StandardShippingStrategy()
);
frenchPricing.addDiscountStrategy(new VolumeDiscountStrategy());
frenchPricing.addDiscountStrategy(new LoyaltyDiscountStrategy());
const germanPricing = new PricingEngine(
new GermanyTaxStrategy(),
new EUShippingStrategy()
);
L — Liskov Substitution Principle
"Les objets d'une classe dérivée doivent pouvoir remplacer les objets de la classe parente sans altérer le comportement du programme."
La violation classique
// ❌ VIOLATION : le carré hérite du rectangle mais change son comportement
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number) {
this.width = w;
this.height = w; // ⚠️ Effet de bord inattendu
}
setHeight(h: number) {
this.width = h; // ⚠️ Effet de bord inattendu
this.height = h;
}
}
// Ce code fonctionne avec Rectangle mais PAS avec Square
function doubleWidth(rect: Rectangle) {
const originalHeight = rect.area() / rect.area(); // simplifié
rect.setWidth(rect.area() / 10); // comportement imprévisible avec Square
}
Application dans notre e-commerce
Imaginons nos stratégies de paiement. Un piège courant :
// ❌ VIOLATION : CashOnDelivery ne peut pas "capturer" un paiement
interface PaymentProcessor {
authorize(amount: number): Promise<PaymentAuth>;
capture(authId: string): Promise<PaymentCapture>;
refund(captureId: string, amount: number): Promise<PaymentRefund>;
}
class StripeProcessor implements PaymentProcessor {
async authorize(amount: number) { /* ... fonctionne */ }
async capture(authId: string) { /* ... fonctionne */ }
async refund(captureId: string, amount: number) { /* ... fonctionne */ }
}
class CashOnDeliveryProcessor implements PaymentProcessor {
async authorize(amount: number) {
// Pas de vraie autorisation pour du paiement à la livraison
return { id: "cod-" + Date.now(), amount, status: "authorized" };
}
async capture(authId: string) {
// ⚠️ Comment "capturer" du cash ? On lance une exception
throw new Error("Cannot capture cash payment online");
}
async refund(captureId: string, amount: number) {
// ⚠️ Impossible de refund un paiement non-capturé
throw new Error("Cash payments cannot be refunded online");
}
}
CashOnDeliveryProcessor ne peut pas se substituer à StripeProcessor. Le code appelant qui invoque capture() va crasher. C'est une violation LSP.
La correction
// ✅ CORRECT : interfaces séparées qui respectent LSP
interface PaymentAuthorizer {
authorize(amount: number): Promise<PaymentAuth>;
}
interface PaymentCapturer {
capture(authId: string): Promise<PaymentCapture>;
}
interface PaymentRefunder {
refund(captureId: string, amount: number): Promise<PaymentRefund>;
}
// Stripe implémente tout
class StripeProcessor
implements PaymentAuthorizer, PaymentCapturer, PaymentRefunder
{
async authorize(amount: number): Promise<PaymentAuth> {
const intent = await this.stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: "eur",
});
return { id: intent.id, amount, status: "authorized" };
}
async capture(authId: string): Promise<PaymentCapture> {
const captured = await this.stripe.paymentIntents.capture(authId);
return { id: captured.id, status: "captured" };
}
async refund(captureId: string, amount: number): Promise<PaymentRefund> {
const refund = await this.stripe.refunds.create({
payment_intent: captureId,
amount: Math.round(amount * 100),
});
return { id: refund.id, amount, status: "refunded" };
}
}
// Cash on delivery n'implémente que ce qu'il sait faire
class CashOnDeliveryProcessor implements PaymentAuthorizer {
async authorize(amount: number): Promise<PaymentAuth> {
return {
id: cod-,
amount,
status: "authorized",
};
}
// Pas de capture, pas de refund — et c'est parfaitement cohérent
}
Chaque implémentation respecte intégralement les contrats qu'elle déclare. Aucune exception surprise, aucun comportement dégradé.
I — Interface Segregation Principle
"Aucun client ne devrait être forcé de dépendre de méthodes qu'il n'utilise pas."
Le problème
// ❌ Interface trop large — "fat interface"
interface UserService {
createUser(data: CreateUserDTO): Promise<User>;
updateUser(id: string, data: UpdateUserDTO): Promise<User>;
deleteUser(id: string): Promise<void>;
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
resetPassword(email: string): Promise<void>;
verifyEmail(token: string): Promise<void>;
updatePreferences(id: string, prefs: Preferences): Promise<void>;
getActivityLog(id: string): Promise<Activity[]>;
exportUserData(id: string): Promise<Buffer>; // RGPD
}
Un composant d'affichage de profil n'a besoin que de getUser(). Pourquoi devrait-il connaître deleteUser() ou exportUserData() ?
La solution
// ✅ Interfaces ciblées et cohérentes
interface UserReader {
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
}
interface UserWriter {
createUser(data: CreateUserDTO): Promise<User>;
updateUser(id: string, data: UpdateUserDTO): Promise<User>;
deleteUser(id: string): Promise<void>;
}
interface UserAuthActions {
resetPassword(email: string): Promise<void>;
verifyEmail(token: string): Promise<void>;
}
interface UserPreferences {
updatePreferences(id: string, prefs: Preferences): Promise<void>;
}
interface UserCompliance {
getActivityLog(id: string): Promise<Activity[]>;
exportUserData(id: string): Promise<Buffer>;
}
// Chaque consommateur ne reçoit que ce dont il a besoin
class ProfileComponent {
constructor(private readonly users: UserReader) {}
async render(userId: string) {
const user = await this.users.getUser(userId);
return this.buildProfile(user);
}
}
class AdminPanel {
constructor(
private readonly reader: UserReader,
private readonly writer: UserWriter,
private readonly compliance: UserCompliance
) {}
}
class AuthController {
constructor(private readonly auth: UserAuthActions) {}
}
Application à notre système de notifications
// ✅ Interfaces segregées pour les notifications
interface OrderNotifier {
notifyOrderConfirmed(order: Order): Promise<void>;
notifyOrderShipped(order: Order, tracking: string): Promise<void>;
}
interface PaymentNotifier {
notifyPaymentReceived(payment: Payment): Promise<void>;
notifyPaymentFailed(payment: Payment, reason: string): Promise<void>;
}
interface InventoryNotifier {
notifyLowStock(product: Product, current: number): Promise<void>;
notifyOutOfStock(product: Product): Promise<void>;
}
// Implémentation email — compose les interfaces nécessaires
class EmailNotificationService
implements OrderNotifier, PaymentNotifier
{
constructor(private readonly mailer: Mailer) {}
async notifyOrderConfirmed(order: Order) {
await this.mailer.send({
to: order.customerEmail,
template: "order-confirmed",
data: { orderId: order.id, total: order.total },
});
}
async notifyOrderShipped(order: Order, tracking: string) {
await this.mailer.send({
to: order.customerEmail,
template: "order-shipped",
data: { orderId: order.id, tracking },
});
}
async notifyPaymentReceived(payment: Payment) {
await this.mailer.send({
to: payment.customerEmail,
template: "payment-received",
data: { amount: payment.amount },
});
}
async notifyPaymentFailed(payment: Payment, reason: string) {
await this.mailer.send({
to: payment.customerEmail,
template: "payment-failed",
data: { reason },
});
}
}
D — Dependency Inversion Principle
"Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions."
Le couplage toxique initial
// ❌ Couplage direct aux implémentations concrètes
class OrderService {
private db = new PostgresDatabase(); // Couplage Postgres
private stripe = new Stripe(process.env.STRIPE_KEY!); // Couplage Stripe
private mailer = new NodemailerTransport(); // Couplage Nodemailer
private redis = new RedisClient(); // Couplage Redis
// Impossible de tester sans Postgres, Stripe, Gmail et Redis...
}
L'inversion complète
// ✅ Abstractions au centre — les détails à la périphérie
interface OrderRepository {
save(order: Order): Promise<Order>;
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
updateStatus(id: string, status: OrderStatus): Promise<void>;
}
interface PaymentGateway {
charge(amount: number, currency: string): Promise<PaymentResult>;
refund(paymentId: string, amount: number): Promise<RefundResult>;
}
interface NotificationService {
send(notification: Notification): Promise<void>;
}
interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
invalidate(pattern: string): Promise<void>;
}
// ✅ OrderService ne dépend que d'abstractions
class OrderService {
constructor(
private readonly validator: OrderValidator,
private readonly pricing: PricingEngine,
private readonly inventory: InventoryManager,
private readonly repository: OrderRepository,
private readonly payment: PaymentGateway,
private readonly notifications: NotificationService,
private readonly cache: CacheService
) {}
async createOrder(userId: string, items: CartItem[]): Promise<Order> {
// 1. Validation
const validation = this.validator.validate(items);
if (!validation.isValid) {
throw new ValidationError(validation.errors);
}
// 2. Réserver le stock
const reservation = await this.inventory.reserveStock(items);
try {
// 3. Calculer le prix
const price = this.pricing.calculate(items);
// 4. Effectuer le paiement
const payment = await this.payment.charge(price.total, "eur");
if (!payment.success) {
throw new PaymentError(payment.error);
}
// 5. Créer la commande
const order: Order = {
id: crypto.randomUUID(),
userId,
items,
price,
paymentId: payment.id,
status: "confirmed",
createdAt: new Date(),
};
const savedOrder = await this.repository.save(order);
// 6. Notifier
await this.notifications.send({
type: "order_confirmed",
recipient: userId,
data: { orderId: savedOrder.id, total: price.total },
});
// 7. Invalider le cache
await this.cache.invalidate(user::orders);
return savedOrder;
} catch (error) {
// Compensation : libérer le stock en cas d'échec
await this.inventory.releaseReservation(reservation.id);
throw error;
}
}
}
// Composition Root — le seul endroit où les concrétions apparaissent
function bootstrapApplication() {
const orderRepository = new PostgresOrderRepository(databasePool);
const paymentGateway = new StripePaymentGateway(stripeClient);
const notificationService = new EmailNotificationService(mailer);
const cacheService = new RedisCacheService(redisClient);
const orderService = new OrderService(
new OrderValidator(),
new PricingEngine(new FranceTaxStrategy(), new StandardShippingStrategy()),
new InventoryManager(new PostgresInventoryRepository(databasePool)),
orderRepository,
paymentGateway,
notificationService,
cacheService
);
return { orderService };
}
L'architecture finale
Voici la vue d'ensemble de notre architecture refactorisée :
src/
├── domain/ # Cœur métier — zéro dépendance
│ ├── entities/
│ │ ├── order.ts # Entité Order
│ │ ├── cart-item.ts # Value Object CartItem
│ │ └── price-breakdown.ts
│ ├── interfaces/ # Ports (abstractions)
│ │ ├── order-repository.ts
│ │ ├── payment-gateway.ts
│ │ ├── notification-service.ts
│ │ └── cache-service.ts
│ ├── services/ # Services métier
│ │ ├── order-validator.ts
│ │ ├── pricing-engine.ts
│ │ └── inventory-manager.ts
│ └── errors/ # Erreurs métier typées
│ ├── validation-error.ts
│ ├── payment-error.ts
│ └── stock-error.ts
├── infrastructure/ # Adaptateurs — implémentations concrètes
│ ├── persistence/
│ │ ├── postgres-order-repository.ts
│ │ └── postgres-inventory-repository.ts
│ ├── payment/
│ │ ├── stripe-payment-gateway.ts
│ │ └── paypal-payment-gateway.ts
│ ├── notifications/
│ │ ├── email-notification-service.ts
│ │ └── sms-notification-service.ts
│ ├── cache/
│ │ └── redis-cache-service.ts
│ └── strategies/
│ ├── tax/
│ │ ├── france-tax-strategy.ts
│ │ ├── germany-tax-strategy.ts
│ │ └── spain-tax-strategy.ts
│ └── shipping/
│ ├── standard-shipping-strategy.ts
│ └── express-shipping-strategy.ts
├── application/ # Use cases / orchestration
│ └── order-service.ts
└── composition-root.ts # Assemblage final
Les dépendances pointent toujours vers l'intérieur : l'infrastructure dépend du domaine, jamais l'inverse.
Tests et validation
L'un des bénéfices majeurs de SOLID : la testabilité. Chaque composant peut être testé isolément avec des mocks simples :
describe("OrderService", () => {
let service: OrderService;
let mockPayment: jest.Mocked<PaymentGateway>;
let mockRepository: jest.Mocked<OrderRepository>;
let mockNotifications: jest.Mocked<NotificationService>;
beforeEach(() => {
mockPayment = {
charge: jest.fn().mockResolvedValue({ success: true, id: "pay_123" }),
refund: jest.fn(),
};
mockRepository = {
save: jest.fn().mockImplementation((order) =>
Promise.resolve({ ...order, id: "order_123" })
),
findById: jest.fn(),
findByUserId: jest.fn(),
updateStatus: jest.fn(),
};
mockNotifications = {
send: jest.fn().mockResolvedValue(undefined),
};
service = new OrderService(
new OrderValidator(),
new PricingEngine(new FranceTaxStrategy(), new StandardShippingStrategy()),
new InventoryManager(new InMemoryInventoryRepository()),
mockRepository,
mockPayment,
mockNotifications,
new InMemoryCacheService()
);
});
it("should create an order with correct total", async () => {
const items: CartItem[] = [
{ productId: "prod_1", price: 29.99, quantity: 2 },
{ productId: "prod_2", price: 49.99, quantity: 1 },
];
const order = await service.createOrder("user_123", items);
expect(order.status).toBe("confirmed");
expect(mockPayment.charge).toHaveBeenCalledTimes(1);
expect(mockRepository.save).toHaveBeenCalledTimes(1);
expect(mockNotifications.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "order_confirmed" })
);
});
it("should rollback stock on payment failure", async () => {
mockPayment.charge.mockResolvedValue({
success: false,
error: "Insufficient funds",
});
await expect(
service.createOrder("user_123", [
{ productId: "prod_1", price: 10, quantity: 1 },
])
).rejects.toThrow(PaymentError);
});
});
// Test du PricingEngine isolé
describe("PricingEngine", () => {
it("should apply French tax correctly", () => {
const engine = new PricingEngine(
new FranceTaxStrategy(),
new StandardShippingStrategy()
);
const result = engine.calculate([
{ productId: "1", price: 100, quantity: 1 },
]);
expect(result.tax).toBe(20); // 20% TVA
expect(result.shipping).toBe(0); // Gratuit au-dessus de 50€
expect(result.total).toBe(120);
});
it("should apply volume discount", () => {
const engine = new PricingEngine(
new FranceTaxStrategy(),
new StandardShippingStrategy()
);
engine.addDiscountStrategy(new VolumeDiscountStrategy());
const items = Array.from({ length: 6 }, (_, i) => ({
productId: prod_,
price: 10,
quantity: 1,
}));
const result = engine.calculate(items);
expect(result.discountAmount).toBe(3); // 5% sur 60€
});
});
Conclusion
Les principes SOLID ne sont pas de la théorie abstraite. Appliqués méthodiquement, ils transforment un code monolithique ingérable en une architecture modulaire, testable et extensible.
Ce que nous avons accompli
| Avant | Après |
|---|---|
| 1 classe de 200 lignes | 15+ classes spécialisées |
| 0% de couverture de tests | 95%+ de couverture |
| 7 raisons de changer | 1 raison par classe |
| Couplage dur à Stripe, Postgres... | Abstractions injectables |
| Ajout d'un pays = modifier le code existant | Ajout d'un pays = créer une classe |
Les règles d'or
- SRP : Si votre description de classe contient "et", divisez-la
- OCP : Préférez la composition et les stratégies aux conditions
- LSP : Si un sous-type lance des exceptions inattendues, reconsidérez l'héritage
- ISP : Préférez 5 petites interfaces à 1 grosse
- DIP : Les abstractions au centre, les détails à la périphérie
Ces principes ne sont pas des fins en soi — ils sont au service de la maintenabilité, de la testabilité et de l'évolutivité de votre code. Appliquez-les avec discernement : un script de 50 lignes n'a pas besoin d'architecture hexagonale. Mais dès que votre projet dépasse la phase prototype, SOLID devient votre meilleur allié.


