SOLID principles in Ruby

Category: Design Patterns :: Published at: 23.05.2022

SOLID is a popular group of design principles and if you are a developer, for sure you had contact with it.

It is must if you want to get a nice job, but still many programmers have a problem to deal with this in 100%.

This is my explanation what is SOLID about in Ruby ecosystem.

S - SINGLE RESPONSIBILITY PRINCIPLE

A class should have one, and only one reason to change - Robert C Martin

This rule is one of the simpliest to understand. Let's say, we have a payment flow inside our application, and we want
to create a class which will help to deal with some things inside the process. Let's check out this example:

def PaymentFlow
  def initialize(payment)
    @payment = payment
  end

  def update_counters
    SaleCounter.count_up(@payment.items.count)
  end

  def send_email
    SomeMailer.send_email(payment: @payment)
  end
end

Now, if we want to use methods, we can use it like this:

def some_method
  flow = PaymentFlow.new(payment)
  flow.update_counters
  flow.send_email
end

In this example you can obviously see, that the class is responsible here for more than one thing. It should not be like that.

Single Responsibility rule says, that the class should be responsible only for one thing. In our case above, we should create
two seperated classes, and use them seperately.

def UpdateCountersService
  def initialize(payment)
    @payment = payment
  end

  def call
    SaleCounter.count_up(@payment.items.count)
  end
end
def SendSuccessEmailService
  def initialize(payment)
    @payment = payment
  end

  def call
    SomeMailer.send_email(payment: @payment)
  end
end

and our calls should look like that:

def some_method
  UpdateCountersService.new(payment).call
  SendSuccessEmailService.new(payment).call
end

What advatage this rule gives to us?

  • decreases the coupling inside our code - if something changes - it is easier to control it - we need to change only one thing instead of looking inside many connected methods and trying to resolve, what happened here

 

O - OPEN / CLOSED PRINCIPLE

In the contrast - it was one of the hardest rules to understand for me and it took quite some time for me to get it 100%.

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Let's say, we have some class like below:

class SendSuccessEmailNotificationService
  def initialize(payment)
    @payment = payment
  end

  def call
    # some logic for send success email
  end
end

As you can see this class will send a success email to the payer.

What if we want to send another email depending on the payment type? The easiest way would be:

class SendSuccessEmailNotificationService
  def initialize(payment)
    @payment = payment
  end

  def call
    if @payment.type == "vip"
      # some logic for send success email to the vips
    else
      # some logic for send success email to the other people
    end
  end
end

But this is not the good pattern. As you can see we are changing completely the logic of this class and now we need
to take care of two different paths here. The correct way with using Open/Closed Principle here would be:

class SendSuccessEmailNotificationService
  MAILERS = {
    "vip" => SendVipSuccessEmailService,
    "normal" => SendSuccessEmailService
  }.freeze

  def initialize(payment)
    @payment = payment
  end

  def call
    MAILERS[@payment.type].new(payment: @payment).call
  end
end

As you can see, we did not modify the logic of our class - we just have extended its functionality.

 

L - LISKOV SUBSTITUTION PRINCIPLE

If S is a subtype of T, then objects of type T may be replaced with objects of type S - Barbara Liskov

This is quite straight-forward. Let's say we have class like this:

class ClientInvoices
  def initialize(user)
    @client = client
  end

  def invoices
    @client.invoices
  end
end

Now we want to create some subclass named VipInvoices which will interhit from ClientInvoices and will return some documents too:

class VipInvoices < ClientInvoices
  def invoices
    invoices = super

    invoices.pluck(:name)
  end
end

The example above will break the rule of Liskov Substitution Principle, because it will return different type of the data.

To keep invoices method corresponding to LSP rule - three things should match:

  • name of the methods should be the same
  • arguments passed to those methods should be consistent
  • the data, method will return should have the same type

The really nice example, how we can use this principle in practice is the code below:

class File
  def regenerate
    raise "NotImplemented"
  end
end

class Picture < File
  def regenerate
    # some code
  end
end

class Audio < File
end

This code will return raise "Not Implemented" error to us if we call it. It is an easy way to keep the code clean and simple.

 

I - INTERFACE SEGREGATION PRINCIPLE

Clients should not be forced to depend upon interfaces that they don't use. - Robert C. Martin

This principle is strictly connected with static languages and there is no concept of interfaces in dynamic
languages like Ruby.

But we can somehow move it into Ruby reality. In simple words we can say, that this principle is all about
dividing big class into smaller classes and big method into smaller methods.

 

D - DEPENDENCY INVERSION PRINCIPLE

A high-level module should not depend on a low-level module; both should depend on abstraction.

The last rule od SOLID is about dependentions. Let's look at this example:

class UploadImageService
  def initialize(image)
    @image = image
  end

  def call
    if @image.url.end_with?(".jpg")
      FirstUploader.new(@image).call
    else
      SecondUploader.new(@image).call
    end
  end
end

As you can see, we have 2 uploader classes and depending on the extension of our file, we use different class to upload a file.

This is a violation of Dependency Inversion Principle. The simple resolution of our example is just passing a class name into our name.

class UploadImageService
  def initialize(image, uploader_class)
    @image = image
    @uploader_class = uploader_class
  end

  def call
    uploader_class.new(@image).call
  end
end

my_image = Image.new(params)
UploadImageService.new(my_image, FirstUploader).call

The situation, you can see above we are used to call a Dependency Injection.

 

In the end, i need to quote a really nice SOLID rules summary, wrote by Paweł Dąbrowski on his blog - longliveruby.com.

He took all the rule names and wrote one important sentence about each of them to make them easy to understand:

  • Single responsibility principle - one class is responsible for only one thing
  • Open/closed principle - class should be open for extension and closed for modification
  • Liskov substitution principle - it should be possible to replace the parent class with its child class, and it would work the same
  • Interface segregation principle - it’s better to have multiple simple methods instead of one bigger
  • Dependency inversion principle - let the person who calls method define the dependency by passing them as arguments

The source of this really nice summary is here.

 


- Click if you liked this article

Views: 33