If you’re interested in translating or adapting this post, please email us first .

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 and else -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:

Our schema

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

Read about amoeba and other alternatives in our docs.

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 (only order_items ).

into an existing order (only ). 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:

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.