SOLID Principles

Single Responsibility Principle

The most misunderstood SOLID principle. It's NOT "a class should do one thing." It's "a class should have only one reason to change." This page explains the difference — with real C# code, real bugs, and the exact refactoring techniques that make SRP practical, not dogmatic.

10 Q&As 6 Bug Studies 6 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR — The Swiss Army Knife Problem

  • What SRP actually means (hint: it's NOT "a class should do one thing")
  • Robert C. Martin's real definition — and why most developers get it wrong
  • The "actor" test: a simple question that tells you if a class violates SRP
  • A quick C# example showing an SRP violation and how to fix it

A class should have only one reason to change — meaning only one actor (stakeholder or role) should be able to demand changes to it.

Think about a Swiss Army knife. It has a blade, a corkscrew, a screwdriver, scissors, a toothpick, and a tiny saw. Sounds incredibly useful, right? One tool that does everything. But here's the problem: if you need a sharper blade, you can't upgrade just the blade. You have to redesign the entire knife. If the corkscrew breaks, you send the whole tool for repair — and lose everything else while it's gone. The scissors are tiny because they share space with everything else. No single tool is great at its job because they're all crammed into one handle.

Now picture a professional kitchen. There's a chef's knife — just a knife, but it's razor-sharp and perfectly balanced. There's a separate corkscrew designed for exactly one purpose. Dedicated scissors, a proper saw. Each tool does one thing brilliantly because it doesn't have to compromise for everything else. If the knife gets dull, you sharpen the knife. The corkscrew is unaffected.

That's the Single Responsibility PrincipleThe first principle in SOLID. Originally stated by Robert C. Martin (Uncle Bob) in 2003. Often misunderstood as "do one thing" — but the real meaning is about having one reason to change, which means serving one actor or stakeholder. in a nutshell. Don't build Swiss Army knife classes that do everything. Build dedicated tools — each one focused on serving one purpose, one team, one reason to change.

The Swiss Army Knife Problem Swiss Army Knife Class One class that does everything UserService Login() SendEmail() GenerateReport() CacheUser() Security Team Marketing Analytics 3 actors = 3 reasons to change Change email format? Risk breaking login. Change report query? Risk breaking cache. FRAGILE. RISKY. MERGE CONFLICTS. Dedicated Tools Each class serves one actor AuthService Security Team EmailService Marketing ReportService Analytics CacheService Infrastructure 1 actor per class = 1 reason to change Change emails? Only EmailService changes. SAFE. TESTABLE. ZERO CONFLICTS.

Here's the key insight that trips people up. The most common explanation of SRP is: "a class should do only one thing." That sounds right, but it's actually wrong — or at least dangerously vague. A PaymentProcessor that validates, charges, and logs payments is doing "one thing" (processing payments). That's fine. The real question isn't how many things the class does. It's how many people would ask you to change it.

Robert C. Martin — the person who coined SRP — later clarified his own definition. The modern, precise version is: "A module should be responsible to one, and only one, actor." An actorThe person, team, or stakeholder who would request changes to a piece of code. The CFO is the actor for financial reports. The security team is the actor for authentication logic. If two different actors can request changes to the same class, that class has two responsibilities. is the person or team who would request a change. If your security team AND your marketing team AND your analytics team all have reasons to change the same class, that class has three responsibilities. Not because it does three things — but because three different groups of people can demand changes to it.

Count the actors, not the methods. A class with 15 methods that all serve the same actor is perfectly fine. A class with 2 methods that serve different actors is a violation. SRP is a social principle disguised as a technical one — it's about organizing code around the humans who change it.

Here's a quick taste of what an SRP violation looks like in C#, and how to fix it. We'll go much deeper in Section 5.

// This class has THREE reasons to change: // 1. Security team wants to change authentication logic // 2. Marketing team wants to change email templates // 3. Analytics team wants to change report format public class UserService { public bool Login(string username, string password) { // Hash password, check database, create session... var hash = BCrypt.HashPassword(password); return _db.Users.Any(u => u.Username == username && u.PasswordHash == hash); } public void SendWelcomeEmail(User user) { // Build HTML template, connect to SMTP, send... var body = $"<h1>Welcome {user.Name}!</h1><p>Thanks for signing up.</p>"; _smtpClient.Send("welcome@app.com", user.Email, "Welcome!", body); } public Report GenerateUserReport(DateTime from, DateTime to) { // Query database, aggregate stats, build PDF... var users = _db.Users.Where(u => u.CreatedAt >= from && u.CreatedAt <= to); return new Report { TotalUsers = users.Count(), ActiveUsers = users.Count(u => u.IsActive) }; } }

Three methods. Three completely unrelated responsibilities. Three different teams that would ask you to change this class. If the security team asks you to switch from BCrypt to Argon2, you're editing the same file that handles email templates. One wrong keystroke and your welcome emails break — even though you were only trying to change authentication. That's the real cost of violating SRP: unrelated changes create unrelated risks.

What: A class should have only one reason to change — meaning only one actor (person, team, or role) should be able to demand changes to it.

When: Your class serves multiple unrelated stakeholders, has methods that change for different reasons, or causes merge conflicts between teams working on unrelated features.

In C#/.NET: Extract responsibilities into separate classes. Use interfaces to define contracts. Wire them together with dependency injection in Program.cs.

SRP isn't "do one thing." It's "have one reason to change." The reason to change comes from the actor — the person or team who would request that change. Count the actors, not the methods. If multiple unrelated teams can demand changes to a single class, that class violates SRP. The fix: extract each responsibility into its own class, each serving one actor.
Section 2

Prerequisites

SRP is one of the most approachable SOLID principles — you don't need advanced C# knowledge to understand it. But a few basics will help you follow the code examples.

SRP is the S in SOLID — it's the first principle for a reason. It's the foundation that makes all the other principles (Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) easier to understand. If you nail SRP, the rest of SOLID clicks into place much faster. You need basic OOP and C# syntax. Understanding coupling (how connected classes are) and cohesion (how focused a class is) helps but isn't required — we'll build that understanding throughout the page. Dependency injection knowledge is a bonus.
Section 3

Real-World Analogies

The Restaurant Where the Chef Does Everything

Imagine a small restaurant where the chef also takes orders from customers, washes dishes after every meal, manages the finances and payroll, and handles customer complaints. One person. Every role. It works — sort of — when the restaurant has five tables and three customers. But the moment things get busy, everything falls apart.

Why? Because every change flows through one person. Want to change the menu? The chef has to be involved. Want to update the billing system? The chef has to learn new software. A customer complains about a cold dish? The chef has to stop cooking to deal with it — and now everyone's food is getting cold. The chef is a bottleneckA single point in a system where everything slows down because all work funnels through it. In software, a bottleneck is usually a class or service that too many other parts depend on. In a restaurant, it's one person doing every job. — a single point where everything gets stuck.

Now picture a well-run restaurant. There's a chef who only cooks. A waiter who handles orders and customer interactions. A dishwasher who cleans. An accountant who manages finances. Each person has one job — one reason to come to work, one set of skills, one boss who tells them what to change.

The owner wants to change the menu? They talk to the chef. Not the waiter, not the dishwasher. The accountant wants to switch from QuickBooks to Xero? They do it without the chef even knowing. Changes are isolated. One person changing their process doesn't ripple through everyone else.

The Restaurant Analogy Chef Does EVERYTHING 👨‍🍳 THE CHEF Take orders Wash dishes Do payroll Complaints Change menu? Chef must be involved. Change billing? Chef must learn it. Handle complaint? All food gets cold. Chef is the BOTTLENECK = God Class in code Separated Roles (SRP) Chef Only cooks. Actor: Head Chef Waiter Only orders. Actor: Manager Dishwasher Only cleans. Actor: Kitchen Mgr Accountant Only finances. Actor: Owner Change menu? Only chef involved. Change billing? Only accountant. Handle complaint? Food keeps coming. ISOLATED CHANGES = SRP-compliant services in code

The Employee Who Does Everything

Here's another way to think about it. You know that one person at a company who does everything? They handle customer support, write marketing copy, fix the printer, manage the social media accounts, and somehow also do the quarterly financial reports. Everyone depends on them. They're the hero. They're also a disaster waiting to happen.

What happens when they go on vacation? Everything stops. What happens when they get sick? Three different departments are blocked. What happens when two urgent requests come in from different teams? They can only do one at a time — the other team waits. In software, this person is the God classA class that knows too much or does too much. It's the dumping ground for any functionality that doesn't clearly belong elsewhere. God classes are a classic symptom of SRP violations — they grow and grow until they're thousands of lines long and nobody dares to change them. — the 3,000-line file that everyone is afraid to touch because changing one method might break something three methods away.

The 5,000-Line Merge Conflict

One more analogy from the real world of software teams. You're on a team of five developers. There's a file called AppService.cs that handles user management, email notifications, payment processing, and report generation. Every feature touches this file. Developer A is adding a new email template. Developer B is fixing a payment bug. Developer C is changing the report format. All three are editing the same file at the same time.

When they try to merge, Git lights up like a Christmas tree. Three-way merge conflicts on a 5,000-line file. Half a day lost to resolving conflicts that shouldn't have existed in the first place — because these three features have nothing to do with each other. If each responsibility had been in its own file, the three developers would never have conflicted at all.

SRP isn't about making classes small. It's about making classes focused. A 500-line class that handles one complex responsibility (like a sophisticated payment processor with validation, retry logic, and idempotency — all for the payments team) is better than 10 tiny classes that fragment a single responsibility across multiple files. Don't split things that belong together. SRP is like a well-run restaurant: each role (chef, waiter, accountant) handles one responsibility and reports to one boss. When the chef also does accounting and complaints, everything becomes fragile. In code, the "God class" that does everything is the bottleneck — it causes merge conflicts, makes testing hard, and means unrelated changes create unrelated risks.
Section 4

The Principle Defined

Let's get precise. Robert C. Martin — the person who introduced SRP as part of SOLIDFive design principles for object-oriented software: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Introduced by Robert C. Martin (Uncle Bob). The goal: code that's easy to maintain, extend, and test. — has refined the definition over the years. Here's the evolution:

"A module should be responsible to one, and only one, actor."

— Robert C. Martin, Clean Architecture (2017)

Let's break this down word by word, because every word matters.

"A module" — this means a class, a struct, a file, a function, or even a group of closely related functions. In C#, think of it as a class — but SRP applies at every level of abstraction.

"Should be responsible to" — not "responsible for." This is the subtle but critical distinction. "Responsible for" would mean "does one thing." "Responsible to" means "serves one group of people." A class can do multiple related things, as long as they're all for the same audience.

"One, and only one, actor" — an actor is the person, role, or team who would request a change. The CFO requests changes to financial reports. The CTO requests changes to infrastructure code. The UX designer requests changes to UI logic. Different actors = different reasons to change = different responsibilities.

The "Who Would Ask Me to Change This?" Test

Here's the simplest test for SRP. Look at a class and ask: "Who would ask me to change this?" If the answer is one person or team, you're fine. If the answer is two or more unrelated people, the class has too many responsibilities.

The Actor Test "Who would ask me to change this class?" SRP VIOLATION Employee CFO "Change tax calc" CTO "Change save format" COO "Change hours calc" 3 actors = 3 reasons to change Changes collide. Bugs spread. SRP COMPLIANT CFO PayCalculator CTO EmployeeSaver COO HoursReporter 1 actor per class = isolated changes CFO changes pay? Only PayCalculator touched.

The Most Common Misunderstanding

Let's be crystal clear about the mistake almost everyone makes when they first hear about SRP.

Wrong: "SRP means a class should do only one thing."

Right: "SRP means a class should serve only one actor."

Why does this distinction matter? Because "do one thing" is hopelessly vague. Is Login() "one thing"? It hashes the password, queries the database, creates a session token, and logs the attempt. That's four operations. Does that violate SRP? No. All four operations serve the same actor (the security team). They all change for the same reason. They belong together.

The dangerous version of "do one thing" leads to what's called over-decompositionSplitting code into so many tiny pieces that no single class does anything meaningful. You end up with PasswordHasher, DatabaseQuerier, SessionCreator, LoginLogger — four classes to do one thing. This makes the code harder to understand, not easier. SRP doesn't ask for this. — splitting a single responsibility into 15 microscopic classes, each doing one trivial operation. You end up with PasswordHasher, UserDatabaseQuerier, SessionTokenCreator, and LoginAttemptLogger — all to handle one login. That's not SRP. That's a nightmare.

If you split a class and the resulting pieces can't function independently — if they always need each other and always change together — you've split too far. You've taken one responsibility and fragmented it. That makes the code harder to understand, not easier. SRP says "group things that change for the same reason." If they change together, they belong together.

Why SRP Matters: The Robert C. Martin Story

Uncle Bob tells this story in Clean Architecture. An Employee class had three methods: CalculatePay(), ReportHours(), and Save(). They all seemed related — they're all about employees. But here's the problem:

CalculatePay() was specified by the accounting department (CFO). ReportHours() was specified by the human resources department (COO). Save() was specified by the database administrators (CTO). Three actors. Three reasons to change. All in one class.

One day, the CFO asked for a change to how overtime was calculated. A developer modified the shared RegularHours() helper method that both CalculatePay() and ReportHours() used. The pay calculation was correct. But ReportHours() — which the COO relied on — now reported wrong numbers. The HR department made staffing decisions based on incorrect data. The cost? Hundreds of thousands of dollars in budget errors. All because two unrelated actors shared one piece of code.

That's not a hypothetical. That's the kind of bug SRP prevents.

Look at your class. Ask: "Who would ask me to change this?" If the answer is more than one team or role, the class has too many responsibilities. Split it so that each piece serves exactly one actor. SRP's official definition: "A module should be responsible to one, and only one, actor." An actor is the person/team who would request a change. The test is simple: ask "who would ask me to change this class?" If the answer is more than one team, split it. Don't confuse SRP with "do one thing" — that leads to over-decomposition. Things that change for the same reason belong together.
Section 5

Code Implementation

Let's see SRP in action with real C# code. We'll start with a class that violates SRP, then refactor it step by step — explaining the why behind every extraction. Finally, we'll show a modern .NET 8+ version that takes advantage of the latest features.

The God Class: UserService

Here's a class you'll find in thousands of real-world codebases. It "works." It passes all the tests. But it's a ticking time bomb — and here's why.

public class UserService { private readonly AppDbContext _db; private readonly SmtpClient _smtp; public UserService(AppDbContext db, SmtpClient smtp) { _db = db; _smtp = smtp; } // ── Authentication (Actor: Security Team) ────────────── public async Task<AuthResult> LoginAsync(string email, string password) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); if (user is null) return AuthResult.Failed("User not found"); var valid = BCrypt.Net.BCrypt.Verify(password, user.PasswordHash); if (!valid) return AuthResult.Failed("Invalid password"); user.LastLoginAt = DateTime.UtcNow; await _db.SaveChangesAsync(); var token = GenerateJwtToken(user); return AuthResult.Success(token); } private string GenerateJwtToken(User user) { var key = new SymmetricSecurityKey("hardcoded-secret-key"u8.ToArray()); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( claims: [new Claim("sub", user.Id.ToString()), new Claim("email", user.Email)], expires: DateTime.UtcNow.AddHours(1), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } // ── Email Notifications (Actor: Marketing Team) ──────── public async Task SendWelcomeEmailAsync(User user) { var body = $@" <h1>Welcome to our platform, {user.Name}!</h1> <p>We're thrilled to have you. Here's what to do next:</p> <ul> <li>Complete your profile</li> <li>Connect with friends</li> </ul>"; var msg = new MailMessage("hello@app.com", user.Email, "Welcome!", body) { IsBodyHtml = true }; await _smtp.SendMailAsync(msg); } // ── Reporting (Actor: Analytics Team) ────────────────── public async Task<UserReport> GenerateMonthlyReportAsync(int month, int year) { var users = await _db.Users .Where(u => u.CreatedAt.Month == month && u.CreatedAt.Year == year) .ToListAsync(); return new UserReport { TotalSignups = users.Count, ActiveUsers = users.Count(u => u.LastLoginAt > DateTime.UtcNow.AddDays(-30)), ChurnRate = CalculateChurnRate(users), GeneratedAt = DateTime.UtcNow }; } private double CalculateChurnRate(List<User> users) { if (users.Count == 0) return 0; var inactive = users.Count(u => u.LastLoginAt < DateTime.UtcNow.AddDays(-90)); return (double)inactive / users.Count * 100; } }

Let's identify the actors:

  • Security Team controls LoginAsync and GenerateJwtToken. They'd ask you to change these when switching from BCrypt to Argon2, or from JWT to OAuth tokens, or when adding MFA.
  • Marketing Team controls SendWelcomeEmailAsync. They'd ask you to change the email template, add tracking pixels, switch to a different email provider like SendGrid, or A/B test subject lines.
  • Analytics Team controls GenerateMonthlyReportAsync and CalculateChurnRate. They'd ask you to change the churn formula, add new metrics, or switch the date range logic.

Three actors. Three reasons to change. One file. When the marketing team asks you to change the email HTML, you're editing the same file that handles JWT tokens. If you accidentally change an import statement, the login system could break. That's the real danger.

Extract Each Responsibility Into Its Own Class

The fix is straightforward: give each actor its own class. Each class has one reason to change, one set of dependencies, and one team that "owns" it.

// Interface for authentication — owned by the Security Team public interface IAuthService { Task<AuthResult> LoginAsync(string email, string password); } // Actor: Security Team // Reason to change: authentication logic, password hashing, token generation public class AuthService : IAuthService { private readonly AppDbContext _db; private readonly IConfiguration _config; public AuthService(AppDbContext db, IConfiguration config) { _db = db; _config = config; } public async Task<AuthResult> LoginAsync(string email, string password) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); if (user is null) return AuthResult.Failed("User not found"); var valid = BCrypt.Net.BCrypt.Verify(password, user.PasswordHash); if (!valid) return AuthResult.Failed("Invalid password"); user.LastLoginAt = DateTime.UtcNow; await _db.SaveChangesAsync(); return AuthResult.Success(GenerateJwtToken(user)); } private string GenerateJwtToken(User user) { // Secret comes from config, not hardcoded var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( claims: [new Claim("sub", user.Id.ToString()), new Claim("email", user.Email)], expires: DateTime.UtcNow.AddHours(1), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } } // Interface for email — owned by the Marketing Team public interface IEmailService { Task SendWelcomeEmailAsync(User user); } // Actor: Marketing Team // Reason to change: email templates, providers, tracking public class EmailService : IEmailService { private readonly ISmtpClient _smtp; public EmailService(ISmtpClient smtp) { _smtp = smtp; } public async Task SendWelcomeEmailAsync(User user) { var body = $@" <h1>Welcome to our platform, {user.Name}!</h1> <p>We're thrilled to have you.</p> <ul> <li>Complete your profile</li> <li>Connect with friends</li> </ul>"; var msg = new MailMessage("hello@app.com", user.Email, "Welcome!", body) { IsBodyHtml = true }; await _smtp.SendMailAsync(msg); } } // Interface for reporting — owned by the Analytics Team public interface IReportService { Task<UserReport> GenerateMonthlyReportAsync(int month, int year); } // Actor: Analytics Team // Reason to change: metrics, date ranges, churn formula public class ReportService : IReportService { private readonly AppDbContext _db; public ReportService(AppDbContext db) => _db = db; public async Task<UserReport> GenerateMonthlyReportAsync(int month, int year) { var users = await _db.Users .Where(u => u.CreatedAt.Month == month && u.CreatedAt.Year == year) .ToListAsync(); return new UserReport { TotalSignups = users.Count, ActiveUsers = users.Count(u => u.LastLoginAt > DateTime.UtcNow.AddDays(-30)), ChurnRate = CalculateChurnRate(users), GeneratedAt = DateTime.UtcNow }; } private static double CalculateChurnRate(List<User> users) { if (users.Count == 0) return 0; var inactive = users.Count(u => u.LastLoginAt < DateTime.UtcNow.AddDays(-90)); return (double)inactive / users.Count * 100; } } // Wire everything together with DI var builder = WebApplication.CreateBuilder(args); // Each service is registered independently // Change one? The others are completely unaffected. builder.Services.AddDbContext<AppDbContext>(); builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IEmailService, EmailService>(); builder.Services.AddScoped<IReportService, ReportService>(); var app = builder.Build(); // Endpoints use focused services, not a God class app.MapPost("/login", async (LoginRequest req, IAuthService auth) => await auth.LoginAsync(req.Email, req.Password)); app.MapPost("/welcome-email", async (int userId, IEmailService email, AppDbContext db) => { var user = await db.Users.FindAsync(userId); if (user is not null) await email.SendWelcomeEmailAsync(user); }); app.MapGet("/reports/monthly", async (int month, int year, IReportService reports) => await reports.GenerateMonthlyReportAsync(month, year)); app.Run();

Now look at what we gained:

  • Isolation: The security team can rewrite AuthService from JWT to OAuth2 without touching a single line in EmailService or ReportService.
  • Testability: You can unit test ReportService by mocking only AppDbContext. No need to mock SMTP clients or JWT libraries that have nothing to do with reporting.
  • Zero merge conflicts: Three developers can work on auth, email, and reporting simultaneously. Different files. No conflicts.
  • Clear ownership: When a bug appears in email delivery, you know exactly where to look — EmailService.cs. Not a 200-line method in a God class.

Taking Advantage of .NET 8+ Features

Modern C# gives us cleaner syntax while maintaining the same SRP structure. Records for immutable data, primary constructors, and file-scoped namespaces reduce boilerplate without sacrificing clarity.

// .NET 8+ with primary constructors and modern syntax public class AuthService(AppDbContext db, IConfiguration config) : IAuthService { public async Task<AuthResult> LoginAsync(string email, string password) { var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email); if (user is null) return AuthResult.Failed("User not found"); if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) return AuthResult.Failed("Invalid password"); user.LastLoginAt = DateTime.UtcNow; await db.SaveChangesAsync(); return AuthResult.Success(GenerateJwt(user)); } private string GenerateJwt(User user) => new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( claims: [new("sub", user.Id.ToString()), new("email", user.Email)], expires: DateTime.UtcNow.AddHours(1), signingCredentials: new( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)), SecurityAlgorithms.HmacSha256))); } // .NET 8+ — primary constructor, concise public class EmailService(ISmtpClient smtp) : IEmailService { public async Task SendWelcomeEmailAsync(User user) { var msg = new MailMessage("hello@app.com", user.Email, "Welcome!", $$""" <h1>Welcome, {{user.Name}}!</h1> <p>We're thrilled to have you.</p> """) { IsBodyHtml = true }; await smtp.SendMailAsync(msg); } } // .NET 8+ — expression bodies and static helpers public class ReportService(AppDbContext db) : IReportService { public async Task<UserReport> GenerateMonthlyReportAsync(int month, int year) { var users = await db.Users .Where(u => u.CreatedAt.Month == month && u.CreatedAt.Year == year) .ToListAsync(); var now = DateTime.UtcNow; return new() { TotalSignups = users.Count, ActiveUsers = users.Count(u => u.LastLoginAt > now.AddDays(-30)), ChurnRate = ChurnRate(users, now), GeneratedAt = now }; } private static double ChurnRate(List<User> users, DateTime now) => users.Count is 0 ? 0 : (double)users.Count(u => u.LastLoginAt < now.AddDays(-90)) / users.Count * 100; } // Immutable records — clean, concise data carriers public record LoginRequest(string Email, string Password); public record AuthResult(bool Succeeded, string? Token = null, string? Error = null) { public static AuthResult Success(string token) => new(true, Token: token); public static AuthResult Failed(string error) => new(false, Error: error); } public record UserReport { public int TotalSignups { get; init; } public int ActiveUsers { get; init; } public double ChurnRate { get; init; } public DateTime GeneratedAt { get; init; } }

The modern syntax is shorter but the SRP structure is identical. Primary constructors eliminate the boilerplate fields. Raw string literals make email HTML readable. Records enforce immutability on data objects. The principle doesn't change — only the syntax evolves.

How the Refactored Services Collaborate ASP.NET Core DI resolves the right service for each endpoint HTTP Request DI Container Program.cs registration Resolves interface → concrete class IAuthService → AuthService IEmailService → EmailService IReportService → ReportService DB + JWT Config SMTP Client DB only Before: 1 class, all dependencies tangled UserService needed DB + SMTP + JWT Config After: each service has minimal dependencies ReportService only needs DB. Clean. Focused.

Comparison: Before vs After

The refactoring is straightforward: identify the actors, extract each responsibility into its own class with an interface, and wire them together with DI. The result is more files but dramatically less risk — each service can be tested, changed, and deployed independently. The .NET 8+ version uses modern syntax but the SRP structure is identical.
Section 6

Junior vs Senior Implementation

Both implementations produce the exact same behavior — a user management system with registration, profile updates, and notifications. The difference isn't what they build. It's how they think about change, ownership, and risk.

How a Junior Thinks

"I'll put everything in one class. It's all about users, so it belongs together. One file is simpler than five. I can find everything in one place. It works, so why make it more complicated?"

// "It all works! Ship it!" 🚀 public class ApplicationManager { private readonly AppDbContext _db; private readonly HttpClient _http; public ApplicationManager(AppDbContext db, HttpClient http) { _db = db; _http = http; } // ── User Registration (Actor: Product Team) ──────────── public async Task<User> RegisterUserAsync(string name, string email, string password) { if (await _db.Users.AnyAsync(u => u.Email == email)) throw new InvalidOperationException("Email already in use"); var user = new User { Name = name, Email = email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), CreatedAt = DateTime.UtcNow }; _db.Users.Add(user); await _db.SaveChangesAsync(); // Send welcome email right here — "it's just one line" await SendEmailAsync(user.Email, "Welcome!", $"Hi {name}, welcome to our app!"); // Log to external analytics — "it's just one more line" await LogAnalyticsEventAsync("user_registered", user.Id); // Update cache — "while we're at it..." UserCache[user.Id] = user; return user; } // ── Email (Actor: Marketing Team) ────────────────────── public async Task SendEmailAsync(string to, string subject, string body) { var response = await _http.PostAsJsonAsync("https://api.sendgrid.com/v3/mail/send", new { to, subject, body, from = "noreply@app.com" }); if (!response.IsSuccessStatusCode) Console.WriteLine($"Email failed: {response.StatusCode}"); } // ── Analytics (Actor: Data Team) ─────────────────────── public async Task LogAnalyticsEventAsync(string eventName, int userId) { await _http.PostAsJsonAsync("https://analytics.app.com/events", new { eventName, userId, timestamp = DateTime.UtcNow }); } // ── Caching (Actor: Infrastructure Team) ─────────────── private static readonly Dictionary<int, User> UserCache = new(); public User? GetCachedUser(int id) => UserCache.TryGetValue(id, out var user) ? user : null; public void InvalidateCache(int id) => UserCache.Remove(id); // ── Profile Updates (Actor: Product Team) ────────────── public async Task UpdateProfileAsync(int userId, string newName) { var user = await _db.Users.FindAsync(userId) ?? throw new KeyNotFoundException("User not found"); user.Name = newName; await _db.SaveChangesAsync(); // Update cache here too UserCache[userId] = user; // Notify via email await SendEmailAsync(user.Email, "Profile Updated", $"Hi {newName}, your profile has been updated."); // Log it await LogAnalyticsEventAsync("profile_updated", userId); } }

What's Wrong Here (Beyond "It Works")

Four Actors, One File

Product team, marketing team, data team, and infrastructure team all have reasons to change this class. That's four different groups of people whose requests can collide.

Side Effects Hidden in Business Logic

RegisterUserAsync does registration, then secretly sends an email, fires an analytics event, and updates a cache. A developer reading the method name expects registration — not email delivery. These hidden side effects make the code unpredictable.

Testing is a Nightmare

Want to test that registration saves a user to the database? You also need to mock HTTP calls (email + analytics) and deal with the static cache. Every test carries the weight of unrelated dependencies.

Thread-Unsafe Static Cache

The Dictionary<int, User> cache is static and shared across all requests. Under concurrent load, this will throw InvalidOperationException because Dictionary is not thread-safe. A proper cache (like IMemoryCache or Redis) should be a separate, dedicated service.

Juniors often organize code by entity — "everything about users goes in UserService." This feels logical but it's wrong. SRP says organize by actor — "everything the security team owns goes in AuthService, everything the marketing team owns goes in EmailService." The entity (User) is shared data. The responsibility is about who drives change.

How a Senior Thinks

"Before I write anything, I ask myself: who would ask me to change this code? Registration logic belongs to the product team. Email formatting belongs to marketing. Analytics events belong to the data team. Cache management belongs to infrastructure. Four actors means four services. The registration method should do registration — period. Side effects (email, analytics, cache) happen through separate services that are injected and can be tested independently."

// Actor: Product Team — only registration logic public interface IUserRegistrationService { Task<User> RegisterAsync(string name, string email, string password); } public class UserRegistrationService( AppDbContext db, IEmailService email, IAnalyticsService analytics, IUserCacheService cache) : IUserRegistrationService { public async Task<User> RegisterAsync(string name, string email, string password) { if (await db.Users.AnyAsync(u => u.Email == email)) throw new InvalidOperationException("Email already in use"); var user = new User { Name = name, Email = email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), CreatedAt = DateTime.UtcNow }; db.Users.Add(user); await db.SaveChangesAsync(); // Side effects are explicit and injected — not hidden await email.SendWelcomeAsync(user); await analytics.TrackAsync("user_registered", user.Id); cache.Set(user); return user; } } // Actor: Marketing Team — email templates, providers, tracking public interface IEmailService { Task SendWelcomeAsync(User user); Task SendProfileUpdatedAsync(User user); } public class SendGridEmailService(HttpClient http) : IEmailService { public async Task SendWelcomeAsync(User user) => await SendAsync(user.Email, "Welcome!", $"Hi {user.Name}, welcome to our app!"); public async Task SendProfileUpdatedAsync(User user) => await SendAsync(user.Email, "Profile Updated", $"Hi {user.Name}, your profile was updated."); private async Task SendAsync(string to, string subject, string body) { var response = await http.PostAsJsonAsync( "https://api.sendgrid.com/v3/mail/send", new { to, subject, body, from = "noreply@app.com" }); if (!response.IsSuccessStatusCode) throw new EmailDeliveryException( $"SendGrid returned {response.StatusCode}"); } } // Actor: Data Team — event tracking, schemas, endpoints public interface IAnalyticsService { Task TrackAsync(string eventName, int userId); } public class AnalyticsService(HttpClient http) : IAnalyticsService { public async Task TrackAsync(string eventName, int userId) => await http.PostAsJsonAsync("https://analytics.app.com/events", new { eventName, userId, timestamp = DateTime.UtcNow }); } // Actor: Infrastructure Team — caching strategy, eviction, providers public interface IUserCacheService { User? Get(int id); void Set(User user); void Invalidate(int id); } // Thread-safe, using IMemoryCache (not a raw Dictionary) public class UserCacheService(IMemoryCache cache) : IUserCacheService { private static readonly TimeSpan Expiry = TimeSpan.FromMinutes(30); public User? Get(int id) => cache.TryGetValue($"user:{id}", out User? user) ? user : null; public void Set(User user) => cache.Set($"user:{user.Id}", user, Expiry); public void Invalidate(int id) => cache.Remove($"user:{id}"); } var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<AppDbContext>(); builder.Services.AddMemoryCache(); // Each service registered with its own lifetime and dependencies builder.Services.AddScoped<IUserRegistrationService, UserRegistrationService>(); builder.Services.AddScoped<IEmailService, SendGridEmailService>(); builder.Services.AddScoped<IAnalyticsService, AnalyticsService>(); builder.Services.AddSingleton<IUserCacheService, UserCacheService>(); // HttpClient for services that need it builder.Services.AddHttpClient<SendGridEmailService>(); builder.Services.AddHttpClient<AnalyticsService>(); var app = builder.Build(); app.MapPost("/register", async (RegisterRequest req, IUserRegistrationService reg) => await reg.RegisterAsync(req.Name, req.Email, req.Password)); app.Run();

Notice the thought process. The senior didn't start with "what classes do I need?" They started with "who are the actors?" Then they drew a boundary around each actor's code. The registration service orchestrates the other services but doesn't implement email delivery, analytics, or caching itself. Each piece can be swapped, tested, or upgraded independently.

If you made it this far and the senior approach makes sense to you, congratulations — you understand SRP better than most working developers. The jump from junior to senior isn't about knowing more syntax. It's about asking "who would request changes to this?" before writing a single line.

Bottom Line

Juniors organize by entity ("all user stuff in one class"). Seniors organize by actor ("each team gets its own service"). The senior's code has more files but dramatically less risk — each service is independently testable, thread-safe, and changeable. The key mindset shift: "it works" is not the goal. "It's safe to change" is.
Section 7

Evolution in .NET

SRP wasn't something .NET developers talked about in the early days. Not because the principle didn't exist — Robert C. Martin wrote about it in the early 2000s — but because the tooling made it painful to practice. If you wanted to break a God class into 5 focused classes, you had to manually wire them all together. No dependency injection container. No built-in service registration. Just raw new statements everywhere. That's a LOT of friction, and friction kills good habits.

As .NET evolved, each major version removed another barrier. The story of SRP in .NET is really the story of how tooling caught up with the principle. Let's walk through each era.

In the early days of .NET, most projects had a handful of massive classes that did everything. A typical UserManager would handle authentication, profile updates, email notifications, and audit logging — all in one file. Why? Because splitting it up was painful. Without a DI framework, you had to manually create and pass dependencies everywhere.

If you extracted an EmailService from UserManager, you had to find every place that created a UserManager and also create an EmailService to pass in. In a large codebase, that could mean touching 50 files just to do the right thing. So people didn't.

// Everything lives in one class — authentication, email, logging, data access public class UserManager { private SqlConnection _connection; public UserManager() { // Hard-coded connection string — no IConfiguration yet _connection = new SqlConnection("Server=...;Database=...;"); } public bool Authenticate(string username, string password) { // SQL query + password hashing + session creation — all here var cmd = new SqlCommand("SELECT * FROM Users WHERE ...", _connection); // ... 80 lines of authentication logic return true; } public void SendWelcomeEmail(string email) { // SMTP setup + template rendering — inside UserManager! var smtp = new SmtpClient("mail.company.com"); smtp.Send(new MailMessage("noreply@co.com", email, "Welcome!", "...")); } public void LogAction(string action) { // File I/O for logging — also in UserManager! File.AppendAllText("C:\\logs\\app.log", $"{DateTime.Now}: {action}\n"); } // ... 800 more lines handling profiles, roles, reports } Every developer touching this file risks breaking unrelated features. Marketing wants to change the email template? They're editing the same file as the security team fixing authentication. Merge conflicts are guaranteed.

LINQ changed how developers thought about code. Suddenly you could write expressive, composable queries — and that mindset started leaking into architecture. People began extracting "services" from God classes. An EmailService here, a UserRepository there. The pattern was emerging.

But without built-in DI, wiring these services together was still manual. Developers used "poor man's DI" — constructors with default parameters — or third-party containers like Autofac or Ninject. It worked, but it was boilerplate-heavy.

public class UserManager { private readonly IEmailService _email; private readonly IUserRepository _repo; // "Poor man's DI" — default constructor creates concrete instances public UserManager() : this(new SmtpEmailService(), new SqlUserRepository()) { } // Testable constructor — but nobody discovers it without reading the code public UserManager(IEmailService email, IUserRepository repo) { _email = email; _repo = repo; } public bool Authenticate(string username, string password) { var user = _repo.FindByUsername(username); // ... authentication logic (still here, but at least data access is extracted) return true; } } The parameterless constructor defeats the purpose — it still hard-codes the concrete types. Tests can use the second constructor, but production code uses the first. SRP is happening, but the tooling makes it clunky.

This was the game changer. ASP.NET Core shipped with a built-in dependency injection container. For the first time in .NET history, splitting a class into focused services was easy. You register each service in Program.cs, and the framework wires them together automatically. No manual instantiation. No third-party libraries required.

This single change made SRP practical for everyday developers — not just architecture astronauts. Extracting a responsibility became a 3-step process: create an interface, create a class, register it in DI. Done.

var builder = WebApplication.CreateBuilder(args); // Each service has ONE responsibility — DI wires them together builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IEmailService, EmailService>(); builder.Services.AddScoped<IAuditLogger, AuditLogger>(); builder.Services.AddScoped<IUserRepository, UserRepository>(); // UserService now ONLY coordinates user workflows // It doesn't know HOW authentication, email, or logging work builder.Services.AddScoped<IUserService, UserService>(); public class UserService : IUserService { private readonly IAuthService _auth; private readonly IEmailService _email; private readonly IAuditLogger _audit; // Constructor declares ALL dependencies — nothing hidden public UserService(IAuthService auth, IEmailService email, IAuditLogger audit) { _auth = auth; _email = email; _audit = audit; } public async Task<bool> RegisterAsync(UserDto dto) { // Orchestration only — each step delegates to a focused service var user = await _auth.CreateAccountAsync(dto); await _email.SendWelcomeAsync(user.Email); await _audit.LogAsync("user.registered", user.Id); return true; } } Notice how UserService doesn't contain authentication logic, email sending logic, or logging logic. It orchestrates. Each extracted service can be tested, deployed, and modified independently. This is SRP made practical.

Minimal APIs stripped away even more ceremony. File-scoped namespaces removed a level of nesting. Records made DTOs trivial. Top-level statements meant a small service could be just a handful of lines. The result? Creating focused, single-responsibility classes became so lightweight that there was zero excuse not to.

namespace MyApp.Services; // file-scoped — no extra nesting public record RegisterCommand(string Email, string Name); // immutable DTO — 1 line public class UserService( IAuthService auth, IEmailService email, IAuditLogger audit) : IUserService { public async Task<Result> RegisterAsync(RegisterCommand cmd) { var user = await auth.CreateAccountAsync(cmd); await email.SendWelcomeAsync(cmd.Email); await audit.LogAsync("user.registered", user.Id); return Result.Ok(user); } }

Primary constructors arrived in .NET 8 for regular classes — not just records. This means your DI dependencies are declared right in the class declaration. Less boilerplate means even less friction for SRP. And keyed services let you register multiple implementations of the same interface, making it easy to have focused services for different contexts.

// Dependencies declared inline — no private fields, no constructor body public class AuthService( ILogger<AuthService> logger, IUserRepository repo, IPasswordHasher hasher) : IAuthService { public async Task<User> CreateAccountAsync(RegisterCommand cmd) { logger.LogInformation("Creating account for {Email}", cmd.Email); var hash = hasher.Hash(cmd.Password); return await repo.InsertAsync(new User(cmd.Email, cmd.Name, hash)); } } // Keyed services: multiple implementations of the same interface builder.Services.AddKeyedScoped<INotifier, EmailNotifier>("email"); builder.Services.AddKeyedScoped<INotifier, SmsNotifier>("sms"); builder.Services.AddKeyedScoped<INotifier, PushNotifier>("push"); // Each notifier has ONE job — the strategy selection is separate Each .NET version removes more friction. What took 50 lines of boilerplate in .NET 1.x takes 5 lines in .NET 8. The principle hasn't changed — but the effort to practice it has dropped by 90%.

Evolution Summary

EraSRP BarrierWhat ChangedSRP Effort
.NET 1.0–2.0No DI frameworkManual new everywhereHigh
.NET 3.5Manual wiringPoor man's DI, third-party containersMedium
.NET CoreNone — DI built-inAddScoped / AddSingletonLow
.NET 6+NoneRecords, file-scoped namespaces, minimal APIsVery Low
.NET 8+NonePrimary constructors, keyed servicesTrivial
SRP went from painful to trivial across .NET's history. The principle never changed — the tooling caught up. Built-in DI in .NET Core was the turning point: extracting a class into a focused service became a 3-step process (interface, class, register). Modern .NET 8+ with primary constructors makes SRP the path of least resistance.
Section 8

SRP in .NET Framework & Libraries

The best way to understand SRP is to see it practiced at scale — and Microsoft's own .NET libraries are full of examples. The framework doesn't just support SRP; it's designed around it. Each major subsystem (logging, configuration, HTTP, caching) is isolated behind interfaces, so your code never has to take on responsibilities that belong to the framework.

Let's look at four key examples. In each case, notice the pattern: your class receives a focused interface, and the framework handles the complexity behind it.

Your Class Stays Focused — .NET Handles the Rest YourService (business logic ONLY) ILogger<T> Logging responsibility IOptions<T> Configuration responsibility IHttpClientFactory HTTP responsibility IMediator Command routing responsibility Each interface owns ONE concern — your service doesn't touch any of them directly

Before .NET Core, developers would new up loggers inside their classes — creating file writers, formatting messages, managing log levels. That's a logging responsibility mixed into business logic. The ILogger<T> abstraction flips this: your class says "I need to log things," and the DI container gives it a properly configured logger. Your class never decides where logs go (console? file? Seq?) or how they're formatted.

public class OrderService(ILogger<OrderService> logger, IOrderRepository repo) : IOrderService { public async Task<Order> PlaceAsync(OrderRequest request) { // Your class just says WHAT happened — not WHERE it goes logger.LogInformation("Placing order for {Customer}", request.CustomerId); var order = await repo.CreateAsync(request); logger.LogInformation("Order {OrderId} placed successfully", order.Id); return order; } // No file handles. No format strings. No log level config. // That's the logging framework's responsibility — not yours. } The logging destination (console, file, cloud) is configured in Program.cs — completely separate from business logic. You can switch from Serilog to NLog without touching a single service class.

Reading config files, parsing JSON, handling environment variable overrides — that's a whole responsibility on its own. The IOptions<T> pattern separates it cleanly: you define a POCO (plain old C# object) that describes what settings you need, bind it in Program.cs, and your service receives strongly-typed, validated configuration. No File.ReadAllText. No JsonSerializer.Deserialize. No config parsing at all.

// Settings POCO — describes the shape of configuration public class EmailSettings { public string SmtpHost { get; init; } = ""; public int SmtpPort { get; init; } = 587; public string FromAddress { get; init; } = ""; } // Program.cs — binding happens once, in the composition root builder.Services.Configure<EmailSettings>( builder.Configuration.GetSection("Email")); // Service — receives typed settings, doesn't know where they came from public class EmailService(IOptions<EmailSettings> options) : IEmailService { private readonly EmailSettings _cfg = options.Value; public async Task SendAsync(string to, string subject, string body) { using var client = new SmtpClient(_cfg.SmtpHost, _cfg.SmtpPort); // Uses _cfg.FromAddress — doesn't read appsettings.json directly await client.SendMailAsync(_cfg.FromAddress, to, subject, body); } }

Making HTTP calls sounds simple, but managing connection pooling, DNS rotation, timeout policies, and retry logic is a whole subsystem. If your service creates new HttpClient() instances directly, it's taking on that responsibility. IHttpClientFactory extracts it: the factory manages the handler pool, rotates DNS entries, and applies Polly retry policies — your service just makes the call.

// Program.cs — HTTP configuration lives here, not in the service builder.Services.AddHttpClient<PaymentGateway>(client => { client.BaseAddress = new Uri("https://api.stripe.com/v1/"); client.Timeout = TimeSpan.FromSeconds(10); }) .AddStandardResilienceHandler(); // retries, circuit breaker, timeout // Service — receives a managed HttpClient, doesn't create or configure it public class PaymentGateway(HttpClient client) : IPaymentGateway { public async Task<ChargeResult> ChargeAsync(decimal amount, string token) { var response = await client.PostAsJsonAsync("charges", new { amount, token }); return await response.Content.ReadFromJsonAsync<ChargeResult>(); } // No connection pooling. No retry logic. No DNS management. // That's IHttpClientFactory's responsibility. }

MediatR is a popular third-party library (but so widely used it's practically part of the ecosystem). It separates "what to do" from "who does it." Instead of a controller calling services directly, it sends a command or query object to a mediator, which routes it to the right handler. Each handler has exactly one job — handle that one command. This is SRP taken to its logical endpoint: one class, one operation.

// Command — describes WHAT should happen (no logic) public record CreateOrderCommand(string CustomerId, List<LineItem> Items) : IRequest<OrderResult>; // Handler — does exactly ONE thing: creates an order public class CreateOrderHandler( IOrderRepository repo, ILogger<CreateOrderHandler> logger) : IRequestHandler<CreateOrderCommand, OrderResult> { public async Task<OrderResult> Handle( CreateOrderCommand cmd, CancellationToken ct) { logger.LogInformation("Creating order for {Customer}", cmd.CustomerId); var order = await repo.CreateAsync(cmd, ct); return new OrderResult(order.Id, order.Total); } } // Controller — just routes HTTP to MediatR, no business logic at all app.MapPost("/orders", async (CreateOrderCommand cmd, IMediator mediator) => await mediator.Send(cmd)); Without MediatR, controllers tend to accumulate business logic — they become orchestrators for 10 different operations. With MediatR, each operation is a separate handler class. The controller becomes a thin routing layer that maps HTTP requests to commands.
.NET's own architecture demonstrates SRP at every level. ILogger separates logging, IOptions separates configuration, IHttpClientFactory separates HTTP management, and MediatR separates command routing. Your service classes stay focused on business logic because the framework takes ownership of infrastructure concerns.
Section 9

When To Use / When Not To

SRP sounds like it should apply everywhere — and conceptually, it does. But the real question isn't "should I follow SRP?" but rather "is this class violating SRP in a way that actually causes problems?" A 400-line parser that does one complex thing well is NOT an SRP violation, even though it's big. A 200-line service that handles both authentication and email sending IS a violation, even though it's small. Size doesn't determine whether you need to split a class — the number of actors does.

SRP Decision Flowchart Does this class serve multiple actors? NO Keep as-is. Single responsibility. YES Would a change for Actor A affect Actor B's code? NO Monitor it. Low risk for now. YES EXTRACT! Split into focused classes. The key question is always: who requests changes to this class, and why? A 200-line class with high cohesion — where every method works together toward one goal — is better than 10 scattered 20-line classes that fragment a single responsibility. The goal isn't "small classes." The goal is "one reason to change." Apply SRP when a class serves multiple actors who would request changes for different reasons. Don't apply it when a class does one complex thing well, even if that thing takes 400 lines. The decision flowchart: identify actors, check if their changes overlap, and extract only when overlap causes real problems.
Section 10

Comparisons

SRP often gets confused with related concepts. "Isn't SRP the same as Separation of Concerns?" "Isn't it the same as Interface Segregation?" They're related but different — and understanding the differences will help you apply each one correctly. Here's a breakdown of the three most common confusions.

SRP vs ISP vs Separation of Concerns — Different Scopes Separation of Concerns (Architecture Level) "Keep UI, business logic, and data access in separate layers" SRP (Class Level) "One class = one actor" Splits implementations into focused classes ISP (Interface Level) "One interface = one client" Splits contracts into focused interfaces SoC is the broad principle. SRP and ISP are specific techniques at different levels.

SRP vs Interface Segregation Principle (ISP)

These two are cousins, not twins. SRP says "a class should have one reason to change" — it's about implementations. ISP says "an interface should not force clients to depend on methods they don't use" — it's about contracts. They're complementary: SRP splits the code behind the interface, and ISP splits the interface itself. You can follow SRP perfectly while violating ISP (one focused class implementing a bloated interface), or follow ISP while violating SRP (slim interfaces backed by a God class).

SRP vs Separation of Concerns (SoC)

Separation of Concerns is the big picture principle — it says "keep unrelated things apart." It operates at the architecture level: separate your UI layer from your business logic layer from your data access layer. SRP is a specific technique that helps achieve SoC at the class level. SoC tells you to separate the layers; SRP tells you how to structure the classes within each layer.

SRP vs DRY (Don't Repeat Yourself)

This is the one that trips people up. DRY says "don't duplicate knowledge." SRP says "one reason to change per class." Sometimes these two principles directly conflict. If two actors need similar logic that will evolve independently, keeping separate copies is correct SRP — even though it violates DRY. Why? Because merging them creates coupling between two actors, and when one actor's requirements change, the other actor's code breaks.

If two actors need similar code that will evolve independently, duplication is correct. Shared code between different actors creates coupling. The question isn't "do these look the same?" — it's "will they change for the same reason?" If no, keep them separate. SRP (one actor per class), ISP (one role per interface), and SoC (separate layers) operate at different levels but reinforce each other. The surprising insight: SRP sometimes requires violating DRY. When two actors share similar code that evolves independently, duplication is the right call because merging creates cross-actor coupling.
Section 11

SOLID Mapping

SRP is the first letter in SOLID for a reason — it's the foundation that makes the other four principles easier to apply. When a class has only one responsibility, extending it is simpler (OCP), substituting it is safer (LSP), its interfaces stay focused (ISP), and dependency injection wires everything together (DIP). Here's how SRP connects to each of its siblings.

OCP says "open for extension, closed for modification." If a class has three responsibilities, extending one of them risks modifying the others — which violates OCP. But if each responsibility lives in its own class, you can extend one without touching the rest. SRP makes OCP natural.

Example: A ReportService that handles data fetching, formatting, AND export. To add PDF export, you'd modify the class (violating OCP). But if export is extracted into an IReportExporter, you just add a new PdfExporter implementation — the existing code stays untouched.

SRP isolates responsibilities → OCP extends them independently. Without SRP, OCP requires careful surgery. With SRP, OCP happens naturally.

LSP says "subtypes must be substitutable for their base types without breaking anything." When a class has one responsibility, its contract is narrow and clear — making correct substitution easier. When a class has five responsibilities, any subclass has to honor all five contracts, which makes LSP violations almost inevitable.

Example: A FileStorage class that handles both reading and writing. A ReadOnlyFileStorage subclass can't properly implement the write methods — it throws NotSupportedException, violating LSP. If reading and writing were separate interfaces from the start (SRP + ISP), this problem never arises.

SRP keeps classes focused → LSP substitution is easier to get right. Fewer responsibilities = fewer contracts to honor = fewer ways to break substitutability.

SRP and ISP are two sides of the same coin. SRP splits classes so each has one responsibility. ISP splits interfaces so each has one role. When you practice SRP, your classes naturally implement small, focused interfaces — because a class with one responsibility only needs methods for that one responsibility.

Example: An IUserService interface with Authenticate, UpdateProfile, SendNotification, and GenerateReport. If you apply SRP to split the class, the interface splits too: IAuthService, IProfileService, INotificationService, IReportService. SRP on classes naturally leads to ISP on interfaces.

SRP splits implementations → ISP splits contracts. Apply SRP to your classes, and ISP on your interfaces often follows automatically.

DIP says "depend on abstractions, not concretions." But here's the practical question: how do all those extracted, focused classes wire together? The answer is DI — dependency injection. DIP provides the mechanism that makes SRP practical. Without DI, splitting a God class into 5 focused classes creates a nightmare of manual instantiation. With DI, you register each class with the container and it wires them automatically.

Example: After applying SRP, UserService depends on IAuthService, IEmailService, and IAuditLogger. DIP means those dependencies are interfaces, not concrete classes. The DI container resolves them at runtime. DIP is the glue that holds SRP's extracted pieces together.

SRP extracts responsibilities into classes → DIP connects them via interfaces → DI container wires them at runtime. DIP is the delivery mechanism for SRP.
SRP is the foundation of SOLID. It isolates responsibilities so OCP can extend them, LSP can substitute them, ISP can define clean contracts for them, and DIP can wire them together via dependency injection. Start with SRP, and the other four principles become natural follow-ups.
Section 12

Bug Case Studies

Theory is one thing — production outages are another. These six bugs all share the same root cause: a class that had too many responsibilities, where a change intended for one responsibility accidentally broke another. These aren't hypothetical scenarios — they're patterns that show up in real codebases every week.

A developer optimized the monthly report query in UserService. Login broke for all users for 2 hours on a Friday afternoon. The report query change accidentally modified the user lookup query that authentication depended on.

What Went Wrong: The UserService class handled both authentication (login, password hashing, session creation) and monthly reporting (user activity stats, growth metrics). Both features queried the Users table, but for completely different reasons. When the reporting developer added a WHERE IsActive = 1 filter to speed up the monthly report, they didn't realize the same GetUser private method was also used by the authentication flow. Inactive users could no longer log in to reactivate their accounts.

Failure Chain: Report Optimization Breaks Login UserService Login() MonthlyReport() GetUser() ← shared TWO actors, ONE class Report dev changes GetUser() + WHERE IsActive = 1 "Just a performance tweak" Login BROKEN Inactive users can't log in to reactivate their accounts 2-hour outage on production SRP fix: AuthService and ReportService each have their own query methods // ❌ Two actors share one class — and one private method public class UserService { // Shared method — used by BOTH auth and reporting private User GetUser(string username) { return _db.Users .Where(u => u.Username == username) .Where(u => u.IsActive) // ❌ Report dev added this "optimization" .FirstOrDefault(); } public bool Login(string username, string password) { var user = GetUser(username); // ❌ Now returns null for inactive users if (user == null) return false; return _hasher.Verify(password, user.PasswordHash); } public ReportData GetMonthlyReport() { // Report only needs active users — the filter makes sense HERE var users = _db.Users.Where(u => u.IsActive).ToList(); return BuildReport(users); } } // ✅ Separate classes — each owns its own data access public class AuthService : IAuthService { public bool Login(string username, string password) { // Auth query: find user by username, regardless of status var user = _db.Users .FirstOrDefault(u => u.Username == username); if (user == null) return false; return _hasher.Verify(password, user.PasswordHash); } } public class ReportService : IReportService { public ReportData GetMonthlyReport() { // Report query: only active users (separate from auth) var users = _db.Users.Where(u => u.IsActive).ToList(); return BuildReport(users); } } When two different actors (security team and analytics team) share the same class, a "safe" optimization for one actor can silently break the other. Separate classes mean separate queries, separate tests, and separate blast radius. Look for private methods used by multiple public methods that serve different actors. If GetUser is called by both Login and GenerateReport, that private method is a shared dependency between two responsibilities — a ticking time bomb.
Marketing changed email templates. Backend changed notification logic. Both edited NotificationService.cs. A merge conflict was resolved incorrectly, causing welcome emails to use the password reset template.

What Went Wrong: The NotificationService handled both email template rendering and notification delivery logic. Marketing was updating the welcome email copy. The backend team was adding retry logic to the SMTP sender. Both PRs modified the same file. The merge conflict fell on a Friday afternoon, and the resolver picked the wrong block — template rendering code got mixed with retry logic, causing the GetTemplate() method to return the wrong template for welcome emails.

Two Teams, One File, Bad Merge Marketing PR Update email templates Backend PR Add SMTP retry logic NotificationService.cs MERGE CONFLICT Wrong template sent 50K users get password reset email at signup // ❌ Templates AND delivery in one class — two teams edit the same file public class NotificationService { public string GetTemplate(string type) => type switch { "welcome" => "<h1>Welcome!</h1>...", // Marketing edits this "reset" => "<h1>Reset Password</h1>...", // Marketing edits this _ => throw new ArgumentException() }; public async Task SendAsync(string email, string type) { var body = GetTemplate(type); // Backend team adds retry logic here — in the SAME file for (int retry = 0; retry < 3; retry++) { try { await _smtp.SendAsync(email, body); return; } catch { await Task.Delay(1000 * retry); } } } } // ✅ Templates are Marketing's responsibility public class EmailTemplateService : IEmailTemplateService { public string GetTemplate(string type) => type switch { "welcome" => "<h1>Welcome!</h1>...", "reset" => "<h1>Reset Password</h1>...", _ => throw new ArgumentException() }; } // ✅ Delivery logic is Backend's responsibility public class EmailSender : IEmailSender { public async Task SendAsync(string email, string body) { for (int retry = 0; retry < 3; retry++) { try { await _smtp.SendAsync(email, body); return; } catch { await Task.Delay(1000 * retry); } } } } If two teams regularly edit the same file, that file has too many responsibilities. Separate teams = separate actors = separate classes. Merge conflicts are a symptom of SRP violations. Check your Git history: git log --oneline NotificationService.cs. If commits from different teams interleave, the class serves multiple actors.
A security fix for the payment endpoints accidentally removed CORS headers for the user endpoints. A single ASP.NET controller handled users, orders, AND payments — all 15 endpoints in one file.

What Went Wrong: An ApiController with 15 endpoints spanning three domains (users, orders, payments). The security team added a custom [Authorize] attribute to payment endpoints and, in the process, restructured the controller's class-level attributes. The CORS policy that the mobile app depended on was on a class-level attribute — removing it broke all user endpoints for the mobile app, even though the security team only intended to change payments.

One Controller, Three Domains, One Broken Attribute ApiController.cs 5 user endpoints 5 order endpoints 5 payment endpoints [EnableCors] on class level Security fix Restructured attributes CORS header removed Mobile app broken User endpoints return CORS errors for 6 hours All mobile users affected // ❌ One controller for EVERYTHING — 15 endpoints, 3 domains [ApiController] [EnableCors("AllowMobileApp")] // ❌ Class-level — applies to ALL 15 endpoints [Route("api")] public class ApiController : ControllerBase { // 5 user endpoints [HttpGet("users/{id}")] public async Task<IActionResult> GetUser(int id) { ... } [HttpPost("users")] public async Task<IActionResult> CreateUser(UserDto dto) { ... } // ... 3 more user endpoints // 5 order endpoints [HttpGet("orders/{id}")] public async Task<IActionResult> GetOrder(int id) { ... } // ... 4 more order endpoints // 5 payment endpoints — security team modifies these [HttpPost("payments/charge")] public async Task<IActionResult> Charge(ChargeDto dto) { ... } // ... 4 more payment endpoints } // ✅ Separate controllers — each domain is independent [ApiController] [EnableCors("AllowMobileApp")] [Route("api/users")] public class UsersController(IUserService userService) : ControllerBase { [HttpGet("{id}")] public async Task<IActionResult> Get(int id) => Ok(await userService.GetAsync(id)); } [ApiController] [Authorize(Policy = "PaymentAdmin")] // Security team changes only THIS controller [Route("api/payments")] public class PaymentsController(IPaymentService paymentService) : ControllerBase { [HttpPost("charge")] public async Task<IActionResult> Charge(ChargeDto dto) => Ok(await paymentService.ChargeAsync(dto)); } Controllers are classes too — SRP applies to them. One controller per domain (users, orders, payments) means security changes to payments can't accidentally affect user endpoints. Count the endpoints in a controller. If it has more than 7, and they serve different domains, it's a God controller waiting for a cross-domain bug.
A developer changed a date format helper in Helper.cs for the reporting team. The billing team's invoice generation used the same helper — invoices printed wrong dates for 3 days before anyone noticed.

What Went Wrong: A 3,000-line Helper.cs file with 60+ static methods used across the entire application. The reporting team needed dates formatted as "MMM yyyy" (for monthly headers). The developer changed FormatDate() from "yyyy-MM-dd" to "MMM yyyy." But the billing team used FormatDate() for invoice dates — which now showed "Apr 2026" instead of "2026-04-04." Invoices looked wrong, and downstream accounting systems rejected them.

One Helper Method, Two Consumers, Different Needs Helper.FormatDate() Changed: "yyyy-MM-dd" → "MMM yyyy" Reporting Team Gets "Apr 2026" ✅ Billing Team Gets "Apr 2026" ❌ (needs 2026-04-04) // ❌ 3000-line utility class — everyone depends on everything public static class Helper { // Used by Reporting (wants "MMM yyyy") AND Billing (wants "yyyy-MM-dd") public static string FormatDate(DateTime date) { return date.ToString("MMM yyyy"); // ❌ Changed for reporting, broke billing } // ... 59 more static methods for strings, numbers, files, etc. } // ✅ Each actor gets their own formatting public static class ReportDateFormatter { public static string Format(DateTime date) => date.ToString("MMM yyyy"); // Reporting team controls this } public static class InvoiceDateFormatter { public static string Format(DateTime date) => date.ToString("yyyy-MM-dd"); // Billing team controls this } Giant utility classes are SRP violations in disguise. Each "helper" method serves a different actor. When those actors need the same method to behave differently, someone breaks. Extract helpers into domain-specific formatters. Any file named Helper.cs, Utils.cs, or Common.cs over 500 lines is almost certainly serving multiple actors. Check how many different teams or features depend on it.
A service class created its own StreamWriter for logging inside the constructor. When used in a tight loop via DI (registered as Transient), hundreds of unclosed file handles accumulated, crashing the server.

What Went Wrong: An OrderProcessor class had two responsibilities: processing orders AND logging to a file. It created a StreamWriter in its constructor to append to a log file. The class was registered as Transient in DI (a new instance per request). Under load, hundreds of instances were created per minute, each holding an open file handle. The file handles were never closed because the class didn't implement IDisposable — it didn't know it needed to because logging wasn't supposed to be its job.

Transient + Self-Managed Logging = Handle Leak DI Container Transient lifetime new OrderProcessor() new OrderProcessor() new OrderProcessor() 200/min under load Open File Handles 200/min x 60min = 12,000 handles! SERVER CRASH SRP fix: extract logging to ILogger — DI container manages its lifecycle separately // ❌ Order processing + logging in one class public class OrderProcessor : IOrderProcessor { private readonly StreamWriter _logWriter; // ❌ Self-managed resource public OrderProcessor() { // Every new instance opens a file handle — never closed! _logWriter = new StreamWriter("logs/orders.log", append: true); } public async Task ProcessAsync(Order order) { _logWriter.WriteLine($"{DateTime.Now}: Processing order {order.Id}"); // ... order processing logic _logWriter.WriteLine($"{DateTime.Now}: Order {order.Id} completed"); } // No IDisposable! StreamWriter handle leaks when GC collects the instance // Under load: 200 instances/min x 60 min = 12,000 open handles → crash } // ✅ Logging extracted to ILogger — DI manages lifecycle public class OrderProcessor( ILogger<OrderProcessor> logger, IOrderRepository repo) : IOrderProcessor { public async Task ProcessAsync(Order order) { logger.LogInformation("Processing order {OrderId}", order.Id); await repo.UpdateStatusAsync(order.Id, OrderStatus.Processing); // ... order processing logic logger.LogInformation("Order {OrderId} completed", order.Id); } // No file handles to manage. ILogger is Singleton — one instance, no leaks. // OrderProcessor can safely be Transient or Scoped. } When a class manages its own logging resources, it takes on lifecycle management responsibility. If DI creates many short-lived instances, resources leak. Extract logging to ILogger — the framework manages the lifecycle correctly because it's a Singleton, not Transient. Search for new StreamWriter, new FileStream, or File.Open inside service constructors. If the class is Transient, every instance creates a new handle. That's a leak waiting to happen.
A ProductService managed both product data and caching. A developer added a new DiscountPercent field to the product model but forgot to update the cache serialization. Cached products showed wrong prices for 4 hours until the cache expired.

What Went Wrong: The ProductService handled data access (CRUD operations on products) AND caching (serialization, cache key management, TTL configuration). When the developer added DiscountPercent to the Product entity and updated the database query, they forgot that the caching logic in the same class was serializing products to JSON with a fixed schema. The new field wasn't included in the cache serialization. Products fetched from cache showed the old price (no discount), while products fetched from the database showed the correct discounted price.

New Field Added to DB but Not to Cache Serialization ProductService CRUD + Caching Dev adds DiscountPercent to DB query ✅ cache ❌ DB: $100 - 20% = $80 ✅ New field included Cache: $100 (no discount) ❌ Stale serialization Users see $80 or $100 depending on cache hit/miss 4 hours of wrong prices SRP fix: ProductRepository for data, ProductCacheService for caching // ❌ Data access AND caching in one class public class ProductService { private readonly IMemoryCache _cache; public async Task<Product> GetByIdAsync(int id) { var key = $"product:{id}"; if (_cache.TryGetValue(key, out Product cached)) return cached; // ❌ Cached version may be stale var product = await _db.Products .Where(p => p.Id == id) .Select(p => new Product { Id = p.Id, Name = p.Name, Price = p.Price, DiscountPercent = p.DiscountPercent // ✅ Dev added this to DB query }) .FirstAsync(); // ❌ But the cache entry was set before this field existed // Old cached entries don't have DiscountPercent → shows full price _cache.Set(key, product, TimeSpan.FromHours(4)); return product; } } // ✅ Data access is one responsibility public class ProductRepository(AppDbContext db) : IProductRepository { public async Task<Product> GetByIdAsync(int id) => await db.Products.FindAsync(id); // Always returns full entity } // ✅ Caching is a separate responsibility (decorator pattern) public class CachedProductRepository( IProductRepository inner, IMemoryCache cache) : IProductRepository { public async Task<Product> GetByIdAsync(int id) { return await cache.GetOrCreateAsync($"product:{id}", async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); return await inner.GetByIdAsync(id); // Delegates to real repo }); } // Cache always wraps the latest entity shape — no serialization mismatch } When data access and caching live in the same class, schema changes to one don't automatically propagate to the other. Using the Decorator pattern to wrap a repository with caching separates the two responsibilities — the cache always wraps the latest entity, so schema mismatches are impossible. Look for IMemoryCache or IDistributedCache usage inside repository or service classes alongside database queries. If the same class handles both "get data" and "cache data," a schema change can cause stale reads.
All six bugs share one root cause: a class with too many responsibilities where a change intended for one responsibility accidentally broke another. The pattern is consistent — auth broke by report changes, emails broke by merge conflicts, mobile broke by security fixes, invoices broke by format changes, servers crashed from leaked resources, and prices went wrong from stale caches. The fix is always the same: extract responsibilities into focused classes with clear boundaries.
Section 13

Pitfalls & Anti-Patterns

SRP is one of those principles that sounds simple until you try to apply it. The tricky part isn't understanding the rule — it's knowing where the boundaries are. Most SRP mistakes come from going too far, not going far enough, or going in the wrong direction entirely. Here are six traps that catch even experienced developers.

The Mistake: You hear "single responsibility" and interpret it as "single method." So you create UserNameValidator, UserEmailValidator, UserPhoneValidator, UserAgeValidator, and UserAddressValidator — five classes for what could be one cohesive UserValidator.

Why It's Bad: SRP is about actors, not method count. If the same validation team owns all these rules, and they all change together when the user model changes, then they belong in one class. Splitting them creates a "nano-class explosion" — dozens of tiny files that scatter related logic across the codebase. You spend more time navigating between files than actually reading code. The cure is worse than the disease.

The Fix: Ask "who requests changes to this logic?" If the answer is one team or one stakeholder, keep the methods together. A UserValidator with five validation methods is perfectly SRP-compliant — one actor (the validation/product team), one reason to change (validation rules evolve).

BAD: 5 nano-classes, 1 actor NameValidator EmailValidator PhoneValidator AgeValidator AddressValidator All owned by: Validation Team Scattered logic, file explosion GOOD: 1 class, 1 actor UserValidator ValidateName() ValidateEmail() ValidatePhone() ValidateAge() + ValidateAddress() // One class per validation rule — absurd when the same team owns all of them public class UserNameValidator { public bool Validate(string name) => !string.IsNullOrWhiteSpace(name) && name.Length <= 100; } public class UserEmailValidator { public bool Validate(string email) => email.Contains('@') && email.Contains('.'); } public class UserPhoneValidator { public bool Validate(string phone) => phone.Length >= 10 && phone.All(char.IsDigit); } // ... and 2 more files. 5 classes, 5 files, 5 constructors to wire up. // All change when the product team updates validation rules. // One class with five methods — all owned by the same actor (product/validation team) public class UserValidator { public bool ValidateName(string name) => !string.IsNullOrWhiteSpace(name) && name.Length <= 100; public bool ValidateEmail(string email) => email.Contains('@') && email.Contains('.'); public bool ValidatePhone(string phone) => phone.Length >= 10 && phone.All(char.IsDigit); public bool ValidateAge(int age) => age is >= 13 and <= 150; public bool ValidateAddress(Address addr) => !string.IsNullOrWhiteSpace(addr.Street) && !string.IsNullOrWhiteSpace(addr.City); }

The Mistake: You create UserDataAccess, UserBusinessLogic, and UserPresentation. It looks like SRP because you split by layer. But if all three change every time the user model changes, they all serve the same actor — and you've just scattered one responsibility across three files.

Why It's Bad: Layered splitting is a false separationWhen classes appear independent because they're in different layers, but actually change together in lockstep because they serve the same actor. The technical boundary doesn't match the responsibility boundary.. You add a new field to the user model, and you have to update the data access class, the business logic class, and the presentation class. Three files changed for one feature — that's a sign the boundary is wrong. The split should follow actors, not architectural layers.

The Fix: Split by who requests changes, not by where code runs. If the user profile team changes all three layers together, keep them close. The right split might be: UserProfileService (profile CRUD, owned by profile team), UserAuthService (login/password, owned by security team), UserAnalyticsService (tracking, owned by data team).

BAD: Split by layer (same actor) UserPresentation UserBusinessLogic UserDataAccess Add "bio" field? Change ALL 3 files GOOD: Split by actor UserProfileService Actor: Profile team UserAuthService Actor: Security team UserAnalyticsService Actor: Data team Add "bio"? Change 1 file // All three change when the user model changes — same actor, wrong boundary public class UserDataAccess { public User GetById(int id) => _db.Users.Find(id); public void Save(User user) => _db.SaveChanges(); } public class UserBusinessLogic { public void UpdateProfile(int id, string name, string bio) { var user = _dataAccess.GetById(id); user.Name = name; user.Bio = bio; // Added "bio" — now change data access AND presentation too _dataAccess.Save(user); } } public class UserPresentation { public UserDto ToDto(User user) => new() { Name = user.Name, Bio = user.Bio }; } // Split by who requests changes, not by technical layer public class UserProfileService // Actor: Profile team { public UserDto GetProfile(int id) { /* fetch, map, return */ } public void UpdateProfile(int id, UpdateProfileRequest req) { /* validate, save, map */ } } public class UserAuthService // Actor: Security team { public AuthResult Login(string email, string password) { /* auth logic */ } public void ChangePassword(int id, string oldPw, string newPw) { /* password logic */ } } public class UserAnalyticsService // Actor: Data/analytics team { public void TrackLogin(int userId) { /* event tracking */ } public UserActivityReport GetReport(int userId) { /* analytics query */ } }

The Mistake: You extract an IUserService interface with 15 methods — Login, Register, SendEmail, GenerateReport, UpdateProfile, etc. It feels like progress because you now have an abstraction. But the single implementation class behind that interface still does everything. The interface is a facade, not SRP.

Why It's Bad: An interface doesn't magically create separation. If UserService implements IUserService and has 15 methods touching auth, email, and reporting, you still have one class that changes for three different actors. The interface just adds a layer of indirection that hides the God class from callers — the coupling and complexity are still there.

The Fix: Split the interface AND the implementation. IAuthService (login, register), IEmailService (send welcome, send reset), IReportService (generate, export). Each interface has its own focused implementation class.

BAD: Interface hides a God class IUserService (15 methods) UserService Auth + Email + Reports + Profile Still a God class! GOOD: Split interface AND class IAuthService AuthService IEmailService EmailService IReportService ReportService Each interface + class = 1 actor // The interface has 15 methods — that's a smell right there public interface IUserService { AuthResult Login(string email, string password); void Register(UserDto dto); void SendWelcomeEmail(int userId); void SendPasswordReset(int userId); ReportData GenerateUserReport(int userId); void UpdateProfile(int userId, ProfileDto dto); // ... 9 more methods } // The implementation is still a God class — the interface changes nothing public class UserService : IUserService { // 15 methods, 500 lines, 8 injected dependencies // Auth team, Email team, and Analytics team ALL change this class } public interface IAuthService { AuthResult Login(string email, string password); void Register(UserDto dto); } public interface IEmailService { void SendWelcomeEmail(int userId); void SendPasswordReset(int userId); } public interface IReportService { ReportData GenerateUserReport(int userId); } // Each implementation: ~50 lines, 1-2 dependencies, 1 actor

The Mistake: A PaymentProcessor validates the card, charges the amount, sends a receipt, and logs the transaction. A developer says "that's four things — SRP violation!" and splits it into four classes.

Why It's Bad: If the payments team owns all four steps, and they all change together when the payment flow changes, then this is one responsibility: processing a payment. The four steps are sub-tasks of that responsibility, not separate responsibilities. SRP doesn't count methods or steps — it counts actors. "One thing" is a Unix philosophy, not SRP. Robert Martin explicitly distinguishes the two.

The Fix: Apply the actor test. "If we change how payment receipts look, who decides?" If it's the payments team (same actor who owns the charge logic), keep it together. If it's the communications team (different actor), then that specific step should be extracted — but only that step, not all four.

PaymentProcessor — 4 steps, but 1 actor (Payments Team) ValidateCard() ChargeAmount() SendReceipt() LogTransaction() SRP-compliant: 4 steps, 1 actor, 1 reason to change Only extract a step if a DIFFERENT actor owns it

The Mistake: Your UserService class is 800 lines long and does auth, email, and reporting. You "fix" it by extracting everything into UserHelper. Now UserService is thin — it delegates to UserHelper. But UserHelper is still 800 lines with three actors. You renamed the God class, you didn't fix the problem.

Why It's Bad: Extracting to a "helper" or "utility" class is the most common form of cargo cult refactoring. It looks like progress in the commit history ("refactored UserService!"), but the coupling, the merge conflicts, and the testing pain are all still there — just in a file called UserHelper instead of UserService.

The Fix: When splitting a God class, split by actor, not into "main" and "helper." Create AuthService, EmailService, and ReportService — each responsible to one actor. The old UserService becomes an orchestrator that calls the focused services (or disappears entirely if the controller can call them directly).

BAD: Renamed the God class UserService (thin) UserHelper Auth + Email + Reports Still 800 lines, 3 actors! GOOD: Actually split by actor AuthService EmailService ReportService Each: 150 lines, 1 actor, independently testable

The Mistake: You build a new feature and immediately split it into 5 classes because "someday different teams might own these pieces." But right now, one developer writes all the code, it all changes together, and there's only one actor. You're speculating about future team structure that may never materialize.

Why It's Bad: Premature splitting violates YAGNIYou Aren't Gonna Need It — a principle that says don't add functionality or complexity until you actually need it. Speculating about future requirements leads to over-engineering. (You Aren't Gonna Need It). Each split adds a class, an interface, a DI registration, and constructor parameters. For one developer working on one feature, that's pure overhead — more files to navigate, more abstractions to understand, zero benefit. You're paying the cost of SRP without getting its reward (isolated change).

The Fix: Split when you have evidence, not speculation. Evidence includes: merge conflicts between team members on the same file, unrelated changes in the same class (auth bug fix touches email code), or different change frequencies (auth changes weekly, reporting changes quarterly). When the pain arrives, that's your signal to split — and you'll know exactly where to draw the line because the real actors will be obvious.

When to split: evidence, not speculation Day 1 "Let me split just in case" TOO EARLY Month 3 Merge conflicts, different change rates EVIDENCE = SPLIT NOW Month 12 Never needed the split YAGNI confirmed
Six SRP pitfalls: (1) Nano-classes — don't split by method count; split by actor. (2) Layer splitting — split by who requests changes, not by technical layer. (3) Interface facade — splitting the interface without splitting the implementation solves nothing. (4) "One thing" confusion — multiple steps can be one responsibility if one actor owns them all. (5) Helper rename — moving code from UserService to UserHelper isn't refactoring, it's renaming. (6) Premature splitting — wait for evidence (merge conflicts, different change rates) before splitting; YAGNI applies to SRP too.
Section 14

Testing Strategies

Here's the dirty secret about SRP: its biggest payoff isn't in production code — it's in tests. A class with one responsibility is trivially testable. A class with three responsibilities is a testing nightmare. If you've ever spent 20 minutes just setting up mocks before writing the first assertion, you've felt the pain of an SRP violation. Let's see why.

Testing: God Class vs SRP Classes UserService (God Class) Test setup needs: Mock<IDb> Mock<IEmail> Mock<ILogger> Mock<ICache> Mock<IAuth> Mock<IConfig> Mock<IQueue> Mock<IReport> 8 mocks just to test login! SRP Classes AuthService 2 mocks: IDb, ITokenService EmailService 1 mock: ISmtpClient ReportService 2 mocks: IDb, IFormatter 1-2 mocks each, laser-focused tests

When a class has one responsibility, its tests are straightforward. You mock only its direct dependencies — typically one or two. Each test method covers one behavior. The test file is short, readable, and fast.

Contrast this with a God class: to test the login method, you also need to set up mocks for email, reporting, caching, and other unrelated dependencies. Half your test setup is irrelevant noise. When a test fails, you can't immediately tell whether it's a real bug or a mock configuration issue.

public class AuthServiceTests { private readonly Mock<IUserRepository> _userRepo = new(); private readonly Mock<ITokenService> _tokenService = new(); private readonly AuthService _sut; public AuthServiceTests() { // 2 mocks — that's it. No email, no reporting, no caching. _sut = new AuthService(_userRepo.Object, _tokenService.Object); } [Fact] public async Task Login_ValidCredentials_ReturnsToken() { // Arrange var user = new User { Email = "test@mail.com", PasswordHash = "hashed" }; _userRepo.Setup(r => r.GetByEmail("test@mail.com")).ReturnsAsync(user); _tokenService.Setup(t => t.Generate(user)).Returns("jwt-token-123"); // Act var result = await _sut.Login("test@mail.com", "password123"); // Assert — clean, focused, obvious Assert.True(result.Success); Assert.Equal("jwt-token-123", result.Token); } [Fact] public async Task Login_WrongPassword_ReturnsFailure() { _userRepo.Setup(r => r.GetByEmail("test@mail.com")).ReturnsAsync((User?)null); var result = await _sut.Login("test@mail.com", "wrong"); Assert.False(result.Success); Assert.Null(result.Token); } } public class UserServiceTests { // 8 mocks — most are irrelevant to the login test private readonly Mock<IUserRepository> _userRepo = new(); private readonly Mock<ITokenService> _tokenService = new(); private readonly Mock<IEmailSender> _emailSender = new(); // not needed for login private readonly Mock<IReportGenerator> _reportGen = new(); // not needed for login private readonly Mock<ICacheService> _cache = new(); // not needed for login private readonly Mock<ILogger<UserService>> _logger = new(); // not needed for login private readonly Mock<IConfiguration> _config = new(); // not needed for login private readonly Mock<IQueueService> _queue = new(); // not needed for login private readonly UserService _sut; public UserServiceTests() { // Constructor from hell — and you need this for EVERY test class _sut = new UserService( _userRepo.Object, _tokenService.Object, _emailSender.Object, _reportGen.Object, _cache.Object, _logger.Object, _config.Object, _queue.Object); } [Fact] public async Task Login_ValidCredentials_ReturnsToken() { // Same test, but buried under 6 irrelevant mocks // If any of those mocks change their interface, THIS test breaks // even though login has nothing to do with email or reports } }

SRP classes work together — and you need to verify that collaboration. Integration tests check that OrderService correctly calls PaymentService and InventoryService without getting tangled in implementation details.

The beauty of testing SRP classes is that each class is independently verified in unit tests. Integration tests only need to check the interactions — "did OrderService call PaymentService.Charge with the right amount?" — not the internal logic of each class.

public class OrderFlowIntegrationTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; public OrderFlowIntegrationTests(WebApplicationFactory<Program> factory) => _factory = factory; [Fact] public async Task PlaceOrder_ChargesPaymentAndReducesInventory() { // Arrange — use real DI container, mock only external services var client = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace only the payment gateway (external dependency) services.AddSingleton<IPaymentGateway>(new FakePaymentGateway()); }); }).CreateClient(); // Act — call the order endpoint; internally it coordinates // OrderService -> PaymentService -> InventoryService var response = await client.PostAsJsonAsync("/api/orders", new { ProductId = 1, Quantity = 2, PaymentMethod = "card" }); // Assert — verify the whole flow worked Assert.Equal(HttpStatusCode.Created, response.StatusCode); // Each SRP class did its job; the integration test verified the wiring } }

Your tests themselves can reveal SRP violations. Here are four heuristics that reliably indicate a class is doing too much:

  1. Mock count > 3: If your test constructor needs more than 3 mocks, the class has too many dependencies, which almost always means too many responsibilities.
  2. Test file longer than implementation: When your test file is 500 lines but the class is 200 lines, you're spending most of the test on setup for unrelated responsibilities.
  3. Unrelated test groups: If your test class has sections like "Login Tests," "Email Tests," "Report Tests" — those are three different responsibilities living in one class.
  4. "Fragile test" syndrome: If changing how email is sent causes login tests to fail, those two concerns are coupled in the implementation.
If you need more than 3 mocks in a test constructor, stop and ask: "Is this class doing too much?" In nearly every case, the answer is yes. Refactor the class first, then write the tests — you'll save time overall.

When you need to split a God class, the safest approach is: write tests first, then extract. The existing tests verify current behavior. After you extract into focused classes, the same tests confirm nothing broke. Only then do you rewrite the tests to match the new structure.

  1. Step 1 — Characterization tests: Write tests that capture the current behavior of the God class, even if the code is messy. These are your safety net.
  2. Step 2 — Extract one responsibility: Move one actor's methods into a new class. The old class delegates to the new one. Run the characterization tests — everything should still pass.
  3. Step 3 — Write focused tests: Now write proper unit tests for the extracted class with minimal mocks. Keep the old characterization tests until all responsibilities are extracted.
  4. Step 4 — Repeat: Extract the next responsibility. Run all tests. Continue until the God class is either empty (delete it) or reduced to a thin orchestrator.
// Step 1: Capture the God class's current behavior [Fact] public void PlaceOrder_CurrentBehavior_Snapshot() { // This test documents what the God class does TODAY // It's ugly, has 6 mocks, and tests too much at once // But it's your safety net during the refactor var sut = new OrderService( _repo.Object, _payment.Object, _inventory.Object, _email.Object, _logger.Object, _config.Object); var result = sut.PlaceOrder(testOrder); // Verify all side effects (payment charged, inventory reduced, email sent) _payment.Verify(p => p.Charge(It.IsAny<decimal>()), Times.Once); _inventory.Verify(i => i.Reduce(1, 2), Times.Once); _email.Verify(e => e.SendConfirmation(It.IsAny<Order>()), Times.Once); } // Step 3: After extracting PaymentService, write focused tests [Fact] public void PaymentService_Charge_ValidCard_Succeeds() { // Clean, focused, 1 mock — this is what SRP tests look like var gateway = new Mock<IPaymentGateway>(); gateway.Setup(g => g.Process(It.IsAny<PaymentRequest>())) .Returns(PaymentResult.Success); var sut = new PaymentService(gateway.Object); var result = sut.Charge(99.99m, "card_token_123"); Assert.True(result.IsSuccess); }
SRP's biggest payoff is testability. (1) Focused classes need 1-2 mocks vs 6-8 for God classes. (2) Integration tests verify class collaboration, not internal logic. (3) Use the 3-mock heuristic: more than 3 mocks means the class has too many responsibilities. (4) When refactoring a God class, write characterization tests first as a safety net, then extract one responsibility at a time.
Section 15

Performance Considerations

"But doesn't SRP mean more classes, more method calls, more overhead?" This is the most common pushback against SRP — and it's almost entirely wrong. Let's look at the actual numbers and understand why SRP rarely hurts performance and often helps it.

The fear sounds reasonable: more classes means more object allocations, more virtual dispatch, more DI resolution. But modern .NET is astonishingly good at handling this.

Let's look at the actual costs:

  • Object creation: Allocating a small object in .NET takes about 20 nanoseconds. That's 0.00002 milliseconds. Creating 50 SRP classes for a request adds ~1 microsecond of overhead — invisible compared to any database call (1-10ms) or HTTP request (50-200ms).
  • Method calls: The JIT compilerJust-In-Time compiler: .NET compiles your C# code to optimized machine code at runtime. It can inline small methods (remove the call overhead entirely), devirtualize known types, and optimize hot paths — all automatically. inlines small methods automatically. If your extracted method is under ~32 bytes of IL code, the JIT eliminates the call overhead entirely. Your SRP extraction literally costs zero at runtime.
  • DI resolution: The built-in .NET DI container caches singleton and scoped resolutions. The first resolution has a small cost (~0.001ms). Subsequent resolutions return the cached instance — basically a dictionary lookup.
SRP Overhead vs Real-World Costs SRP overhead (negligible) Object alloc: 0.00002ms JIT-inlined call: 0ms DI resolution: 0.001ms Real bottlenecks (1000x bigger) DB query: 5ms HTTP call: 100ms File I/O: 2ms SRP costs are 10,000x to 1,000,000x smaller than real bottlenecks

Ironically, SRP violations are worse for performance than SRP compliance. Here's why:

  • God classes load unnecessary dependencies: A UserService that handles auth, email, and reporting injects an SMTP client, a report generator, and a queue service — even when you only need to check a password. Those objects take memory and may trigger their own initialization (connection pools, configuration parsing).
  • You can't optimize what you can't isolate: If email sending is slow, you can't cache just the email part of a God class. You'd need to refactor it first (into SRP classes!) before you can add targeted caching. SRP makes optimization possible.
  • God classes can't be individually scaled: In a microservice or modular monolith architecture, SRP classes can be deployed independently. A reporting service that runs heavy queries can get its own resources. A God class forces you to scale everything together.

Here's a real benchmark comparing direct instantiation vs DI-resolved objects in .NET 8:

// BenchmarkDotNet results — .NET 8, x64, 100,000 iterations | Method | Mean | Allocated | |-----------------------|-----------|-----------| | Direct new() | 18.7 ns | 24 B | | DI Singleton resolve | 22.1 ns | 0 B | | DI Scoped resolve | 45.3 ns | 0 B | | DI Transient resolve | 67.8 ns | 48 B | // For context: a single SQL query takes 1,000,000+ ns (1ms+) // The difference between 18ns and 67ns is invisible in any real application // You'd need to resolve 15,000 services per request to add 1ms of overhead
SRP never makes things slower. But SRP violations make things harder to optimize — because you can't optimize one piece without touching everything else. Worry about database queries, network calls, and serialization. Never worry about having "too many classes." SRP has zero meaningful performance cost: object allocation takes 20ns, JIT inlines small methods, DI caches resolutions. The real performance problem is SRP violations — God classes load unnecessary dependencies, prevent targeted optimization, and can't be individually scaled. The benchmark: DI resolution adds ~50ns per service, while a single database query takes 1,000,000+ns. Worry about I/O, never about class count.
Section 16

How to Explain SRP in an Interview

SRP comes up in almost every SOLID interview question. The trick is sounding like you understand the principle, not like you memorized a textbook definition. Here's how to nail it at different time lengths.

Drop these naturally: "reason to change," "actor" (not just "thing"), "cohesion," "DI enables SRP." Interviewers listen for these — they signal you understand SRP beyond the surface level.

"SRP says a class should have only one reason to change — meaning only one actor or stakeholder should be able to demand changes to that class. It's about isolating change, not about limiting method count."

"If a UserService handles authentication, email notifications, and usage reporting, three different teams can force changes to the same file. The auth team changes password hashing, the marketing team changes email templates, the analytics team changes reporting queries — all in one class. That causes merge conflicts, accidental side effects, and painful testing. SRP says extract into AuthService, EmailService, ReportService — each owned by one team, wired together via dependency injection."

Define it correctly: "SRP states that a class should be responsible to one, and only one, actor. Robert Martin refined the original 'one reason to change' phrasing to emphasize actors — it's about who requests changes, not how many methods exist."

Give a concrete example: "The classic violation is an Employee class with CalculatePay and GenerateReport. The CFO's team owns pay calculations, HR owns report formatting. If accounting changes the pay formula, a shared utility method could accidentally break HR's reports. Separate classes mean separate blast radii."

Show modern .NET awareness: "In ASP.NET Core, the middleware pipeline is SRP in action — each middleware handles one cross-cutting concern. MediatR takes it further: one handler per use case, wired through DI. The pattern scales from methods to microservices."

Show practical judgment: "I apply SRP when I feel the pain — merge conflicts on the same file, tests that need 5+ mocks, or a change in one feature breaking another. I don't split prematurely because YAGNI applies to design principles too."

Close strong: "SRP isn't about class size — a 300-line class can be SRP-compliant if it serves one actor. It's about isolating change so modifications are local, tests are focused, and teams don't block each other."

Interviewers love to push back after your SRP answer. Here are the three most common follow-ups and how to handle them:

  • "Doesn't SRP create too many classes?" — "More files, but each is smaller and independently testable. IDE navigation makes file count irrelevant. I'd rather have 20 files I understand than 1 file nobody dares touch."
  • "How do you know when to split?" — "I use the actor test: if I changed this class, who would I need to notify? If it's two different teams, it's time to split. I also watch mock count in tests — more than 3 mocks means the class is doing too much."
  • "What's the relationship between SRP and DI?" — "DI is SRP's best friend. Without DI, splitting a class means manually wiring 5 constructors — painful. With .NET's DI container, you register each class and the container handles wiring. SRP becomes practically free."
For SRP interviews, have three versions ready: 10 seconds (definition + "actor" framing), 30 seconds (UserService example with three teams), 90 seconds (full answer with .NET specifics and practical judgment). Key phrases: "reason to change," "actor," "cohesion," "DI enables SRP." Anticipate follow-ups about class count, when to split, and the SRP/DI relationship.
Section 17

Interview Q&As

Each question has a detailed answer with the reasoning chain — not just what to say, but why that answer works. Key questions include SVG diagrams to help you visualize the concepts.

The strong answer: SRP says a class should have one, and only one, reason to change. But "reason to change" doesn't mean "thing it does" — it means actor. An actor is a group of stakeholders who want the same kind of changes. Think of it like a restaurant: the head chef is responsible to the kitchen manager. If the head chef also did accounting, they'd be responsible to two actors — the kitchen manager AND the CFO. A change in tax law would force the chef to change, even though cooking hasn't changed.

A class can have many methods and still be SRP-compliant, as long as all those methods serve the same actor. A TaxCalculator with CalculateSalesTax(), CalculateVAT(), and CalculateGST() is fine — one actor (finance team), one reason to change (tax rules).

Restaurant Analogy: One Boss vs Three Bosses SRP: Chef reports to Kitchen Manager Kitchen Manager (one actor) Chef Violation: Chef reports to 3 bosses Kitchen Mgr CFO HR Chef 3 reasons to change!

The strong answer: The classic .NET violation is a controller that does too much. Imagine an AdminController with 15 endpoints: user management, product CRUD, order processing, report generation, and system settings. Five different teams need to modify the same file. A change to product validation risks breaking the order endpoints because they share private helper methods.

The fix: split into UserAdminController, ProductController, OrderController, ReportController, and SettingsController. Each controller is thin — it delegates to a focused service. The product team can deploy changes without touching the order team's code.

Bonus point: Mention that MediatR takes this further — each endpoint becomes a separate handler class. One request, one handler, one file. Maximum SRP compliance.

Five reliable signs:

  1. Multiple actors: Ask "who would request changes to this class?" If the answer includes two or more teams (e.g., product AND infrastructure), it's a violation.
  2. Merge conflicts: Two developers on different features keep conflicting on the same file. That file serves multiple actors.
  3. Large test setup: Your test constructor needs 5+ mocks. Each mock represents a dependency — too many dependencies means too many responsibilities.
  4. Unrelated private methods: The class has helper methods that serve completely different public methods. A FormatCurrency() helper used only by GenerateReport() sitting next to a HashPassword() helper used only by Login() — those are two different concerns sharing a class.
  5. "And" in the description: If you describe the class as "it handles authentication and email and reporting," each "and" is a candidate for extraction.
5 Signs of SRP Violation Multiple Actors "Who requests changes?" 2+ teams = split Merge Conflicts 2 devs, different features, same file = shared actors 5+ Mocks Test setup is longer than the actual test Unrelated Privates FormatCurrency() next to HashPassword() "And" Test "It handles auth AND email AND reporting"

The strong answer: "One thing" is a Unix philosophy — each program should do one thing well. SRP is about actors, not actions. A PaymentProcessor that validates, charges, receipts, and logs is doing "four things" by the Unix definition. But if the payments team owns all four steps and they change together, it's one responsibility by SRP's definition.

The confusion comes from the word "single" — people hear "single" and think "one method" or "one action." Robert Martin specifically warned against this interpretation. The litmus test is: "How many different groups of people can force this class to change?" If the answer is one, it's SRP-compliant regardless of method count.

When "one thing" and SRP diverge: Consider a ReportGenerator that queries data, formats it, and exports to PDF. If the data team owns queries, the design team owns formatting, and the compliance team owns PDF export — that's three actors, three responsibilities. The "one thing" (generating a report) is actually three SRP responsibilities because three different groups request different kinds of changes.

The strong answer: Dependency Injection is the mechanism that makes SRP practical. Without DI, splitting a God class into five focused classes means the calling code must manually create and wire all five objects — tedious and error-prone. With DI, you register each class once in Program.cs, and the container handles all the wiring automatically.

In .NET, this looks like:

// SRP + DI: register focused services, let the container wire them builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IEmailService, EmailService>(); builder.Services.AddScoped<IReportService, ReportService>(); // The controller just declares what it needs — no manual wiring public class UserController : ControllerBase { public UserController( IAuthService auth, // injected automatically IEmailService email, // injected automatically IReportService reports) // injected automatically { } }

Without DI, this becomes painful constructor chains where each class manually creates its dependencies. DI removes the friction, so SRP becomes a "free" design decision — you pay zero wiring cost.

The strong answer: Absolutely. Over-applying SRP leads to what I call the "nano-class anti-pattern" — dozens of tiny classes with one method each, all serving the same actor. The codebase becomes a maze of indirection where understanding a feature requires opening 15 files. The symptoms: you can't understand what the system does without drawing a call graph, new developers take weeks to onboard, and every change touches 10 files.

The balance: SRP says "one actor," not "one method." If five methods serve the same stakeholder, they belong in one class. The sweet spot is classes of 50-300 lines with 3-7 public methods, each serving a clear actor. If you find yourself creating UserNameValidator, UserEmailValidator, and UserAgeValidator when one UserValidator would serve the same team — you've gone too far.

The strong answer: SRP is about implementation — the class itself should have one reason to change. ISP (Interface Segregation Principle) is about contracts — clients shouldn't be forced to depend on methods they don't use.

They often work together but address different problems. You can have an SRP-compliant class that violates ISP: a UserProfileService class with one actor (profile team) but a fat interface that forces the read-only dashboard to depend on write methods it never calls.

Conversely, you can have perfect ISP (small, focused interfaces) but violate SRP: the implementation class behind those interfaces still handles three actors' concerns. ISP splits the contract; SRP splits the implementation.

The step-by-step strategy:

  1. Identify actors: Read every public method and ask "which team/stakeholder requests changes to this?" Group methods by actor. You'll typically find 2-4 groups.
  2. Write characterization tests: Before touching the code, write tests that capture current behavior. These are your safety net — they'll catch regressions during the refactor.
  3. Extract one group at a time: Start with the most independent group (fewest shared private methods). Create a new class, move the methods, update the God class to delegate. Run tests.
  4. Extract shared state: If two groups share a private method, either duplicate it (if small) or extract it into a shared utility/value object that both new classes use.
  5. Update DI registration: Register the new class in Program.cs. Update callers to inject the new class directly instead of going through the God class.
  6. Repeat: Extract the next group. Run tests. Continue until the God class is either empty (delete it) or a thin orchestrator.
God Class Refactoring Pipeline 1. Identify Group methods by actor 2. Test First Characterization tests = safety net 3. Extract One actor group at a time 4. Wire DI Register new services 5. Repeat Until God class is empty or thin Run tests after EVERY extraction step

The strong answer: Yes, but with a different focus. For classes, SRP is about actors — who requests changes. For methods, SRP is more about cohesion — a method should do one logical operation at one level of abstraction.

A method like ProcessOrder() that validates input, charges payment, updates inventory, sends confirmation email, and logs the transaction is doing too much. Not because of SRP's "actor" definition, but because it's mixing abstraction levels — high-level orchestration with low-level SMTP calls. The fix: extract each step into its own method, and have ProcessOrder() read like a recipe: validate, charge, update, notify, log.

But don't confuse this with class-level SRP. A class with five well-named methods can be SRP-compliant even if each method is complex internally — as long as all five serve the same actor.

The strong answer: SRP and KISS (Keep It Simple, Stupid) seem to conflict — SRP pushes for more classes, KISS pushes for fewer moving parts. The resolution is timing.

Start simple (KISS wins): When building a new feature, keep it in one class. One file, one class, easy to understand. Don't pre-split based on speculation about future actors.

Split when evidence arrives (SRP wins): When you see the pain — merge conflicts, unrelated changes in the same file, tests needing 5+ mocks — that's your signal. Now splitting actually reduces complexity because the existing structure is actively causing problems.

The framework: KISS governs your starting point. SRP governs your refactoring trigger. Together they prevent both over-engineering (premature SRP) and under-engineering (ignoring growing complexity). The best codebases are ones that were simple at first and evolved their structure in response to real pressures, not imagined ones.

Ten SRP interview Q&As covering: (1) definition with actor framing and restaurant analogy, (2) real C# violation (God controller), (3) five detection signs (multiple actors, merge conflicts, mock count, unrelated privates, "and" test), (4) SRP vs "one thing" distinction, (5) DI as SRP enabler, (6) over-application danger (nano-classes), (7) SRP vs ISP, (8) God class refactoring pipeline, (9) SRP for methods (cohesion focus), (10) SRP + KISS balance (start simple, split on evidence).
Section 18

Practice Exercises

Reading about SRP is one thing. Applying it to real code is another. These exercises go from straightforward identification to messy real-world refactoring. Try each one before peeking at the hints — the struggle is where the learning happens.

This CustomerService class has four methods. Your job: identify which methods belong to different actors and explain how you'd split them into SRP-compliant classes.

public class CustomerService { private readonly AppDbContext _db; private readonly ISmtpClient _smtp; private readonly IReportEngine _reports; private readonly ILogger _logger; public Customer CreateCustomer(CreateCustomerDto dto) { var customer = new Customer { Name = dto.Name, Email = dto.Email }; _db.Customers.Add(customer); _db.SaveChanges(); return customer; } public void SendWelcomeEmail(int customerId) { var customer = _db.Customers.Find(customerId); _smtp.Send(customer.Email, "Welcome!", $"Hi {customer.Name}, welcome aboard!"); } public Invoice GenerateInvoice(int customerId, decimal amount) { var customer = _db.Customers.Find(customerId); return _reports.CreateInvoice(customer, amount); } public void LogActivity(int customerId, string action) { _logger.Information("Customer {Id} performed {Action}", customerId, action); } }

Actor 1 — Customer/Product Team: CreateCustomer — changes when the onboarding flow changes.

Actor 2 — Marketing/Communications Team: SendWelcomeEmail — changes when email templates or messaging strategy changes.

Actor 3 — Finance/Billing Team: GenerateInvoice — changes when invoicing rules or formats change.

Actor 4 — Infrastructure/DevOps Team: LogActivity — changes when logging infrastructure changes. (Though in practice, logging is often handled via a cross-cutting concern like middleware, not a dedicated method.)

Refactoring: Extract CustomerRegistrationService, CustomerEmailService, InvoiceService. The logging can stay as a cross-cutting concern injected via ILogger in each service — it doesn't need its own class.

This OrderController has 12 endpoints spanning three domains. Refactor it into SRP-compliant controllers.

[ApiController] [Route("api/[controller]")] public class OrderController : ControllerBase { // Order endpoints [HttpPost] public IActionResult CreateOrder(OrderDto dto) { /* ... */ } [HttpGet("{id}")] public IActionResult GetOrder(int id) { /* ... */ } [HttpPut("{id}")] public IActionResult UpdateOrder(int id, OrderDto dto) { /* ... */ } [HttpDelete("{id}")] public IActionResult CancelOrder(int id) { /* ... */ } // Payment endpoints (different actor: payments team) [HttpPost("{id}/pay")] public IActionResult ProcessPayment(int id, PaymentDto dto) { /* ... */ } [HttpGet("{id}/payment-status")] public IActionResult GetPaymentStatus(int id) { /* ... */ } [HttpPost("{id}/refund")] public IActionResult RefundPayment(int id) { /* ... */ } [HttpGet("payments/report")] public IActionResult PaymentReport(DateTime from, DateTime to) { /* ... */ } // Shipping endpoints (different actor: logistics team) [HttpPost("{id}/ship")] public IActionResult CreateShipment(int id, ShipmentDto dto) { /* ... */ } [HttpGet("{id}/tracking")] public IActionResult TrackShipment(int id) { /* ... */ } [HttpPut("{id}/delivery")] public IActionResult ConfirmDelivery(int id) { /* ... */ } [HttpGet("shipping/report")] public IActionResult ShippingReport(DateTime from, DateTime to) { /* ... */ } }

Split by actor:

OrdersController (route: api/orders) — CreateOrder, GetOrder, UpdateOrder, CancelOrder. Actor: Order/Product team.

PaymentsController (route: api/payments) — ProcessPayment, GetPaymentStatus, RefundPayment, PaymentReport. Actor: Payments team.

ShippingController (route: api/shipping) — CreateShipment, TrackShipment, ConfirmDelivery, ShippingReport. Actor: Logistics team.

Each controller injects its own focused service (e.g., IPaymentService), keeping the controllers thin and each responsible to one team.

A FileProcessor class reads CSV files, validates data, transforms rows, and writes to a database. Is this an SRP violation? This is a trick question — justify your answer carefully.

public class FileProcessor { public async Task ProcessFile(string csvPath) { var rows = ReadCsv(csvPath); // Step 1: Read var valid = Validate(rows); // Step 2: Validate var transformed = Transform(valid); // Step 3: Transform await WriteToDB(transformed); // Step 4: Write } private List<RawRow> ReadCsv(string path) { /* ... */ } private List<RawRow> Validate(List<RawRow> rows) { /* ... */ } private List<CleanRow> Transform(List<RawRow> rows) { /* ... */ } private Task WriteToDB(List<CleanRow> rows) { /* ... */ } }

If one data team owns the entire pipeline (read, validate, transform, write) and all four steps change together when the data format changes — this is SRP-compliant. One actor, one reason to change. The four steps are sub-tasks of one responsibility: "process incoming data files."

If different teams own different steps — say, the data engineering team owns CSV parsing, the data quality team owns validation rules, the analytics team owns transformations, and the platform team owns database writes — then it IS an SRP violation. Each step should be its own class.

The lesson: You can't determine SRP compliance from code structure alone. You need to know the organizational context — who requests changes to each piece? This is why SRP is about actors, not about code patterns.

Refactor this 200-line NotificationManager into SRP-compliant classes. Show the new class structure and the DI registration in Program.cs.

public class NotificationManager { private readonly ISmtpClient _smtp; private readonly ISmsGateway _sms; private readonly IPushService _push; private readonly IUserRepository _users; private readonly IPreferenceStore _prefs; // Email notifications public Task SendEmail(int userId, string subject, string body) { /* ... */ } public Task SendBulkEmail(List<int> userIds, string subject, string body) { /* ... */ } public string RenderEmailTemplate(string template, Dictionary<string, object> data) { /* ... */ } // SMS notifications public Task SendSms(int userId, string message) { /* ... */ } public Task SendBulkSms(List<int> userIds, string message) { /* ... */ } // Push notifications public Task SendPush(int userId, string title, string body) { /* ... */ } public Task SendBulkPush(List<int> userIds, string title, string body) { /* ... */ } // Notification preferences public NotificationPrefs GetPreferences(int userId) { /* ... */ } public void UpdatePreferences(int userId, NotificationPrefs prefs) { /* ... */ } public bool ShouldNotify(int userId, string channel) { /* ... */ } }

Actors: Email team (templates, SMTP config), SMS team (gateway contracts, character limits), Mobile team (push tokens, device management), Product team (preference UX, opt-in/opt-out rules).

Classes:

  • EmailNotificationService : IEmailNotificationService — Send, SendBulk, RenderTemplate. Depends on: ISmtpClient, IUserRepository.
  • SmsNotificationService : ISmsNotificationService — Send, SendBulk. Depends on: ISmsGateway, IUserRepository.
  • PushNotificationService : IPushNotificationService — Send, SendBulk. Depends on: IPushService, IUserRepository.
  • NotificationPreferenceService : INotificationPreferenceService — GetPreferences, UpdatePreferences, ShouldNotify. Depends on: IPreferenceStore.

Program.cs:

builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>(); builder.Services.AddScoped<ISmsNotificationService, SmsNotificationService>(); builder.Services.AddScoped<IPushNotificationService, PushNotificationService>(); builder.Services.AddScoped<INotificationPreferenceService, NotificationPreferenceService>(); // Optional: a coordinator that checks preferences before dispatching builder.Services.AddScoped<INotificationDispatcher, NotificationDispatcher>();

You inherit a 3000-line ApplicationService.cs that handles user authentication, product catalog management, order processing, payment, and shipping. Design a complete refactoring plan: identify actors, define the new class/interface structure, draw the dependency graph, and show the DI registration. You don't need to write every method — just the architecture.

public class ApplicationService // 3000 lines, 15 injected dependencies { // Auth (Actor: Security team) — ~400 lines public AuthResult Login(string email, string password) { /* ... */ } public void Register(RegisterDto dto) { /* ... */ } public void ResetPassword(string email) { /* ... */ } public void EnableTwoFactor(int userId) { /* ... */ } // Product Catalog (Actor: Product team) — ~600 lines public Product CreateProduct(ProductDto dto) { /* ... */ } public Product UpdateProduct(int id, ProductDto dto) { /* ... */ } public List<Product> Search(string query, ProductFilter filter) { /* ... */ } public void UpdateInventory(int productId, int quantity) { /* ... */ } // Order Processing (Actor: Operations team) — ~800 lines public Order PlaceOrder(OrderDto dto) { /* ... */ } public Order UpdateOrderStatus(int orderId, OrderStatus status) { /* ... */ } public List<Order> GetOrderHistory(int userId) { /* ... */ } public void CancelOrder(int orderId) { /* ... */ } // Payment (Actor: Finance team) — ~700 lines public PaymentResult ChargeCard(int orderId, CardDto card) { /* ... */ } public PaymentResult RefundOrder(int orderId) { /* ... */ } public FinancialReport GenerateFinancialReport(DateRange range) { /* ... */ } // Shipping (Actor: Logistics team) — ~500 lines public Shipment CreateShipment(int orderId) { /* ... */ } public TrackingInfo TrackShipment(string trackingNumber) { /* ... */ } public void ConfirmDelivery(int orderId) { /* ... */ } }

Step 1 — Identify actors (5 actors, 5 services):

  • IAuthService / AuthService — Security team. Dependencies: IUserRepository, ITokenService, ITwoFactorProvider.
  • ICatalogService / CatalogService — Product team. Dependencies: IProductRepository, ISearchEngine, IInventoryStore.
  • IOrderService / OrderService — Operations team. Dependencies: IOrderRepository, IPaymentService, IInventoryStore.
  • IPaymentService / PaymentService — Finance team. Dependencies: IPaymentGateway, IOrderRepository, IReportEngine.
  • IShippingService / ShippingService — Logistics team. Dependencies: IShippingProvider, IOrderRepository.

Step 2 — Order of extraction: Start with AuthService (most independent — doesn't call other domain services). Then CatalogService. Then PaymentService. Then ShippingService. OrderService last because it orchestrates the others.

Step 3 — Dependency graph: OrderService depends on IPaymentService and IShippingService (it calls them during order placement). All others are independent. This means OrderService is the orchestrator — it coordinates, but each service handles its own domain logic.

Step 4 — DI registration:

builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<ICatalogService, CatalogService>(); builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddScoped<IPaymentService, PaymentService>(); builder.Services.AddScoped<IShippingService, ShippingService>(); // The 3000-line ApplicationService is deleted // 15 dependencies become 2-3 per service // Each service: 200-400 focused lines
Five SRP exercises: (1) Spot 4 actors in CustomerService and extract per-actor classes. (2) Split a 12-endpoint fat controller into 3 domain controllers. (3) Trick question — FileProcessor may be SRP-compliant if one team owns the whole pipeline. (4) Refactor NotificationManager into email/SMS/push/preference services with DI registration. (5) Full refactoring plan for a 3000-line God class: identify 5 actors, define interfaces, plan extraction order, wire DI.
Section 19

Cheat Sheet

Everything you need to remember about SRP on one screen. Pin it, screenshot it, tattoo it on your forearm — whatever works.

  • A class should have only one reason to change
  • That reason = one actor (team/person) who requests changes
  • Uncle Bob's formulation, not "do one thing"
  • Ask: "Who would ask me to change this class?"
  • If more than one team/role answers → SRP violation
  • Sales team + Finance team = two actors = split it
  • 500+ lines of code
  • 10+ constructor dependencies
  • "and" in the class description
  • Frequent merge conflicts on the same file
  • 1. Identify all actors touching the class
  • 2. Group methods by actor
  • 3. Extract one class per actor
  • 4. Wire extracted classes with DI
  • PaymentProcessor doing validate + charge + receipt
  • All three serve the payments team
  • That's one actor → SRP is satisfied
  • Don't create UserNameValidator, UserEmailValidator...
  • If the same actor owns all validation → keep together
  • Split by actor, not by method count
  • services.AddScoped<IAuthService, AuthService>()
  • Constructor injection makes extraction trivial
  • Swap implementations without touching callers
  • If you need 5+ mocks to test a class → suspect SRP violation
  • Each mock = one dependency = one potential actor
  • Focused classes need 1-2 mocks max
  • Different actors with similar code? Keep separate!
  • Shared code = shared coupling between actors
  • Duplication is cheaper than wrong abstraction
  • SRP violations cause merge conflicts (multiple teams, one file)
  • Side-effect bugs (change for Actor A breaks Actor B)
  • Untestable code (too many dependencies to mock)
  • SRP boils down to one question: "Who would ask me to change this class?" If two different teams would, split it. Watch for God class signs (500+ lines, 10+ deps, merge conflicts), use DI to wire extracted classes, and remember: SRP is about actors, not method count.
    Section 20

    Deep Dive — Cohesion and Coupling

    SRP tells you what to do: keep one reason to change per class. But how do you measure whether you're doing it right? That's where two fundamental ideas come in — cohesion and coupling. Think of them as the speedometer and fuel gauge of your class design.

    Cohesion measures how strongly the methods inside a class belong together. If every method works toward the same goal for the same actor, cohesion is high. If methods are thrown together because "they were needed somewhere," cohesion is low.

    Coupling measures how much one class depends on another. If changing class A forces you to change class B, they're tightly coupled. The goal is always: high cohesion inside each class, low coupling between classes.

    Cohesion Spectrum WORST BEST Coincidental Methods in the same class for NO reason at all. e.g., Utils, Helpers Temporal Methods that run at the same TIME (startup, cleanup). e.g., AppInitializer Functional Methods that work together on ONE task for ONE actor. e.g., InvoiceCalculator SRP = Functional Cohesion When every method in a class serves the same actor and purpose, you've achieved functional cohesion — and SRP is naturally satisfied.

    Cohesion Types (Worst to Best)

    Coincidental cohesion is the worst kind. Methods live in the same class purely by accident — they don't share data, they don't share purpose, they don't share an actor. The classic example is the dreaded Utils or Helpers class. It's a junk drawer: string formatting sits next to date parsing sits next to file I/O. The only thing these methods have in common is that someone needed to put them somewhere.

    Temporal cohesion is slightly better but still problematic. Methods are grouped because they run at the same time — like a startup initialization class that configures logging, seeds the database, and warms the cache. These are three different actors (DevOps, DBA, infrastructure team) forced into one class just because they all happen during startup.

    Functional cohesion is the gold standard. Every method in the class works toward the same goal for the same actor. An InvoiceCalculator that computes subtotals, applies discounts, and calculates tax — all for the finance team — is functionally cohesive. This is what SRP looks like when measured.

    Coupling Types

    Tight Coupling (God Class) OrderService IEmailSender IPayment IInventory IAnalytics Change one → ripples everywhere High fan-out, content coupling Loose Coupling (SRP Classes) OrderOrchestrator PaymentSvc InventorySvc EmailSvc AnalyticsSvc Each class: 1 dependency, 1 actor Low fan-out, data coupling only

    Content coupling is the worst. It happens when one class directly accesses or modifies another class's internal fields. Imagine OrderService reaching into PaymentGateway._httpClient to set a timeout. Now any change to PaymentGateway's internals breaks OrderService. This is a maintenance nightmare.

    Data coupling is the best you can achieve. Classes share only the data they need, through method parameters or clean interfaces. PaymentService.ChargeAsync(decimal amount, string currency) — it takes exactly what it needs, nothing more. The caller doesn't know or care how PaymentService works internally.

    How to Measure

    You don't need gut feeling — there are concrete numbers you can check. Count a class's fan-out (how many other classes it depends on) and its fan-in (how many other classes depend on it). A class with high fan-out AND high fan-in is a coupling magnet — it likely has multiple responsibilities.

    // Quick heuristic: count constructor parameters public class OrderService( // Fan-out = 6 (RED FLAG!) IOrderRepository repo, IPaymentGateway payment, IEmailSender email, IInventoryService inventory, IAnalyticsService analytics, ILogger<OrderService> logger) { // This class is coupled to 6 different concerns. // If 4 different teams own those 6 services, // that's 4 actors = SRP violation. } // After SRP extraction: each class has 1-2 deps public class PaymentProcessor( // Fan-out = 2 (healthy) IPaymentGateway gateway, ILogger<PaymentProcessor> logger) { // Only the payments team drives changes here. } SRP is the principle. Cohesion is the measurement. When you achieve high functional cohesion inside each class and low data coupling between them, SRP is naturally satisfied — and your codebase becomes a joy to maintain. Cohesion measures how well methods belong together (aim for functional cohesion). Coupling measures how much classes depend on each other (aim for data coupling). High fan-out + high fan-in = likely SRP violation. Count constructor parameters as a quick heuristic: 5+ dependencies means the class probably serves multiple actors.
    Section 21

    Real-World Mini-Project

    Let's walk through a realistic scenario: building an Order Processing System. We'll start with the classic monolithic approach (the way most codebases begin), identify the SRP problems, and refactor step by step. By the end, you'll see the exact mechanics of extracting responsibilities.

    Stage 1: Monolith OrderProcessor ValidateOrder() ChargePayment() ReserveInventory() SendConfirmation() UpdateAnalytics() 5 actors, 1 class Identify actors Stage 2: SRP Services OrderOrchestrator OrderValidator ← Sales team PaymentService ← Finance team InventoryService ← Warehouse team NotificationService ← Marketing team Wire with DI Program.cs AddScoped<IOrderValidator> AddScoped<IPaymentService> AddScoped<IInventoryService> AddScoped<INotificationSvc> AddScoped<OrderOrchestrator> Clean, testable, independent

    Step 1: The Monolith (Where Most Projects Start)

    Here's the God class. It does everything: validates orders, charges payments, updates inventory, sends emails, and tracks analytics. It works... until two teams need to change it at the same time.

    // The classic God class — 5 responsibilities, 5 actors, 1 file public class OrderProcessor( AppDbContext db, IPaymentGateway paymentGateway, IEmailSender emailSender, IInventoryApi inventoryApi, IAnalyticsClient analytics, ILogger<OrderProcessor> logger) { public async Task<OrderResult> ProcessAsync(Order order) { // Responsibility 1: Validation (Sales team) if (order.Items.Count == 0) throw new ValidationException("Order must have items"); if (order.Items.Any(i => i.Quantity < 1)) throw new ValidationException("Invalid quantity"); // Responsibility 2: Payment (Finance team) var charge = await paymentGateway.ChargeAsync( order.CustomerId, order.Total); if (!charge.Success) return OrderResult.PaymentFailed(charge.Error); // Responsibility 3: Inventory (Warehouse team) foreach (var item in order.Items) { var reserved = await inventoryApi.ReserveAsync( item.ProductId, item.Quantity); if (!reserved) { await paymentGateway.RefundAsync(charge.Id); return OrderResult.OutOfStock(item.ProductId); } } // Responsibility 4: Persistence (DBA / backend team) order.Status = OrderStatus.Confirmed; order.ChargeId = charge.Id; db.Orders.Add(order); await db.SaveChangesAsync(); // Responsibility 5: Notification (Marketing team) await emailSender.SendAsync(order.CustomerEmail, "Order Confirmed", $"Order #{order.Id} is on its way!"); // Responsibility 6: Analytics (Data/Product team) analytics.Track("order.completed", new { orderId = order.Id, total = order.Total }); return OrderResult.Success(order.Id); } } // Problem 1: WHO changes this class? // - Sales team changes validation rules // - Finance team changes payment flow // - Warehouse team changes inventory logic // - Marketing team changes email templates // - Data team changes analytics events // That's 5+ actors in one file! // Problem 2: Merge conflicts // When Sales adds a new validation rule at the same time // Finance changes the refund logic, they collide in the // same file — even though their changes are unrelated. // Problem 3: Testing requires 6 mocks // To test validation logic, you must mock: // - AppDbContext, IPaymentGateway, IEmailSender, // IInventoryApi, IAnalyticsClient, ILogger // Most of those are irrelevant to validation! // Problem 4: Side-effect bugs // Finance adds retry logic to ChargeAsync. // That retry accidentally calls ReserveAsync twice. // Warehouse sees double reservations. // Root cause? Unrelated code sharing the same class.

    Step 2: Identify Actors and Extract

    We ask the SRP question for each block of code: "Who would request this change?" Then we extract each responsibility into its own class with its own interface.

    // Actor: Sales team — they own validation rules public interface IOrderValidator { Task<ValidationResult> ValidateAsync(Order order); } public class OrderValidator : IOrderValidator { public Task<ValidationResult> ValidateAsync(Order order) { var errors = new List<string>(); if (order.Items.Count == 0) errors.Add("Order must have at least one item"); if (order.Items.Any(i => i.Quantity < 1)) errors.Add("All items must have quantity >= 1"); if (order.Total <= 0) errors.Add("Order total must be positive"); return Task.FromResult(errors.Count == 0 ? ValidationResult.Ok() : ValidationResult.Fail(errors)); } } // Actor: Finance team — they own payment processing public interface IPaymentService { Task<ChargeResult> ChargeAsync(Order order); Task RefundAsync(string chargeId); } public class PaymentService( IPaymentGateway gateway, ILogger<PaymentService> logger) : IPaymentService { public async Task<ChargeResult> ChargeAsync(Order order) { logger.LogInformation("Charging {Total} for order", order.Total); return await gateway.ChargeAsync( order.CustomerId, order.Total); } public async Task RefundAsync(string chargeId) { logger.LogInformation("Refunding charge {ChargeId}", chargeId); await gateway.RefundAsync(chargeId); } } // Actor: Warehouse team — they own stock management public interface IInventoryService { Task<bool> ReserveAllAsync(IReadOnlyList<OrderItem> items); } public class InventoryService( IInventoryApi api, ILogger<InventoryService> logger) : IInventoryService { public async Task<bool> ReserveAllAsync( IReadOnlyList<OrderItem> items) { foreach (var item in items) { var reserved = await api.ReserveAsync( item.ProductId, item.Quantity); if (!reserved) { logger.LogWarning("Out of stock: {ProductId}", item.ProductId); return false; } } return true; } } // Actor: Marketing team — they own customer communications public interface INotificationService { Task SendOrderConfirmationAsync(Order order); } public class NotificationService( IEmailSender emailSender) : INotificationService { public async Task SendOrderConfirmationAsync(Order order) { await emailSender.SendAsync( order.CustomerEmail, "Order Confirmed", $"Your order #{order.Id} has been confirmed!"); } }

    Step 3: The Orchestrator + DI Wiring

    The original God class becomes a thin orchestrator. It doesn't do anything itself — it just coordinates the focused services in the right order. And the DI container in Program.cs wires everything together.

    // The orchestrator: coordinates, doesn't implement public class OrderOrchestrator( IOrderValidator validator, IPaymentService payment, IInventoryService inventory, INotificationService notifications, AppDbContext db) { public async Task<OrderResult> ProcessAsync(Order order) { // Step 1: Validate var validation = await validator.ValidateAsync(order); if (!validation.IsValid) return OrderResult.Invalid(validation.Errors); // Step 2: Charge var charge = await payment.ChargeAsync(order); if (!charge.Success) return OrderResult.PaymentFailed(charge.Error); // Step 3: Reserve inventory if (!await inventory.ReserveAllAsync(order.Items)) { await payment.RefundAsync(charge.Id); return OrderResult.OutOfStock(); } // Step 4: Persist order.Status = OrderStatus.Confirmed; order.ChargeId = charge.Id; db.Orders.Add(order); await db.SaveChangesAsync(); // Step 5: Notify (fire-and-forget is OK here) _ = notifications.SendOrderConfirmationAsync(order); return OrderResult.Success(order.Id); } } var builder = WebApplication.CreateBuilder(args); // Each service: one actor, one reason to change builder.Services.AddScoped<IOrderValidator, OrderValidator>(); builder.Services.AddScoped<IPaymentService, PaymentService>(); builder.Services.AddScoped<IInventoryService, InventoryService>(); builder.Services.AddScoped<INotificationService, NotificationService>(); // The orchestrator just coordinates — no business logic builder.Services.AddScoped<OrderOrchestrator>(); // External dependencies builder.Services.AddScoped<IPaymentGateway, StripeGateway>(); builder.Services.AddScoped<IEmailSender, SendGridSender>(); builder.Services.AddScoped<IInventoryApi, WarehouseApi>(); var app = builder.Build(); app.Run(); // Testing validation: ZERO payment/email/inventory mocks needed! public class OrderValidatorTests { private readonly OrderValidator _sut = new(); [Fact] public async Task EmptyOrder_ReturnsValidationError() { var order = new Order { Items = [] }; var result = await _sut.ValidateAsync(order); Assert.False(result.IsValid); Assert.Contains("at least one item", result.Errors[0]); } [Fact] public async Task ValidOrder_ReturnsOk() { var order = new Order { Items = [new OrderItem("SKU-1", 2, 19.99m)], Total = 39.98m }; var result = await _sut.ValidateAsync(order); Assert.True(result.IsValid); } } // Testing payments: only need IPaymentGateway mock public class PaymentServiceTests { [Fact] public async Task ChargeAsync_DelegatesToGateway() { var gateway = Substitute.For<IPaymentGateway>(); gateway.ChargeAsync("cust-1", 50m) .Returns(ChargeResult.Ok("ch_123")); var sut = new PaymentService( gateway, NullLogger<PaymentService>.Instance); var result = await sut.ChargeAsync( new Order { CustomerId = "cust-1", Total = 50m }); Assert.True(result.Success); } } We took a 6-dependency God class and split it into 4 focused services plus a thin orchestrator. Each service has 1-2 dependencies, serves one actor, and can be tested in isolation. The DI container in Program.cs wires everything together. This is SRP in practice: identify actors, extract by actor, wire with DI.
    Section 22

    Migration Guide

    You've inherited a codebase with God classes. You can't rewrite everything at once. Here's how to refactor toward SRP safely and incrementally without breaking production.

    Don't guess. Use git history to find the file that changes most often — that's your biggest SRP violator. Multiple teams changing one file = multiple actors.

    # Find the most-changed files in the last 6 months git log --format=format: --name-only --since=2024-01-01 \ | sort | uniq -c | sort -rn | head -20 # Typical output: # 47 src/Services/OrderService.cs <-- START HERE # 31 src/Controllers/OrderController.cs # 12 src/Models/Order.cs # 3 src/Repositories/OrderRepo.cs # Also check: which file has the most merge conflicts? git log --all --oneline --merges --diff-filter=U -- "src/Services/" \ | head -10 Start with ONE God class, not all of them. Trying to fix everything at once will derail the project. Pick the file with the most git churn and fix that one first.

    Before you move a single line of code, write tests that capture the current behavior. These aren't ideal tests — they're your safety net. They should fail if you accidentally change behavior during refactoring.

    // Characterization test: captures CURRENT behavior, warts and all // Don't clean up the code yet — just lock in what it does today public class OrderServiceCharacterizationTests { private readonly OrderService _sut; private readonly Mock<IPaymentGateway> _payment = new(); private readonly Mock<IEmailSender> _email = new(); private readonly Mock<IInventoryApi> _inventory = new(); // ... yes, lots of mocks. That's the SRP violation staring at you. [Fact] public async Task ProcessOrder_ValidOrder_ChargesAndReserves() { // Arrange: set up the happy path exactly as it works today _payment.Setup(p => p.ChargeAsync(It.IsAny<string>(), 100m)) .ReturnsAsync(ChargeResult.Ok("ch_1")); _inventory.Setup(i => i.ReserveAsync("SKU-1", 2)) .ReturnsAsync(true); var order = BuildTestOrder(); // Act var result = await _sut.ProcessAsync(order); // Assert: current behavior, not ideal behavior Assert.True(result.IsSuccess); _payment.Verify(p => p.ChargeAsync("cust-1", 100m), Times.Once); _inventory.Verify(i => i.ReserveAsync("SKU-1", 2), Times.Once); _email.Verify(e => e.SendAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once); } [Fact] public async Task ProcessOrder_PaymentFails_DoesNotReserve() { _payment.Setup(p => p.ChargeAsync(It.IsAny<string>(), It.IsAny<decimal>())) .ReturnsAsync(ChargeResult.Fail("declined")); var result = await _sut.ProcessAsync(BuildTestOrder()); Assert.False(result.IsSuccess); _inventory.Verify(i => i.ReserveAsync( It.IsAny<string>(), It.IsAny<int>()), Times.Never); } } If you can't write characterization tests because the class is too tangled, that's an even stronger signal you need SRP. Start by extracting the easiest responsibility first — the one with the fewest entanglements.

    Pick the easiest responsibility to extract — usually the one that touches the fewest fields. Create the new class, move the methods, update callers. Run characterization tests after each extraction to make sure nothing broke.

    public class OrderService { // 6 dependencies = 6 potential actors public OrderService( AppDbContext db, IPaymentGateway payment, IEmailSender email, // <-- Extract this first (easiest) IInventoryApi inventory, IAnalyticsClient analytics, ILogger<OrderService> logger) { } public async Task<OrderResult> ProcessAsync(Order order) { // ... validation, payment, inventory ... // This block is self-contained — easy to extract await _email.SendAsync(order.CustomerEmail, "Order Confirmed", $"Order #{order.Id} confirmed!"); // ... analytics ... } } // NEW: Extracted notification responsibility public interface IOrderNotificationService { Task SendConfirmationAsync(Order order); } public class OrderNotificationService( IEmailSender email) : IOrderNotificationService { public async Task SendConfirmationAsync(Order order) { await email.SendAsync(order.CustomerEmail, "Order Confirmed", $"Order #{order.Id} confirmed!"); } } // UPDATED: God class now has 5 deps instead of 6 public class OrderService( AppDbContext db, IPaymentGateway payment, IOrderNotificationService notifications, // <-- interface, not IEmailSender IInventoryApi inventory, IAnalyticsClient analytics, ILogger<OrderService> logger) { public async Task<OrderResult> ProcessAsync(Order order) { // ... validation, payment, inventory ... await notifications.SendConfirmationAsync(order); // ... analytics ... } } Extract one responsibility per pull request. Each PR should be small, reviewed quickly, and merged before starting the next extraction. Don't try to extract everything in one giant PR — that defeats the purpose of safe migration.

    Register each newly extracted service in the DI container. Update the God class to receive extracted services via constructor injection. Repeat Steps 3-4 until the God class is just a thin orchestrator.

    // After all extractions, the DI container tells the full story: builder.Services.AddScoped<IOrderValidator, OrderValidator>(); builder.Services.AddScoped<IPaymentService, PaymentService>(); builder.Services.AddScoped<IInventoryService, InventoryService>(); builder.Services.AddScoped<IOrderNotificationService, OrderNotificationService>(); builder.Services.AddScoped<IOrderAnalyticsService, OrderAnalyticsService>(); // The former God class is now a thin orchestrator builder.Services.AddScoped<OrderOrchestrator>(); // Migration timeline (realistic): // Week 1: Extract notifications (easiest, self-contained) // Week 2: Extract analytics (also self-contained) // Week 3: Extract validation (needs careful testing) // Week 4: Extract payment + inventory (most complex, most risk) // Week 5: Rename OrderService → OrderOrchestrator, delete dead code Don't delete the old God class until every extraction is merged, tested in staging, and running in production for at least a sprint. Keep the old code commented out for a few weeks as a safety net before final cleanup.
    Migrate to SRP in 4 steps: (1) find the worst offender via git churn, (2) lock behavior with characterization tests, (3) extract one responsibility per PR, (4) wire via DI. Spread this over weeks, not hours. Each step should be safe, reviewable, and independently deployable.
    Section 23

    Code Review Checklist

    Use this checklist during code reviews. If a class triggers 3+ of these red flags, it probably violates SRP and should be split.

    Question to Ask Red Flag Answer Action
    Does the class name contain "Manager", "Handler", "Processor", or "Service" without specificity? YesOrderManager is vague Rename to reflect the actual responsibility, or split into specific classes
    Does the constructor have more than 5 dependencies? Yes — each dep is likely a different actor Group related deps into a focused service and extract
    Can you describe the class without using the word "and"? No — "It validates orders and sends emails and..." Each "and" is a candidate for extraction
    Would changes for Actor A affect Actor B's code in this class? Yes — payment changes affect notification logic Split by actor boundaries
    Does the test file need more than 3-4 mocks? Yes — 6+ mocks = 6+ concerns Extract until each class needs 1-2 mocks
    Is this the most frequently changed file in git log? Yes — multiple actors driving changes Prioritize this file for SRP refactoring
    Does the class have methods with completely unrelated parameters? YesSaveOrder(Order o) + SendEmail(string to, string body) Methods serving different actors should be in different classes
    New class in PR Start review 5+ deps? Yes "and" in desc? Yes Multi actor? Yes Split by actor Extract + DI No No No SRP looks good — approve During code review, ask: (1) more than 5 constructor deps? (2) can you describe it without "and"? (3) do multiple actors drive changes? If 3+ red flags fire, request SRP extraction before merging. The table above gives you 7 concrete questions to ask on every PR.