Slim operations instead code mess by using Light operations
What it is
This is a gem light_operations
gem 'light_operations'
$ gem install light_operations
How it works
Basicly this is a Container for buissnes logic.
You can define dependencies during initialization and run with custom parameters. When you define deferred actions on success and fail before operation execution is finished, after execution one of those action depend for execution result will be executed. Actions could be a block (Proc) or you could delgate execution to method other object, by binding operation with specific object with those methods. You also could use operation as simple execution and check status by success? or fail? method and then by using subject and errors method build your own logic to finish your result. There is many possible usecases where and how you could use operations. You can build csacade of opreations, use them one after the other, use them recursively and a lot more.
How it looks like in code
Class
class MyOperation < LightOperations::Core
def execute(_params = nil)
dependency(:my_service) # when missing MissingDependency error will be raised
end
end
Initialization
MyOperation.new(my_service: MyService.new)
You can add deferred actions for success and fail
# 1
MyOperation.new.on_success { |model| render :done, locals: { model: model } }
# 2
MyOperation.new.on(success: -> () { |model| render :done, locals: { model: model } )
When you bind operation with other object you could delegate actions to binded object methods
# 1
MyOperation.new.bind_with(self).on_success(:done)
# 2
MyOperation.new.bind_with(self).on(success: :done)
Execution method #run
finalize actions execution
MyOperation.new.bind_with(self).on(success: :done).run(params)
After execution operation hold execution state you could get back all info you need
-
#success?
=>true/false
-
#fail?
=>true/false
-
#subject?
=>success or fail object
-
#errors
=>errors by default array but you can return any objec tou want
Default usage
operation.new(dependencies)
.on(success: :done, fail: :show_error)
.bind_with(self)
.run(params)
or
operation.new(dependencies).tap do |op|
return op.run(params).success? ? op.subject : op.errors
end
success block or method receive subject as argument
(subject) -> { }
or
def success_method(subject)
...
end
fail block or method receive subject and errors as argument
(subject, errors) -> { }
or
def fail_method(subject, errors)
...
end
Usage
Uses cases
Basic vote logic
Operation
class ArticleVoteBumperOperation < LightOperations::Core
rescue_from ActiveRecord::ActiveRecordError, with: :on_ar_error
def execute(_params = nil)
dependency(:article_model).tap do |article|
article.vote = article.vote.next
article.save
end
{ success: true }
end
def on_ar_error(_exception)
fail!(vote: 'could not be updated!')
end
end
Controller
class ArticleVotesController < ApplicationController
def up
response = operation.run.success? ? response.subject : response.errors
render :up, json: response
end
private
def operation
@operation ||= ArticleVoteBumperOperation.new(article_model: article)
end
def article
Article.find(params.require(:id))
end
end
Basic recursive execution to collect newsfeeds from 2 sources
Operation
class CollectFeedsOperation < LightOperations::Core
rescue_from Timeout::Error, with: :on_timeout
def execute(params = {})
dependency(:http_client).get(params.fetch(:url)).body
end
def on_timeout
fail!
end
end
Controller
class NewsFeedsController < ApplicationController
DEFAULT_NEWS_URL = 'http://rss.best_news.pl'
BACKUP_NEWS_URL = 'http://rss.not_so_bad_news.pl'
def news
collect_feeds_op
.bind_with(self)
.on(success: :display_news, fail: :second_attempt)
.run(url: DEFAULT_NEWS_URL)
end
private
def second_attempt(_news, _errors)
collect_feeds_op
.on_fail(:display_old_news)
.run(url: BACKUP_NEWS_URL)
end
def display_news(news)
render :display_news, locals: { news: news }
end
def display_old_news
end
def collect_feeds_op
@collect_feeds_op ||= CollectFeedsOperation.new(http_client: http_client)
end
def http_client
MyAwesomeHttpClient
end
end
Basic with activemodel/activerecord object
Operation
class AddBookOperation < LightOperations::Core
def execute(params = {})
dependency(:book_model).new(params).tap do |model|
model.valid? # this method automatically provide errors from model.errors
end
end
end
Controller
class BooksController < ApplicationController
def index
render :index, locals: { collection: Book.all }
end
def new
render_book_form
end
def create
add_book_op
.bind_with(self)
.on(success: :book_created, fail: :render_book_form)
.run(permit_book_params)
end
private
def book_created(book)
redirect_to :index, notice: "book #{book.name} created"
end
def render_book_form(book = Book.new, _errors = nil)
render :new, locals: { book: book }
end
def add_book_op
@add_book_op ||= AddBookOperation.new(book_model: Book)
end
def permit_book_params
params.requre(:book)
end
end
Simple case when you want have user authorization
Operation
class AuthOperation < LightOperations::Core
rescue_from AuthFail, with: :on_auth_error
def execute(params = {})
dependency(:auth_service).login(login: login(params), password: password(params))
end
def on_auth_error(_exception)
fail!([login: 'unknown']) # or subject.errors.add(login: 'unknown')
end
def login(params)
params.fetch(:login)
end
def password(params)
params.fetch(:password)
end
end
Controller way #1
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op
.bind_with(self)
.on_success(:create_session_with_dashbord_redirection)
.on_fail(:render_account_with_errors)
.run(params)
end
private
def create_session_with_dashbord_redirection(account)
session_create_for(account)
redirect_to :dashboard
end
def render_account_with_errors(account, _errors)
render :new, locals: { account: account }
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Controller way #2
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op
.on_success{ |account| create_session_with_dashbord_redirection(account) }
.on_fail { |account, _errors| render :new, locals: { account: account } }
.run(params)
end
private
def create_session_with_dashbord_redirection(account)
session_create_for(account)
redirect_to :dashboard
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Controller way #3
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op.on_success(&go_to_dashboard).on_fail(&go_to_login).run(params)
end
private
def go_to_dashboard
-> (account) do
session_create_for(account)
redirect_to :dashboard
end
end
def go_to_login
-> (account, _errors) { render :new, locals: { account: account } }
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Register success and fails action is avialable by #on
like :
def create
auth_op.bind_with(self).on(success: :dashboard, fail: :show_error).run(params)
end
Operation have some helper methods (to improve recursive execution)
-
#clear!
=> return operation to init state -
#unbind!
=> unbind binded object -
#clear_subject_with_errors!
=> clear subject and errors
When operation status is most importent we can simply use #success?
or #fail?
on the executed operation
Errors are available by #errors
after operation is executed
I hope this gem helps you to build more readable and clean code with separated logic. This is very early version but it should works and be nice in use.