Single Responsibility Principle (SRP)
Definition: "A class should have one, and only one, reason to change." — Robert C. Martin
In simple words: a class should do one job, and do it well. If a class does many unrelated jobs, a change in any one of those jobs can break the others.
Uncle Bob later explained "reason to change" more precisely: a class should be responsible to one actor, meaning one group of people who ask for changes. For example, the finance team asks for changes to tax calculation, the marketing team asks for changes to email templates, and the DBA asks for changes to SQL queries. If one class contains all three, three different teams are editing the same file.
Real-life example
A Swiss army knife has a knife, scissors, a screwdriver and a bottle opener. It is handy for a picnic, but no professional carpenter uses it. If the screwdriver breaks, you lose the whole tool. Professionals use separate tools, each designed for one job. Classes are the same.
The problem: a "god class"
This OrderService from an online shop does everything. Code like this is extremely common in real projects:
public class OrderService
{
public void PlaceOrder(Order order)
{
// Job 1: validation
if (order.Items.Count == 0)
throw new ValidationException("Order has no items");
if (string.IsNullOrWhiteSpace(order.CustomerEmail))
throw new ValidationException("Email is required");
// Job 2: business calculation (GST + discount)
decimal subTotal = order.Items.Sum(i => i.Price * i.Quantity);
decimal discount = subTotal > 5000 ? subTotal * 0.10m : 0;
order.Total = (subTotal - discount) * 1.18m;
// Job 3: database access
using var con = new SqlConnection("Server=.;Database=Shop;Trusted_Connection=True;");
con.Open();
var cmd = new SqlCommand("INSERT INTO Orders (CustomerEmail, Total) VALUES (@email, @total)", con);
cmd.Parameters.AddWithValue("@email", order.CustomerEmail);
cmd.Parameters.AddWithValue("@total", order.Total);
cmd.ExecuteNonQuery();
// Job 4: sending email
var smtp = new SmtpClient("smtp.company.com");
smtp.Send("noreply@shop.com", order.CustomerEmail, "Order confirmed",
$"Your order total is {order.Total:C}");
// Job 5: logging to a file
File.AppendAllText("orders.log", $"{DateTime.Now}: Order saved, total {order.Total}\n");
}
}
Why is this a problem?
| Change request | Who asks | Which part changes |
|---|---|---|
| GST changes from 18% to 12% for some products | Finance team | Calculation |
| Move from ADO.NET to Entity Framework Core | Tech lead | Database code |
| Send SMS and WhatsApp as well as email | Marketing | Notification |
| Log to Serilog / Application Insights instead of a file | DevOps | Logging |
| Phone number becomes mandatory | Product owner | Validation |
Five different reasons to change, all in one method. Every change risks breaking the other four jobs, the class is hard to read, merge conflicts are frequent, and you can't unit test the discount logic without a real database and SMTP server.
The solution: one class per responsibility
Step 1: Validation
public interface IOrderValidator
{
void Validate(Order order);
}
public class OrderValidator : IOrderValidator
{
public void Validate(Order order)
{
if (order.Items.Count == 0)
throw new ValidationException("Order has no items");
if (string.IsNullOrWhiteSpace(order.CustomerEmail))
throw new ValidationException("Email is required");
}
}
Step 2: Price calculation (pure business logic)
public interface IPriceCalculator
{
decimal CalculateTotal(Order order);
}
public class PriceCalculator : IPriceCalculator
{
private const decimal GstRate = 0.18m;
public decimal CalculateTotal(Order order)
{
decimal subTotal = order.Items.Sum(i => i.Price * i.Quantity);
decimal discount = subTotal > 5000 ? subTotal * 0.10m : 0;
return (subTotal - discount) * (1 + GstRate);
}
}
Step 3: Data access
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task SaveAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
}
}
Step 4: Notification
public interface IOrderNotifier
{
Task SendConfirmationAsync(Order order);
}
public class EmailOrderNotifier : IOrderNotifier
{
private readonly IEmailSender _email;
public EmailOrderNotifier(IEmailSender email) => _email = email;
public Task SendConfirmationAsync(Order order) =>
_email.SendAsync(order.CustomerEmail, "Order confirmed",
$"Your order total is {order.Total:C}");
}
Step 5: OrderService only coordinates
Logging is handled by the built-in ILogger<T>, which can write to a file, the console, Serilog or Application Insights without changing this class.
public class OrderService
{
private readonly IOrderValidator _validator;
private readonly IPriceCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly IOrderNotifier _notifier;
private readonly ILogger<OrderService> _logger;
public OrderService(IOrderValidator validator, IPriceCalculator calculator,
IOrderRepository repository, IOrderNotifier notifier,
ILogger<OrderService> logger)
{
_validator = validator;
_calculator = calculator;
_repository = repository;
_notifier = notifier;
_logger = logger;
}
public async Task PlaceOrderAsync(Order order)
{
_validator.Validate(order);
order.Total = _calculator.CalculateTotal(order);
await _repository.SaveAsync(order);
await _notifier.SendConfirmationAsync(order);
_logger.LogInformation("Order {OrderId} placed, total {Total}", order.Id, order.Total);
}
}
Step 6: Register everything in Program.cs
builder.Services.AddScoped<IOrderValidator, OrderValidator>();
builder.Services.AddScoped<IPriceCalculator, PriceCalculator>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderNotifier, EmailOrderNotifier>();
builder.Services.AddScoped<OrderService>();
Step 7: A thin controller
The controller also has a single responsibility: handle HTTP (read the request, return the right status code).
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(OrderService orderService) => _orderService = orderService;
[HttpPost]
public async Task<IActionResult> PlaceOrder(Order order)
{
try
{
await _orderService.PlaceOrderAsync(order);
return Ok(new { order.Id, order.Total });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
}
}
What did we gain?
| Change request | Before | After |
|---|---|---|
| GST rate changes | Edit the big method and retest everything | Edit only PriceCalculator |
| Move to Dapper / another database | Rewrite part of the big method | Write a new IOrderRepository class |
| Add SMS notification | Add more code to the big method | Add an SmsOrderNotifier class |
| Unit test the discount rule | Needs a database and SMTP server | Test PriceCalculator alone, in milliseconds |
[Fact]
public void Gives_10_percent_discount_above_5000()
{
var order = new Order { Items = { new OrderItem { Price = 6000, Quantity = 1 } } };
decimal total = new PriceCalculator().CalculateTotal(order);
Assert.Equal(6372m, total); // (6000 - 600) * 1.18
}
How to spot an SRP violation
- You describe the class using the word "and": "it validates the order and saves it and sends email".
- The class has a very large constructor (7+ dependencies) or is hundreds of lines long.
- The class name is vague:
Manager,Helper,Utility,Processor,CommonService. - Different teams or features keep editing the same file, causing merge conflicts.
- You need many unrelated
usingstatements:System.Data.SqlClient,System.Net.Mail,System.IOin one class.
Common mistakes
- Splitting too much. SRP does not mean "one method per class". A
PriceCalculatorwith methods for subtotal, discount and tax is fine: they all change for the same reason (pricing rules). - Fat controllers. Putting business logic and SQL inside controller actions is the most common SRP violation in ASP.NET Core projects. Keep controllers thin.
- Confusing SRP with "do one thing" for methods. Small methods are good, but SRP is about reasons to change at class level.
Summary
- A class should have only one reason to change, meaning it serves one actor or one job.
- Split god classes into focused classes: validator, calculator, repository, notifier.
- Let one class coordinate them, and keep controllers thin.
- Benefits: easier changes, fewer bugs, fewer merge conflicts and easy unit testing.
- Don't over-split: group code that changes for the same reason.