Integrating dependencies into our applications can be difficult. This is particularly true when we are customizing a generic framework like Solidus to meet our specific business needs. As eager rubyists, many of us jump at the chance to take advantage of Ruby's open classes. We have even been encouraged to do so.
What's so bad about class_eval?
While using tools like class_eval
can be an expedient fix
to add some logic into a dependency you don't control, if you use it too often
you're going to end up having a terrible, horrible, no good, very bad day.
No API enforcement
Opening a ruby class gives you the ability to add, change, and remove behaviour from ANY method in the class. Nothing is off-limits: private methods, method signatures, and everything else. This makes it incredibly easy to break the dependency by changing a method that is used within it or somewhere else you didn't expect.
# dependency/order.rb
class Dependency::Order
def order_total
items.sum(&:amount)
end
end
# my_app/order_decorator.rb
Dependency::Order.class_eval do
def order_total(tax_amount)
items.sum(&:amount) + tax_amount
end
end
# We just changed the method signature. If the dependency ever calls
# order.order_total internally, we get an argument error
In addition, when we are only modifying existing methods, we end up copying
dependency code into our application. This is great in the short term... until
a new version of the dependency is released and the internal implementation of
the method is changed. Your class_eval
override is now obscuring that change
from your application.
# dependency/order.rb
# Version 2.0
class Dependency::Order
def order_total
perform_super_important_fraud_check
items.sum(&:amount)
end
end
# If we upgrade to v2.0 of the dependency, our class_eval above won't know
# about the super_important_fraud_check
class_eval
collisions
So you've used a class_eval
on a dependency. Oops. Not the end of the
world... until someone else tries to hijack the same class with a second
class_eval
. This is more likely to happen then you might think, especially in
an eco-system like Solidus-Contrib. Many
extensions in Solidus need to add behaviour to the same classes: User, Order,
Shipment, etc. So what happens if more than one class_eval
is used on the
same class? If both decorators try and change the behaviour of the same method,
only one of the new implementations will be used.
# dependency_extension/order.rb
Dependency::Order.class_eval do
def order_total
subtotal = items.sum(&:amount)
add_promotion_discounts(subtotal)
end
end
# Assuming we also have the code from previous examples. What happens when I call
# order.order_total? Does the amount include taxes or does it include promotion
# discounts? Who knows really...
This is called a load order dependency. Your application will exhibit different behaviour depending on the order the classes and decorators are loaded. This is bad and incredibly difficult to debug.
So what's the alternative?
"Alright, I get it". class_eval == 'bad'
. We still need a way of customizing
our dependencies to meet our own application needs. Here are a few strategies
for adding some custom logic into other people's code, without breaking it.
Composition
The Composition pattern is arguably the most flexible solution, but it requires some negotiation with the dependency. If your dependency can provide endpoints to swap out certain classes which are responsible for specific tasks, it gives everyone using the dependency the ability to customise the it for their needs. So long as your modular class responds to the API that the dependency expects, your new custom code should integrate smoothly with the dependency.
This is, in fact, what Solidus is doing to provide extension points for things like the Variant Pricer and the Stock Estimator. If you want an extension point somewhere new: send a PR. You're likely not the only person wanting to customize that area, but the core team may not know that.
# dependency/order.rb
class Dependency::Order
def order_total
Config.order_total_calculator_class.new(items).total
end
end
# my_app/order_total_calculator
class MyApp::OrderTotalCalculator
def total
items.sum(&:amount) + tax_amount
end
def tax_amount
# ...
end
end
# my_appy/initializers/dependency.rb
Dependency::Config.order_total_calculator_class = MyAppy::OrderTotalCalculator
# We have created our own order total calculator and plugged it into the
# dependency
Advantages:
- Your changes are visible to the dependency.
- You're not changing dependency code.
- You are immune to others overriding your behaviour with conflicting
class_eval
s. - You have provided a safe interface to add customized logic to the dependency.
Disadvantages:
- You need to have an existing extension point.
- Or, you need to be able to send a PR to your dependency and create the extension point. (Yaay open source! Let's make software better and more flexible together.)
When to use:
- You have an extension point to hook into.
- You can provide an extension point to hook into.
Decorators... real ones
The Decorator pattern allow you to wrap a dependency class with a class of your own. You can add methods, change methods and add to existing methods all within the safety of your own application.
# my_app/taxable_order.rb
class TaxableOrder
def new(dependency_order)
@order = dependency_order
end
def order_total(tax_amount)
@order.order_total + tax_amount
end
end
# Awesome we added some custom logic without changing anything internal to the
# dependency. However, this class is only accessible from our application, not from
# within the dependency itself.
Advantages:
- You're not changing dependency code.
- You are immune to others overriding your behaviour with conflicting
class_eval
s.
Disadvantages:
- It's still very possible to obscure changes made in the dependency.
- Your changes are not visible to the dependency. Your changes only affect the places in your application where you interact with the decorator.
When to use:
- Your changes are independent of the original implementation.
- You are only adding, not changing existing behaviour.
Module#prepend
When all else fails and the above options are not viable for your situation,
we can fallback to Module#prepend (as of Ruby 2.0). Let's agree now that this
is only marginally better than a class_eval
. The difference being a prepended
module is added to the ancestors chain of the receiving class. This small
difference gives us the ability to use super
, and that can make a huge difference.
# my_app/taxable_order.rb
module TaxableOrder
def order_total
super + tax_amount
end
def tax_amount
# ...
end
end
Dependency::Order.prepend TaxableOrder
So long as we are only adding methods, or adding behaviour around an existing method, we can use the super keyword to call the original implementation. This means when the dependency is updated, your module automagically knows about it.
Advantages:
- Your changes are visible to the dependency.
- You can take advantage of updates in the dependency without changing your application code.
Disadvantages:
- It's still very possible to obscure changes made in the dependency (if you forget to use super).
- Your implementation may conflict with other
prepend
s orclass_eval
s.
When to use:
- After all else has failed you.
After Thoughts
Here at Stembolt, we are a no class_eval company. We think you should be too. Despite it being the fast and easy solution, monkey patching classes leads to fragile code that is difficult to debug, maintain, and extend. Our solution is to provide customizable extension points to allow collaborators to safely extend our applications without breaking core functionality. If you have your own solution to this problem let us know. We would love to hear about it!