Aikido

Why classes should follow the single responsibility principle

Readability

Rule
Classes should have single responsibility.
Classes handling multiple concerns violate
the Single Responsibility Principle.

Supported languages: JS, TS, PY, JAVA, C/C++,
C#, Swift/Objective C, Ruby. PHP, Kotlin, 
Scala, Rust, Haskell, Groovy, Dart. Julia,
Elixit, Klojure, OCaml, Delphi

Introduction

Classes that do too much become bottlenecks. A class handling authentication, emails, and validation requires changes whenever any concern evolves, risking breaks in unrelated functionality. Testing requires mocking the entire class even when testing one aspect. The Single Responsibility Principle states a class should have only one reason to change.

Why it matters

Code maintainability: Classes with multiple responsibilities change more often because any concern's evolution affects the entire class.

Testing complexity: Testing multi-responsibility classes requires mocking all dependencies, even for testing one feature.

Reusability: You can't extract one responsibility without bringing all dependencies. Developers duplicate code rather than untangling multi-responsibility classes.

Team coordination: Multiple developers working on the same class for different features create frequent merge conflicts. Single-responsibility classes enable parallel development without conflicts.

Code examples

❌ Non-compliant:

class UserManager {
    async createUser(userData) {
        const user = await db.users.insert(userData);
        await this.sendWelcomeEmail(user.email);
        await this.logEvent('user_created', user.id);
        await cache.set(`user:${user.id}`, user);
        return user;
    }

    async sendWelcomeEmail(email) {
        const template = this.loadEmailTemplate('welcome');
        await emailService.send(email, template);
    }

    async logEvent(event, userId) {
        await analytics.track(event, { userId, timestamp: Date.now() });
    }
}

Why it's wrong: This class handles database operations, email sending, logging, and caching. Changes to email templates, logging formats, or cache strategy all require modifying this class. Testing user creation means mocking email services, analytics, and cache, making tests slow and brittle.

✅ Compliant:

class UserRepository {
    async create(userData) {
        return await db.users.insert(userData);
    }
}

class EmailNotificationService {
    async sendWelcomeEmail(email) {
        const template = await this.templateLoader.load('welcome');
        return await this.emailSender.send(email, template);
    }
}

class UserEventLogger {
    async logCreation(userId) {
        return await this.analytics.track('user_created', {
            userId,
            timestamp: Date.now()
        });
    }
}

class UserService {
    constructor(repository, emailService, eventLogger, cache) {
        this.repository = repository;
        this.emailService = emailService;
        this.eventLogger = eventLogger;
        this.cache = cache;
    }

    async createUser(userData) {
        const user = await this.repository.create(userData);
        await Promise.all([
            this.emailService.sendWelcomeEmail(user.email),
            this.eventLogger.logCreation(user.id),
            this.cache.set(`user:${user.id}`, user)
        ]);
        return user;
    }
}

Why this matters: Each class has one clear responsibility: data persistence, email sending, event logging, or orchestration. Changes to email templates only affect EmailNotificationService. Testing user creation can use simple stubs for dependencies. Classes can be reused independently across different features.

Conclusion

The Single Responsibility Principle isn't about making classes as small as possible, it's about ensuring each class has one clear reason to change. When a class starts handling multiple concerns, refactor by extracting each responsibility into its own class with a focused interface. This makes code easier to test, maintain, and evolve without cascading changes across unrelated functionality.

FAQs

Got Questions?

How do I identify when a class has too many responsibilities?

Look for classes with multiple reasons to change. If modifying email logic, logging format, and database schema all require changing the same class, it has too many responsibilities. Check method names: if they cover unrelated verbs like sendEmail(), logEvent(), and validateData() in the same class, that's a red flag. Classes with more than 300-400 lines often indicate multiple responsibilities, though size alone isn't definitive.

Doesn't splitting classes create more files and complexity?

More files doesn't equal more complexity. Ten focused classes of 50 lines each are easier to understand than one 500-line class handling everything. The key is that each class is simple and has a clear purpose. Navigation in modern IDEs makes file count irrelevant. The complexity reduction comes from being able to reason about each class independently without considering unrelated concerns.

What about classes that naturally need to coordinate multiple operations?

Coordination is itself a responsibility. A UserService class can orchestrate calls to UserRepository, EmailService, and EventLogger without implementing those concerns itself. This is the orchestrator or facade pattern. The difference is the orchestrator delegates to specialized classes rather than implementing multiple concerns directly. It's thin glue code, not business logic.

How does this principle apply to utility classes with static methods?

Utility classes are particularly prone to violating single responsibility because it's easy to keep adding unrelated static methods. A StringUtils class might start with formatting helpers but grow to include validation, parsing, encryption, and encoding. Split these into focused utility classes like StringFormatter, StringValidator, and StringEncoder. Each has a cohesive set of related operations.

How do I refactor existing classes that violate this principle?

Start by identifying distinct responsibilities within the class. Extract the easiest one first into a new class, update tests, and verify everything works. Repeat iteratively rather than attempting a big refactor. Use the strangler fig pattern: create new single-responsibility classes and gradually move code from the old class. Once the old class is empty or minimal, deprecate it. Each step should be a working, testable increment.

Does single responsibility mean single method?

No. A class can have multiple methods as long as they're all related to the same responsibility. A UserRepository class might have create(), update(), delete(), and findById() methods because they all serve the single responsibility of user data persistence. The methods are cohesive variations of the same concern, not separate concerns packaged together.

Get secure now

Secure your code, cloud, and runtime in one central system.
Find and fix vulnerabilities fast automatically.

No credit card required | Scan results in 32secs.