Chapter 02 · Article 03 of 55

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is the first of the five SOLID principles of object-oriented design. It was introduced by Robert C. Martin (Uncle Bob) and is arguably…

Article outline14 sections on this page

Overview

The Single Responsibility Principle (SRP) is the first of the five SOLID principles of object-oriented design. It was introduced by Robert C. Martin (Uncle Bob) and is arguably the most fundamental yet most frequently misunderstood principle.

Robert C. Martin's Definition:

"A class should have one, and only one, reason to change."

A more refined version from his later work states:

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

Here, an actor refers to a group of stakeholders or users who want the system to change in the same way. SRP is not about a class doing "one thing" - it's about a class serving one group of people whose requirements change together.

Key Insight: SRP is about change, not about size. A class with 500 lines might still satisfy SRP if all those lines change for the same reason. A class with 20 lines might violate SRP if two different stakeholders can independently request changes to it.


Problem It Solves

Without SRP, you encounter:

  • Fragile code - A change requested by one stakeholder breaks functionality used by another.
  • Merge conflicts - Multiple teams modify the same file for unrelated reasons.
  • Difficult testing - You must mock unrelated dependencies to test one behavior.
  • Rigid deployments - A small change in notification logic forces redeployment of authentication logic.
  • Cognitive overload - Developers must understand the entire class to safely modify any part.
  • Hidden coupling - Shared private methods create invisible dependencies between unrelated features.

Real scenario: A UserService class handles authentication, database persistence, and email notifications. The security team wants to change password hashing. The DBA wants to optimize queries. The marketing team wants to change email templates. All three changes hit the same file, creating merge conflicts and regression risk.


When to Use / When NOT to Use

SituationUse SRPDon't Over-Apply SRP
Class has methods that change for different business reasonsSplit it
Multiple teams frequently modify the same fileSplit it
Unit tests require mocking unrelated dependenciesSplit it
Class is a simple data transfer object (DTO)Keep it together
Splitting would create classes with only 1-2 trivial methodsKeep it together
The "responsibilities" always change together in practiceKeep it together
You're building a prototype or throwaway codeDon't over-engineer
Class coordinates between multiple services (Facade)Context-dependentFacades are acceptable if thin
Microservice boundary designEach service = one business capability

Key Concepts & Theory

Responsibility Defined

A responsibility is a reason to change. More precisely, it's an axis along which requirements evolve independently. If two behaviors always change together, they are the same responsibility.

Cohesion

Cohesion measures how strongly related the elements within a module are. SRP drives toward high cohesion - every method and field in a class should relate to the same responsibility.

Types of cohesion (best to worst):

  1. Functional - All elements contribute to a single well-defined task
  2. Sequential - Output of one element feeds into the next
  3. Communicational - Elements operate on the same data
  4. Temporal - Elements are grouped because they execute at the same time
  5. Coincidental - Elements have no meaningful relationship (SRP violation)

Coupling

SRP reduces coupling between unrelated concerns. When responsibilities are separated, changes in one module don't ripple into others. Low coupling + high cohesion = maintainable systems.

Axis of Change

Every class sits at the intersection of multiple potential axes of change. SRP asks: "Who are the different actors that might request changes to this class?" Each distinct actor represents a separate axis. If you find more than one axis, the class has more than one responsibility.

Heuristic: Describe what the class does. If you use the word "and" or "or," you likely have multiple responsibilities.


ASCII Class Diagram

Violation - God Class

┌─────────────────────────────────┐
│          UserService            │
├─────────────────────────────────┤
│ - db: Database                  │
│ - mailer: SmtpClient            │
│ - hasher: PasswordHasher        │
│ - logger: Logger                │
├─────────────────────────────────┤
│ + authenticate(email, pwd)      │  ← Security team owns this
│ + hashPassword(pwd)             │  ← Security team owns this
│ + saveUser(user)                │  ← DBA/persistence team owns this
│ + findUserByEmail(email)        │  ← DBA/persistence team owns this
│ + sendWelcomeEmail(user)        │  ← Marketing team owns this
│ + sendPasswordReset(user)       │  ← Marketing team owns this
│ + logActivity(action)           │  ← Ops team owns this
└─────────────────────────────────┘
        4 actors → 4 reasons to change

Correct Design - Separated Responsibilities

┌──────────────────────┐    ┌──────────────────────┐
│  AuthenticationService│    │  UserRepository      │
├──────────────────────┤    ├──────────────────────┤
│ - hasher: Hasher     │    │ - db: Database       │
├──────────────────────┤    ├──────────────────────┤
│ + authenticate()     │    │ + save(user)         │
│ + hashPassword()     │    │ + findByEmail(email) │
└──────────────────────┘    └──────────────────────┘

┌──────────────────────┐    ┌──────────────────────┐
│ NotificationService  │    │  ActivityLogger      │
├──────────────────────┤    ├──────────────────────┤
│ - mailer: SmtpClient │    │ - logger: Logger     │
├──────────────────────┤    ├──────────────────────┤
│ + sendWelcome(user)  │    │ + log(action)        │
│ + sendReset(user)    │    └──────────────────────┘
└──────────────────────┘
    Each class → 1 actor → 1 reason to change

Pseudocode Implementation

BAD - God Class (SRP Violation)

class UserService {
    private db: Database
    private mailer: SmtpClient
    private hasher: BCryptHasher
    private logger: FileLogger

    function register(email, password, name) {
        // Responsibility 1: Validation
        if not isValidEmail(email) then throw InvalidEmailError
        if password.length < 8 then throw WeakPasswordError

        // Responsibility 2: Security/Hashing
        hashedPwd = hasher.hash(password, generateSalt())

        // Responsibility 3: Persistence
        user = new User(email, hashedPwd, name)
        db.execute("INSERT INTO users VALUES (?, ?, ?)", user.fields())

        // Responsibility 4: Notification
        htmlBody = renderTemplate("welcome.html", {name: name})
        mailer.send(email, "Welcome!", htmlBody)

        // Responsibility 5: Logging/Audit
        logger.write("[" + timestamp() + "] User registered: " + email)

        return user
    }

    function authenticate(email, password) {
        user = db.execute("SELECT * FROM users WHERE email = ?", email)
        if user == null then throw UserNotFoundError
        if not hasher.verify(password, user.hashedPassword) then
            logger.write("[" + timestamp() + "] Failed login: " + email)
            throw InvalidCredentialsError
        logger.write("[" + timestamp() + "] Successful login: " + email)
        return generateToken(user)
    }

    function changeEmail(userId, newEmail) {
        // Mixes validation + persistence + notification
        if not isValidEmail(newEmail) then throw InvalidEmailError
        db.execute("UPDATE users SET email = ? WHERE id = ?", newEmail, userId)
        mailer.send(newEmail, "Email Changed", "Your email was updated.")
        logger.write("[" + timestamp() + "] Email changed for user: " + userId)
    }
}

Problems: Testing authenticate requires mocking the mailer. Changing the email template format risks breaking the SQL queries. The security team and marketing team edit the same file.

GOOD - Separated Responsibilities

class UserValidator {
    function validateEmail(email) {
        if not isValidEmail(email) then throw InvalidEmailError
    }

    function validatePassword(password) {
        if password.length < 8 then throw WeakPasswordError
    }
}

class AuthenticationService {
    private hasher: PasswordHasher
    private userRepo: UserRepository
    private tokenGenerator: TokenGenerator

    function authenticate(email, password) {
        user = userRepo.findByEmail(email)
        if user == null then throw UserNotFoundError
        if not hasher.verify(password, user.hashedPassword) then
            throw InvalidCredentialsError
        return tokenGenerator.generate(user)
    }

    function hashPassword(password) {
        return hasher.hash(password, generateSalt())
    }
}

class UserRepository {
    private db: Database

    function save(user) {
        db.execute("INSERT INTO users VALUES (?, ?, ?)", user.fields())
    }

    function findByEmail(email) {
        return db.execute("SELECT * FROM users WHERE email = ?", email)
    }

    function updateEmail(userId, newEmail) {
        db.execute("UPDATE users SET email = ? WHERE id = ?", newEmail, userId)
    }
}

class NotificationService {
    private mailer: SmtpClient
    private templateEngine: TemplateEngine

    function sendWelcome(user) {
        body = templateEngine.render("welcome.html", {name: user.name})
        mailer.send(user.email, "Welcome!", body)
    }

    function sendEmailChanged(email) {
        mailer.send(email, "Email Changed", "Your email was updated.")
    }
}

class ActivityLogger {
    private logger: Logger

    function logRegistration(email) { logger.info("User registered: " + email) }
    function logLogin(email)        { logger.info("Successful login: " + email) }
    function logFailedLogin(email)  { logger.warn("Failed login: " + email) }
}

// Orchestrator  -  thin coordination layer (acceptable)
class UserRegistrationUseCase {
    private validator: UserValidator
    private authService: AuthenticationService
    private userRepo: UserRepository
    private notifier: NotificationService
    private activityLog: ActivityLogger

    function execute(email, password, name) {
        validator.validateEmail(email)
        validator.validatePassword(password)
        hashedPwd = authService.hashPassword(password)
        user = new User(email, hashedPwd, name)
        userRepo.save(user)
        notifier.sendWelcome(user)
        activityLog.logRegistration(email)
        return user
    }
}

Benefits: Each class can be tested in isolation. The security team modifies only AuthenticationService. The DBA optimizes only UserRepository. Marketing changes only NotificationService.


Real-World Examples

1. Logging Framework

Violation: A Logger class that formats messages, writes to files, rotates logs, and sends alerts.

Fix: Separate into LogFormatter, LogWriter, LogRotator, and AlertDispatcher. The formatter doesn't care about rotation policy; the writer doesn't care about alert thresholds.

2. Report Generation

Violation: A ReportGenerator that queries the database, applies business calculations, formats output as PDF, and emails the result.

Fix: ReportDataFetcherReportCalculatorPdfRendererReportDistributor. When the CFO wants a new metric, only ReportCalculator changes. When switching from PDF to Excel, only the renderer changes.

3. Input Validation

Violation: A FormValidator that validates email format, checks password strength, verifies CAPTCHA, and rate-limits submissions.

Fix: EmailValidator, PasswordPolicyChecker, CaptchaVerifier, RateLimiter. Rate-limiting policy (ops concern) shouldn't live with email regex (business rule).

4. Payment Processing

Violation: A PaymentService that validates card numbers, communicates with payment gateway, applies discounts, updates inventory, and sends receipts.

Fix: CardValidator, PaymentGatewayClient, DiscountEngine, InventoryService, ReceiptSender. The finance team owns discounts; the ops team owns gateway configuration; the warehouse team owns inventory.


Comparison with Other SOLID Principles

PrincipleRelationship to SRP
Open/Closed (OCP)SRP enables OCP. When responsibilities are separated, you can extend one behavior without modifying classes that handle other behaviors.
Liskov Substitution (LSP)SRP reduces the temptation to create subtypes that override unrelated methods. Focused classes lead to cleaner inheritance hierarchies.
Interface Segregation (ISP)ISP is SRP applied to interfaces. "No client should depend on methods it doesn't use" mirrors "no class should have responsibilities it doesn't own."
Dependency Inversion (DIP)SRP creates small, focused classes that are natural candidates for abstraction behind interfaces, enabling DIP.
DRYSRP can appear to conflict with DRY when two classes have similar code but serve different actors. Prefer duplication over coupling unrelated responsibilities.
YAGNITension exists: SRP says separate concerns; YAGNI says don't over-engineer. Resolution: split only when you observe multiple axes of change, not speculatively.

Advantages & Disadvantages

AdvantagesDisadvantages
Easier to understand - each class has a clear purposeMore classes and files to navigate
Easier to test - fewer dependencies to mockIncreased indirection - harder to trace execution flow
Reduced merge conflicts - teams work on separate filesRequires upfront design thinking
Lower regression risk - changes are isolatedCan lead to "class explosion" if taken too far
Better reusability - focused classes compose wellOrchestration layer adds complexity
Parallel development - teams work independentlyNewcomers may struggle to find where logic lives
Simpler debugging - failures are localizedOver-separation can scatter related logic

Constraints & Edge Cases

Class Explosion - When SRP Goes Too Far

If you have UserEmailValidator, UserEmailFormatter, UserEmailNormalizer, UserEmailDomainChecker as separate classes, you've likely over-applied SRP. These all change for the same reason (email validation rules change) and serve the same actor.

Signs you've gone too far:

  • Classes with only one trivial method that delegates to another class
  • You need to open 10+ files to understand a simple feature
  • The orchestration layer is more complex than the business logic
  • Every change requires modifying 5+ files

Finding the Right Granularity

Heuristics:

  1. Actor test - Can you name two different people/teams who would independently request changes? If not, it's one responsibility.
  2. Change frequency - Do these methods historically change together in version control? If yes, they belong together.
  3. Reuse test - Would you ever want to reuse one behavior without the other? If yes, separate them.
  4. Description test - Can you describe the class without using "and"? If not, consider splitting.
  5. Pain-driven - Don't split preemptively. Split when you feel the pain of merge conflicts, difficult testing, or cascading changes.

Edge Cases

  • Aggregate Roots in DDD - May appear to violate SRP but are cohesive around a business invariant boundary.
  • Event Handlers - A handler that does one thing in response to an event satisfies SRP even if it coordinates multiple steps.
  • Configuration classes - Grouping related config values is fine; they change together when deployment requirements change.

Interview Follow-ups

Q1: How do you identify SRP violations in existing code?

Model Answer: I look for several signals. First, I check the git log - if a file is modified in commits with unrelated messages ("fix auth bug" and "update email template"), it likely has multiple responsibilities. Second, I examine the class's dependencies: if the constructor takes a database connection, an HTTP client, and a message queue, it's probably doing too much. Third, I apply the "describe it" test - if I need "and" to explain what the class does, it's a candidate for splitting. Finally, I look at test files: if unit tests require mocking many unrelated services, the class under test has too many responsibilities.

Q2: Does SRP mean a class should have only one method?

Model Answer: Absolutely not. SRP means one reason to change, not one method. A UserRepository might have save(), findById(), findByEmail(), delete(), and update() - that's five methods but one responsibility (user persistence). They all change for the same reason: the database schema changes or the persistence strategy changes. The actor is the same (the DBA or the persistence team). Confusing "one method" with "one responsibility" leads to class explosion and unnecessary complexity.

Q3: How does SRP apply at different architectural levels?

Model Answer: SRP is fractal - it applies at every level of abstraction. At the method level, a function should do one thing. At the class level, a class should serve one actor. At the module/package level, a module should encapsulate one business capability. At the service level (microservices), each service should own one bounded context. The principle is the same; only the granularity changes. A microservice that handles both user authentication and order processing violates SRP at the service level, just as a class that mixes both would violate it at the class level.

Q4: Can SRP conflict with performance requirements? (Hints only - practice this yourself)

Hints:

  • Consider the overhead of multiple objects, method calls, and indirection
  • Think about database queries: N+1 problem when responsibilities are too separated
  • Discuss how you'd balance clean design with batch operations
  • Mention that premature optimization shouldn't override good design, but production hotspots may justify pragmatic coupling

Q5: How would you refactor a 2000-line God class without breaking production? (Hints only - practice this yourself)

Hints:

  • Start with characterization tests (tests that capture current behavior)
  • Extract one responsibility at a time using the Strangler Fig pattern
  • Use the "Sprout Class" technique - new features go in new classes
  • Keep the old class as a thin delegating facade during transition
  • Feature flags to gradually route traffic to new implementations
  • Discuss the role of integration tests as a safety net during refactoring

Counter Questions to Ask Interviewer

Use these to demonstrate depth of thinking and turn the interview into a discussion:

  1. "In your codebase, how do you decide when a class has grown beyond one responsibility - is it driven by team structure, deployment boundaries, or code metrics?" - Shows you understand SRP is context-dependent.

  2. "Do you follow Conway's Law in your team structure? I ask because SRP boundaries often align with team ownership boundaries." - Demonstrates systems thinking.

  3. "When you've applied SRP, how do you handle the orchestration layer - do you use a mediator pattern, domain events, or a use-case/application service?" - Shows practical implementation awareness.

  4. "How do you balance SRP with the cost of indirection in performance-critical paths?" - Shows you think about trade-offs, not just theory.

  5. "Do you apply SRP at the service level too, or do your microservices intentionally bundle related responsibilities for operational simplicity?" - Shows architectural maturity.


References & Whitepapers

  1. Martin, R.C. - "The Single Responsibility Principle" (2002), published in Agile Software Development: Principles, Patterns, and Practices
  2. Martin, R.C. - Clean Code: A Handbook of Agile Software Craftsmanship (2008), Chapter 10: Classes
  3. Martin, R.C. - Clean Architecture (2017), Chapter 7: SRP - The Single Responsibility Principle
  4. Martin, R.C. - "The Principles of OOD" - butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
  5. Parnas, D.L. - "On the Criteria To Be Used in Decomposing Systems into Modules" (1972) - The foundational paper on information hiding that SRP builds upon
  6. Martin Fowler - Refactoring: Improving the Design of Existing Code (2018), relevant patterns: Extract Class, Move Method
  7. Steve McConnell - Code Complete (2004), Chapter 5.3: Design Building Blocks - Cohesion discussion