Single Responsibility Principle (SRP): Refactoring an Invoice Class #
Introduction #
The Single Responsibility Principle (SRP) is the first principle of SOLID. It states that a class should have only one reason to change, meaning only one responsibility.
In this article, we will analyze a classic case of SRP violation with an Invoice class that does “everything”, then we will see how to refactor it by applying this fundamental principle.
Problem: The “God Object” Class #
Monolithic Invoice Class #
Here is a typical Invoice class that violates 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('Invoice saved to database');
}
sendEmailToCustomer(): void {
if (!this.validateCustomerEmail()) {
throw new Error('Invalid email');
}
const smtpServer = new SMTPConnection('smtp.company.com');
smtpServer.send({
to: this.customerEmail,
subject: 'Your invoice',
body: 'Hello, here is your invoice...'
});
}
generatePDF(): Buffer {
const pdf = new PDFGenerator();
pdf.addText('Invoice #' + this.id);
pdf.addText('Customer: ' + this.customerName);
this.items.forEach(item => {
pdf.addText(item.name + ': ' + item.price + '€');
});
pdf.addText('Total: ' + this.calculateTotal() + '€');
return pdf.render();
}
printInvoice(): void {
console.log('=== INVOICE ===');
console.log('ID: ' + this.id);
console.log('Customer: ' + this.customerName);
console.log('Total: ' + this.calculateTotal() + '€');
}
}
Item Class (Support) #
class Item {
name: string;
price: number;
quantity: number;
}
Analysis: How Many Responsibilities? #
Monolithic Class Diagram #
classDiagram
class Invoice {
-id: String
-customerName: String
-customerEmail: String
-items: Item[]
-discount: Number
-taxRate: Number
+calculateSubTotal() Number
+calculateTax() Number
+applyDiscount() void
+validateCustomerEmail() boolean
+saveToDatabase() void
+sendEmailToCustomer() void
+generatePDF() Buffer
+printInvoice() void
}
class Item {
+name: String
+price: Number
+quantity: Number
}
Invoice "1" --> "*" Item : contains
Revealing Questions #
Ask yourself the right questions:
-
How many responsibilities does the Invoice class cover?
- Management of billing data
- Financial calculations (subtotal, taxes, discounts)
- Email validation
- Database persistence
- Sending emails
- PDF generation
- Console display
Answer: 7 different responsibilities!
-
What happens if the database structure changes?
- The
Invoiceclass needs to be modified - Risk of regressions in unrelated functionality (calculations, PDF, email)
- The
-
What happens if I need to handle a different tax rate?
- The
Invoiceclass needs to be modified - Potential impact on saving, sending emails, etc.
- The
-
Estimate how many times you’ll need to touch this class
- Change SMTP server → Modify
Invoice - New PDF format → Modify
Invoice - Database migration → Modify
Invoice - New tax calculation → Modify
Invoice - Stricter email validation → Modify
Invoice
Answer: TOO OFTEN!
- Change SMTP server → Modify
SRP Violations #
This class violates SRP because it has multiple reasons to change:
| Reason for Change | Impacted Responsibility | Risk |
|---|---|---|
| Modification of tax calculation rules | calculateTax() |
Bugs in PDF, email, database |
| Change of email server | sendEmailToCustomer() |
Bugs in calculations |
| Database migration | saveToDatabase() |
Bugs everywhere |
| New PDF format | generatePDF() |
Bugs in emails |
| Stricter email validation | validateCustomerEmail() |
Bugs in saving |
Solution: Separation of Responsibilities #
Refactored Architecture #
Instead of a monolithic class, we will create:
- Invoice: Management of business data only
- InvoiceRepository: Database persistence
- EmailService: Sending emails
- PDFGenerator: PDF document generation
- PricingStrategy: Financial calculations (taxes, discounts)
- CreateInvoice: Orchestration (use case)
Refactored Class Diagram #
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 {
<<interface>>
+save(invoice: Invoice) void
}
class EmailService {
<<interface>>
+send(invoice: Invoice) void
}
class PDFGenerator {
<<interface>>
+generate(invoice: Invoice) void
}
class PricingStrategy {
<<interface>>
+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
Simplified View: Data Flow #
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
Refactored Implementation #
1. Invoice Entity - Business Data Only #
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;
}
// Simple calculation based on external strategy
calculateTotal(): number {
// Delegation to pricing strategy
return 0; // Will be calculated by PricingStrategy
}
}
2. Support Entities #
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. InvoiceRepository Interface - Persistence #
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('Invoice saved to database');
}
}
// Possibility to add other implementations
class FileInvoiceRepository implements InvoiceRepository {
save(invoice: Invoice): void {
// Save to JSON file
console.log('Invoice saved to file');
}
}
4. EmailService Interface - Sending 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('Invalid email');
}
const smtpServer = new SMTPConnection('smtp.company.com');
smtpServer.send({
to: customer.getEmail(),
subject: 'Your invoice',
body: `Hello ${customer.getName()}, here is your invoice...`
});
console.log('Email sent to ' + customer.getEmail());
}
}
// Alternative with cloud service
class SendGridEmailService implements EmailService {
send(invoice: Invoice): void {
// Using SendGrid API
console.log('Email sent via SendGrid');
}
}
5. PDFGenerator Interface - PDF Generation #
interface PDFGenerator {
generate(invoice: Invoice): void;
}
class StandardPDFGenerator implements PDFGenerator {
generate(invoice: Invoice): void {
const pdf = new PDFLib();
const customer = invoice.getCustomer();
pdf.addText('Invoice #' + invoice.getId());
pdf.addText('Customer: ' + customer.getName());
invoice.getInvoicesItems().forEach(item => {
pdf.addText(`${item.name}: ${item.price}€ x ${item.quantity}`);
});
return pdf.render();
}
}
// Alternative with more elaborate template
class FancyPDFGenerator implements PDFGenerator {
generate(invoice: Invoice): void {
// PDF generation with advanced design
console.log('PDF generated with professional template');
}
}
6. PricingStrategy Interface - Financial Calculations #
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 {
// Calculate subtotal
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// Calculate tax
const tax = subtotal * this.taxRate;
// Final total
return subtotal + tax - this.discount;
}
}
// Alternative strategy for premium customers
class PremiumPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// 10% automatic discount + no tax
return subtotal * 0.90;
}
}
7. CreateInvoice Use Case - Orchestration #
class CreateInvoice {
constructor(
private repository: InvoiceRepository,
private emailService: EmailService,
private pdfGenerator: PDFGenerator,
private pricingStrategy: PricingStrategy
) {}
process(customer: Customer, items: InvoiceItem[]): void {
// 1. Create the invoice
const invoice = new Invoice(customer);
// 2. Add items
items.forEach(item => invoice.addInvoiceItem(item));
// 3. Calculate total with strategy
const total = this.pricingStrategy.calculate(invoice.getInvoicesItems());
console.log(`Total calculated: ${total}€`);
// 4. Save to database
this.repository.save(invoice);
// 5. Generate PDF
this.pdfGenerator.generate(invoice);
// 6. Send by email
this.emailService.send(invoice);
console.log('Invoice processed successfully!');
}
}
Sequence Diagram: Invoice Creation #
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 For each item
UseCase->>Invoice: addInvoiceItem(item)
end
UseCase->>Strategy: calculate(items)
Strategy-->>UseCase: total
UseCase->>Repo: save(invoice)
Repo-->>UseCase: saved
UseCase->>PDF: generate(invoice)
PDF-->>UseCase: PDF generated
UseCase->>Email: send(invoice)
Email-->>UseCase: email sent
UseCase-->>Client: Invoice processed
Practical Usage #
Before: Coupled and Rigid Code #
// Everything is mixed in the Invoice class
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();
After: Decoupled and Flexible Code #
// Each responsibility is separated
// Configuration of dependencies (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
);
// Usage
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);
Flexibility: Change Behavior Easily #
// Scenario 1: Premium customer with different strategy
const premiumStrategy = new PremiumPricingStrategy();
const createPremiumInvoice = new CreateInvoice(
repository,
emailService,
new FancyPDFGenerator(), // More elaborate PDF
premiumStrategy
);
// Scenario 2: Test mode with mocks
const mockRepository = new InMemoryInvoiceRepository();
const mockEmailService = new NoOpEmailService();
const createTestInvoice = new CreateInvoice(
mockRepository,
mockEmailService,
pdfGenerator,
pricingStrategy
);
// Scenario 3: File save instead of database
const fileRepository = new FileInvoiceRepository();
const createFileInvoice = new CreateInvoice(
fileRepository,
emailService,
pdfGenerator,
pricingStrategy
);
Before/After Comparison #
Comparative Table #
| Aspect | Monolithic Class (Before) | SRP Architecture (After) |
|---|---|---|
| Number of responsibilities | 7 in one class | 1 per class |
| Lines of code per class | ~150 lines | ~30 lines average |
| Testability | Difficult (hidden dependencies) | Easy (dependency injection) |
| Database modification | Changes Invoice |
Changes only InvoiceRepository |
| Email modification | Changes Invoice |
Changes only EmailService |
| PDF modification | Changes Invoice |
Changes only PDFGenerator |
| New pricing strategy | Changes Invoice |
Add new PricingStrategy |
| Reusability | Low (everything coupled) | High (independent components) |
| Understanding | Difficult (complex code) | Simple (clear responsibilities) |
Dependency Diagram #
graph TB
subgraph "BEFORE - Strong Coupling"
A1[Invoice]
A1 -.-> A2[Calculations]
A1 -.-> A3[Database]
A1 -.-> A4[SMTP Email]
A1 -.-> A5[PDF]
A1 -.-> A6[Validation]
A1 -.-> A7[Display]
end
subgraph "AFTER - Weak Coupling"
B1[CreateInvoice]
B2[Invoice]
B3[InvoiceRepository]
B4[EmailService]
B5[PDFGenerator]
B6[PricingStrategy]
B1 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
B1 --> B6
end
Concrete Benefits of SRP #
1. Improved Testability #
Before:
// Difficult to test - hidden dependencies
describe('Invoice', () => {
it('should save and send email', () => {
const invoice = new Invoice('John', 'john@test.com');
// How to mock the database? How to mock SMTP?
// Impossible without modifying source code!
});
});
After:
// Easy to test - injection of 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. Simplified Maintenance #
Example: Change email server
Before:
// Modification in Invoice class (risk of regression)
class Invoice {
sendEmailToCustomer(): void {
// Change from smtp.company.com to smtp.newprovider.com
const smtpServer = new SMTPConnection('smtp.newprovider.com');
// ... rest of code
// Risk: bug in calculateTotal(), generatePDF(), etc.
}
}
After:
// Isolated modification in EmailService
class SMTPEmailService implements EmailService {
send(invoice: Invoice): void {
// Isolated change - no impact on Invoice, PDF, etc.
const smtpServer = new SMTPConnection('smtp.newprovider.com');
// ...
}
}
3. Evolvability #
Adding a new calculation mode (different VAT by country):
// New strategy without touching existing code
class EuropeanPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
// European VAT variable by country
return subtotal * 1.21; // 21% Belgium for example
}
}
// Immediate usage
const euInvoice = new CreateInvoice(
repository,
emailService,
pdfGenerator,
new EuropeanPricingStrategy() // No changes elsewhere
);
4. Reusability #
// EmailService can be reused for other cases
class SendOrderConfirmation {
constructor(private emailService: EmailService) {}
send(order: Order): void {
// Reuse of same email service
this.emailService.send(order);
}
}
// PDFGenerator can serve for other documents
class GenerateReport {
constructor(private pdfGenerator: PDFGenerator) {}
create(report: Report): void {
this.pdfGenerator.generate(report);
}
}
SOLID Principles Applied #
Single Responsibility Principle (SRP) #
Heart of our refactoring! Each class has only one reason to change:
Invoice: data structure changesInvoiceRepository: persistence mode changesEmailService: email provider changesPDFGenerator: PDF format changesPricingStrategy: calculation rules change
Open/Closed Principle (OCP) #
Classes are open for extension (new strategies) but closed for modification.
// Add new strategy WITHOUT modifying existing code
class PromotionalPricingStrategy implements PricingStrategy {
calculate(items: InvoiceItem[]): number {
// 20% discount on everything!
const subtotal = items.reduce((sum, item) =>
sum + item.getTotal(), 0);
return subtotal * 0.80;
}
}
Dependency Inversion Principle (DIP) #
CreateInvoice depends on abstractions (interfaces), not concrete implementations.
// CreateInvoice only knows interfaces
class CreateInvoice {
constructor(
private repository: InvoiceRepository, // Interface
private emailService: EmailService, // Interface
private pdfGenerator: PDFGenerator, // Interface
private pricingStrategy: PricingStrategy // Interface
) {}
}
SRP Checklist: How to Identify Violations #
Ask yourself these questions to identify SRP violations:
- Does my class have more than one reason to change?
- Can I describe my class in one sentence without using “and”?
- Does my class depend on multiple external libraries? (Database, SMTP, PDF, etc.)
- If I need to modify X, do I need to touch Y?
- Is my class more than 200 lines?
- Does my class have methods that only use part of the attributes?
- Can I divide my class into coherent subclasses?
If you answer YES to 2+ questions → Probable SRP violation!
Anti-Patterns to Avoid #
God Object / God Class #
// BAD - Does everything
class Application {
connectDatabase() {}
sendEmail() {}
generatePDF() {}
processPayment() {}
validateUser() {}
logActivity() {}
// ... 50 other methods
}
Hidden Responsibilities #
// BAD - Responsibilities hidden in constructor
class User {
constructor(name: string) {
this.name = name;
this.saveToDatabase(); // Hidden side-effect!
this.sendWelcomeEmail(); // Hidden side-effect!
}
}
Mixing Business Logic and Infrastructure #
// BAD - Business logic + SQL in same class
class Order {
calculateTotal(): number {
// Business logic OK
return this.items.reduce((sum, item) => sum + item.price, 0);
}
save(): void {
// Infrastructure SQL in business entity
db.query('INSERT INTO orders...');
}
}
Conclusion #
The Single Responsibility Principle transforms code radically:
Summary of Benefits #
| Before SRP | After SRP |
|---|---|
| Monolithic class of 150+ lines | Targeted classes of ~30 lines |
| 7 mixed responsibilities | 1 responsibility per class |
| Impossible to unit test | Simple tests with mocks |
| Strong coupling (everything depends on everything) | Weak coupling (clear dependencies) |
| Risky modification (domino effect) | Safe modification (isolated impact) |
| Difficult reuse | Reusable components |
| Complex understanding | Self-documented code |
The Golden Rule of SRP #
“A class should have only one reason to change”
— Robert C. Martin (Uncle Bob)
Next Steps #
Now that you master SRP, discover how to combine it with other SOLID principles:
- Open/Closed Principle (OCP): Extension without modification
- Liskov Substitution Principle (LSP): Substitution without surprises
- Interface Segregation Principle (ISP): Targeted interfaces
- Dependency Inversion Principle (DIP): Depend on abstractions
Practical advice: Always start by applying SRP before other SOLID principles. It is the foundation of any clean architecture!