Skip to main content
  1. Examples/

Single Responsibility Principle (SRP) - Invoice Class Refactoring

·13 mins· loading · loading · · ·
Design Back-End
Adrien D'acunto
Author
Adrien D’acunto
Table of Contents

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:

  1. 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!

  2. What happens if the database structure changes?

    • The Invoice class needs to be modified
    • Risk of regressions in unrelated functionality (calculations, PDF, email)
  3. What happens if I need to handle a different tax rate?

    • The Invoice class needs to be modified
    • Potential impact on saving, sending emails, etc.
  4. 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!

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:

  1. Invoice: Management of business data only
  2. InvoiceRepository: Database persistence
  3. EmailService: Sending emails
  4. PDFGenerator: PDF document generation
  5. PricingStrategy: Financial calculations (taxes, discounts)
  6. 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 changes
  • InvoiceRepository: persistence mode changes
  • EmailService: email provider changes
  • PDFGenerator: PDF format changes
  • PricingStrategy: 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!

Related

Interfaces in Object-Oriented Programming - SOLID Principle and Practical Example
·6 mins· loading · loading
Design Back-End
The Singleton Pattern - Guaranteeing a Single Instance
·11 mins· loading · loading
Design Back-End
Database Modeling - The Langlois Case
·6 mins· loading · loading
Design Back-End
How Databases Work - Complete Guide
··23 mins· loading · loading
Back-End
Automatic Backlog Documentation Generation with Google Sheets and Apps Script
·2 mins· loading · loading
Documentation
Designs Patterns
·6 mins· loading · loading
Conception