Single Responsibility Principle: Not "One Thing" but One Actor
SRP does not mean "a class does one thing"; in Martin's definition a module should be responsible to one actor, the single person or group that requests the same change. An Employee example where an accounting change breaks the HR report, sharing data while separating behavior, a Facade, .NET's Stream/StreamWriter/Encoding split and the fragmentation trap.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore has an Employee class written for its shop staff. Accounting opened a ticket (#412): training hours will no longer be paid at the hourly rate; instead a fixed weekly training allowance will be paid. The developer updated the RegularHours method used by the pay calculation, checked Grace's pay, and the number matched what accounting expected. This week Grace worked 36 regular hours and 8 hours of training. Before the change the HR (human resources) report said "44 h worked, 40 regular, 4 overtime". What does it say now?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Employee grace = new(7, "Grace", hourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 3), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 4), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 5), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training),
new TimeEntry(new DateOnly(2026, 11, 7), 4, WorkKind.Regular)
]);
Console.WriteLine($"Pay: {grace.CalculatePay()} EUR");
Console.WriteLine($"Report: {grace.ReportHours()}");
public enum WorkKind { Regular, Training }
public record TimeEntry(DateOnly Date, decimal Hours, WorkKind Kind);
public sealed class Employee(int id, string name, decimal hourlyRate, IReadOnlyList<TimeEntry> week)
{
private const decimal TrainingAllowance = 100m;
// Requested by accounting (CFO)
public decimal CalculatePay()
{
bool hadTraining = week.Any(e => e.Kind == WorkKind.Training);
return RegularHours() * hourlyRate + (hadTraining ? TrainingAllowance : 0m);
}
// Requested by HR and operations (COO)
public string ReportHours()
{
decimal total = week.Sum(e => e.Hours);
decimal regular = RegularHours();
return $"{name}: {total} h worked, {regular} regular, {total - regular} overtime";
}
// Requested by the database team (CTO)
public void Save() => Console.WriteLine($"UPDATE Employees SET ... WHERE Id = {id}");
// Shared helper: used by both pay and the report
private decimal RegularHours()
{
// Accounting request #412: training hours are no longer paid hourly
decimal worked = week.Where(e => e.Kind != WorkKind.Training).Sum(e => e.Hours);
return Math.Min(worked, 40);
}
}
Accounting's Change Broke HR's Report
Output:
Pay: 820 EUR
Report: Grace: 44 h worked, 36 regular, 8 overtime
The pay is correct: accounting wanted 36 hours × 20 EUR + a 100 EUR allowance. But the HR report now says Grace worked 8 hours of overtime; in reality it was 4. This report feeds legal working-time tracking, and under HR's rules training counts as working time. Nobody told HR "we are changing your report", because nobody changed the report; only a small helper shared by two methods changed.
This scenario is the example Robert C. Martin (also known as "Uncle Bob", also the author of Clean Code) uses to explain SRP (Single Responsibility Principle) in his book Clean Architecture (2017), adapted to Bookstore. Martin's diagnosis: CalculatePay and ReportHours sit in the same class and share the same helper, but they serve different people. One serves the team of the CFO (Chief Financial Officer), the other the team of the COO (Chief Operating Officer), and Save serves the team of the CTO (Chief Technology Officer). The problem is not that the class "does too much"; it is that it ties three different sources of change to one place.
The Common Mistake: "A Class Does One Thing"
SRP is the most misunderstood letter of SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), because its name invites misunderstanding. Most people who hear "single responsibility" take it as "every class should do one thing". That is actually a separate rule about functions: a function should do one thing and do it well. We will cover that rule at the method level in the upcoming Clean Code series. SRP says something else.
Martin's first formulation was "a module should have one, and only one, reason to change". That was open to interpretation too: is a bug fix a reason? Is a refactoring? In his 2014 post "The Single Responsibility Principle" Martin clarified it: reasons for change are people. In his own words: "This principle is about people." In Clean Architecture (2017) he gave the final form: a module should be responsible to one, and only one, actor. An actor is the person or group of people who request the same change; a role, a team, a business unit.
This definition turns SRP from a technical rule into an organizational observation. You cannot tell whether a class follows SRP by counting its methods; you have to ask who will want to change those methods. A class with twenty methods that serves a single actor is fine from SRP's point of view; a class with two methods that changes at the request of two separate teams is not.
The idea is not new. In the same post Martin traces its roots to the 1972 paper on decomposing systems into modules by David Parnas (who introduced information hiding), the separation of concerns of Edsger Dijkstra (a pioneer of structured programming) and the cohesion of Larry Constantine (one of the authors of Structured Design, which introduced coupling and cohesion). Functional cohesion, which we saw in the previous post, and SRP ask the same question from different angles: what holds these parts together? SRP's answer: changing for the same reason.
Finding the Actor: Who Asks for the Change?
In practice a few questions help you find the actor. Who sends requests to change this method's behavior? Who approves those requests? If this method misbehaves, who notices and who complains? For Employee the answers are clear: accounting if the pay is wrong, HR if the hours report is wrong, the database team if the table structure is going to change.
Two symptoms also leave traces in the code base. The first is accidental breakage like the one at the top of this post: one team's request breaks another team's feature. The second is merge conflicts: if two separate requests from accounting and HR change the same file in the same week, two developers collide on the same class. Martin lists these two as the symptoms of an SRP violation. If you see commits arriving at the same file with ticket numbers from different teams in the git history, that file has more than one actor.
One thing to watch for: the same person can be different actors wearing different hats. In a small company the same manager may request both the pay rule and the report. But if the two rules come from different regulations and processes and change at different times, it is still right to treat them as separate actors. The question is not finding the person but finding the source of change.
Splitting: Share the Data, Separate the Behavior
The first solution Martin proposes is to separate data from behavior: a data structure without behavior that all three parties read, and a separate class for each actor. Employees.cs:
namespace Bookstore.Staff;
public enum WorkKind { Regular, Training }
public record TimeEntry(DateOnly Date, decimal Hours, WorkKind Kind);
// Data with no behavior, read by all three parties
public sealed record EmployeeData(int Id, string Name, decimal HourlyRate, IReadOnlyList<TimeEntry> Week);
// Accounting (CFO): pay rules change here
public sealed class PayCalculator
{
private const decimal TrainingAllowance = 100m;
public decimal Calculate(EmployeeData employee)
{
bool hadTraining = employee.Week.Any(e => e.Kind == WorkKind.Training);
return PaidRegularHours(employee) * employee.HourlyRate + (hadTraining ? TrainingAllowance : 0m);
}
private static decimal PaidRegularHours(EmployeeData employee) =>
Math.Min(employee.Week.Where(e => e.Kind != WorkKind.Training).Sum(e => e.Hours), 40);
}
// HR and operations (COO): the hours report changes here
public sealed class HourReporter
{
private const decimal WeeklyLimit = 40;
public string Report(EmployeeData employee)
{
decimal total = employee.Week.Sum(e => e.Hours);
decimal regular = Math.Min(total, WeeklyLimit);
return $"{employee.Name}: {total} h worked, {regular} regular, {total - regular} overtime";
}
}
// Database team (CTO): the storage format changes here
public sealed class EmployeeRepository
{
public void Save(EmployeeData employee) =>
Console.WriteLine($"UPDATE Employees SET ... WHERE Id = {employee.Id}");
}
Program.cs:
using System.Globalization;
using Bookstore.Staff;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
EmployeeData grace = new(7, "Grace", HourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 3), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 4), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 5), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training),
new TimeEntry(new DateOnly(2026, 11, 7), 4, WorkKind.Regular)
]);
Console.WriteLine($"Pay: {new PayCalculator().Calculate(grace)} EUR");
Console.WriteLine($"Report: {new HourReporter().Report(grace)}");
new EmployeeRepository().Save(grace);
Pay: 820 EUR
Report: Grace: 44 h worked, 40 regular, 4 overtime
UPDATE Employees SET ... WHERE Id = 7
The pay is still 820 EUR and the report is correct again: 40 regular, 4 overtime. Notice that the "regular hours" calculation now lives in two places, and the two differ. That is not a flaw; it is exactly the point of the split. Accounting's "paid regular hours" and HR's "worked regular hours" started out looking like the same code, but they were not the same knowledge; Martin calls this accidental duplication. Merging them into one method in the first version let one side's change leak into the other. We will see in detail in the upcoming DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid) and YAGNI (You Aren't Gonna Need It) post that DRY targets duplicated knowledge, not duplicated code.
This design has a cost: you now have to create three classes and find the right one. Martin's second suggestion, a Facade, makes that easier.
One Door In with a Facade
A Facade is a thin entry point that does no work itself and only forwards requests to the right class:
namespace Bookstore.Staff;
// Single entry point: does no work, only forwards to the right class
public sealed class EmployeeFacade(EmployeeData employee)
{
private readonly PayCalculator _pay = new();
private readonly HourReporter _hours = new();
private readonly EmployeeRepository _repository = new();
public decimal CalculatePay() => _pay.Calculate(employee);
public string ReportHours() => _hours.Report(employee);
public void Save() => _repository.Save(employee);
}
using System.Globalization;
using Bookstore.Staff;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
EmployeeFacade grace = new(new EmployeeData(7, "Grace", HourlyRate: 20m,
[
new TimeEntry(new DateOnly(2026, 11, 2), 8, WorkKind.Regular),
new TimeEntry(new DateOnly(2026, 11, 6), 8, WorkKind.Training)
]));
Console.WriteLine($"Pay: {grace.CalculatePay()} EUR");
Console.WriteLine($"Report: {grace.ReportHours()}");
Pay: 260 EUR
Report: Grace: 16 h worked, 16 regular, 0 overtime
Client code still works through a single object as before, but the logic now lives in separate classes. The Facade can change too, but only when a new operation is added; it doesn't need to change when one actor's rule changes. Martin mentions one more variation: keep the most important behavior (say, the pay calculation) in the original class and move the others out. Which one you choose depends on which behavior most clients use.
In a real ASP.NET Core application we would take these three classes through the constructor rather than creating them with new inside the Facade; we will discuss the reasons and the composition root in the upcoming Dependency Inversion post. For the mechanics of a DI (dependency injection) container, see "Dependency Injection Basics" in the Advanced C# series.
SRP in .NET: Stream, StreamWriter and Encoding
.NET's IO (input/output) design is a good example of SRP. Writing text to a file involves three separate decisions: where the bytes go, how the text becomes bytes, and what format the text has. .NET spreads these three decisions over three separate pieces:
using System.Text;
string line = "978-2253096337;Les Misérables;Victor Hugo";
Encoding[] encodings = [new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), Encoding.Unicode, Encoding.Latin1];
foreach (Encoding encoding in encodings)
{
// Storage (Stream) and text encoding (StreamWriter + Encoding) are separate pieces
using MemoryStream storage = new();
using (StreamWriter writer = new(storage, encoding, leaveOpen: true))
{
writer.Write(line);
}
Console.WriteLine($"{encoding.WebName,-10} {storage.Length,3} bytes");
}
utf-8 42 bytes
utf-16 84 bytes
iso-8859-1 41 bytes
The same line produced three different byte lengths. In the UTF (Unicode Transformation Format) family, the variable-length UTF-8 stores "é" in two bytes, in Latin-1 (ISO-8859-1, a single-byte encoding for Western European languages) one byte; UTF-16 (the Unicode encoding built on two-byte units) writes two bytes per character of this text plus a two-byte BOM (byte order mark). What matters here is that the difference comes entirely from the Encoding object. MemoryStream knows nothing about text and only stores bytes; swapping it for a FileStream doesn't affect the encoding. StreamWriter, in turn, converts the text without knowing where the bytes go. Each piece has a different reason to change: the storage medium, the character encoding standard and your application's line format.
ASP.NET Core's logging design makes a similar split. With ILogger<T> the developer writing the application decides what to log; logger providers decide whether the log goes to the console, a file or a central system; configuration decides which levels get written. Application code is not affected when the operations team changes the log destination.
Levels of SRP: Method, Class, Component
The idea of "gather together the things that change for the same reasons, separate those that change for different reasons" shows up at every level, but under a different name at each one:
| Level | Rule | Where we discuss it |
|---|---|---|
| Method | A function does one thing and stays at one level of abstraction | Clean Code series |
| Class | A class is responsible to one actor (SRP) | This post |
| Component (project) | Classes that change for the same reason and at the same time live in the same project (Common Closure Principle) | The component principles post |
| Architecture | Boundaries are drawn between layers that change at different speeds | Clean, Onion and Hexagonal Architecture series |
The table makes one thing clear: bringing SRP down to the method level and applying it as "one method per class" means using the principle at the wrong level. The method-level rule is about readability; the class-level rule is about where change comes from.
Common Pitfalls
Fragmentation: a class for every method. People who read SRP as "one class, one job" produce code like this:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
OrderLine[] lines = [new("Dune", 2, 12.99m), new("Emma", 1, 7.50m)];
OrderTotalCalculator total = new(new OrderSubtotalCalculator(), new OrderVatCalculator(), new OrderRoundingPolicy());
Console.WriteLine(total.Calculate(lines));
public record OrderLine(string Title, int Quantity, decimal UnitPrice);
// Four classes, one actor: accounting opens all four for every change
public sealed class OrderSubtotalCalculator
{
public decimal Calculate(IEnumerable<OrderLine> lines) => lines.Sum(l => l.Quantity * l.UnitPrice);
}
public sealed class OrderVatCalculator
{
public decimal Calculate(decimal subtotal) => subtotal * 0.20m;
}
public sealed class OrderRoundingPolicy
{
public decimal Apply(decimal amount) => Math.Round(amount, 2, MidpointRounding.AwayFromZero);
}
public sealed class OrderTotalCalculator(
OrderSubtotalCalculator subtotal, OrderVatCalculator vat, OrderRoundingPolicy rounding)
{
public decimal Calculate(IEnumerable<OrderLine> lines)
{
decimal net = subtotal.Calculate(lines);
return rounding.Apply(net + vat.Calculate(net));
}
}
40.18
There are four classes, but they all serve the same actor, accounting. Every change, whether it is the VAT (value-added tax) rate, the rounding rule or including shipping in the subtotal, will come from accounting and will often require opening all four classes together. In Martin Fowler's Refactoring (1999) this is called shotgun surgery: touching many places for a single change. A single OrderPricing class would be more cohesive here and closer to SRP. SRP is as much about gathering things together as it is about separating them.
"Fixing" accidental duplication. Months after the split, someone notices the similar lines in PayCalculator and HourReporter and pulls them into a shared WorkHours.Regular() helper; the bug from the top of this post comes back. A short comment next to two similar pieces of code explaining why they are kept apart reduces this risk.
Multiple responsibilities hidden behind a name. Names like OrderManager, CustomerService and BookHelper don't limit any responsibility; anything can go inside. Try to name a class in the actor's language (PayCalculator, HourReporter); if you can't, it most likely serves more than one actor.
Applying SRP to data classes. Splitting a behaviorless record like EmployeeData because "it has too many properties" has nothing to do with SRP. The shape of a data structure changes with the behaviors that use it; the real question is where those behaviors live.
When Not to Split
When there is a single actor. If, in an internal admin screen maintained by one team, pay, reports and storage all change at the same person's request, splitting them into three classes only increases the file count. When the actors diverge (say, the report moves to HR), splitting is not too late; the transformation in this post is a small refactoring.
When no change is coming. Splitting a class that hasn't been touched for years, has tests and gets no new requests, just because "it doesn't follow SRP", is an investment whose risk isn't worth the reward. The principle exists to reduce the pain of change; if there is no pain, the medicine can wait.
In prototypes and scripts. A data migration script or a one-week experiment won't pay back the time spent on actor analysis. Once it is clear that the code will become a product, splitting comes first.
A Small Experiment
★☆☆ In the Employee class from the first example, change RegularHours so that it meets accounting's request without breaking the HR report, but without splitting the class. Write one sentence on why this solution can break again when the next request arrives.
★★☆ Merge the four classes from the fragmentation example into a single OrderPricing class and confirm you get the same 40.18 result. Then implement the request "include shipping in the subtotal". How many files did you touch, and how many would you have touched in the old design?
★★★ Pick the class in your own project with the most commits in the last six months. Group the tickets in its commit messages by team or business unit. If more than one actor appears, sketch a split with a Facade: which method goes to which class, and which record holds the shared data?
In the next post we move to the letter O: the Open/Closed Principle. We will discuss the growing notification switch in our series example, the two different definitions of OCP (Open/Closed Principle) by Bertrand Meyer (designer of the Eiffel language) and Martin, the keyed services that arrived with .NET 8, and the cost of putting extension points where nothing will change.