0.02
No release in over a year
A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

>= 6.1
 Project Readme

CleanActions

Gem Version Tests status

A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values.

class AddItemToCart < CleanActions::Base
  includes Dry::Initializer

  option :user
  option :item

  # This will report an error if someone accidentally returns wrong instance from #perform_actions.
  returns OrderItem

  # Such checks are happening inside the transaction right before #perform_actions, so
  # you can halt early.
  fail_with(:banned_user) { @user.banned? }

  # This method is executed inside the database transaction.
  # If transaction was opened by another action, which called this one - savepoint won't be created.
  # Last line will be used as a returned value.
  def perform_actions
    @order = CreateOrder.call(user: @user) # if CreateOrder fails - transaction will be rolled back
    @order.order_items.create!(item: @item) # if something else fails here - transaction will be rolled back as well
  end

  # This method will be called for each action after whole transaction commits successfully.
  def after_commit
    ItemAddedSubscription.trigger(order: @order)
  end
end

You can support my open–source work here.

Usage

Add this line to your application's Gemfile:

gem 'clean_actions'

Writing your actions

Inherit your actions from CleanActions::Base, which by defaut includes typed returns and fail_with.

If you want to exclude something — inherit from CleanActions::Action and configure all includes you need.

You should implement at least one of two methods—#perform_actions or #after_commit:

class AddItemToCart < CleanActions::Base
  def perform_actions
    @order = CreateOrder.call(user: @user)
    @order_item = @order.order_items
      .create_with(quantity: 0)
      .find_or_create_by!(item: @item)
  end

  def after_commit
    NotifyUserAboutUpdatedOrderItemJob.perform_later(order_item: @order_item)
  end
end

When first action is called, it will be wrapped to the database transaction, and all actions called by it will be inside the same transaction. All #perform_actions will happen inside the transaction (and rolled back if needed). After that, in case of successful commit, all #after_commit actions will happen in order.

Error handling

If something goes wrong and transaction will raise an error—it will cause transaction to be rolled back. Errors should not be used as a way to manage a control flow, so all unhandled exceptions raised inside actions, will be reraised.

However, if you do expect an error—it's better to represent it as a returned value. Use #fail!(:reason) for that:

class AddItemToCart < CleanActions::Base
  def perform_actions
    fail!(:shop_is_closed)
  end
end

AddItemToCart.call # => CleanActions::ActionFailure(reason: :shop_is_closed)

Typed Returns

Have you ever been in situation, when it's not clear, what will be returned by the class? Do you have some type system in your project? While you are setting it up—use typed returns:

class FetchOrder < CleanActions::Base
  returns Order

  option :order_id

  def perform_actions
    User.find(order_id)
  end
end

FetchOrder.call(42) # => "expected FetchOrder to return Order, returned User" is logged

The last line of #perform_actions will be returned. Note that if you have this module on but configure nothing—action will return nil.

Isolation levels

By default transactions are executed in READ COMMITTED level. You can override it for a specific aciton:

class FetchOrder < CleanActions::Base
  with_isolation_level :repeatable_read

  option :order_id

  def perform_actions
    # actions
  end
end

FetchOrder.call(42) # => "expected FetchOrder to return Order, returned User" is logged

Also, you can configure it for the whole project:

CleanActions.config.isolation_level = :serializable

Savepoints

If you want to run one action inside another but want a nested one be inside the (SAVEPOINT)—use with_savepoint:

class AddItemToCart < CleanActions::Base
  def perform_actions
    @order = CreateOrder.call(user: @user, with_savepoint: true)
  end
end

Note that after_commit still happens when the transaction from the root action is commited.

Error configuration

When something weird happens during the action execution, the message is sent to the Rails log. Also, errors are raised in development and test environments. To change that you can use .config object:

CleanActions.config.raise_errors = true

Here is a list of errors affected by this config:

  • type mismatch from (Typed Returns)[/README.md#Typed-Returns];
  • action with (#before_transaction)[/README.md#before_transaction] is called inside the transaction;
  • invalid isolation levels;
  • action calls from unexpected places.

Advanced Lifecycle

This section contains some additional hooks to improve your actions.

before_transaction

If you want to do something outside the transaction (e.g., some IO operation)—use before_transaction:

class SyncData < CleanActions::Base
  def before_transaction
    @response = ApiClient.fetch
  end

  def perform_actions
    # use response
  end
end

Please note, that error will be risen if this action will be called from another action (and transaction will be already in progress):

class OtherAction < CleanActions::Base
  def perform_actions
    SyncData.call
  end
end

OtherAction.call # => "SyncData#before_transaction was called inside the transaction"  is logged

⚠️ Do not call other actions from this method!

before_actions

If you want to do something before action — use #before_action callback, that is run inside the transaction but before #perform_actions:

class AddItemToCart < CleanActions::Base
  def before_actions
    @order = Order.find(order_id)
  end

  def perform_actions
    # use order
  end
end

⚠️ Do not call other actions from this method!

fail_with

Fail with is a syntax sugar over #fail! to decouple pre–checks from the execution logic. Take a look at the improved example from the Error Handling section:

class AddItemToCart < CleanActions::Base
  fail_with(:shop_is_closed) { Time.now.hour.in?(10..18) }

  def perform_actions
    # only when shop is open
  end
end

If you want to check that action can be called successfully (at least, preconditions are met) — you can use #dry_call, which will run all preconditions and return all failures:

class CheckNumber < CleanActions::Base
  fail_with(:fail1) { @value == 1 }
  fail_with(:fail_odd) { @value.odd? }

  def initialize(value:)
    @value = value
  end
end

CheckNumber.dry_call(value: 1) # => [CleanActions::ActionFailure.new(:fail_odd), CleanActions::ActionFailure.new(:fail1)]

⚠️ Do not call other actions from this method!

rollback

Actions rollback things inside #perform_actions in case of failure because of the database transactions. However, what if you want to rollback something non–transactional?

Well, if you sent an email or enqueued background job—you cannot do much,. Just in case, you want do something—here is a #rollback method that happens only when action fails.

class DumbCounter < CleanActions::Base
  def perform_actions
    Thread.current[:counter] ||= 0
    Thread.current[:counter] += 1
    fail!(:didnt_i_say_its_a_dumb_counter)
  end

  def rollback
    Thread.current[:counter] ||= 0
    Thread.current[:counter] -= 1
  end
end

DumbCounter.call
Thread.current[:counter] # => 0

ensure

Opened file inside #perform_actions or want to do some other cleanup even when action fails? Use #ensure:

class UseFile < CleanActions::Base
  def perform_actions
    @file = File.open # ...
  end

  def ensure
    @file.close
  end
end

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/DmitryTsepelev/clean_actions.

License

The gem is available as open source under the terms of the MIT License.