DEV Community

Joshua Williams
Joshua Williams

Posted on

SOLID in C# Part 1: Single Responsibility

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?

Here's the 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

This class has four reasons to change:

  1. The discount rules change → you edit this class
  2. The database schema changes → you edit this class
  3. The email format changes → you edit this class
  4. The logging approach changes → you edit this class

That's four different people on your team who might need to modify this file at the same time. It's also nearly impossible to test any one of these behaviors in isolation.

This is sometimes 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 actually testable:

// Contracts: 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);
}
Enter fullscreen mode Exit fullscreen mode
// This class only knows about pricing rules
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;
    }
}
Enter fullscreen mode Exit fullscreen mode
// This class only knows about persistence
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();
    }
}
Enter fullscreen mode Exit fullscreen mode
// This class only knows about sending notifications
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);
    }
}
Enter fullscreen mode Exit fullscreen mode
// This class only knows about logging
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");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now OrderService becomes a coordinator. It doesn't do the work, it delegates:

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

    public OrderService(
        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 price) {
        decimal total = calculator.CalculateTotal(quantity, price);

        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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the naming: the interface is IOrderNotifier, but the implementation is EmailOrderNotifier. Tomorrow when your team switches to SendGrid, you write SendGridOrderNotifier and swap the DI registration. Nothing else changes.

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)