Single Responsibility Principle (SRP) : Refactoring d’une Classe Invoice #
Introduction #
Le Single Responsibility Principle (SRP) est le premier principe SOLID. Il stipule qu’une classe ne devrait avoir qu’une seule raison de changer, c’est-à-dire une seule responsabilité.
Dans cet article, nous allons analyser un cas classique de violation du SRP avec une classe Invoice qui fait “tout”, puis nous verrons comment la refactoriser en appliquant ce principe fondamental.
Problème : La Classe “God Object” #
Classe Invoice Monolithique #
Voici une classe Invoice typique qui viole le SRP :
class Invoice {
private id: string;
private customerName: string;
private customerEmail: string;
private items: Item[];
private discount: number;
private taxRate: number;
constructor(customerName: string, customerEmail: string) {
this.id = Math.random().toString();
this.customerName = customerName;
this.customerEmail = customerEmail;
this.items = [];
this.discount = 0;
this.taxRate = 0.20;
}
addItem(item: Item): void {
this.items.push(item);
}
calculateSubTotal(): number {
return this.items.reduce((sum, item) =>
sum + item.price item.quantity, 0);
}
calculateTax(): number {
return this.calculateSubTotal() this.taxRate;
}
calculateTotal(): number {
const subtotal = this.calculateSubTotal();
const tax = this.calculateTax();
return subtotal + tax - this.discount;
}
applyDiscount(amount: number): void {
this.discount = amount;
}
validateCustomerEmail(): boolean {
const emailRegex = /^[\w\d]+@[\w\d]+\.[\w\d]+$/;
return emailRegex.test(this.customerEmail);
}
saveToDatabase(): void {
const db = new DatabaseConnection();
db.query('INSERT INTO invoices VALUES (...)');
console.log('Facture sauvegardée en base');
}
sendEmailToCustomer(): void {
if (!this.validateCustomerEmail()) {
throw new Error('Email invalide');
}
const smtpServer = new SMTPConnection('smtp.company.com');
smtpServer.send({
to: this.customerEmail,
subject: 'Votre facture',
body: 'Bonjour, voici votre facture...'
});
}
generatePDF(): Buffer {
const pdf = new PDFGenerator();
pdf.addText('Facture #' + this.id);
pdf.addText('Client: ' + this.customerName);
this.items.forEach(item => {
pdf.addText(item.name + ': ' + item.price + '€');
});
pdf.addText('Total: ' + this.calculateTotal() + '€');
return pdf.render();
}
printInvoice(): void {
console.log('=== FACTURE ===');
console.log('ID: ' + this.id);
console.log('Client: ' + this.customerName);
console.log('Total: ' + this.calculateTotal() + '€');
}
}
Classe Item (Support) #
class Item {
name: string;
price: number;
quantity: number;
}
Analyse : Combien de Responsabilités ? #
Diagramme de la Classe Monolithique #
classDiagram
class Invoice {
-id: String
-customerName: String
-customerEmail: String
-items: Item[]
-discount: Number
-taxRate: Number
+calculateSubTotal() Number
+calculateTax() Number
+applyDiscount() void
+validateCustomerEmail() boolean
+sendTooDB() void
+sendEmailToCustomer() void
+generatePDF() Buffer
+printInvoice() void
}
class Item {
+name: String
+price: Number
+quantity: Number
}
Invoice "1" --> "" Item : contains
Les Questions Révélatrices #
Posez-vous les bonnes questions :
-
Combien de responsabilités la classe Invoice couvre-t-elle ?
- Gestion des données de facturation
- Calculs financiers (sous-total, taxes, remises)
- Validation d’email
- Persistance en base de données
- Envoi d’emails
- Génération de PDF
- Affichage console
Réponse : 7 responsabilités différentes !
-
Que se passe-t-il si la structure de la base de données change ?
- Il faut modifier la classe
Invoice - Risque de régressions sur des fonctionnalités sans rapport (calculs, PDF, email)
- Il faut modifier la classe
-
Que se passe-t-il si je dois traiter un autre taux de TVA ?
- Il faut modifier la classe
Invoice - Impact potentiel sur la sauvegarde, l’envoi d’email, etc.
- Il faut modifier la classe
-
Essayez d’estimer le nombre de fois où je devrais remettre le nez dans cette classe
- Changement de serveur SMTP → Modification de
Invoice - Nouveau format de PDF → Modification de
Invoice - Migration de base de données → Modification de
Invoice - Nouveau calcul de taxe → Modification de
Invoice - Validation email plus stricte → Modification de
Invoice
Réponse : TROP SOUVENT !
- Changement de serveur SMTP → Modification de
Violations du SRP #
Cette classe viole le SRP car elle a plusieurs raisons de changer :
| Raison du Changement | Responsabilité Impactée | Risque |
|---|---|---|
| Modification des règles de calcul de TVA | calculateTax() |
Bugs dans PDF, email, BDD |
| Changement de serveur email | sendEmailToCustomer() |
Bugs dans les calculs |
| Migration de base de données | saveToDatabase() |
Bugs partout |
| Nouveau format de PDF | generatePDF() |
Bugs dans les emails |
| Validation email plus stricte | validateCustomerEmail() |
Bugs dans la sauvegarde |
Solution : Séparation des Responsabilités #
Architecture Refactorisée #
Au lieu d’une classe monolithique, nous allons créer :
- Invoice : Gestion des données métier uniquement
- InvoiceRepository : Persistance en base de données
- EmailService : Envoi d’emails
- PDFGenerator : Génération de documents PDF
- PricingStrategy : Calculs financiers (taxes, remises)
- CreateInvoice : Orchestration (use case)
Diagramme de Classes Refactorisé #
classDiagram
class Invoice {
-id: String
-customer: Customer
-items: InvoiceItem[]
+addInvoiceItem(item: InvoiceItem) void
+calculateTotal() Number
+getInvoicesItems() InvoiceItem[]
+setCustomer(customer: Customer) void
}
class Customer {
-name: String
-email: String
+getName() String
+getEmail() String
+isValidEmail() boolean
}
class InvoiceItem {
-name: String
-price: Number
-quantity: Number
+getTotal() Number
}
class InvoiceRepository {
<>
+save(invoice: Invoice) void
}
class EmailService {
<>
+send(invoice: Invoice) void
}
class PDFGenerator {
<>
+generate(invoice: Invoice) void
}
class PricingStrategy {
<>
+calculate(items: InvoiceItem[]) number
}
class StandardPricingStrategy {
-taxRate: Number
-discount: Number
+calculate(invoice: Invoice) void
}
class CreateInvoice {
-repository: InvoiceRepository
-emailService: EmailService
-pdfGenerator: PDFGenerator
-pricingStrategy: PricingStrategy
+process() void
}
Invoice "1" --> "1" Customer : has
Invoice "1" --> "" InvoiceItem : contains
CreateInvoice ..> Invoice : uses
CreateInvoice --> InvoiceRepository : uses
CreateInvoice --> EmailService : uses
CreateInvoice --> PDFGenerator : uses
CreateInvoice --> PricingStrategy : uses
PricingStrategy <|.. StandardPricingStrategy : implements
Vue Simplifiée : Flux de Données #
graph LR
A[CreateInvoice] --> B[Invoice]
A --> C[InvoiceRepository]
A --> D[EmailService]
A --> E[PDFGenerator]
A --> F[PricingStrategy]
F --> G[StandardPricingStrategy]
style A fill:#4CAF50
style B fill:#2196F3
style C fill:#FF9800
style D fill:#9C27B0
style E fill:#F44336
style F fill:#00BCD4
Implémentation Refactorisée #
1. Entité Invoice - Données Métier Uniquement #
class Invoice {
private id: string;
private customer: Customer;
private items: InvoiceItem[];
constructor(customer: Customer) {
this.id = Math.random().toString();
this.customer = customer;
this.items = [];
}
addInvoiceItem(item: InvoiceItem): void {
this.items.push(item);
}
getInvoicesItems(): InvoiceItem[] {
return this.items;
}
setCustomer(customer: Customer): void {
this.customer = customer;
}
getCustomer(): Customer {
return this.customer;
}
getId(): string {
return this.id;
}
// Calcul simple basé sur une stratégie externe
calculateTotal(): number {
// Délégation à une stratégie de pricing
return 0; // Sera calculé par PricingStrategy
}
}
2. Entités Support #
class Customer {
private name: string;
private email: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
getName(): string {
return this.name;
}
getEmail(): string {
return this.email;
}
isValidEmail(): boolean {
const emailRegex = /^[\w\d]+@[\w\d]+\.[\w\d]+$/;
return emailRegex.test(this.email);
}
}
class InvoiceItem {
constructor(
public name: string,
public price: number,
public quantity: number
) {}
getTotal(): number {
return this.price this.quantity;
}
}
3. Interface InvoiceRepository - Persistance #
interface InvoiceRepository {
save(invoice: Invoice): void;
}
class DatabaseInvoiceRepository implements InvoiceRepository {
save(invoice: Invoice): void {
const db = new DatabaseConnection();
db.query('INSERT INTO invoices VALUES (...)');
console.log('Facture sauvegardée en base');
}
}
// Possibilité d'ajouter d'autres implémentations
class FileInvoiceRepository implements InvoiceRepository {
save(invoice: Invoice): void {
// Sauvegarde dans un fichier JSON
console.log('Facture sauvegardée dans un fichier');
}
}
4. Interface EmailService - Envoi d’Emails #
interface EmailService {
send(invoice: Invoice): void;
}
class SMTPEmailService implements EmailService {
send(invoice: Invoice): void {
const customer = invoice.getCustomer();
if (!customer.isValidEmail()) {
throw new Error('Email invalide');
}
const smtpServer = new SMTPConnection('smtp.company.com');
smtpServer.send({
to: customer.getEmail(),
subject: 'Votre facture',
body: `Bonjour ${customer.getName()}, voici votre facture...`
});
console.log('Email envoyé à ' + customer.getEmail());
}
}
// Alternative avec un service cloud
class SendGridEmailService implements EmailService {
send(invoice: Invoice): void {
// Utilisation de SendGrid API
console.log('Email envoyé via SendGrid');
}
}
5. Interface PDFGenerator - Génération PDF #
interface PDFGenerator {
generate(invoice: Invoice): void;
}
class StandardPDFGenerator implements PDFGenerator {
generate(invoice: Invoice): void {
const pdf = new PDFLib();
const customer = invoice.getCustomer();
pdf.addText('Facture #' + invoice.getId());
pdf.addText('Client: ' + customer.getName());
invoice.getInvoicesItems().forEach(item => {
pdf.addText(`${item.name}: ${item.price}€ x ${item.quantity}`);
});
return pdf.render();
}
}
// Alternative avec un template plus élaboré
class FancyPDFGenerator implements PDFGenerator {
generate(invoice: Invoice): void {
// Génération PDF avec design avancé
console.log('PDF généré avec template professionnel');
}
}
6. Interface PricingStrategy - Calculs Financiers #
interface PricingStrategy {
calculate(items: InvoiceItem[]): number;
}
class StandardPricingStrategy implements PricingStrategy {
private taxRate: number;
private discount: number;
constructor(taxRate: number = 0.20, discount: number = 0) {
this.taxRate = taxRate;
this.discount = discount;
}
calculate(items: InvoiceItem[]): number {
// Calcul du sous-total
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// Calcul de la taxe
const tax = subtotal this.taxRate;
// Total final
return subtotal + tax - this.discount;
}
}
// Stratégie alternative pour clients premium
class PremiumPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// 10% de réduction automatique + pas de taxe
return subtotal 0.90;
}
}
7. Use Case CreateInvoice - Orchestration #
class CreateInvoice {
constructor(
private repository: InvoiceRepository,
private emailService: EmailService,
private pdfGenerator: PDFGenerator,
private pricingStrategy: PricingStrategy
) {}
process(customer: Customer, items: InvoiceItem[]): void {
// 1. Créer la facture
const invoice = new Invoice(customer);
// 2. Ajouter les items
items.forEach(item => invoice.addInvoiceItem(item));
// 3. Calculer le total avec la stratégie
const total = this.pricingStrategy.calculate(invoice.getInvoicesItems());
console.log(`Total calculé: ${total}€`);
// 4. Sauvegarder en base
this.repository.save(invoice);
// 5. Générer le PDF
this.pdfGenerator.generate(invoice);
// 6. Envoyer par email
this.emailService.send(invoice);
console.log('Facture traitée avec succès !');
}
}
Diagramme de Séquence : Création de Facture #
sequenceDiagram
participant Client
participant UseCase as CreateInvoice
participant Invoice
participant Strategy as PricingStrategy
participant Repo as InvoiceRepository
participant PDF as PDFGenerator
participant Email as EmailService
Client->>UseCase: process(customer, items)
UseCase->>Invoice: new Invoice(customer)
Invoice-->>UseCase: invoice
loop Pour chaque item
UseCase->>Invoice: addInvoiceItem(item)
end
UseCase->>Strategy: calculate(items)
Strategy-->>UseCase: total
UseCase->>Repo: save(invoice)
Repo-->>UseCase: sauvegardé
UseCase->>PDF: generate(invoice)
PDF-->>UseCase: PDF généré
UseCase->>Email: send(invoice)
Email-->>UseCase: email envoyé
UseCase-->>Client: Facture traitée
Utilisation Pratique #
Avant : Code Couplé et Rigide #
// Tout est mélangé dans la classe Invoice
const invoice = new Invoice('John Doe', 'john@example.com');
invoice.addItem({ name: 'Laptop', price: 999, quantity: 1 });
invoice.addItem({ name: 'Mouse', price: 29, quantity: 2 });
invoice.applyDiscount(50);
console.log('Total:', invoice.calculateTotal());
invoice.saveToDatabase();
invoice.generatePDF();
invoice.sendEmailToCustomer();
Après : Code Découplé et Flexible #
// Chaque responsabilité est séparée
// Configuration des dépendances (IoC/DI)
const repository = new DatabaseInvoiceRepository();
const emailService = new SMTPEmailService();
const pdfGenerator = new StandardPDFGenerator();
const pricingStrategy = new StandardPricingStrategy(0.20, 50);
// Use case
const createInvoice = new CreateInvoice(
repository,
emailService,
pdfGenerator,
pricingStrategy
);
// Utilisation
const customer = new Customer('John Doe', 'john@example.com');
const items = [
new InvoiceItem('Laptop', 999, 1),
new InvoiceItem('Mouse', 29, 2)
];
createInvoice.process(customer, items);
Flexibilité : Changer le Comportement Facilement #
// Scénario 1 : Client premium avec stratégie différente
const premiumStrategy = new PremiumPricingStrategy();
const createPremiumInvoice = new CreateInvoice(
repository,
emailService,
new FancyPDFGenerator(), // PDF plus élaboré
premiumStrategy
);
// Scénario 2 : Mode test avec mock
const mockRepository = new InMemoryInvoiceRepository();
const mockEmailService = new NoOpEmailService();
const createTestInvoice = new CreateInvoice(
mockRepository,
mockEmailService,
pdfGenerator,
pricingStrategy
);
// Scénario 3 : Sauvegarde fichier au lieu de BDD
const fileRepository = new FileInvoiceRepository();
const createFileInvoice = new CreateInvoice(
fileRepository,
emailService,
pdfGenerator,
pricingStrategy
);
Comparaison Avant/Après #
Tableau Comparatif #
| Aspect | Classe Monolithique (Avant) | Architecture SRP (Après) |
|---|---|---|
| Nombre de responsabilités | 7 dans une seule classe | 1 par classe |
| Lignes de code par classe | ~150 lignes | ~30 lignes en moyenne |
| Testabilité | Difficile (dépendances cachées) | Facile (injection de dépendances) |
| Modification BDD | Change Invoice |
Change uniquement InvoiceRepository |
| Modification Email | Change Invoice |
Change uniquement EmailService |
| Modification PDF | Change Invoice |
Change uniquement PDFGenerator |
| Nouvelle stratégie de pricing | Change Invoice |
Ajoute nouvelle PricingStrategy |
| Réutilisabilité | Faible (tout est couplé) | Forte (composants indépendants) |
| Compréhension | Difficile (code complexe) | Simple (responsabilités claires) |
Diagramme de Dépendances #
graph TB
subgraph "AVANT - Couplage Fort"
A1[Invoice]
A1 -.-> A2[Calculs]
A1 -.-> A3[Base de données]
A1 -.-> A4[Email SMTP]
A1 -.-> A5[PDF]
A1 -.-> A6[Validation]
A1 -.-> A7[Affichage]
end
subgraph "APRÈS - Couplage Faible"
B1[CreateInvoice]
B2[Invoice]
B3[InvoiceRepository]
B4[EmailService]
B5[PDFGenerator]
B6[PricingStrategy]
B1 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
B1 --> B6
end
Bénéfices Concrets du SRP #
1. Testabilité Améliorée #
Avant :
// Difficile à tester - dépendances cachées
describe('Invoice', () => {
it('should save and send email', () => {
const invoice = new Invoice('John', 'john@test.com');
// Comment mocker la BDD ? Comment mocker SMTP ?
// Impossible sans modifier le code source !
});
});
Après :
// Facile à tester - injection de mocks
describe('CreateInvoice', () => {
it('should process invoice correctly', () => {
const mockRepo = new MockInvoiceRepository();
const mockEmail = new MockEmailService();
const mockPDF = new MockPDFGenerator();
const mockPricing = new MockPricingStrategy();
const useCase = new CreateInvoice(
mockRepo,
mockEmail,
mockPDF,
mockPricing
);
const customer = new Customer('John', 'john@test.com');
const items = [new InvoiceItem('Test', 100, 1)];
useCase.process(customer, items);
expect(mockRepo.saveCalled).toBe(true);
expect(mockEmail.sendCalled).toBe(true);
});
});
2. Maintenance Simplifiée #
Exemple : Changement de serveur email
Avant :
// Modification dans la classe Invoice (risque de régression)
class Invoice {
sendEmailToCustomer(): void {
// Changer de smtp.company.com à smtp.newprovider.com
const smtpServer = new SMTPConnection('smtp.newprovider.com');
// ... reste du code
// Risque : bug dans calculateTotal(), generatePDF(), etc.
}
}
Après :
// Modification isolée dans EmailService
class SMTPEmailService implements EmailService {
send(invoice: Invoice): void {
// Changement isolé - aucun impact sur Invoice, PDF, etc.
const smtpServer = new SMTPConnection('smtp.newprovider.com');
// ...
}
}
3. Évolutivité #
Ajouter un nouveau mode de calcul (TVA différente par pays) :
// Nouvelle stratégie sans toucher au code existant
class EuropeanPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// TVA européenne variable selon pays
return subtotal 1.21; // 21% Belgique par exemple
}
}
// Utilisation immédiate
const euInvoice = new CreateInvoice(
repository,
emailService,
pdfGenerator,
new EuropeanPricingStrategy() // Aucun changement ailleurs
);
4. Réutilisabilité #
// EmailService peut être réutilisé pour d'autres cas
class SendOrderConfirmation {
constructor(private emailService: EmailService) {}
send(order: Order): void {
// Réutilisation du même service email
this.emailService.send(order);
}
}
// PDFGenerator peut servir pour d'autres documents
class GenerateReport {
constructor(private pdfGenerator: PDFGenerator) {}
create(report: Report): void {
this.pdfGenerator.generate(report);
}
}
Principes SOLID Appliqués #
Single Responsibility Principle (SRP) #
Cœur de notre refactoring ! Chaque classe a une seule raison de changer :
Invoice: structure des données changeInvoiceRepository: mode de persistance changeEmailService: provider email changePDFGenerator: format PDF changePricingStrategy: règles de calcul changent
Open/Closed Principle (OCP) #
Les classes sont ouvertes à l’extension (nouvelles stratégies) mais fermées à la modification.
// Ajouter une nouvelle stratégie SANS modifier le code existant
class PromotionalPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
// 20% de réduction sur tout !
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
return subtotal 0.80;
}
}
Dependency Inversion Principle (DIP) #
CreateInvoice dépend des abstractions (interfaces), pas des implémentations concrètes.
// CreateInvoice ne connaît que les interfaces
class CreateInvoice {
constructor(
private repository: InvoiceRepository, // Interface
private emailService: EmailService, // Interface
private pdfGenerator: PDFGenerator, // Interface
private pricingStrategy: PricingStrategy // Interface
) {}
}
Checklist SRP : Comment Identifier les Violations #
Posez-vous ces questions pour identifier les violations du SRP :
- Ma classe a-t-elle plus d’une raison de changer ?
- Puis-je décrire ma classe en une seule phrase sans utiliser “et” ?
- Ma classe dépend-elle de bibliothèques externes multiples ? (BDD, SMTP, PDF, etc.)
- Si je dois modifier X, dois-je toucher à Y ?
- Ma classe fait-elle plus de 200 lignes ?
- Ma classe a-t-elle des méthodes qui n’utilisent qu’une partie des attributs ?
- Est-ce que je peux diviser ma classe en sous-classes cohérentes ?
Si vous répondez OUI à 2+ questions → Violation probable du SRP !
Anti-Patterns à Éviter #
God Object / Classe Dieu #
// MAUVAIS - Fait tout
class Application {
connectDatabase() {}
sendEmail() {}
generatePDF() {}
processPayment() {}
validateUser() {}
logActivity() {}
// ... 50 autres méthodes
}
Responsabilités Cachées #
// MAUVAIS - Responsabilités masquées dans le constructeur
class User {
constructor(name: string) {
this.name = name;
this.saveToDatabase(); // Side-effect caché !
this.sendWelcomeEmail(); // Side-effect caché !
}
}
Mixage Logique Métier et Infrastructure #
// MAUVAIS - Logique métier + SQL dans la même classe
class Order {
calculateTotal(): number {
// Logique métier OK
return this.items.reduce((sum, item) => sum + item.price, 0);
}
save(): void {
// Infrastructure SQL dans l'entité métier
db.query('INSERT INTO orders...');
}
}
Conclusion #
Le Single Responsibility Principle transforme le code de manière radicale :
Résumé des Bénéfices #
| Avant SRP | Après SRP |
|---|---|
| Classe monolithique de 150+ lignes | Classes ciblées de ~30 lignes |
| 7 responsabilités mélangées | 1 responsabilité par classe |
| Impossible à tester unitairement | Tests simples avec mocks |
| Couplage fort (tout dépend de tout) | Couplage faible (dépendances claires) |
| Modification risquée (effet domino) | Modification sûre (impact isolé) |
| Réutilisation difficile | Composants réutilisables |
| Compréhension complexe | Code auto-documenté |
La Règle d’Or du SRP #
“Une classe ne devrait avoir qu’une seule raison de changer”
— Robert C. Martin (Uncle Bob)
Prochaines Étapes #
Maintenant que vous maîtrisez le SRP, découvrez comment le combiner avec les autres principes SOLID :
- Open/Closed Principle (OCP) : Extension sans modification
- Liskov Substitution Principle (LSP) : Substitution sans surprises
- Interface Segregation Principle (ISP) : Interfaces ciblées
- Dependency Inversion Principle (DIP) : Dépendre des abstractions
Conseil pratique : Commencez toujours par appliquer le SRP avant les autres principes SOLID. C’est la fondation de toute architecture propre !