SOLID in C# Part 1: Single Responsibility

Published: (February 21, 2026 at 06:10 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

This is Part 1 of my SOLID Principles in C# series. Each article walks through a principle with real code, the kind you’d actually see in a production codebase.

What Is the Single Responsibility Principle?

Simple version: a class should have one reason to change.

Not one method. Not one line of code. One reason to change – that means one job, one area of responsibility.

When someone on your team says “I need to update how we send emails,” that change should affect one class, not a class that also handles order calculations and database queries.

The Problem: A Class That Does Everything

I’ve seen this pattern in almost every codebase I’ve worked in. At some point, someone creates an OrderService and just keeps adding to it:

public class OrderService {
    private readonly string connectionString;

    public OrderService(string connectionString) {
        this.connectionString = connectionString;
    }

    public void PlaceOrder(string customerEmail, string product, int quantity, decimal price) {
        // 1. Calculate the total
        decimal total = quantity * price;
        if (quantity > 10)
            total *= 0.9m; // 10% bulk discount

        // 2. Save to database
        using var connection = new SqlConnection(connectionString);
        connection.Open();
        using var command = new SqlCommand(
            "INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
            connection);
        command.Parameters.Add("@e", SqlDbType.NVarChar).Value = customerEmail;
        command.Parameters.Add("@p", SqlDbType.NVarChar).Value = product;
        command.Parameters.Add("@q", SqlDbType.Int).Value = quantity;
        command.Parameters.Add("@t", SqlDbType.Decimal).Value = total;
        command.ExecuteNonQuery();

        // 3. Send confirmation email
        var message = new MimeMessage();
        message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
        message.To.Add(new MailboxAddress("", customerEmail));
        message.Subject = "Order Confirmed";
        message.Body = new TextPart("plain") {
            Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
        };
        using var smtp = new SmtpClient();
        smtp.Connect("smtp.company.com", 587);
        smtp.Send(message);
        smtp.Disconnect(true);

        // 4. Log it
        File.AppendAllText("orders.log",
            $"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
    }
}

Why This Is a Problem

This class has four reasons to change:

ReasonWhat changes
Discount rulesEdit the calculation logic
Database schemaEdit the SQL command
Email formatEdit the message construction
Logging approachEdit the file‑writing code

Four different people on your team might need to modify this file at the same time, and it’s nearly impossible to test any one of these behaviors in isolation. This is often called a “God Class” – it knows too much and does too much.

The Fix: One Job Per Class

Let’s split this up. Each class gets a single reason to exist, and we’ll program against interfaces so these pieces are testable.

Contracts (Interfaces)

// Each interface represents one responsibility
public interface IOrderCalculator {
    decimal CalculateTotal(int quantity, decimal unitPrice);
}

public interface IOrderRepository {
    void Save(Order order);
}

public interface IOrderNotifier {
    void SendConfirmation(string customerEmail, string product, int quantity, decimal total);
}

public interface IOrderLogger {
    void Log(string product, int quantity, decimal total);
}

Implementations

Order Calculator – pricing rules only

public class OrderCalculator : IOrderCalculator {
    public decimal CalculateTotal(int quantity, decimal unitPrice) {
        decimal total = quantity * unitPrice;

        if (quantity > 10)
            total *= 0.9m; // bulk discount

        return total;
    }
}

Order Repository – persistence only

public class OrderRepository : IOrderRepository {
    private readonly string connectionString;

    public OrderRepository(string connectionString) {
        this.connectionString = connectionString;
    }

    public void Save(Order order) {
        using var connection = new SqlConnection(connectionString);
        connection.Open();
        using var command = new SqlCommand(
            "INSERT INTO Orders (Email, Product, Qty, Total) VALUES (@e, @p, @q, @t)",
            connection);
        command.Parameters.Add("@e", SqlDbType.NVarChar).Value = order.CustomerEmail;
        command.Parameters.Add("@p", SqlDbType.NVarChar).Value = order.Product;
        command.Parameters.Add("@q", SqlDbType.Int).Value = order.Quantity;
        command.Parameters.Add("@t", SqlDbType.Decimal).Value = order.Total;
        command.ExecuteNonQuery();
    }
}

Email Order Notifier – notification only

public class EmailOrderNotifier : IOrderNotifier {
    public void SendConfirmation(string customerEmail, string product, int quantity, decimal total) {
        // Using MailKit (the modern replacement for System.Net.Mail.SmtpClient,
        // which has been deprecated since .NET 6)
        var message = new MimeMessage();
        message.From.Add(new MailboxAddress("Orders", "orders@company.com"));
        message.To.Add(new MailboxAddress("", customerEmail));
        message.Subject = "Order Confirmed";
        message.Body = new TextPart("plain") {
            Text = $"Thanks for ordering {quantity}x {product}. Total: ${total}"
        };
        using var smtp = new SmtpClient();
        smtp.Connect("smtp.company.com", 587);
        smtp.Send(message);
        smtp.Disconnect(true);
    }
}

Order Logger – logging only

public class FileOrderLogger : IOrderLogger {
    public void Log(string product, int quantity, decimal total) {
        File.AppendAllText("orders.log",
            $"{DateTime.Now}: Order placed - {product} x{quantity} = ${total}\n");
    }
}

Orchestrating the Process

public class OrderProcessor {
    private readonly IOrderCalculator calculator;
    private readonly IOrderRepository repository;
    private readonly IOrderNotifier notifier;
    private readonly IOrderLogger logger;

    public OrderProcessor(IOrderCalculator calculator,
                          IOrderRepository repository,
                          IOrderNotifier notifier,
                          IOrderLogger logger) {
        this.calculator = calculator;
        this.repository = repository;
        this.notifier = notifier;
        this.logger = logger;
    }

    public void PlaceOrder(string customerEmail, string product, int quantity, decimal unitPrice) {
        var total = calculator.CalculateTotal(quantity, unitPrice);
        var order = new Order {
            CustomerEmail = customerEmail,
            Product = product,
            Quantity = quantity,
            Total = total
        };

        repository.Save(order);
        notifier.SendConfirmation(customerEmail, product, quantity, total);
        logger.Log(product, quantity, total);
    }
}

Now each class has one reason to change, making the codebase easier to understand, test, and maintain.

What Did We Actually Gain?

You can test each piece in isolation. Want to verify the bulk discount? Test OrderCalculator directly. No database, no email server, no file system:

[Test]
public void CalculateTotal_BulkOrder_AppliesTenPercentDiscount() {
    var calculator = new OrderCalculator();

    var total = calculator.CalculateTotal(quantity: 15, unitPrice: 10m);

    Assert.That(total, Is.EqualTo(135m)); // 150 * 0.9
}

Try writing that test against the original God Class. You’d need a database connection, an SMTP server, and a writable file system just to test arithmetic.

Changes are scoped. When your boss says “we’re switching from SMTP to SendGrid,” you write a new IOrderNotifier implementation. The calculator, the repository, the logger: none of them are touched, none of them can accidentally break.

New developers find things faster. “Where’s the pricing logic?” → OrderCalculator.cs. Not buried on line 47 of a 200‑line method.

How to Spot SRP Violations

Red flags I look for during code reviews:

  • The class name has “And” in it (or should): OrderAndEmailService, UserValidationAndStorage.
  • You use the word “also” when describing it: “It calculates the total and also sends the email and also saves to the database.”
  • Dependencies from completely different domains: a class that takes both SmtpClient and SqlConnection is doing too much.
  • Methods longer than ~30 lines: not a hard rule, but long methods are usually multiple responsibilities jammed together.
  • Multiple people on the team keep editing the same file for unrelated reasons.

The Most Common Pushback

“But now I have so many files!”

Yes. Five small, focused files are easier to understand, test, and modify than one massive file. Your IDE has Ctrl+T. Use it.

The cost of more files is close to zero. The cost of a God Class is code that nobody wants to touch and bugs that cascade across unrelated features.

Quick Rules

  • Ask: “Can I describe this class’s job in one sentence without using and?”
  • If a class has more than 3‑4 dependencies, it might be doing too much.
  • Coordinators are fine. OrderService above coordinates work but doesn’t do the work itself.
  • Don’t go overboard. You don’t need a class for every method. Group things that change for the same reason.

Next up: Part 2 – Open/Closed Principle, “Extend, Don’t Modify.” We’ll look at how to add new behavior without touching existing classes.

This is part of a series that goes from SOLID → Unit Testing → Clean Architecture → Integration Testing. Follow me to catch the next one.

0 views
Back to Blog

Related posts

Read more »

cppsp v1.5 --module system update

Overview cppsp_compiler mod.cppsp -header Generates a .h file and turns int main{...} into a comment. Configuration - module.ini – Example entry: C:...modfolde...