csovaq
Last Updated: February 25, 2016
·
131
· 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