Not the full truth about decorators
Shock! Not the full truth about decorators, everything that they have been hiding from us! Without registration and SMS.
Gem 'draper' is not a decorator! And 'cells' too.
- Okay, people, move along! It's a wrap. Everyone knows it already. Right?
The first article about interactors quickened interest in the audience. So here is the continuance, this time about decorators!
To the point
Let's agree from the beginning: I adhere to the GOF definition of a 'Decorator' pattern. That is exactly what Wikipedia says.
Decorator is a design pattern that allows behavior to be added to an individual object dynamically. Decorator pattern provides the flexible alternative to the practice of creating subclasses in order to extend functionality.
The added functionality is realized in a small objects. The advantage is that one can dynamically add the functionality before or after the main object functionality.
As always, the example makes it easier to understand. We will continue to discuss the order placement, more specifically, we will implement the delivery cost calculation.
However strange it may sound, but the delivery cost depends on the delivery volume, weight, destination, whether it will be up-to-door shipping or the delivery to the city access point, whether you need the order to be wrapped in a bubble-pack or in a bandel, and, of course, which delivery service you are going to use. In short, there is a load of parameters! Just look at the site of any shipping company and you will see the calculator with thousands of parameters.
In the beginning we will have the following set.
We have the class
Order with attributes: weight, width, height, length and city where the order has to be delivered to.
class Order attr_accessor :weight, :width, :height, :length, :city def initialize(weight, width, height, length, city) @weight = weight @width = width @height = height @length = length @city = city end def volume # not the volume of sound width*height*length end end
Also, we have an external service for calculating the delivery cost depending on the city (always returns 100).
class DlhApi # the delivery service with a red-yellow logo def self.calculate(city) 100 end end
And the delivery class itself.
class Delivery WEIGHT_COEFFICIENT = 1 VOLUME_COEFFICIENT = 1 def initialize(order) @order = order end def cost @order.weight * WEIGHT_COEFFICIENT + @order.volume * VOLUME_COEFFICIENT end def city @order.city end end order = Order.new(10, 5, 1, 1, "London") Delivery.new(order).cost #=> 15
Okay, this is done. There is an order. There is a delivery our order is passed onto. And there is a method for calculating the delivery cost. Seems pretty easy so far.
- Brute-force solution. It is worth noting that one shouldn't always go after beautiful abstractions. Sometimes the brute force solution is the right thing.
And the solution is that we add all our "cost modifiers" into the class
Delivery itself as options. And there in the method
cost we will change the final cost just directly searching the options. All in all, why not, if there are only 2-3 modifiers? But we have a lot of them. Just the delivery services alone can count 10.
The inheritance solution. We can create the subclass
UpToDoorDelivery, which we will create without any if-s. But following the same logic, we will have to create the subclass
OneDayDlhUpToDoorBubbleWrappedDelivery. And that is the problem that the Decorator pattern is destined to solve.
Solution with decorators. And that's how it will look like.
Decorators have a sort of tradition - every New Year they gather together in a bath-house and wrap each other (if you know what I mean). But speaking seriously, decorator is a design pattern, the subtype of a composite pattern. We can wrap one decorator with another, then another, and this way every new layer will add the new functionality. Here's a simple example:
class DeliveryDecorator def initialize(obj) @obj = obj end def cost @obj.cost + 10 end end DeliveryDecorator.new( Delivery.new( order )).cost #=> 25 DeliveryDecorator.new( DeliveryDecorator.new( Delivery.new( order ))).cost #=> 35 DeliveryDecorator.new( DeliveryDecorator.new( DeliveryDecorator.new( Delivery.new( order )))).cost #=> 45
We save the order that we are decorating upon initialization, and then call its method
cost, inside our implementation of the method
From this we can conclude: all the decorators of one object should have the common interface (in our case this is the method
Reader's question: The most obvious question - the code organization.... What and where should be placed....
Expert committee answer: These shoulda be placed ova hea!
And as an argument to the expert committee answer I'll attach the notarized screenshot.
Such structure is evident and easily understandable for me.
- First of all, decorators are placed in the decorators' folder.
- Secondly, such structure allows me to utilize rails' namespaces. If I place something in the folder 'delivery' and after that I will have the namespace 'Delivery', too - that is cool!
The author of this article can become
your personal mentor.
Let's keep going
I'm sure that you've already had a sneaky peek at the bubble decorator implementation on the picture. That's why I'll give an example of the two other decorators' implementation.
class Delivery::Dlh def initialize(obj) @obj = obj end def cost @obj.cost + SomeDhlApi.calculate(@obj.city) end end class Delivery::UpToDoor STANDARD_TIP = 10 def initialize(obj) @obj = obj end def cost @obj.cost + STANDARD_TIP end end order = Order.new(10, 5, "London") # Just delivery Delivery.new( order ).cost #=> 15 # Expedited shipping Delivery::Dlh.new( Delivery.new(order)).cost #=> 115 # Expedited shipping, the parcel is wrapped in a bubble-pack Delivery::BubbleWrapped.new( Delivery::Dlh.new( Delivery.new( order ) ) ).cost #=> 125 # Up to door shipping, the parcel is wrapped in a bubble-pack Delivery::UpToDoor.new( Delivery::BubbleWrapped.new( Delivery::Dlh.new( Delivery.new( order ) ) ) ).cost #=> 140
Isn't that wonderful? We upped and divided all this complicated logic to the logical units, put it into different files, we make such beautiful calls. Can it be any better?
Homework - figure out how all this works, here's an example.
module Delivery::BubbleWrapped def cost super + 10 end end module Delivery::UpToDoor def cost super + 15 end end module Delivery::Dhl def cost super + SomeDhlApi.calculate(city) end end order = Order.new(10, 5, 1, 1, "London") delivery = Delivery.new(order) delivery.cost #=> 15 delivery.extend(Delivery::Dhl) delivery.cost #=> 115 delivery.extend(Delivery::BubbleWrapped) delivery.cost #=> 125 delivery.extend(Delivery::UpToDoor) delivery.cost #=> 140
This is not simply referring to where I got the material from. This is for more extensive self-study. Trust me, this is worth it.
- Design Patterns in Ruby. The chapter about decorators
- Tidy Views and Beyond with Decorators
- Evaluating Alternative Decorator Implementations In Ruby
- Decorators, Presenters, Delegators and Rails
- Stackoverflow. Decorators vs presenters
P.S. And now, what is wrong with the draper?
Well, everything's fine. Gem
draper allows us to create a full decorator.
In order to get that done, one should add into the
Draper::Decorator the call
delegate_all, so that our call would be passed in chain order unless the last layer implements the method we need.
Ah, and there's one more restriction at the moment, draper isn't able to wrap decorator and another similar decorator (ref. the last 2 lines of the example), the same problem as with the modular approach.
require 'draper' class DeliveryDecorator < Draper::Decorator delegate_all def cost object.cost + 10 end end DeliveryDecorator.new(Delivery.new(order)).cost #=> 25 DeliveryDecorator.new(DeliveryDecorator.new(Delivery.new(order))).cost #=> 25 ...
The reason why I dared to make such a loud statement is that
draper is not a decorator in the way that it's used. And it is used as a bridge between a model and a view. And that's how they position themselves - Decorators/View-Models for Rails Applications.
But in fact the
Presenter pattern is responsible for that. But we'll talk about it later on. Stay tuned.