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.
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
// 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
// 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
// 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 adminsThe 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 methodsThe 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
Andin the name:ValidateAndSave,ProcessAndNotify - A class with more than 2-3 injected dependencies
- Difficulty writing a focused unit test for one behavior
Key Takeaways
- SRP = one reason to change, not one method or one line
- Splitting by responsibility makes classes easier to test independently
- Each class should have a name that precisely describes what it does
- The orchestrator (RegistrationService) composes focused classes — it's the one that coordinates
- 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.