DEV Community

Cover image for SOLID in real .NET: the S and D that actually change your code
Anaya Upadhyay
Anaya Upadhyay

Posted on

SOLID in real .NET: the S and D that actually change your code

Last Monday someone on my feed changed an invoice email template and watched the tax calculation tests fail. Six red tests, none of them about email. If that sequence sounds impossible, you have not met InvoiceService yet.

SOLID usually gets taught as five poster definitions, recited once for the interview and never opened again. In day-to-day .NET work, though, two of the letters do almost all of the refactoring: the S and the D. This is the working version of both, with the before and after in full, targeting .NET 10.

S is "one reason to change," not "one thing to do"

The Single Responsibility Principle is Robert C. Martin's, and his formulation is precise: a class should have one reason to change. The popular paraphrase, "a class should do one thing," is the version that misleads people, because "one thing" is whatever granularity you feel like defending in review. "This class handles invoices" sounds like one thing. It is not.

Count reasons to change instead. Better yet, count the people who can demand a change:

public class InvoiceService
{
    // finance changes this one
    public decimal CalculateTax(Invoice invoice)
    {
        var rate = invoice.Category == ProductCategory.Reduced ? 0.05m : 0.13m;
        return Math.Round(invoice.Subtotal * rate, 2);
    }

    // design changes this one
    public byte[] RenderPdf(Invoice invoice)
    {
        using var doc = new PdfDocument();
        doc.AddHeader(invoice.Number);
        doc.AddTotals(invoice.Subtotal, CalculateTax(invoice));
        return doc.ToBytes();
    }

    // marketing changes this one
    public async Task SendEmailAsync(Invoice invoice)
    {
        var body = EmailTemplates.Render("invoice-ready", invoice);
        await _mailer.SendAsync(invoice.CustomerEmail, body);
    }
}
Enter fullscreen mode Exit fullscreen mode

Three stakeholders, one file. Every edit for any of them re-opens code the other two depend on, which is how a template tweak lands in the tax test report. The class compiles, passes review, and quietly taxes every future change with a full regression run.

The split is not clever. That is the point:

public class TaxCalculator
{
    public decimal Calculate(Invoice invoice)
    {
        var rate = invoice.Category == ProductCategory.Reduced ? 0.05m : 0.13m;
        return Math.Round(invoice.Subtotal * rate, 2);
    }
}

public class InvoicePdfRenderer
{
    public byte[] Render(Invoice invoice, decimal tax)
    {
        using var doc = new PdfDocument();
        doc.AddHeader(invoice.Number);
        doc.AddTotals(invoice.Subtotal, tax);
        return doc.ToBytes();
    }
}

public class InvoiceMailer(IMailer mailer)
{
    public Task SendAsync(Invoice invoice)
    {
        var body = EmailTemplates.Render("invoice-ready", invoice);
        return mailer.SendAsync(invoice.CustomerEmail, body);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the email template edit touches InvoiceMailer, its tests, and nothing else. The blast radius of a change drops to one file, and the diff a reviewer reads matches the requirement that caused it. Reviewers notice that. So does whoever gets paged.

One honest caveat: you can over-split. If two pieces of code change for the same reason at the same time, separating them is ceremony, not architecture. The stakeholder count is the test, in both directions.

D is about who owns the concrete type

The Dependency Inversion Principle, also Martin's, says high-level policy should not depend on low-level detail; both should depend on abstractions. In C# terms it is more blunt: your business rule should not new up the infrastructure it talks to.

Here is the weld:

public class OrderService
{
    public async Task PlaceAsync(Order order)
    {
        // the high-level rule owns a wire-level detail
        var mailer = new SmtpMailer("smtp.internal");
        await mailer.SendAsync(order.Receipt());
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is wrong, concretely and not philosophically:

  • The order rule is welded to SMTP. Moving to SendGrid, or to a queue, means editing checkout logic that has nothing to do with either.
  • It cannot be tested in isolation. Run this test suite and a real mail server answers, or the test fails for network reasons your logic does not have.
  • The dependency is invisible. Nothing in the constructor tells a reader that placing an order sends mail.

The fix is constructor injection with the concrete type owned by the composition root:

// the abstraction the rule depends on
public interface IMailer
{
    Task SendAsync(string to, string body);
}

// C# 12 primary constructor, current on .NET 10
public class OrderService(IMailer mailer)
{
    public async Task PlaceAsync(Order order)
    {
        // rule stays pure; delivery is a detail
        await mailer.SendAsync(order.CustomerEmail, order.Receipt());
    }
}
Enter fullscreen mode Exit fullscreen mode
// Program.cs, the one place that knows the concrete type
builder.Services.AddScoped<IMailer, SmtpMailer>();
builder.Services.AddScoped<OrderService>();
Enter fullscreen mode Exit fullscreen mode

Swap SMTP for SendGrid and the diff is one registration line. Test the order rule and the fake is three lines. The constructor now tells the truth about what the class needs, which is the property everything else hangs off.

A registration note, since it comes up: AddScoped gives you one instance per request in ASP.NET Core, which is the safe default for anything holding request-shaped state. A stateless mailer could be a singleton. What matters for the principle is where the concrete type lives, not which lifetime you pick, and lifetimes deserve their own article anyway.

Why S and D need each other

Split a class without inverting its dependencies and you own small classes you still cannot fake in a test. Invert dependencies on a class with five responsibilities and you get a constructor with nine parameters shouting at you, which is at least useful information.

Do both and every seam becomes two things at once: a swap point and a test point. TaxCalculator behind ITaxCalculator can be replaced the day tax rules move to a rules engine, and faked the day you want a checkout test that does not care about VAT. The S made the seam small enough to be honest; the D made it loose enough to be useful.

This is also where Open/Closed quietly shows up for free. Once the rule depends on IMailer, extending behavior means writing a new implementation rather than editing a working one. You did not set out to satisfy O. It fell out.

The other three, honestly

Liskov Substitution earns its keep in inheritance-heavy trees, and modern .NET leans composition hard enough that many teams meet L rarely, mostly in older code. Interface Segregation is S applied to interfaces: if a fake has to implement six members to test one, the interface is hoarding. Fine principles, both. They just drive fewer of the refactors you will actually perform this quarter, which is why this article gave them a paragraph and gave S and D the code.

The pre-PR checklist

  • One reason to change per class. Count stakeholders, not methods.
  • No new for anything you would ever swap or fake in a test.
  • The constructor names every dependency. Injecting IServiceProvider is a service locator wearing a lanyard, and it moves failures to runtime.
  • Interfaces stay small. A six-member fake for a one-member test is a smell.
  • Concrete types live in Program.cs registrations, nowhere else.

Version note: the samples above use C# 12 primary constructors and target .NET 10, the current LTS. Everything conceptual applies to any supported .NET version; only the constructor syntax is version-flavored, and the classic constructor form works identically.

If you prefer this material as a visual walkthrough, the 9-slide breakdown of this exact refactor lives at @thesharpfuture, where a new one lands every week.

Top comments (0)