References

Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change. Each module or class should be responsible for exactly one part of the functionality.

Core Patterns

  • If you need “and” to describe a class, split it into separate classes
  • Separate validation, persistence, hashing, email, and orchestration into distinct classes
  • React: split data fetching, data transformation, and presentation into separate units
  • One reason to change does not mean one method—a repository with findById, save, and delete has one responsibility (data access)
  • Over-splitting into 20 tiny single-method classes violates the spirit of SRP

1. Single Responsibility Principle (SRP)

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

Each module/class should be responsible for one part of the functionality. If you need to describe the class with “and”, it’s doing too much.

Backend Example

// ❌ WRONG: Multiple responsibilities
class UserManager {
  async createUser(userData: CreateUserDTO) {
    // Validation
    if (!userData.email.includes("@")) {
      throw new Error("Invalid email");
    }

    // Password hashing
    const hashedPassword = await bcrypt.hash(userData.password, 10);

    // Database operation
    const user = await db.users.create({
      data: { ...userData, password: hashedPassword },
    });

    // Logging
    logger.info(`User created: ${user.id}`);

    // Email sending
    await sendEmail(user.email, "Welcome!", "Thanks for signing up");

    // Analytics tracking
    analytics.track("user_created", { userId: user.id });

    return user;
  }
}

Problems: Hard to test, hard to reuse validation/email logic, changes in email system affect user creation.

// ✅ CORRECT: Separated responsibilities

// 1. Validation (one responsibility)
class UserValidator {
  validate(userData: CreateUserDTO): ValidationResult {
    const errors: string[] = [];

    if (!userData.email.includes("@")) {
      errors.push("Invalid email");
    }

    if (userData.password.length < 8) {
      errors.push("Password too short");
    }

    return {
      valid: errors.length === 0,
      errors,
    };
  }
}

// 2. Repository (one responsibility)
class UserRepository {
  async create(user: User): Promise<User> {
    return await db.users.create({ data: user });
  }

  async findByEmail(email: string): Promise<User | null> {
    return await db.users.findUnique({ where: { email } });
  }
}

// 3. Password service (one responsibility)
class PasswordService {
  async hash(password: string): Promise<string> {
    return await bcrypt.hash(password, 10);
  }

  async verify(password: string, hash: string): Promise<boolean> {
    return await bcrypt.compare(password, hash);
  }
}

// 4. Email service (one responsibility)
class EmailService {
  async sendWelcome(email: string): Promise<void> {
    await this.send(email, "Welcome!", "Thanks for signing up");
  }

  private async send(to: string, subject: string, body: string): Promise<void> {
    await emailProvider.send({ to, subject, body });
  }
}

// 5. User service (orchestration only)
class UserService {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private passwordService: PasswordService,
    private emailService: EmailService,
    private logger: Logger,
  ) {}

  async createUser(userData: CreateUserDTO): Promise<Result<User>> {
    const validation = this.validator.validate(userData);
    if (!validation.valid) {
      return Result.fail(validation.errors.join(", "));
    }

    const hashedPassword = await this.passwordService.hash(userData.password);

    const user = new User({ ...userData, password: hashedPassword });
    await this.repository.create(user);

    this.logger.info(`User created: ${user.id}`);
    await this.emailService.sendWelcome(user.email);

    return Result.ok(user);
  }
}

Benefits: Each class can be tested independently, reused, and changed without affecting others.

Frontend Example (React + Redux Toolkit)

// ❌ WRONG: Component doing everything
const UserProfile = () => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        if (!data.email || !data.name) {
          setError('Invalid user data');
          return;
        }

        const user = {
          ...data,
          displayName: `${data.firstName} ${data.lastName}`
        };

        setUser(user);
      })
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  if (error) return <Alert>{error}</Alert>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.displayName}</h1>
      <p>{user.email}</p>
    </div>
  );
};
// ✅ CORRECT: Separated responsibilities

// 1. API service (data fetching)
// services/userApi.ts
export const userApi = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query<User, void>({
      query: () => 'user'
    })
  })
});

// 2. Selector (data transformation)
// selectors/userSelectors.ts
export const selectUserDisplayName = createSelector(
  [(state: RootState) => state.user],
  (user) => user ? `${user.firstName} ${user.lastName}` : ''
);

// 3. Component (presentation only)
// components/UserProfile.tsx
export const UserProfile = () => {
  const { data: user, isLoading, error } = userApi.useGetUserQuery();
  const displayName = useSelector(selectUserDisplayName);

  if (isLoading) return <Spinner />;
  if (error) return <Alert>Error loading user</Alert>;
  if (!user) return null;

  return (
    <div>
      <h1>{displayName}</h1>
      <p>{user.email}</p>
    </div>
  );
};

Reference