Single Responsibility Principle

David Morales David Morales
/
La palabra 'SOLID' esculpida en grandes letras de piedra rugosa, con la letra 'S' resaltada en rojo.

This is the first article in a series on SOLID principles in Ruby, where we’ll explore each principle with a practical and critical perspective.


The SOLID principles are five object-oriented design guidelines that help us write more maintainable and extensible code. However, like any tool or design principle, their value depends on the context in which they’re applied.

In this first article, we’ll explore the Single Responsibility Principle (SRP), the principle that opens the SOLID acronym. Through practical examples in Ruby, we’ll see when applying this principle adds real value to our code and when, on the contrary, it can introduce unnecessary complexity.

The goal isn’t to learn how to apply SRP religiously, but rather to develop the judgment to recognize when we actually need it.

The Principle and its Multiple Faces

The most well-known definition of SRP, coined by Robert Martin (Uncle Bob), states:

A class should have only one reason to change

Sounds simple, but what exactly does “one reason to change” mean? All code has at least two reasons to change: fixing bugs and adding functionality. So?

This ambiguity led Robert Martin to refine the definition years later in his book Clean Architecture:

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

Where “actor” refers to a group of people with the same role or interest in the system (end users, administrators, the accounting team, etc).

But the most common interpretation of the principle is much simpler: “each class should do one thing”. And although this interpretation isn’t technically correct according to Martin, it’s the one most commonly used day-to-day.

In this article I’ll use this practical interpretation, but with an important caveat: the goal isn’t to split every class to absurdity, but to keep code cohesive and maintainable.

A Real Example: Notification System

Imagine you’re building an application to manage an online bookstore. You start with something simple:

class User
attr_reader :name, :email
def initialize(name, email)
@name = name
@email = email
end
def notify(message)
send_email(message)
end
private
def send_email(message)
puts "Sending email to #{email}: #{message}"
# Actual email sending logic here
end
end
# Usage
user = User.new("John Doe", "[email protected]")
user.notify("Your order has been shipped")

When the Problem Appears

Three months later, your application grows. Now you need to:

The User class starts to grow:

class User
attr_reader :name, :email, :phone, :slack_webhook
attr_accessor :notification_preference
def initialize(name, email, phone: nil, slack_webhook: nil)
@name = name
@email = email
@phone = phone
@slack_webhook = slack_webhook
@notification_preference = :email
end
def notify(message)
case notification_preference
when :email
send_email(message)
when :sms
send_sms(message)
when :slack
send_slack(message)
else
send_email(message) # fallback
end
end
private
def send_email(message)
puts "Sending email to #{email}: #{message}"
# SMTP configuration
# Retry logic
# Error handling
end
def send_sms(message)
return unless phone
puts "Sending SMS to #{phone}: #{message}"
# SMS API integration
# Character limit handling
# Retry logic
end
def send_slack(message)
return unless slack_webhook
puts "Sending Slack to #{slack_webhook}: #{message}"
# HTTP call to webhook
# Slack-specific formatting
# Error handling
end
end

Now the problems are obvious:

  1. The User class is huge and hard to navigate. More than half the code has nothing to do with representing a user.
  2. Testing is complicated. To test that user email validation works correctly, you have to mock SMS and Slack APIs.
  3. Risky changes. If you need to modify how SMS sending works, you’re touching the User class. A bug in notification logic could break basic user functionality.
  4. Impossible reuse. What if you want to notify unregistered users? Or if you need the same notification system for an Admin class?
  5. High coupling. The User class is tightly coupled to multiple external services (SMTP, SMS, Slack). Changing any of them requires modifying User.

This is the moment where SRP adds real value.

Applying SRP: The Refactoring

We extract the notification logic into its own class:

class User
attr_reader :name, :email, :phone, :slack_webhook
attr_accessor :notification_preference
def initialize(name, email, phone: nil, slack_webhook: nil)
@name = name
@email = email
@phone = phone
@slack_webhook = slack_webhook
@notification_preference = :email
end
def contact_info
{
email: email,
phone: phone,
slack_webhook: slack_webhook
}
end
end
class Notifier
def initialize(user)
@user = user
end
def send(message)
channel = channel_for(@user.notification_preference)
channel.deliver(message, @user.contact_info)
end
private
def channel_for(preference)
case preference
when :email
EmailChannel.new
when :sms
SmsChannel.new
when :slack
SlackChannel.new
else
EmailChannel.new
end
end
end
class EmailChannel
def deliver(message, contact_info)
email = contact_info[:email]
puts "Sending email to #{email}: #{message}"
# All email logic here
end
end
class SmsChannel
def deliver(message, contact_info)
phone = contact_info[:phone]
return unless phone
puts "Sending SMS to #{phone}: #{message}"
# All SMS logic here
end
end
class SlackChannel
def deliver(message, contact_info)
webhook = contact_info[:slack_webhook]
return unless webhook
puts "Sending Slack to #{webhook}: #{message}"
# All Slack logic here
end
end
# Usage
user = User.new("John Doe", "[email protected]", phone: "+19833918834")
user.notification_preference = :sms
notifier = Notifier.new(user)
notifier.send("Your order has been shipped")

The Real Benefits

After this refactoring:

  1. User is simple again. It only handles user data. If you need to change how users are stored, you don’t touch the notification code.

  2. Each channel is independent. You can modify how email works without worrying about breaking SMS.

  3. Easier testing. You can test User without worrying about notifications. You can test each channel in isolation.

  4. Reusability. The Notifier can be used with any class that has a contact_info method:

class Admin
def contact_info
{ email: @email, slack_webhook: @webhook }
end
end
admin = Admin.new(...)
Notifier.new(admin).send("System alert")
  1. Extensibility. Adding a new channel is trivial: create a new PushChannel class without touching existing code.

In computer science, this is called low coupling: the parts of the system are independent and can change without affecting each other.

The Danger of Over-Engineering

But here comes the important part: not all problems require this separation.

Imagine you have a simple class to represent books:

class Book
attr_reader :title, :author, :isbn, :price
def initialize(title, author, isbn, price)
@title = title
@author = author
@isbn = isbn
@price = price
end
def summary
"#{title} by #{author} (#{isbn}) - $#{price}"
end
def valid?
!title.empty? && !author.empty? && isbn.match?(/^\d{13}$/) && price > 0
end
end

A developer obsessed with SRP might say: “This class does three things! It stores data, generates presentation, and validates. We need to split it”:

class BookData
attr_reader :title, :author, :isbn, :price
def initialize(title, author, isbn, price)
@title = title
@author = author
@isbn = isbn
@price = price
end
end
class BookValidator
def self.valid?(book)
!book.title.empty? &&
!book.author.empty? &&
book.isbn.match?(/^\d{13}$/) &&
book.price > 0
end
end
class BookPresenter
def self.summary(book)
"#{book.title} by #{book.author} (#{book.isbn}) - $#{book.price}"
end
end
# Usage (now more complicated)
book_data = BookData.new("1984", "George Orwell", "9780451524935", 15.99)
is_valid = BookValidator.valid?(book_data)
summary = BookPresenter.summary(book_data)
puts summary

Is this better? Definitely not.

Let’s see why this design is problematic:

This is an example of premature abstraction. These responsibilities are naturally cohesive: they’re all essential aspects of what it means to be a book in your system.

As Jeremy Evans says in Polished Ruby Programming: Ruby’s String class violates SRP (it can represent text, binary data, act as a builder, as a modifier…), but it’s precisely that flexibility that makes it excellent.

Cohesion vs. Responsibilities

Beyond counting responsibilities, it’s useful to ask about cohesion: do these elements naturally belong together?

Consider this class that processes orders:

class OrderProcessor
def process(order)
return unless order.valid?
order.save
send_confirmation_email(order)
update_inventory(order)
end
private
def send_confirmation_email(order)
# Send email to customer
end
def update_inventory(order)
# Reduce product stock
end
end

Technically it violates SRP: it validates, saves, sends emails, and updates inventory. Should we split it? Probably not. These actions have high cohesion because they’re all naturally part of the order processing flow.

But if you add this:

def process(order)
return unless order.valid?
order.save
send_confirmation_email(order)
update_inventory(order)
log_to_analytics_platform(order)
end

Sending data to an analytics platform has low cohesion with order processing. It doesn’t naturally belong here. This is a good candidate to extract into a separate event system or callbacks.

So, When to Apply SRP?

After studying this principle from multiple angles, here are the conclusions:

Apply SRP when:

  1. The class is hard to understand because it does too many unrelated things
  2. You need to reuse parts in other contexts (like our Notifier)
  3. You want to easily swap implementations (different notification channels)
  4. Changes in one part break another with no clear relationship
  5. Testing becomes complicated due to unrelated dependencies

Don’t apply SRP when:

  1. The responsibilities are naturally cohesive (like Book with its validation)
  2. The separation has no reuse benefit
  3. You only have one implementation and don’t foresee alternatives
  4. The added complexity outweighs the benefits
  5. You can wait for the real problem to appear

The most valuable advice from Jeremy Evans: delay complexity until you actually need it. It’s much easier to add abstractions later than to remove them if they turn out to be unnecessary.

Conclusion: Cohesion over Division

The Single Responsibility Principle isn’t about splitting every class to absurdity. It’s about keeping together what belongs together, and separating what doesn’t.

Start with simple, cohesive code. You don’t need to anticipate all future use cases. When the pain appears (complicated testing, risky changes, impossible reuse) then it’s time to refactor.

Cohesion tells you what belongs together. Pain tells you when to separate.

Test your knowledge

  1. What is the refined definition of SRP according to Robert Martin’s “Clean Architecture”?

  1. In the notification system example, when did SRP start adding real value?

  1. What’s the main problem with splitting the Book class into BookData, BookValidator, and BookPresenter?

  1. What does “cohesion” mean?

  1. When should you apply SRP to split a class?