Meet Clowne—a flexible tool for all your model cloning needs that comes with no strings attached. Your application does not have to be Rails or use Active Record. This Ruby gem shines at a common task: duplicating business logic entities. Read on to see how Clowne does the trick on an all too familiar e-commerce case.
Modern web applications evolve at the speed of light, in response to constantly shifting requirements. What starts as a simple, elegantly implemented feature may turn into a spaghetti-monster after several iterations of UX. Sometimes a new requirement can sound deceptively simple: “I want the user to redo/reuse something.” However, you should not let your guard down: stick with us to see how not to shoot yourself in the foot when implementing something like that.
Attack of the clones
Cloning parts of business logic (usually backed by models) is a standard feature in SaaS web applications: you expect a user to be able to copy something like a board, a course or a to-do. Think Trello, Basecamp, Coursera, or any other platform you know and love.
From the implementation point of view (and now we step into the Ruby world), it can start as simple as MyModel.dup
, clean and easy.
But then the management introduces new “ifs” and “buts” that quickly turn into a soup of
if
-s andelse
-s in your code.
To demonstrate the power of the Clowne, we will introduce a fictional online store called “Martian Souvenirs”. We are the developers, and the manager has just walked in with a new user story: “As a customer, I can repeat my previous order with a click.” Bam!
Having fun with diagrams
Our flow is straightforward: a customer comes, chooses yet-another-souvenir, places an order, chooses some additional items (stickers, gift wrap, etc.)—and does the checkout. Here is how our schema looks like:
So how do we clone an Order
? Take a look at the diagram above and think for a second: should we just copy all the records or is there something else to take into account?
There are a lot of things. We need to
- make sure only available items are included in a new order;
- only use Promotion if it has not expired;
- generate a new unique identifier for a new order; and
- re-calculate the final price (because prices for items might have changed).
Let’s try to accomplish this on our own: just ActiveRecord#dup
and plain old Ruby.
class OrderCloner
def self.call(order)
new_order = order.dup
new_order.uuid = Order.generate_uuid
new_order.promotion_id = nil if order.promotion.expired?
# suppose that we have .available scope
order.order_items.available.find_each do |item|
new_order.order_items << item.dup
end
order.additional_items.find_each do |item|
new_order.additional_items << item.dup
end
new_order.total_cents = OrderCalculator.call(new_order)
end
end
Doesn’t look too complicated, does it? Unfortunately, the code above does not fully work—it does not consider the STI nature of an AdditionalItem
model. For instance, some additional items may associate with other models or have some attributes that we will need to nullify.
How can we handle this? We can add switch
/case
and apply different transformations to different items. Or we can use a tool tailor-made precisely for that kind of work.
Send in the Clowne
We faced a similar situation many times in our projects at Evil Martians. So instead of coming up with a new cloning logic every time, we decided to develop a Swiss Army knife ready to handle all possible cases. That is how Clowne was born.
Clowne provides a declarative approach to cloning models.
All you need is to specify a cloner class that handles all required transformations and inherit it from Clowne::Cloner
:
# app/cloners/order_cloner.rb
class OrderCloner < Clowne::Cloner
include_association :additional_items
include_association :order_items, scope: :available
nullify :payed_at, :delivered_at
finalize do |source, record, _params|
record.promotion_id = nil if source.promotion&.expired?
record.uuid = Order.generate_uuid
record.total_cents = OrderCalculator.call(record)
end
end
The syntax might look familiar if you have ever used an amoeba gem. It is not a coincidence—we did use amoeba
for that sort of tasks, but it turned out to be not flexible enough for us.
To tackle the STI problem all you have to do is to define a cloner for each class. Note that you can define one base cloner and inherit from it:
# app/cloners/additional_items/*_cloner.rb
module AdditionalItems
class BaseCloner < Clowne::Cloner
nullify :price_cents
end
class PackagingCloner < BaseCloner
finalize do |_source, record|
# price might have changed
record.price_cents = Packaging.price_for(record.packing_type)
end
end
class StickerCloner < BaseCloner
finalize do |source, record|
# price might have changed
record.price_cents = source.sticker_pack.price_cents
end
end
end
Clowne infers the correct cloner for your model automatically, using convention over configuration: MyModel
→ MyModelCloner
.
Note that we nullify price_cents
in BaseCloner
—we want to make sure that the price is recalculated in child cloners (otherwise the resulted record will not be validated).
Now it is finally the time to use our cloners!
order = Order.find(params[:id])
cloned = OrderCloner.call(order)
cloned.save!
It looks like we have just reinvented our PORO OrderCloner
service. Did we just over-engineer? Let’s not jump to conclusions though, as even in such a simple case Clowne will prove its worth in testing:
# spec/cloners/order_spec.rb
RSpec.describe OrderCloner, type: :cloner do
subject { described_class }
let(:order) { build_stubbed :order }
specify "associations" do
is_expected.to clone_association(:additional_items)
is_expected.to clone_association(:order_items)
.with_scope(:available)
end
specify "finalize" do
# only apply finalize transformations
cloned_order = described_class.partial_apply(:finalize, order)
expect(cloned_order.uuid).not_to eq order.uuid
end
end
Clowne provides a set of testing helpers, allowing you to test cloners in full isolation (even separate cloning steps).
Now imagine the spec for a PORO cloning service where you have to write complex expectations and generate all the data yourself, including associated records with their STI types, then verify that everything is cloned correctly.
Clowning around with more complexity
Time passes, and our manager comes in again, this time with a new idea: “As a customer, I can merge my previous order to a new order (pending one)“. That is almost the same task as the previous one. The only difference is that instead of creating a new record we need to populate an existing one with features (attributes, associations) from another record.
Clowne has some useful DSL for that: init_as and trait.
Let’s extend our cloner a little bit:
# app/cloners/order_cloner.rb
class OrderCloner < Clowne::Cloner
include_association :additional_items
include_association :order_items, scope: :available
finalize do |source, record, _params|
record.promotion_id = nil if source.promotion&.expired?
record.uuid = Order.generate_uuid if record.new_record?
record.total_cents = OrderCalculator.call(record)
end
trait :merge do
init_as { |_source, current_order:| current_order }
end
end
The init_as
command allows you to specify the initial duplicate record for cloning (by default, Clowne uses source.dup
).
The trait
logic is inspired by factory_bot: each trait contains a set of transformations that are applied only if the trait is activated:
# use traits option to activate traits
old_order = Order.find(params[:old_id])
order = Order.find(params[:id])
merged = OrderCloner.call(old_order, traits: :merge, current_order: order)
merged == order
Note that we pass additional parameters to our call (current_order
). That is also a noticeable feature of Clowne—an ability to pass arbitrary params to any cloner. You can use them in finalize
and init_as
blocks, or for building custom associations scopes.
The final trick
The manager is almost happy with us. He insists on some corrections though:
- Do not merge
additional_items
into an existing order (onlyorder_items
). - Set quantity of every cloned
order_item
to 1.
With Clowne, making these changes is trivial. All we need is to use exclude_association and specify a clone_with
option for order_items
:
# app/cloners/order_cloner.rb
class OrderCloner < Clowne::Cloner
class CountableItemCloner < OrderItemCloner
finalize do |_source, record|
record.quantity = 1
end
end
# ...
trait :merge do
include_association :order_items, scope: :available,
clone_with: CountableItemCloner
exclude_association :additional_items
init_as { |_source, current_order:, **| current_order }
end
end
No joke
No one knows what else a manager (or a customer) might have in mind down the road. With Clowne, you can respond to new requirements quicker and in a more streamlined manner.
Clowne comes with extensive documentation. Here are some significant features:
- Testing utilities
- Flexible usage: traits, in-line configuration
- Ability to add custom declarations and override cloning behavior
- Multiple ORMs support (and Rails-free)
- Active Record integration
Our tool was born from production and proves its worth every day in our projects. Now we are sharing it with the community.
If you have a feature request to make or a bug to report, feel free to contact us through GitHub.