[S]OLID - Single Responsibility Principle by example

Subscribe to receive new articles. No spam. Quality content.

I write a lot about patterns and Object-Oriented Design, so I couldn't miss opportunity to write about SOLID principles of Object-Oriented Design. I'm going to show by example how we can use these principles in Ruby.

SOLID stands for:

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

In this article I'm going to cover first one.

Single Responsibility Principle

Definition from Wikipedia:

A class should have only a single responsibility.

Robert C. Martin describes it as:

A class should have only one reason to change.

Quite simple idea, right? Class should have just one responsibility and one reason to change.

Sometimes developers struggling with definition of 'responsibility'. It's much easier to just add another line to method, then another one and you have huge class which is responsible for too many things.

It's a skill which programmers should develop: to notice the moment when class is getting too big and should be refactored into couple smaller ones.

I feel like the best way to show this principle in action is to go through example. My idea is to start from code which you might have in your app and refactor that following Single Responsibility Principle.

Example

APIs are quite popular these days, so I'm sure in more or less big project you do call third-party APIs.

Let's say we need to call BlogService to get list of blog posts. We have corresponding class: BlogService and class Post as a way to treat posts as an objects.

Without proper Object-Oriented approach code of BlogService could look like this:

require 'net/http'
require 'json'

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

  private

  attr_reader :env
end

class Post
  attr_reader :id, :user_id, :body, :title

  def initialize(id:, user_id:, body:, title:)
    @id = id
    @user_id = user_id
    @body = body
    @title = title
  end
end

blog_service = BlogService.new
puts blog_service.posts

I highly encourage you to go through that code example thoroughly and try to figure out responsibilities of BlogService class. It will help you to improve skill of identifying code smells.

Ok, let's identify responsibilities of BlogService#posts:

Configuration

Presented by these lines:

    url = 'https://jsonplaceholder.typicode.com/posts'
    url = 'https://prod.myserver.com' if env == 'production'

Depending on environment we might change base url of API.

Logging

puts "[BlogService] GET #{url}"

HTTP layer

response = Net::HTTP.get_response(URI(url))
return [] if response.code != '200'

We should do something different from parsing response if response is not 200.

Response processing

We're receiving something like this:

[{
  "userId": 10,
  "id": 95,
  "title": "id minus libero illum nam ad officiis",
  "body": "earum voluptatem facere..."
},
{
  "userId": 10,
  "id": 96,
  "title": "quaerat velit veniam amet cupiditate aut numquam ut sequi",
  "body": "in non odio excepturi sint eum..."
}, ...]

And we need to parse JSON into array of hashes and return array of Post objects:

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

At least 4 responsibilities for one class!

Ok, we know that each class should have one reason to change. Let's try to follow this rule and extract configuration part into separate class:

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

Simple class with single responsibility: return configuration for blog service. For now it's just a base_url, but it could be extended.

Now we can use this class in BlogService:

class BlogService
  # ...
  def posts
    url = "#{config.base_url}/posts"

    puts "[BlogService] GET #{url}"
    response = Net::HTTP.get_response(URI(url))
    # ...
  end

  private
  # ...
  def config
    @config ||= BlogServiceConfig.new(env: @env)
  end
end

Let's move forward and extract logging. Probably we will need to log other requests as well, so it makes sense to extract logging functionality into module:

module RequestLogger
  def log_request(service, url, method = 'GET')
    puts "[#{service}] #{method} #{url}"
  end
end

In last Rails Conf somebody said: "I like to read boring code". That's just awesome idea. This code is boring but that's what we want. We want to read code and understand how it works. With this module we can change basic puts to Rails.logger and our app will work, because now we're going to use this module inside BlogService class:

class BlogService
  include RequestLogger
  # ...
  def posts
    url = "#{config.base_url}/posts"

    log_request('BlogService', url)
    response = Net::HTTP.get_response(URI(url))
    # ...
  end
end

Ok, we've extracted 2 classes so far. Now we need to figure out how to handle HTTP layer and response processing.

This might be tricky, but I'll try to implement it in general way with re-usable pieces:

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

RequestHandler makes http call to url and parses JSON response. It's a 2 responsibilities. But since we don't want to deal with JSON string, it makes sense to parse JSON into array or hash right in this method. Also one of the benefits of this approach that if we decide to switch from Net::HTTP to Faraday - it will be easy to do so because all HTTP-related code lives in one class.

Let's send API call using RequestHandler:

class BlogService
  # ...
  def posts
    url = "#{config.base_url}/posts"

    log_request('BlogService', url)
    posts = request_handler.send_request(url)
    # ...
  end

  private
  # ...
  def request_handler
    @request_handler ||= RequestHandler.new
  end
end

Nice! Now we can create ResponseProcessor to process response.

I'll try to make things reusable, so ResponseProcessor could be implemented this way:

class ResponseProcessor
  def process(response, entity)
    return entity.new(response) if response.is_a?(Hash)

    if response.is_a?(Array)
      response.map { |h| entity.new(h) if h.is_a?(Hash) }
    end
  end
end

It will work with both: Array and Hash response. If it's a hash it will instantiate one entity, if it's an array it will return an array of entities.

Now we can use this processor in our main class:

class BlogService
  # ...    
  def posts
    url = "#{config.base_url}/posts"

    log_request('BlogService', url)
    posts = request_handler.send_request(url)
    response_processor.process(posts, Post)
  end

  private
  # ...
  def response_processor
    @response_processor ||= ResponseProcessor.new
  end
end

Method posts looks much smaller now and we can read some boring code there:

  1. Construct url
  2. Log request
  3. Send request
  4. Process response

If you're interested in one of those steps, you can go to class with simple implementation and figure out how that works. Also all those classes are reusable. So if we need to make call to any other service - we already have a good boilerplate.

But we have one small problem with this implementation. That happens really often. Response we receive doesn't match fields for Post. Remember, in initial implementation we had sort of mapping between fields:

  Post.new(
    id: params['id'],
    user_id: params['userId'],
    body: params['body'],
    title: params['title']
  )

That's the last part we need to implement for ResponseProcessor. It will make code of that class a little bit more complex, but that's how we will keep flexibility:

class ResponseProcessor
  def process(response, entity, mapping = {})
    return entity.new(map(response, mapping)) if response.is_a?(Hash)

    if response.is_a?(Array)
      response.map { |h| entity.new(map(h, mapping)) if h.is_a?(Hash) }
    end
  end

  def map(params, mapping)
    return hash if mapping.empty?

    params.each_with_object({}) { |(k, v), hash| hash[mapping[k]] = params[k] }
  end
end

Ok, having that we can pass any mapping we want to have, for our example it's going to be:

{ 'id' => :id, 'userId' => :user_id, 'body' => :body, 'title' => :title }

Let's check final result.

Result

require 'net/http'
require 'json'

module RequestLogger
  def log_request(service, url, method = :get)
    puts "[#{service}] #{method.upcase} #{url}"
  end
end

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

class ResponseProcessor
  def process(response, entity, mapping = {})
    return entity.new(map(response, mapping)) if response.is_a?(Hash)

    if response.is_a?(Array)
      response.map { |h| entity.new(map(h, mapping)) if h.is_a?(Hash) }
    end
  end

  def map(params, mapping)
    return hash if mapping.empty?

    params.each_with_object({}) { |(k, v), hash| hash[mapping[k]] = params[k] }
  end
end

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

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

class Post
  attr_reader :id, :user_id, :body, :title

  def initialize(id:, user_id:, body:, title:)
    @id = id
    @user_id = user_id
    @body = body
    @title = title
  end
end

blog_service = BlogService.new
puts blog_service.posts.inspect

All classes we've created have more or less single responsibility. What's more important they are reusable and code is pretty simple. One tricky part we have there is mapping between fields, but that allows us to extend functionality.

Let's say we want to add method which would get one post by id. No problem, we will write the same boring code here:

class BlogService
  # ...
  def post(id)
    url = "#{config.base_url}/posts/#{id}"

    log_request('BlogService', url)
    posts = request_handler.send_request(url)
    response_processor.process(posts, Post, mapping)
  end
  # ...
end

It works :)

We could even extract logging logic into RequestHandler. But for now I think we did a really good job with refactoring initial class with 4 responsibilities. Probably naming could be improved as well, but the general idea here is to have 3 reusable classes instead of 1 class with 4 responsibilities.

Let me know if you have good examples of Single Responsibility implementation. Also, I'll appreciate any feedback about this article. I included a lot of code here, let me know if it's easy to read.

Thanks for reading and happy coding!

Subscribe to receive new articles. No spam. Quality content.

Comments