Design Patterns & Practices  

Single Responsibility Principle (SRP) in C#: One Class, One Job

Summary

This article explores the Single Responsibility Principle (SRP)β€”the first and arguably most important of the SOLID principles. You'll learn what "single responsibility" really means, how to identify when classes violate SRP, and practical techniques for refactoring bloated classes into focused, maintainable components. Through real-world C# examples, we'll transform messy, multi-purpose classes into clean, testable code that's easier to understand and modify.

Prerequisites

  • Basic understanding of C# and object-oriented programming

  • Familiarity with classes, methods, and interfaces

  • Understanding of the SOLID principles overview (recommended)

  • Experience with Visual Studio or similar IDE

The Single Responsibility Principle states: A class should have one, and only one, reason to change .

That sounds simple. But what does it mean in practice? Let's explore through real scenarios you face every day.

Understanding "Reason to Change"

The phrase "reason to change" is key. It doesn't mean a class should only do one thing. It means a class should have only one reason someone would need to modify it.

Think about it: if your email validation logic changes, you shouldn't need to touch your database access code. If your invoice format changes, you shouldn't need to modify your payment processing logic. Each responsibility lives in its own space.

When a class handles multiple responsibilities, changes to one responsibility can accidentally break another. That's the core problem SRP solves.

The Invoice Processor Problem

Let's start with a common scenario. You're building an invoicing system, and you create a class that handles everything:

public class InvoiceProcessor
{
    public void ProcessInvoice(Invoice invoice)
    {
        // Calculate totals
        decimal subtotal = 0;
        foreach (var item in invoice.Items)
        {
            subtotal += item.Quantity * item.Price;
        }
        decimal tax = subtotal * 0.08m;
        decimal total = subtotal + tax;
        invoice.Total = total;

        // Save to database
        using (var connection = new SqlConnection("Server=localhost;Database=InvoiceDB"))
        {
            connection.Open();
            var command = new SqlCommand(
                "INSERT INTO Invoices (InvoiceNumber, Total) VALUES (@num, @total)", 
                connection);
            command.Parameters.AddWithValue("@num", invoice.Number);
            command.Parameters.AddWithValue("@total", total);
            command.ExecuteNonQuery();
        }

        // Send email
        var smtp = new SmtpClient("smtp.company.com", 587);
        var message = new MailMessage();
        message.To.Add(invoice.CustomerEmail);
        message.Subject = "Your Invoice";
        message.Body = $"Invoice #{invoice.Number} for ${total}";
        smtp.Send(message);

        // Generate PDF
        var pdf = new PdfDocument();
        pdf.AddPage($"Invoice #{invoice.Number}");
        pdf.AddText($"Total: ${total}");
        pdf.Save($"Invoice_{invoice.Number}.pdf");
    }
}

At first glance, this works. It processes invoices. Job done, right?

Wrong. This class has four reasons to change:

  • Tax calculation rules change

  • Database schema changes

  • Email service changes

  • PDF format requirements change

Any one of these changes forces you to modify InvoiceProcessor . Worse, you risk breaking the other responsibilities while making your change.

Refactoring to Follow SRP

Let's separate these responsibilities:

public class InvoiceCalculator
{
    public void Calculate(Invoice invoice)
    {
        decimal subtotal = 0;
        foreach (var item in invoice.Items)
        {
            subtotal += item.Quantity * item.Price;
        }

        decimal tax = subtotal * 0.08m;
        invoice.Total = subtotal + tax;
    }
}

public class InvoiceRepository
{
    private readonly string _connectionString;

    public InvoiceRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public void Save(Invoice invoice)
    {
        using (var connection = new SqlConnection(_connectionString))
        {
            connection.Open();
            var command = new SqlCommand(
                "INSERT INTO Invoices (InvoiceNumber, Total) VALUES (@num, @total)", 
                connection);
            command.Parameters.AddWithValue("@num", invoice.Number);
            command.Parameters.AddWithValue("@total", invoice.Total);
            command.ExecuteNonQuery();
        }
    }
}

public class InvoiceEmailer
{
    private readonly SmtpClient _smtp;

    public InvoiceEmailer(string smtpHost, int port)
    {
        _smtp = new SmtpClient(smtpHost, port);
    }

    public void SendInvoice(Invoice invoice)
    {
        var message = new MailMessage();
        message.To.Add(invoice.CustomerEmail);
        message.Subject = "Your Invoice";
        message.Body = $"Invoice #{invoice.Number} for ${invoice.Total}";
        _smtp.Send(message);
    }
}

public class InvoicePdfGenerator
{
    public void Generate(Invoice invoice)
    {
        var pdf = new PdfDocument();
        pdf.AddPage($"Invoice #{invoice.Number}");
        pdf.AddText($"Total: ${invoice.Total}");
        pdf.Save($"Invoice_{invoice.Number}.pdf");
    }
}

Now we orchestrate these classes:

public class InvoiceService
{
    private readonly InvoiceCalculator _calculator;
    private readonly InvoiceRepository _repository;
    private readonly InvoiceEmailer _emailer;
    private readonly InvoicePdfGenerator _pdfGenerator;

    public InvoiceService(
        InvoiceCalculator calculator,
        InvoiceRepository repository,
        InvoiceEmailer emailer,
        InvoicePdfGenerator pdfGenerator)
    {
        _calculator = calculator;
        _repository = repository;
        _emailer = emailer;
        _pdfGenerator = pdfGenerator;
    }

    public void ProcessInvoice(Invoice invoice)
    {
        _calculator.Calculate(invoice);
        _repository.Save(invoice);
        _pdfGenerator.Generate(invoice);
        _emailer.SendInvoice(invoice);
    }
}

Yes, we've created more classes. But look what we gained:

  • Tax rules change? Modify only InvoiceCalculator

  • Switch databases? Modify only InvoiceRepository

  • Change email provider? Modify only InvoiceEmailer

  • New PDF requirements? Modify only InvoicePdfGenerator

Each class now has exactly one reason to change.

The Report Generator Trap

Here's another real-world example. You need to generate reports from different data sources:

public class ReportManager
{
    public void GenerateReport(string reportType, DateTime startDate, DateTime endDate)
    {
        if (reportType == "Sales")
        {
            // Connect to sales database
            var salesConn = new SqlConnection("SalesDB");
            var salesData = LoadSalesData(salesConn, startDate, endDate);

            // Format as HTML
            var html = "";
             html += "<h1>Sales Report</h1>";
            foreach (var sale in salesData)
            {
                html += $"<p>{sale.Date}: ${sale.Amount}</p>";
            }
            html += "</body></html>";
            
            // Save to file
            File.WriteAllText("sales_report.html", html);
        }
        else if (reportType == "Inventory")
        {
            // Connect to inventory database
            var invConn = new SqlConnection("InventoryDB");
            var invData = LoadInventoryData(invConn);
            
            // Format as CSV
            var csv = "Item,Quantity,Value\n";
            foreach (var item in invData)
            {
                csv += $"{item.Name},{item.Quantity},{item.Value}\n";
            }
            
            // Save to file
            File.WriteAllText("inventory_report.csv", csv);
        }
    }
}

This class violates SRP in multiple ways:

  • It knows about multiple database connections

  • It handles different report formats (HTML, CSV)

  • It manages file I/O

  • It contains report-specific business logic

Refactoring the Report Generator

Let's apply SRP:

public interface IDataSource
{
    object LoadData(DateTime startDate, DateTime endDate);
}

public class SalesDataSource : IDataSource
{
    private readonly string _connectionString;
    
    public SalesDataSource(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public object LoadData(DateTime startDate, DateTime endDate)
    {
        using (var connection = new SqlConnection(_connectionString))
        {
            // Load sales data
            return LoadSalesData(connection, startDate, endDate);
        }
    }
}

public class InventoryDataSource : IDataSource
{
    private readonly string _connectionString;
    
    public InventoryDataSource(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public object LoadData(DateTime startDate, DateTime endDate)
    {
        using (var connection = new SqlConnection(_connectionString))
        {
            // Load inventory data
            return LoadInventoryData(connection);
        }
    }
}

// Report formatting abstraction
public interface IReportFormatter
{
    string Format(object data);
}

public class HtmlReportFormatter : IReportFormatter
{
    public string Format(object data)
    {
        var salesData = (List<SaleRecord>)data;
        var html = "<html><body>";
        html += "<h1>Sales Report</h1>";
        foreach (var sale in salesData)
        {
            html += $"<p>{sale.Date}: ${sale.Amount}</p>";
        }
        html += "</body></html>";
        return html;
    }
}

public class CsvReportFormatter : IReportFormatter
{
    public string Format(object data)
    {
        var invData = (List<InventoryItem>)data;
        var csv = "Item,Quantity,Value\n";
        foreach (var item in invData)
        {
            csv += $"{item.Name},{item.Quantity},{item.Value}\n";
        }
        return csv;
    }
}

// File writing responsibility
public class ReportWriter
{
    public void WriteToFile(string content, string fileName)
    {
        File.WriteAllText(fileName, content);
    }
}

// Orchestrator
public class ReportService
{
    private readonly IDataSource _dataSource;
    private readonly IReportFormatter _formatter;
    private readonly ReportWriter _writer;
    
    public ReportService(
        IDataSource dataSource,
        IReportFormatter formatter,
        ReportWriter writer)
    {
        _dataSource = dataSource;
        _formatter = formatter;
        _writer = writer;
    }
    
    public void GenerateReport(DateTime startDate, DateTime endDate, string fileName)
    {
        var data = _dataSource.LoadData(startDate, endDate);
        var formatted = _formatter.Format(data);
        _writer.WriteToFile(formatted, fileName);
    }
}

Now each class has a single, clear responsibility. Want to add PDF reports? Create PdfReportFormatter . New data source? Implement IDataSource . Change where reports are saved? Modify only ReportWriter .

The User Manager Anti-Pattern

Here's a violation you see everywhere:

public class UserManager
{
    public void RegisterUser(string username, string password, string email)
    {
        // Validate input
        if (username.Length < 3)
            throw new Exception("Username too short");
        if (!email.Contains("@"))
            throw new Exception("Invalid email");
        if (password.Length < 8)
            throw new Exception("Password too short");

        // Check if user exists
        var existingUser = CheckUserExists(username);
        if (existingUser)
            throw new Exception("User already exists");

        // Hash password
        var hashedPassword = BCrypt.HashPassword(password);

        // Save to database
        var conn = new SqlConnection("UserDB");
        conn.Open();
        var cmd = new SqlCommand("INSERT INTO Users...", conn);
        cmd.ExecuteNonQuery();

        // Send welcome email
        var smtp = new SmtpClient("smtp.server.com");
        smtp.Send("[email protected]", email, "Welcome!", "...");

        // Log the registration
        File.AppendAllText("registrations.log", $"{DateTime.Now}: {username}");
    }
}

This "manager" class manages everything. It's the god class anti-pattern.

Breaking Down the God Class

public class UserValidator
{
    public void Validate(string username, string password, string email)
    {
        if (username.Length < 3)
            throw new ArgumentException("Username too short");
        if (password.Length < 8)
            throw new ArgumentException("Password too short");
        if (!email.Contains("@"))
            throw new ArgumentException("Invalid email");
    }
}

public class PasswordHasher
{
    public string Hash(string password)
    {
        return BCrypt.HashPassword(password);
    }
}

public class UserRepository
{
    private readonly string _connectionString;

    public UserRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public bool UserExists(string username)
    {
        using (var conn = new SqlConnection(_connectionString))
        {
            conn.Open();
            var cmd = new SqlCommand("SELECT COUNT(*) FROM Users WHERE Username = @username", conn);
            cmd.Parameters.AddWithValue("@username", username);
            return (int)cmd.ExecuteScalar() > 0;
        }
    }

    public void Save(User user)
    {
        using (var conn = new SqlConnection(_connectionString))
        {
            conn.Open();
            var cmd = new SqlCommand("INSERT INTO Users (Username, PasswordHash, Email) VALUES...", conn);
            cmd.ExecuteNonQuery();
        }
    }
}

public class WelcomeEmailService
{
    private readonly SmtpClient _smtp;

    public WelcomeEmailService(string smtpHost)
    {
        _smtp = new SmtpClient(smtpHost);
    }

    public void SendWelcomeEmail(string email)
    {
        _smtp.Send("[email protected]", email, "Welcome!", "Welcome to our platform!");
    }
}

public class RegistrationLogger
{
    private readonly string _logPath;

    public RegistrationLogger(string logPath)
    {
        _logPath = logPath;
    }

    public void LogRegistration(string username)
    {
        File.AppendAllText(_logPath, $"{DateTime.Now}: {username} registered
");
    }
}

public class UserRegistrationService
{
    private readonly UserValidator _validator;
    private readonly PasswordHasher _hasher;
    private readonly UserRepository _repository;
    private readonly WelcomeEmailService _emailService;
    private readonly RegistrationLogger _logger;

    public UserRegistrationService(
        UserValidator validator,
        PasswordHasher hasher,
        UserRepository repository,
        WelcomeEmailService emailService,
        RegistrationLogger logger)
    {
        _validator = validator;
        _hasher = hasher;
        _repository = repository;
        _emailService = emailService;
        _logger = logger;
    }

    public void RegisterUser(string username, string password, string email)
    {
        _validator.Validate(username, password, email);

        if (_repository.UserExists(username))
            throw new InvalidOperationException("User already exists");

        var hashedPassword = _hasher.Hash(password);

        var user = new User
        {
            Username = username,
            PasswordHash = hashedPassword,
            Email = email
        };

        _repository.Save(user);
        _emailService.SendWelcomeEmail(email);
        _logger.LogRegistration(username);
    }
}

Now each class is focused and testable. You can test validation without hitting a database. You can test database logic without sending emails. Each responsibility is isolated.

How to Identify SRP Violations

Here are warning signs that a class violates SRP:

1. The class name contains "And", "Or", or "Manager"
UserManagerAndValidator , DataLoaderAndFormatter , ServiceManager – these names suggest multiple responsibilities.

2. The class has many public methods with different themes
If your class has ValidateUser() , SendEmail() , SaveToDatabase() , and GeneratePdf() , it's doing too much.

3. Changes to unrelated features require modifying the same class
If email format changes force you to touch the same class as database schema changes, that's a red flag.

4. The class is hard to test
If testing one method requires mocking ten dependencies, the class probably has too many responsibilities.

5. The class is long (more than 200-300 lines)
While not always true, excessive length often indicates multiple responsibilities.

SRP and Other SOLID Principles

SRP works hand-in-hand with other SOLID principles:

SRP + Open/Closed Principle: When classes have single responsibilities, extending functionality is easier. You add new classes rather than modifying existing ones.

SRP + Dependency Inversion: Single-responsibility classes naturally depend on abstractions. InvoiceService depends on IInvoiceCalculator , not concrete implementations.

SRP + Interface Segregation: When classes do one thing, interfaces naturally stay focused. No bloated interfaces with methods clients don't need.

Common Objections to SRP

"But I have so many classes now!"
Yes, you do. But each class is simpler, more testable, and easier to understand than one massive class doing everything.

"Isn't this over-engineering?"
Not if the responsibilities are genuinely different. Don't split classes arbitrarily, but do separate concerns that change for different reasons.

"My class only has 50 lines. Does SRP still apply?"
Yes. Size isn't the only indicator. If those 50 lines handle validation AND database access, split them.

When NOT to Apply SRP Rigidly

SRP isn't about creating a class for every tiny operation. Use judgment:

  • Simple DTOs (Data Transfer Objects) can bundle related properties

  • Helper classes with closely related utility methods are fine

  • Don't split prematurely – if a responsibility hasn't changed in years, maybe it doesn't need separation yet

Conclusion

The Single Responsibility Principle isn't about doing lessβ€”it's about doing one thing well. When each class has a single, clear purpose, your codebase becomes a collection of focused, testable components rather than a tangled mess of interdependencies.

Remember: a class should have one reason to change. Not one method. Not one line. One reason. When you need to modify how invoices are calculated, you shouldn't touch code that sends emails. When you change database schemas, you shouldn't risk breaking PDF generation.

SRP is the foundation of SOLID. Master it, and the other principles become much easier to understand and apply.

In the next article, we'll explore the Open/Closed Principle and learn how to make code extensible without modification.

Key Takeaways

  • βœ… One reason to change – Each class should have only one reason someone would modify it

  • βœ… Separate responsibilities – Validation, database access, email, logging – each deserves its own class

  • βœ… Warning signs – "Manager" classes, mixed concerns, and hard-to-test code indicate SRP violations

  • βœ… More classes, simpler code – Yes, you'll have more files, but each is easier to understand

  • βœ… Easier testing – Single-responsibility classes are trivial to test in isolation

  • βœ… Reduced bugs – Changes to one feature don't break unrelated features

  • βœ… Use judgment – Don't split arbitrarily; separate concerns that truly change for different reasons

  • βœ… Foundation for SOLID – Master SRP and other principles become easier

What's the biggest SRP violation in your current codebase? Share in the comments below!

References and Further Reading