Single Responsibility Principle

Title: One Class, Four Reasons to Change: How a VAT Update Broke Our Order Emails

Finance asked for a small change: round the VAT on invoices differently. One line, in one method. I made the change, the tests passed, and I shipped it.

The next morning, customers were not getting their order confirmation emails.

The invoice change had nothing to do with email. Except in our code, it did, because both lived inside the same method.

What Single Responsibility really means

Most people read the Single Responsibility Principle as “a class should do only one thing.” That’s not quite it.

The real idea: a class should have only one reason to change. Or put another way, it should answer to only one group of people.

Here is the code we had:

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        // 1. Validation
        if (order.Items.Count == 0)
            throw new InvalidOperationException("Order is empty.");

        // 2. Persistence
        using var connection = new SqlConnection("Server=...;Database=Shop;");
        connection.Open();
        // INSERT order and items ...

        // 3. Invoice
        var subtotal = order.Items.Sum(i => i.Price * i.Quantity);
        var vat = Math.Round(subtotal * 0.15m, 2);
        var invoicePdf = PdfBuilder.Create(order, subtotal, vat);

        // 4. Email
        var message = new MailMessage("no-reply@shop.com", order.CustomerEmail, "Your order", "Thanks!");
        message.Attachments.Add(new Attachment(new MemoryStream(invoicePdf), "invoice.pdf"));
        new SmtpClient("smtp.company.com").Send(message);
    }
}

Count the reasons this class has to change:

  • The business team changes validation rules
  • The database team changes the schema
  • Finance changes tax or invoice rules
  • Marketing changes the email provider or template

Four groups of people, one file. Whenever any of them asks for a change, they edit the same method that also runs everyone else’s logic. Our VAT edit introduced a failure for orders containing a zero-price item. The exception was thrown before the email line, so the email never ran. A tax change broke an email.

The fix

We split the class by reason to change. Each part gets its own class, and OrderService only coordinates:

public class OrderService
{
    private readonly IOrderValidator _validator;
    private readonly IOrderRepository _repository;
    private readonly IInvoiceGenerator _invoices;
    private readonly IOrderEmailSender _email;

    public OrderService(
        IOrderValidator validator,
        IOrderRepository repository,
        IInvoiceGenerator invoices,
        IOrderEmailSender email)
    {
        _validator = validator;
        _repository = repository;
        _invoices = invoices;
        _email = email;
    }

    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
    {
        _validator.Validate(order);
        await _repository.SaveAsync(order, ct);
        var invoice = _invoices.Generate(order);
        await _email.SendConfirmationAsync(order, invoice, ct);
    }
}

The VAT logic now lives in one small class that finance owns:

public class InvoiceGenerator : IInvoiceGenerator
{
    private const decimal VatRate = 0.15m;

    public byte[] Generate(Order order)
    {
        var subtotal = order.Items.Sum(i => i.Price * i.Quantity);
        var vat = Math.Round(subtotal * VatRate, 2);
        return PdfBuilder.Create(order, subtotal, vat);
    }
}

The wiring is one line per class in Program.cs. ASP.NET Core’s container reads the OrderService constructor and supplies each dependency:

builder.Services.AddScoped<IOrderValidator, OrderValidator>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IInvoiceGenerator, InvoiceGenerator>();
builder.Services.AddScoped<IOrderEmailSender, SmtpOrderEmailSender>();
builder.Services.AddScoped<OrderService>();

What changed in practice

The next VAT change touches one file. We can unit test InvoiceGenerator with a zero-price order in two lines, which would have caught our bug before it shipped.

To be fair, SRP doesn’t decide what happens when invoice generation fails. That is still a workflow decision in OrderService. What SRP gives you is that the failure now lives in a small, testable class instead of hiding inside a 200-line method.

A quick test you can use

Describe the class in one sentence. If you need the word “and,” it probably has more than one responsibility. “Validates orders and saves them and emails customers” is three classes.

When not to split

If a class has two small methods that always change together, leave them together. Splitting everything into one-method classes gives you a codebase nobody can follow. Split when the reasons to change are genuinely different.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *