[フレーム]
Last Updated: February 25, 2016
·
313
· pniemczyk

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.

links

code_source

rubygems

AltStyle によって変換されたページ (->オリジナル) /