Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/DmitryTsepelev/clean_actions
A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values.
https://github.com/DmitryTsepelev/clean_actions
rails ruby
Last synced: 3 months ago
JSON representation
A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values.
- Host: GitHub
- URL: https://github.com/DmitryTsepelev/clean_actions
- Owner: DmitryTsepelev
- License: mit
- Created: 2023-08-29T16:51:09.000Z (about 1 year ago)
- Default Branch: master
- Last Pushed: 2023-10-10T22:15:36.000Z (about 1 year ago)
- Last Synced: 2024-08-05T16:54:02.395Z (3 months ago)
- Topics: rails, ruby
- Language: Ruby
- Homepage:
- Size: 43.9 KB
- Stars: 76
- Watchers: 3
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
Awesome Lists containing this project
README
# CleanActions
[![Gem Version](https://badge.fury.io/rb/clean_actions.svg)](https://rubygems.org/gems/clean_actions)
[![Tests status](https://github.com/DmitryTsepelev/clean_actions/actions/workflows/test.yml/badge.svg)](https://github.com/DmitryTsepelev/clean_actions/actions/workflows/test.yml)
![](https://ruby-gem-downloads-badge.herokuapp.com/clean_actions?type=total)A modern modular service object toolkit for Rails, that respects database transactions and adds type checks to returned values.
```ruby
class AddItemToCart < CleanActions::Base
includes Dry::Initializeroption :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
```## Usage
Add this line to your application's Gemfile:
```ruby
gem 'clean_actions'
```## Writing your actions
Inherit your actions from `CleanActions::Base`, which by defaut includes [typed returns](/README.md#Typed-Returns) and [fail_with](/README.md#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`:
```ruby
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)
enddef 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:
```ruby
class AddItemToCart < CleanActions::Base
def perform_actions
fail!(:shop_is_closed)
end
endAddItemToCart.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:
```ruby
class FetchOrder < CleanActions::Base
returns Orderoption :order_id
def perform_actions
User.find(order_id)
end
endFetchOrder.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:
```ruby
class FetchOrder < CleanActions::Base
with_isolation_level :repeatable_readoption :order_id
def perform_actions
# actions
end
endFetchOrder.call(42) # => "expected FetchOrder to return Order, returned User" is logged
```Also, you can configure it for the whole project:
```ruby
CleanActions.config.isolation_level = :serializable
```## Savepoints
If you want to run one action inside another but want a nested one be inside the ([SAVEPOINT](https://www.postgresql.org/docs/current/sql-savepoint.html))—use `with_savepoint`:
```ruby
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:
```ruby
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`:
```ruby
class SyncData < CleanActions::Base
def before_transaction
@response = ApiClient.fetch
enddef 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):
```ruby
class OtherAction < CleanActions::Base
def perform_actions
SyncData.call
end
endOtherAction.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`:
```ruby
class AddItemToCart < CleanActions::Base
def before_actions
@order = Order.find(order_id)
enddef 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](/README.md#Error-Handling) section:
```ruby
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:
```ruby
class CheckNumber < CleanActions::Base
fail_with(:fail1) { @value == 1 }
fail_with(:fail_odd) { @value.odd? }def initialize(value:)
@value = value
end
endCheckNumber.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.
```ruby
class DumbCounter < CleanActions::Base
def perform_actions
Thread.current[:counter] ||= 0
Thread.current[:counter] += 1
fail!(:didnt_i_say_its_a_dumb_counter)
enddef rollback
Thread.current[:counter] ||= 0
Thread.current[:counter] -= 1
end
endDumbCounter.call
Thread.current[:counter] # => 0
```### ensure
Opened file inside `#perform_actions` or want to do some other cleanup even when action fails? Use `#ensure`:
```ruby
class UseFile < CleanActions::Base
def perform_actions
@file = File.open # ...
enddef 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](https://opensource.org/licenses/MIT).