The Single Responsibility Principle (SRP) is one of the five SOLID principles of Object-Oriented Design. While it's simple to state—"a class should have only one reason to change"—applying it effectively requires practice and understanding. Let's explore this principle through a real-world example.
The Problem: A Bloated API Client
Consider a common scenario: building a client for a blog API. Here's what a typical implementation might look like without proper separation of concerns:
class BlogService
def initialize(environment = 'development')
@env = environment
end
def posts
url = 'https://jsonplaceholder.typicode.com/posts'
url = 'https://prod.myserver.com' if env == 'production'
puts "[BlogService] GET #{url}"
response = Net::HTTP.get_response(URI(url))
return [] if response.code != '200'
posts = JSON.parse(response.body)
posts.map do |params|
Post.new(
id: params['id'],
user_id: params['userId'],
body: params['body'],
title: params['title']
)
end
end
end
Identifying Responsibilities
This seemingly simple class actually has multiple responsibilities:
- Configuration management (URL selection based on environment)
- Request logging
- HTTP communication
- Response parsing and object mapping
Each of these responsibilities represents a potential reason for the class to change. Let's refactor this code by applying SRP.
The Solution: Separating Concerns
1. Configuration Management
class BlogServiceConfig
def initialize(env:)
@env = env
end
def base_url
return 'https://prod.myserver.com' if @env == 'production'
'https://jsonplaceholder.typicode.com'
end
end
2. Request Logging
module RequestLogger
def log_request(service, url, method = 'GET')
puts "[#{service}] #{method} #{url}"
end
end
3. HTTP Communication
class RequestHandler
ResponseError = Class.new(StandardError)
def send_request(url, method = :get)
response = Net::HTTP.get_response(URI(url))
raise ResponseError if response.code != '200'
JSON.parse(response.body)
end
end
4. Response Processing
class ResponseProcessor
def process(response, entity, mapping = {})
return entity.new(map(response, mapping)) if response.is_a?(Hash)
response.map { |h| entity.new(map(h, mapping)) if h.is_a?(Hash) }
end
private
def map(params, mapping = {})
return params if mapping.empty?
params.each_with_object({}) do |(k, v), hash|
hash[mapping[k] ? mapping[k] : k] = v
end
end
end
The Refactored BlogService
Now our main class becomes a coordinator, with each dependency handling its specific responsibility:
class BlogService
include RequestLogger
def initialize(environment = 'development')
@env = environment
end
def posts
url = "#{config.base_url}/posts"
log_request('BlogService', url)
posts = request_handler.send_request(url)
response_processor.process(posts, Post, mapping)
end
private
attr_reader :env
def config
@config ||= BlogServiceConfig.new(env: @env)
end
def request_handler
@request_handler ||= RequestHandler.new
end
def response_processor
@response_processor ||= ResponseProcessor.new
end
def mapping
{
'id' => :id,
'userId' => :user_id,
'body' => :body,
'title' => :title
}
end
end
Benefits of This Approach
- Maintainability: Each class has a clear, single purpose
- Reusability: Components can be used independently in other parts of the application
- Testability: Classes can be tested in isolation
- Flexibility: Easy to modify or replace individual components
Adding New Features
The beauty of this design becomes apparent when adding new features. For example, adding a method to fetch a single post is straightforward:
def post(id)
url = "#{config.base_url}/posts/#{id}"
log_request('BlogService', url)
post = request_handler.send_request(url)
response_processor.process(post, Post, mapping)
end
Conclusion
While following SRP might initially seem to create more code, it results in a more maintainable and flexible codebase. Each class has a clear purpose, making the code easier to understand, test, and modify. Remember, the goal isn't to have the least amount of code, but to have code that's easy to maintain and extend.
When identifying responsibilities, ask yourself: "What are the different reasons this class might need to change?" If you find multiple answers, consider splitting the class into smaller, more focused components.