Ruby Science
Replace Mixin with Composition
Mixins are one of two mechanisms for inheritance in Ruby. This refactoring provides safe steps for cleanly removing mixins that have become troublesome.
Removing a mixin in favor of composition involves the following steps:
- Extract a class for the mixin.
- Compose and delegate to the extracted class from each mixed in method.
- Replace references to mixed in methods with references to the composed class.
- Remove the mixin.
Uses
- Liberates business logic trapped in mixins.
- Eliminates name clashes from multiple mixins.
- Makes methods in the mixins easier to test.
Example
In our example applications, invitations can be delivered either by email or private message (to existing users). Each invitation method is implemented in its own class:
# app/models/message_inviter.rb
class MessageInviter < AbstractController::Base
include Inviter
def initialize(invitation, recipient)
@invitation = invitation
@recipient = recipient
end
def deliver
Message.create!(
recipient: @recipient,
sender: @invitation.sender,
body: render_message_body
)end
end
# app/models/email_inviter.rb
class EmailInviter < AbstractController::Base
include Inviter
def initialize(invitation)
@invitation = invitation
end
def deliver
Mailer.invitation_notification(@invitation, render_message_body).deliver
end
end
The logic to generate the invitation message is the same regardless of the delivery mechanism, so this behavior has been extracted.
It’s currently extracted using a mixin:
# app/models/inviter.rb
module Inviter
extend ActiveSupport::Concern
do
included include AbstractController::Rendering
include Rails.application.routes.url_helpers
self.view_paths = 'app/views'
self.default_url_options = ActionMailer::Base.default_url_options
end
private
def render_message_body
template: 'invitations/message'
render end
end
Let’s replace this mixin with a composition.
First, we’ll extract a new class for the mixin:
# app/models/invitation_message.rb
class InvitationMessage < AbstractController::Base
include AbstractController::Rendering
include Rails.application.routes.url_helpers
self.view_paths = 'app/views'
self.default_url_options = ActionMailer::Base.default_url_options
def initialize(invitation)
@invitation = invitation
end
def body
template: 'invitations/message'
render end
end
This class contains all the behavior that formerly resided in the mixin. In order to keep everything working, we’ll compose and delegate to the extracted class from the mixin:
# app/models/inviter.rb
module Inviter
private
def render_message_body
InvitationMessage.new(@invitation).body
end
end
Next, we can replace references to the mixed in methods
(render_message_body
in this case) with direct references
to the composed class:
# app/models/message_inviter.rb
class MessageInviter
def initialize(invitation, recipient)
@invitation = invitation
@recipient = recipient
@body = InvitationMessage.new(@invitation).body
end
def deliver
Message.create!(
recipient: @recipient,
sender: @invitation.sender,
body: @body
)end
end
# app/models/email_inviter.rb
class EmailInviter
def initialize(invitation)
@invitation = invitation
@body = InvitationMessage.new(@invitation).body
end
def deliver
Mailer.invitation_notification(@invitation, @body).deliver
end
end
In our case, there was only one method to move. If your mixin has multiple methods, it’s best to move them one at a time.
Once every reference to a mixed in method is replaced, you can remove the mixed in method. Once every mixed in method is removed, you can remove the mixin entirely.
Next Steps
- Inject dependencies to invert control and allow the composing classes to use different implementations for the composed class.
- Check the composing class for feature envy of the extracted class. Tight coupling is common between mixin methods and host methods, so you may need to use move method a few times to get the balance right.
Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.