Single Responsibility: one reason to change

Keep calculation and persistence in separate roles so a change to storage does not affect pricing rules.

Time O(1) per calculationSpace O(1)Code C++, Python and C#
Watch it animatedStep through it, with the running line highlighted. Free with an account.

The idea

Single Responsibility means a type has one cohesive responsibility and one main reason to change. InvoiceService coordinates the use case; InvoiceCalculator owns the pricing rule; IInvoiceRepository describes saving. It does not mean every class should have only one method.

Code

struct Invoice { double subtotal, taxRate; };

class InvoiceCalculator {
public:
    double total(const Invoice& invoice) const {
        return invoice.subtotal * (1 + invoice.taxRate);
    }
};
class InvoiceRepository {
public:
    virtual void save(const Invoice& invoice) = 0;
    virtual ~InvoiceRepository() = default;
};
class InvoiceService {
    InvoiceCalculator& calculator;
    InvoiceRepository& repository;
public:
    InvoiceService(InvoiceCalculator& calculator,
        InvoiceRepository& repository)
        : calculator(calculator), repository(repository) {}
    double close(const Invoice& invoice) {
        double total = calculator.total(invoice);
        repository.save(invoice);
        return total;
    }
};
from dataclasses import dataclass
from typing import Protocol

@dataclass
class Invoice:
    subtotal: float
    tax_rate: float

class InvoiceCalculator:
    def total(self, invoice: Invoice) -> float:
        return invoice.subtotal * (1 + invoice.tax_rate)

class InvoiceRepository(Protocol):
    def save(self, invoice: Invoice) -> None: ...

class InvoiceService:
    def __init__(self, calculator: InvoiceCalculator,
                 repository: InvoiceRepository):
        self.calculator = calculator
        self.repository = repository

    def close(self, invoice: Invoice) -> float:
        total = self.calculator.total(invoice)
        self.repository.save(invoice)
        return total
record Invoice(decimal Subtotal, decimal TaxRate);

class InvoiceCalculator {
    public decimal Total(Invoice invoice) =>
        invoice.Subtotal * (1 + invoice.TaxRate);
}
interface IInvoiceRepository {
    void Save(Invoice invoice);
}
class InvoiceService {
    private readonly InvoiceCalculator calculator;
    private readonly IInvoiceRepository repository;
    public InvoiceService(InvoiceCalculator calculator,
        IInvoiceRepository repository) {
        this.calculator = calculator;
        this.repository = repository;
    }
    public decimal Close(Invoice invoice) {
        var total = calculator.Total(invoice);
        repository.Save(invoice);
        return total;
    }
}

Tips

In the .NET library

SRP is about cohesive responsibilities, not class size. Here changing the tax formula belongs to InvoiceCalculator; changing database storage belongs to the repository implementation.