SOLID in C# Part 1: Single Responsibility
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:
| Reason | What changes |
|---|---|
| Discount rules | Edit the calculation logic |
| Database schema | Edit the SQL command |
| Email format | Edit the message construction |
| Logging approach | Edit 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
SmtpClientandSqlConnectionis 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.
OrderServiceabove 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.