“Clean code is not overengineering. It’s making sure small changes actually feel small when the time comes.” — Me, after wrestling with my own code
Hi! Are you one of those people who just can’t stand SOLID principles?
Don't worry, I totally get you! They can be really confusing at first.
But once you grasp the main idea behind them, you’ll start to see where they fit in your code and spot the bottlenecks that appear when they’re ignored.
In this SOLID series, I’ll try to explain each principle in a simple, easy-to-understand way. No heavy jargon, no overcomplication, just practical stuff.
So, let’s kick things off with the first one: the Single Responsibility Principle (SRP).
The key to mastering SRP is understanding what “single responsibility” really means.
What counts as a single responsibility, and how you can tell if a class or component is handling more than one.
Let's have a look!
What Exactly is Meant by “Single Responsibility”?
A single responsibility simply means a single reason to change. In other words:
A class, function, or module should only have one reason to be modified.
Here’s what that really means in practice:
🧩 A “responsibility” is a role or purpose your code serves.
For example, fetching data from a database is one responsibility. Formatting that data for display is another. Sending it in an email is yet another.
🔄 If a change in one part of the system forces you to update that class/function, it means the class/function is responsible for that area of change.
So if you have a function that needs changes whenever the business logic changes and also when the report format changes, it has at least two responsibilities.
✂️ Single Responsibilities Principle makes sure that each reason to change should belong to a different class or function.
That way, when a requirement changes, you only touch one small, focused piece of code, not everything at once.
Let's understand this with the help of an example.
Building a Report Generator
Imagine you just joined a logistics company as a .NET developer. Your first task is to Build a simple shipment report generator.
Let’s say you come up with this (very realistic) piece of code:
public void GenerateReport()
{
// 1. Fetch data
var shipments = new List<Shipment>
{
new Shipment { Id = 1, Description = "Cargo A" },
new Shipment { Id = 2, Description = "Cargo B" }
};
// 2. Format as text
var builder = new StringBuilder();
foreach (var shipment in shipments)
{
builder.AppendLine($"ID: {shipment.Id}, Description: {shipment.Description}");
}
var report = builder.ToString();
// 3. Save to disk
var filename = "shipment_report.txt";
File.WriteAllText(filename, report);
// 4. Send email
Console.WriteLine("Sending email to manager...");
// (Pretend there’s real SMTP logic here)
}
Looks good, right? Everything’s working. It fetches data, formats it, saves a file, and even emails it.
But here's the thing.
This version technically has one function GenerateReport() but it’s still doing four different things:
- Fetching data
- Formatting data
- Writing to disk
- Sending an email
So even though it’s written as one function, it’s carrying multiple responsibilities.
That’s the key:
SRP isn’t about how many functions or classes you have. It’s about how many reasons to change exist in one place.
If the email logic changes, you’ll have to edit GenerateReport(). If the file output format changes, same function again. If the report data changes, again.
Each of those is a different reason to change (multiple responsibilities).
So let's fix this by following Single Responsibility Principle.
Rebuilding Report Generator following SRP
Let's start by listing down all the reasons to change aka responsibilities.
- If the way we fetch data changes, that’s one reason.
- If the way we format reports changes, second reason.
- If we save somewhere else, third.
- If we want to notify someone differently, fourth.
So, let’s split it up.
Step 1: Define the Responsibilities
So far we have identified four responsibilities. Let's create dedicated functions to serve each responsibility.
We'll have:
Data Fetcher → Responsible for fetching shipment data from database.
Report Formatter → Responsible for formatting the shipment report to the desired format.
File Saver → Responsible for saving the file to server, cloud storage, etc.
Email Sender → Responsible for sending the emails.
Step 2: Write Dedicated Functions for each Responsibility
Let's write functions for each of the defined responsibilities.
Data Fetcher
FetchShipmentsFromDb()
{
// Simulated DB call
return new List<Shipment>
{
new Shipment { Id = 1, Description = "Cargo A" },
new Shipment { Id = 2, Description = "Cargo B" }
};
}
Report Formatter
FormatAsTextReport(List<Shipment> shipments)
{
var builder = new StringBuilder();
foreach (var shipment in shipments)
{
builder.AppendLine($"ID: {shipment.Id}, Description: {shipment.Description}");
}
return builder.ToString();
}
File Saver
private void SaveToFile(string content, string filename)
{
File.WriteAllText(filename, content);
}
Email Sender
private void SendEmail(string content)
{
Console.WriteLine("Sending email to manager...");
// Imagine SMTP logic here
}
Step 3: Bring it all together
Now let's rewrite the ReportGenerator(), this time, delegating the responsibilities to their respective functions.
public void GenerateReport()
{
// 1. Fetch data from the database
var shipments = FetchShipmentsFromDb();
// 2. Convert to report format
var report = FormatAsTextReport(shipments);
// 3. Save report to disk
SaveToFile(report, "shipment_report.txt");
// 4. Send email to manager
SendEmail(report);
}
That’s it! Our ReportGenerator is now only responsible for assembling the report, while all the individual tasks are delegated to their respective functions.
Want to format the report in Excel? Just update the ReportFormatter.
Need to save the file to the cloud instead of the local server? Modify the FileSaver.
Each function now has a single responsibility.
A change in report formatting doesn’t affect the File Saver, and a change in the Email Sender doesn’t require updates anywhere else.
And with that, you’ve successfully achieved the Single Responsibility Principle. 👏
Now, I get that it’s harder to see the consequences of not following SRP in a small example like this.
But in real-world applications, things rarely exist in isolation. Systems are made up of multiple components and modules that constantly interact with each other.
Having well-defined responsibilities, and making sure each component follows SRP, goes a long way toward keeping your codebase clean and maintainable.
Otherwise, it doesn’t take long before everything becomes intertwined and your code turns into spaghetti.🍝
I hope this helped you grasp the concept without feeling overwhelmed.
💭 What’s your take on SRP? Do you see it as essential, or do you think it’s just over-engineering? I’d love to hear your thoughts.
Next Up: The “O” in SOLID — Open/Closed Principle. Where we’ll dive into how adding a new feature shouldn’t mean rewriting old code (but it often does).
Hi, I’m Bilal 👋, a .NET developer sharing what I learn about building software, thinking in systems, and writing clean, practical code.
If you’re exploring similar ideas, let’s connect and learn together.
Top comments (0)