TL;DR:

  • SRP means each class or module should have only one reason to change.
  • Split validation, data access, and business logic into separate classes.
  • SRP improves maintainability, testability, and scalability.
  • Use C# 12 features like primary constructors and records for clean separation.
  • Avoid “God” classes and mixing unrelated responsibilities.
  • SRP is the foundation for applying other SOLID principles.
  • Refactor large classes by extracting focused components and using dependency injection.

The Single Responsibility Principle gets misunderstood more than any other SOLID principle. It’s not about doing one thing, it’s about having one reason to change. When your class changes for multiple business reasons, you’ve violated SRP and created a maintenance nightmare.

The Problem: Multiple Responsibilities in One Class

Here’s a typical violation, a UserService that handles both validation and persistence:

public class UserService
{
    private readonly string _connectionString;
    
    public UserService(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public async Task<bool> CreateUserAsync(User user)
    {
        // Validation logic
        if (string.IsNullOrEmpty(user.Email) || !user.Email.Contains("@"))
            return false;
        
        if (user.Age < 18)
            return false;
        
        // Persistence logic
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync();
        
        var command = new SqlCommand(
            "INSERT INTO Users (Email, Age) VALUES (@email, @age)", 
            connection);
        
        command.Parameters.AddWithValue("@email", user.Email);
        command.Parameters.AddWithValue("@age", user.Age);
        
        await command.ExecuteNonQueryAsync();
        return true;
    }
}

This class has two reasons to change:

  1. Business rule changes (validation requirements)
  2. Data access changes (database schema, connection logic)

The Solution: Separate Responsibilities

public class UserValidator
{
    public ValidationResult Validate(User user)
    {
        var errors = new List<string>();
        
        if (string.IsNullOrEmpty(user.Email) || !user.Email.Contains("@"))
            errors.Add("Invalid email format");
        
        if (user.Age < 18)
            errors.Add("User must be at least 18 years old");
        
        return new ValidationResult(errors);
    }
}

public class UserRepository
{
    private readonly string _connectionString;
    
    public UserRepository(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public async Task SaveAsync(User user)
    {
        using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync();
        
        var command = new SqlCommand(
            "INSERT INTO Users (Email, Age) VALUES (@email, @age)", 
            connection);
        
        command.Parameters.AddWithValue("@email", user.Email);
        command.Parameters.AddWithValue("@age", user.Age);
        
        await command.ExecuteNonQueryAsync();
    }
}

public class UserService
{
    private readonly UserValidator _validator;
    private readonly UserRepository _repository;
    
    public UserService(UserValidator validator, UserRepository repository)
    {
        _validator = validator;
        _repository = repository;
    }
    
    public async Task<bool> CreateUserAsync(User user)
    {
        var validationResult = _validator.Validate(user);
        if (!validationResult.IsValid)
            return false;
        
        await _repository.SaveAsync(user);
        return true;
    }
}

What Qualifies as a “Reason to Change”?

A reason to change comes from different actors in your system:

  • Business stakeholders changing validation rules
  • DBAs changing database schemas
  • Security teams changing authentication requirements
  • Performance teams changing caching strategies

When the same class needs to change for requests from different actors, you’ve violated SRP.


flowchart LR

    subgraph "SRP Compliance"
        Change1[Business Rule Change] -->|affects only| BusinessLogicClass[Business Logic Class]
        BusinessLogicClass -->|limited impact| Comp1[Component A]
        
        Change2[Database Schema Change] -->|affects only| DataAccessClass[Data Access Class]
        DataAccessClass -->|limited impact| Comp2[Component B]
        
        Change3[Security Policy Change] -->|affects only| SecurityClass[Security Class]
        SecurityClass -->|limited impact| Comp3[Component C]
    end

    subgraph "SRP Violation"
        ChangeA[Business Rule Change] -->|affects| MonolithicClass
        ChangeB[Database Schema Change] -->|affects| MonolithicClass
        ChangeC[Security Policy Change] -->|affects| MonolithicClass
        MonolithicClass -->|cascading changes| ComponentA[Component A]
        MonolithicClass -->|cascading changes| ComponentB[Component B]
        MonolithicClass -->|cascading changes| ComponentC[Component C]
    end
    

    

Impact of changes in SRP violation vs. SRP compliance

Modern C# Features That Help With SRP

In C# 12 and .NET 8, we have several features that make adhering to SRP even easier:

Primary Constructors for Clean Dependency Injection

// C# 12 primary constructor syntax for clean DI
public class UserService(UserValidator validator, UserRepository repository)
{
    public async Task<bool> CreateUserAsync(User user)
    {
        var validationResult = validator.Validate(user);
        if (!validationResult.IsValid)
            return false;
        
        await repository.SaveAsync(user);
        return true;
    }
}

Record Types for Pure Data Classes

// Use records for DTOs to separate data from behavior
public record UserDto(string Email, int Age, string Name);

// Validator focused only on validation with no data access concerns
public class UserValidator
{
    public ValidationResult Validate(UserDto user) => new(
        IsValidEmail(user.Email) && user.Age >= 18
            ? Array.Empty<string>() 
            : new[] { "Invalid user data" }
    );
    
    private bool IsValidEmail(string email) => 
        !string.IsNullOrEmpty(email) && email.Contains('@');
}

Real-World Example: E-Commerce Order Processing

In a real e-commerce system, SRP violations often appear in order processing. Let’s examine a better approach:

// Before: One massive service handling everything
public class OrderProcessor
{
    public async Task ProcessOrder(Order order)
    {
        // Inventory check
        // Payment processing
        // Shipping calculation
        // Notification sending
        // Analytics updating
        // All in one giant method!
    }
}

// After: Each responsibility gets its own service
public interface IInventoryService
{
    Task<bool> CheckInventoryAsync(Order order);
}

public interface IPaymentService
{
    Task<PaymentResult> ProcessPaymentAsync(Order order);
}

public interface IShippingService
{
    Task<ShippingDetails> CalculateShippingAsync(Order order);
}

public interface INotificationService
{
    Task SendOrderConfirmationAsync(Order order, ShippingDetails shipping);
}

// Coordinating service with a focused responsibility
public class OrderCoordinator(
    IInventoryService inventory,
    IPaymentService payment,
    IShippingService shipping,
    INotificationService notification,
    ILogger<OrderCoordinator> logger)
{
    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        // Single responsibility: orchestrating the order flow
        try
        {
            if (!await inventory.CheckInventoryAsync(order))
                return new OrderResult(false, "Insufficient inventory");
                
            var paymentResult = await payment.ProcessPaymentAsync(order);
            if (!paymentResult.Success)
                return new OrderResult(false, paymentResult.Message);
                
            var shippingDetails = await shipping.CalculateShippingAsync(order);
            await notification.SendOrderConfirmationAsync(order, shippingDetails);
            
            return new OrderResult(true, "Order processed successfully");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
            return new OrderResult(false, "An error occurred");
        }
    }
}

public record OrderResult(bool Success, string Message);

Common SRP Pitfalls

  1. “Helper” classes that become dumping grounds for unrelated functions

    // Bad: Utility class with unrelated responsibilities
    public static class StringHelper
    {
        public static string FormatForDisplay(string input) { /* ... */ }
        public static bool ValidateEmail(string email) { /* ... */ }
        public static string EncryptText(string text) { /* ... */ }  // Security concern
        public static string GenerateReportHeader() { /* ... */ }    // Report concern
    }
    
    // Better: Focused classes with clear responsibilities
    public static class StringFormatter { /* Display formatting only */ }
    public static class InputValidator { /* Validation only */ }
    public static class SecurityUtils { /* Encryption only */ }
    public static class ReportHelper { /* Report generation only */ }
    
  2. Overly broad interfaces (IOrderService with 20+ methods)

    // Bad: Interface trying to do everything
    public interface IOrderService
    {
        Task<Order> GetOrderAsync(int id);
        Task<IEnumerable<Order>> GetAllOrdersAsync();
        Task<OrderValidationResult> ValidateOrderAsync(Order order);
        Task<int> SaveOrderAsync(Order order);
        Task<bool> ProcessPaymentAsync(Order order);
        Task<ShippingLabel> GenerateShippingLabelAsync(Order order);
        Task SendOrderConfirmationAsync(Order order);
        // ... 15 more methods
    }
    
    // Better: Focused interfaces by responsibility
    public interface IOrderRepository { /* Data access only */ }
    public interface IOrderValidator { /* Validation only */ }
    public interface IPaymentProcessor { /* Payment only */ }
    public interface IShippingService { /* Shipping only */ }
    
  3. “Manager” classes that do too many things

    // Bad: UserManager with multiple responsibilities
    public class UserManager
    {
        public void RegisterUser(User user) { /* Auth, validation, DB, email */ }
        public void ResetPassword(string email) { /* Auth, DB, email */ }
        public UserStats GenerateUserStatistics() { /* Reporting logic */ }
        public void SyncWithExternalSystem() { /* Integration logic */ }
    }
    
    // Better: Split responsibilities
    public class UserRegistrationService { /* Registration only */ }
    public class PasswordService { /* Password management only */ }
    public class UserAnalyticsService { /* Statistics only */ }
    public class UserSyncService { /* External sync only */ }
    
  4. “God” objects that grow indefinitely

    // Bad: Ever-growing Application class
    public class Application
    {
        private readonly DbContext _dbContext;
        private readonly HttpClient _httpClient;
        private readonly ILogger _logger;
        private readonly EmailSender _emailSender;
        // ... 10 more dependencies
    
        // Methods touching every aspect of the system
        public void Initialize() { /* ... */ }
        public void ProcessData() { /* ... */ }
        public void GenerateReports() { /* ... */ }
        // ... dozens more methods
    }
    
    // Better: Modular components with composition
    public class ApplicationBootstrapper { /* App initialization only */ }
    public class DataProcessor { /* Data processing only */ }
    public class ReportGenerator { /* Report generation only */ }
    

Tips for Maintaining SRP

  1. Name classes after their responsibility - If you can’t name it concisely, it might be doing too much
  2. Keep classes under 200 lines - A rough guideline that encourages splitting up responsibilities
  3. Ask “who might request changes” - If multiple teams would request changes, split the class
  4. Use dependency injection - Makes it easier to compose single-responsibility components
  5. Watch for “and” in class descriptions - “This class validates user input and saves to database” is a red flag

The Payoff

Now validation changes don’t affect persistence code. Database migrations don’t touch business rules. Each class has a single, focused responsibility and can evolve independently. This leads to:

  • Better testability - Test validation logic without a database
  • Easier maintenance - Changes are localized to relevant components
  • Improved team collaboration - Different teams can work on different responsibilities
  • More reusability - Single-purpose components are easier to reuse