Understanding the Single Responsibility Principle in Ruby: A Practical Guide

Jan 5, 2017

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:

  1. Configuration management (URL selection based on environment)
  2. Request logging
  3. HTTP communication
  4. 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

  1. Maintainability: Each class has a clear, single purpose
  2. Reusability: Components can be used independently in other parts of the application
  3. Testability: Classes can be tested in isolation
  4. 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.

Mirzalazuardi Hermawan