Back to blog
Backend Systemsbeginner

Single Responsibility Principle — What It Actually Means

Understand the Single Responsibility Principle with real C# examples. Learn how to spot violations, refactor them, and why SRP leads to more maintainable code.

Asma HafeezApril 17, 20264 min read
csharpsolidsrpclean-codedotnet
Share:𝕏

Single Responsibility Principle

SRP states: a class should have only one reason to change. Not "one method" — one reason to change. A class that mixes business logic, validation, formatting, and persistence has many reasons to change.


Spotting a Violation

C#
// BAD — this class has multiple reasons to change
public class UserManager
{
    private readonly AppDbContext _db;

    public UserManager(AppDbContext db) { _db = db; }

    // Validation logic — changes when validation rules change
    public bool ValidateEmail(string email)
        => email.Contains('@') && email.Length > 5;

    // Business logic — changes when registration workflow changes
    public async Task<User> RegisterAsync(RegisterRequest req)
    {
        if (!ValidateEmail(req.Email))
            throw new ArgumentException("Invalid email");

        var user = new User { Email = req.Email, Name = req.Name };
        _db.Users.Add(user);
        await _db.SaveChangesAsync();

        // Email sending — changes when email template changes
        var body = $"Welcome to our platform, {req.Name}!";
        using var smtp = new SmtpClient("smtp.example.com");
        await smtp.SendMailAsync("noreply@app.com", req.Email, "Welcome!", body);

        // Logging — changes when log format changes
        Console.WriteLine($"[{DateTime.UtcNow}] User registered: {req.Email}");

        return user;
    }
}

This class changes when:

  • Validation rules change
  • Registration workflow changes
  • Email template changes
  • SMTP config changes
  • Log format changes

Applying SRP

C#
// Each class has one reason to change

// 1. Validation — changes when validation rules change
public class UserValidator
{
    public ValidationResult Validate(RegisterRequest req)
    {
        var errors = new List<string>();
        if (!req.Email.Contains('@'))  errors.Add("Invalid email format");
        if (req.Name.Length < 2)       errors.Add("Name too short");
        return new ValidationResult(errors);
    }
}

// 2. Repository — changes when database access changes
public class UserRepository(AppDbContext db) : IUserRepository
{
    public async Task<User> SaveAsync(User user)
    {
        db.Users.Add(user);
        await db.SaveChangesAsync();
        return user;
    }
}

// 3. Email service — changes when email logic changes
public class WelcomeEmailService(IEmailSender sender) : IWelcomeEmailService
{
    public async Task SendAsync(User user)
        => await sender.SendAsync(new Email
        {
            To      = user.Email,
            Subject = "Welcome!",
            Body    = $"Welcome, {user.Name}!"
        });
}

// 4. Registration service — changes when registration workflow changes
public class RegistrationService(
    UserValidator validator,
    IUserRepository users,
    IWelcomeEmailService email,
    ILogger<RegistrationService> logger)
{
    public async Task<User> RegisterAsync(RegisterRequest req)
    {
        var validation = validator.Validate(req);
        if (!validation.IsValid)
            throw new ValidationException(validation.Errors);

        var user = new User { Email = req.Email, Name = req.Name };
        await users.SaveAsync(user);
        await email.SendAsync(user);

        logger.LogInformation("User registered: {Email}", req.Email);
        return user;
    }
}

SRP at Different Levels

SRP applies at every level of code:

Methods

C#
// BAD — method does too much
public void ProcessOrder(Order order)
{
    // Validate
    if (order.Items.Count == 0) throw new Exception("No items");
    // Calculate
    order.Total = order.Items.Sum(i => i.Price * i.Quantity);
    // Persist
    db.Orders.Add(order);
    db.SaveChanges();
    // Notify
    emailService.SendConfirmation(order.CustomerId);
    // Audit
    auditLog.Record($"Order {order.Id} placed");
}

// GOOD — each method has one job
public async Task ProcessOrderAsync(Order order)
{
    ValidateOrder(order);
    CalculateTotal(order);
    await SaveOrderAsync(order);
    await NotifyCustomerAsync(order);
    await AuditAsync(order);
}

Files / Modules

BAD:  UserController.cs — handles auth + profiles + admin + reporting

GOOD:
  AuthController.cs     — login, logout, token refresh
  ProfileController.cs  — view and update user profile
  AdminController.cs    — user management for admins

The Practical Balance

SRP can be taken too far. Don't create a class for every single line.

Too far: EmailSubjectFormatter, EmailBodyFormatter, EmailRecipientResolver...
Right:   EmailService with Send, BuildWelcomeEmail, BuildPasswordResetEmail methods

The question is: if this changes, what else has to change? If the answer is "only this class", you've got the right level.


Signs of SRP Violations

  • Class names ending in Manager, Handler, Processor, Helper, Utils
  • Methods with And in the name: ValidateAndSave, ProcessAndNotify
  • A class with more than 2-3 injected dependencies
  • Difficulty writing a focused unit test for one behavior

Key Takeaways

  1. SRP = one reason to change, not one method or one line
  2. Splitting by responsibility makes classes easier to test independently
  3. Each class should have a name that precisely describes what it does
  4. The orchestrator (RegistrationService) composes focused classes — it's the one that coordinates
  5. Don't over-apply SRP — too many tiny classes can be as hard to understand as one big one

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.