C#  

SOLID Principles in C# Series - Part 1: Single Responsibility Principle (SRP)

Welcome to a Series

If you've been writing C# for a while, you've probably heard the term SOLID thrown around in code reviews, architecture meetings, or interview questions. Maybe you nodded along, maybe you Googled it quietly later. Either way, this series is for you.

This is Article 1 of a 5-part series where we'll cover one SOLID principle at a time, with real C# code, and — most importantly — with practical examples from a real-world project like an HRMS (Human Resource Management System) Software. Here's what's coming:

  1. Single Responsibility Principle (this article)

  2. Open/Closed Principle

  3. Liskov Substitution Principle

  4. Interface Segregation Principle

  5. Dependency Inversion Principle

You don't need any prior SOLID knowledge to follow along — just basic comfort with classes, interfaces, and everyday OOP concepts. By the end of this series, you'll be able to look at your own codebase and immediately spot where these principles are being followed and where they're being broken.

What is SOLID, Briefly?

SOLID is a set of five design principles that help you write code that's easier to maintain, extend, and test. Each letter stands for one principle: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. None of them are complicated math or theory — they're just practical habits that experienced developers naturally pick up, usually after getting burned by messy code at least once.

We'll explore each one fully in its own article. Today, it's all about the "S."

Let's start with the first letter: S.

What is the Single Responsibility Principle (SRP)?

Here's the simplest way to say it: a class should have only one reason to change.

That's it. No complicated definition needed. If a class is responsible for one specific job, then it only needs to change when that one job changes. The moment a class starts doing two or three unrelated things, it now has two or three reasons to change — and that's where problems begin.

A General Real-World Example

Picture a small restaurant with just one employee who has to do everything: cook the food, take orders, handle billing, clean the tables, and deal with customer complaints.

Sounds exhausting, right? Now imagine the billing software changes, or a new food safety rule comes in, or the restaurant adds a new payment method. Every single one of these changes disrupts this one employee's entire workflow, even though most of these changes have nothing to do with cooking.

Now compare that to a properly staffed restaurant: a chef who only cooks, a cashier who only handles billing, a cleaner who only manages hygiene, and a manager who only handles customer complaints. If the billing software changes, only the cashier is affected. The chef keeps cooking without interruption.

That's SRP in a nutshell. One role, one responsibility, one reason to change.

Why Developers Violate SRP Without Realizing It

Nobody sets out to write a class that does five different jobs. It usually starts innocently. You're building a feature, and it feels faster to add "just one more method" to an existing class instead of creating a new one. Over weeks or months, that class quietly grows into something that validates data, talks to the database, sends emails, and formats reports — all in one place.

It often goes unnoticed because the code still works. The trouble only shows up later: a small change in one area unexpectedly breaks something completely unrelated, or two developers can't work on the same class without stepping on each other's changes.

There's also a subtler reason this happens. Many developers were taught to think in terms of "entities" rather than "responsibilities." If you have an Employee entity, it feels natural to put every employee-related operation inside the Employee class — saving it, emailing it, formatting it into reports. But an entity representing data and a service performing an action are two very different things, and mixing them is exactly how SRP violations sneak in unnoticed.

Example 1: Before and After

Let's look at a common scenario — a class responsible for handling an employee's data.

Before (Violates SRP)

public class Employee
{
    public string Name { get; set; }

    public string Email { get; set; }

    public void SaveToDatabase()
    {
        // Code to insert/update employee record in the database
        Console.WriteLine("Employee saved to database.");
    }

    public void SendWelcomeEmail()
    {
        // Code to send a welcome email
        Console.WriteLine("Welcome email sent to " + Email);
    }

    public string GenerateReport()
    {
        // Code to format employee details into a report
        return $"Employee Report: {Name}, {Email}";
    }
}

This Employee class has three reasons to change: a change in database logic, a change in email logic, or a change in report formatting. That's three unrelated responsibilities crammed into one class.

After (Follows SRP)

public class Employee
{
    public string Name { get; set; }

    public string Email { get; set; }
}

public class EmployeeRepository
{
    public void Save(Employee employee)
    {
        Console.WriteLine("Employee saved to database.");
    }
}

public class EmployeeNotificationService
{
    public void SendWelcomeEmail(Employee employee)
    {
        Console.WriteLine("Welcome email sent to " + employee.Email);
    }
}

public class EmployeeReportGenerator
{
    public string GenerateReport(Employee employee)
    {
        return $"Employee Report: {employee.Name}, {employee.Email}";
    }
}

Now each class has exactly one job. If the email provider changes, only EmployeeNotificationService is touched. If the report format changes, only EmployeeReportGenerator is touched. The Employee class itself just represents data — nothing more.

Example 2: A Different Scenario

Let's look at another common case — an order processing class in an e-commerce-style application.

Before (Violates SRP)

public class OrderProcessor
{
    public void ProcessOrder(Order order)
    {
        // Validate order
        if (order.Items.Count == 0)
            throw new Exception("Order has no items.");

        // Calculate total
        decimal total = order.Items.Sum(i => i.Price * i.Quantity);

        // Save to database
        Console.WriteLine("Order saved to database.");

        // Send confirmation
        Console.WriteLine("Confirmation email sent to customer.");
    }
}

This single method is doing validation, calculation, persistence, and notification — four different concerns, all glued together.

After (Follows SRP)

public class OrderValidator
{
    public void Validate(Order order)
    {
        if (order.Items.Count == 0)
            throw new Exception("Order has no items.");
    }
}

public class OrderCalculator
{
    public decimal CalculateTotal(Order order)
    {
        return order.Items.Sum(i => i.Price * i.Quantity);
    }
}

public class OrderRepository
{
    public void Save(Order order)
    {
        Console.WriteLine("Order saved to database.");
    }
}

public class OrderNotificationService
{
    public void SendConfirmation(Order order)
    {
        Console.WriteLine("Confirmation email sent to customer.");
    }
}

Each class is now small, focused, and easy to test on its own. You can unit test OrderValidator without touching the database or worrying about emails at all.

Applying This in HRMS Project

Let's bring this back to something we actually work with — HRMS application.

It's extremely common in HRMS-style systems to find a class like EmployeeService that has slowly grown to handle validation, database operations, and email notifications all at once, simply because "it's the employee service, so everything employee-related goes here."

Before (A Common HRMS Anti-Pattern)

public class EmployeeService
{
    public void RegisterEmployee(Employee employee)
    {
        if (string.IsNullOrEmpty(employee.Name))
            throw new Exception("Employee name is required.");

        // Save employee to database
        Console.WriteLine("Employee record saved.");

        // Notify HR team
        Console.WriteLine("Notification sent to HR team.");
    }
}

If tomorrow the validation rules change, or the notification system moves from email to Slack, or the database logic needs optimization — all of these changes happen inside the same EmployeeService class, increasing the risk of breaking something unrelated.

After (Applying SRP to HRMS)

public class EmployeeValidator
{
    public void Validate(Employee employee)
    {
        if (string.IsNullOrEmpty(employee.Name))
            throw new Exception("Employee name is required.");
    }
}

public class EmployeeRepository
{
    public void Save(Employee employee)
    {
        Console.WriteLine("Employee record saved.");
    }
}

public class HrNotificationService
{
    public void NotifyHrTeam(Employee employee)
    {
        Console.WriteLine("Notification sent to HR team.");
    }
}

public class EmployeeService
{
    private readonly EmployeeValidator _validator = new();
    private readonly EmployeeRepository _repository = new();
    private readonly HrNotificationService _notificationService = new();

    public void RegisterEmployee(Employee employee)
    {
        _validator.Validate(employee);
        _repository.Save(employee);
        _notificationService.NotifyHrTeam(employee);
    }
}

Now EmployeeService simply coordinates the steps, while each individual class owns one specific responsibility. If we later add a LeaveRequest module or a PayrollCalculator, the same pattern applies: keep validation, persistence, and notifications in their own dedicated classes rather than piling everything into one "god class."

Common Mistakes When Applying SRP

A few things to watch out for as you start applying SRP in your own code.

The first is overdoing it — splitting a class into ten tiny pieces when three would do, which just adds unnecessary complexity and makes the codebase harder to navigate rather than easier.

The second is confusing "one responsibility" with "one method" — a class can have multiple methods, as long as they all serve the same single purpose; EmployeeValidator having both Validate() and ValidateBulk() is still perfectly fine under SRP.

And the third is forgetting that SRP applies to modules and layers too, not just individual classes — the same overcrowding problem can happen at the service or controller level, where a single EmployeeController ends up handling employees, departments, and payroll endpoints simply because it was convenient at the time.

Quick Checklist for SRP

Before committing your code, ask yourself:

  • Can I describe what this class does in one sentence, without using the word "and"?

  • If a database change happens, does this class change for an unrelated reason too?

  • Would two different developers ever need to edit this same class for two completely different features?

  • Can I unit test this class without mocking five unrelated dependencies?

  • Does the class name still accurately describe what it does, or has it grown beyond its name?

If you answered "yes" to questions 2 or 3, it's probably time to split that class.

Practical Reasons to NOT Apply SRP

Here are the practical, real-world reasons why you'd hold back from applying SRP — the kind of justification a tech lead would actually give in a code review, not just abstract theory.

1. The Class Is a Simple Data Holder, Not Behavior

In our HRMS, a class like EmployeeAddress with just Street, City, and PinCode properties doesn't need to be split further. There's no behavior to separate — it's just data. Splitting it adds files without adding any real benefit.

2. Splitting Creates More Maintenance Work Than It Saves

If you split LeaveCalculator into LeaveDaysCounter, LeaveRuleValidator, and LeaveResultFormatter, but all three are only ever called together, from one place, and always change together when leave rules change — you've now got three files to open, three places to navigate, and zero added flexibility. You're maintaining more code for the same outcome.

3. The Responsibilities Are Tightly Bound by Business Logic, Not by Accident

A PayrollLineItem that calculates tax and formats the tax line might look like "two responsibilities," but if tax calculation rules and tax display format are defined by the same regulation and always change together, separating them just adds an unnecessary layer between two things that are naturally one unit.

Conclusion

The Single Responsibility Principle is really about giving every class one clear job and one clear reason to change. It doesn't require fancy patterns or frameworks — just the discipline to ask "does this really belong here?" before adding new code to an existing class.

Apply it consistently, and your HRMS modules will be far easier to maintain, test, and hand off to other developers.

In the next article, we'll look at the Open/Closed Principle — how to add new features to your code without rewriting what already works.