[S]OLID - Single Responsibility Principle by example
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:
- Construct url
- Log request
- Send request
- 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 params if mapping.empty?
params.each_with_object({}) do |(k, v), hash|
hash[mapping[k] ? mapping[k] : k] = v
end
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 params if mapping.empty?
params.each_with_object({}) do |(k, v), hash|
hash[mapping[k] ? mapping[k] : k] = v
end
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!
Read more about SOLID Principles in case if you missed it: