Clowne: Clone Ruby models with a smile

Cover for Clowne: Clone Ruby models with a smile

Topics

Share this post on


Translations

If you’re interested in translating or adapting this post, please contact 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:

The schema

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

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: MyModelMyModelCloner.

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).
  • 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.

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.

In the same orbit

How can we help you?

Martians at a glance
17
years in business

We transform growth-stage startups into unicorns, build developer tools, and create open source products.

If you prefer email, write to us at surrender@evilmartians.com