Role Sharing using Modules in Ruby

Understanding Roles
Some problems require sharing behaviour among unrelated objects. This common behaviour is basically a role an object plays. Many of the roles needed by an application are obvious during the design phase, but it is also common to discover new roles during development.
Discovering Roles
We often end up discovering roles which require not only specific message signatures, but also specific behaviour. When a role needs shared behaviour we’re faced with the problem of organising the shared code.
Ideally this code would be defined in a single place but be usable by any object that wished to act as the duck type and play the role. The Ruby way to do this is through Modules.
Methods can be defined in a module and then the module can be added to any object. Modules thus provide a perfect way to share behaviour by allowing objects of different classes to play a common role using a single set of code. When an object includes a module, its methods become available via automatic delegation.
Basically the object receives messages, it doesn’t understand them, they get automatically routed somewhere else, the correct method implementation is magically found, it is executed, and the response is returned. Once we start putting code into modules and adding modules to objects, we expand this shared behaviour the object can acquire.
An object that directly implements few methods might still have a very large response set. The total set of messages to which an object can respond includes
• Those it implements
• Those implemented in all objects above it in the hierarchy
• Those implemented in any module that has been added to it
• Those implemented in all modules added to any object above it in the hierarchy
Organising Responsibilities and Removing Unnecessary Dependencies
Before we can decide whether to create a duck type and put shared behaviour into a module, we have to know how to do it correctly.
class ExpiryChecker
def expired?(object)
case object.class
when AuthenticationToken
expiration_time = 6.months
when Coupon
expiration_time = 30.days
when AdCampaign
expiration_time = 2.weeks
else
return false
end return DateTime.current < object.created_at + expiration_time
end
end
This is an anti-pattern of checking class to know what message to send. Here the ExpiryChecker
checks class to know what value to use and clearly ExpiryChecker
knows too much. This knowledge doesn’t belong in ExpiryChecker
, it belongs in the classes whose names ExpiryChecker
is checking.
The fact that the ExpiryChecker
checks many class names to determine what value to place in one variable (expiration_time
) suggests that the variable name should be turned into a message, which in turn should be sent to each incoming object.
Discovering the Duck Type and Writing Shareable Code
class AuthenticationToken
attr_accessor :created_at def expiration_time
6.months
end
end
class Coupon
attr_accessor :created_at def expiration_time
30.days
end
end
class AdCampaign
attr_accessor :created_at def expiration_time
2.weeks
end
end
class ExpiryChecker
def expired?(object)
return DateTime.current < object.created_at + object.expiration_time
end
end
This change replaces an if statement that checks the class of an object with a message sent to that same object. It simplifies the code and moves the responsibility for knowing the correct expiration days to the last object that could possibly know the correct answer, which is exactly where this responsibility belongs.
The ExpiryChecker
clearly does not care about object’s class, instead it only expects it to respond to a specific message. This message-based expectation transcends class and exposes a role, one played by all objects. The ExpiryChecker
expects the passed object to behave like something that understands expiration_time
, that is, like something that is “expirable” — a duck type. Expirable
s must implement expiration_time
but currently have no other code in common. Discovering and using this duck type improves the code by removing the ExpiryChecker
’s dependency on specific class names, which makes the application more flexible and easier to maintain.
Requiring that other objects know about a third party, to get behaviour from a string complicates the code by adding an unnecessary dependency. This specific example illustrates the general idea that objects should manage themselves, that they should contain their own behaviour. If our interest is in object B, we should not be forced to know about object A if our only use of it is to find things out about B.
The above code snippet breaks this rule. The instigator is trying to know if the object object is ExpiryChecker
. It doesn’t ask this question of object itself, it instead asks a third party, ExpiryChecker
. The object should be able to respond to expired?
on it own, which means expired?
should be added to the interface of Expirable
role
Extracting the Abstraction
class AuthenticationToken
include Expirable attr_accessor :created_at def expiration_time
6.months
end
endclass Coupon
include Expirable attr_accessor :created_at def expiration_time
30.days
end
endclass AdCampaign
include Expirable attr_accessor :created_at def expiration_time
2.weeks
end
endmodule Expirable
def expired?
return DateTime.current < created_at + expiration_time
end def expiration_time
raise NotImplementedError
end
endauthentication_token = AuthenticationToken.new(created_at: DateTime.current - 10.days)
authentication_token.expired? => falsecoupon = Coupon.new(created_at: DateTime.current - 2.weeks)
coupon.expired? => truead_campaign = AdCampaign.new(created_at: DateTime.current - 1.month)
ad_campaign.expired? => false
The rules for modules are the same as for classical inheritance. If a module sends a message it must provide an implementation, even if that implementation just raises an error indicating that users of the module must implement the method.
We moved the methods to the Expirable
module, including the module and overriding expiration_time
. Now that we have created this module other objects can make use of it to become Expirable
themselves. They can play this role without duplicating the code.