Dry-transaction - Business logic on rails

Tomasz Buchta

SRUG 2018.3

Whoami

Tomasz Buchta

Ruby on Rails dev @ Akra Polska

Tell me if I speak:

  • Too fast
  • Too slow

Note

  • The code examples are exargated, I want to show as much goodness in as little time possible
  • The topic of monads is widely discussed - I'm focusing on whats important in our context
  • I'm not trying to impress developers - thats impossible :D

Use case

"As a user I want to update my name and email address"

  1. Validate params

  2. Update existing user record

  3. Send verification email

  4. Return result to user

The code

            
  def update_user_email(params)
    user.find(params[:user_id])
    user.update_attributes(email: params[:user_email])
    UserMailer.send_email_changed_email(user)
  end
            
            
Ok, but things do go wrong on occasion - the code should be resilent
            
  def update_user_email(params)
    user.find_by_id(params[:user_id])
    if user
      user.update_attributes(email: params[:user_email])
      UserMailer.send_email_changed_email(user)
    else
      :user_not_found
    end
  end
            
            
            
  def update_user_email(params)
    user.find_by_id(params[:user_id])
    if user
      if user.update_attributes(email: params[:user_email])
        UserMailer.send_email_changed_email(user)
      else
        user.errors.message
      end
    else
      :user_not_found
    end
  end
            
            
            
  def update_user_id(params)
    user.find_by_email(params[:user_id])
    if user
      if user.update_attributes(email: params[:user_email])
        UserMailer.send_email_changed_email(user)
        catch ResqueFailed
          :resque_failed
      else
        user.errors.message
      end
    else
      :user_not_found
    end
  end
            
                
Duh, error handling covers the purpose

Small primer about monads

Category theory definitions

  • Monad - monoid in category of endofunctors.
  • Endofunctor - Functor that maps a category to itself
  • Functor - homomorphism between categories
  • There is a Result type, which can be either a Success or a Failure
  • Success and Failure are practically containers with different data
  •               
                  def step_implementation(input)
                    return Failure(:error) if input.valid?
                    Success(input)
                  end
                  
                    

Dry transaction is based on following ideas

  • A business transaction is a series of operations where any can fail and stop the processing.
  • A business transaction may resolve its operations using an external container.
  • A business transaction can describe its steps on an abstract level without being coupled to any details about how individual operations work.
  • A business transaction doesn’t have any state.
  • Each operation shouldn’t accumulate state, instead it should receive an input and return an output without causing any side-effects.
  • The only interface of an operation is #call(input).
  • Each operation provides a meaningful piece of functionality and can be reused.
  • Errors in any operation should be easily caught and handled as part of the normal application flow.

What Railway oriented programming is about?

In short: whenever things go bad(derail) we go into 'error rail'

But why?

  • Steps are independent - makes for easier testing
  • Transaction becomes a series of steps - eases the understanding
  • Return Success or Failure - Makes for easier flow and error handling

Our use case using dry-transaction

          
require "dry/transaction"

class UpdateUser
  include Dry::Transaction

  step :validate
  step :save
  step :send_user_update_email

  private

  def validate(input)
    # returns Success(valid_data) or Failure(validation)
  end

  def save(input)
    # returns Success(user)
  end
  #...
end

          
            

Usage

            
create_user = UpdateUser.new
create_user.call(email: 'test@email.com') do |m|
  m.success do |user|
    puts "Updated user email to #{user[:email]}"
  end

  # You can match exact steps, only the first match is executed
  m.failure :validate do |validation|
    puts validation
    puts 'Invalid params for user'
  end

  m.failure do |error|
    puts "Sorry but #{error} stopped us"
  end
end
            
            
Full example available
https://github.com/tomasz-buchta/srug-2018.3-dry-transaction/blob/master/code_samples/use-case-dry-transaction.rb
And lets move the steps implementation out, this will make them easier to test and reuse
            
class UpdateUser
  include Dry::Transaction(container: ::Container)

  step :validate,               with: "validate_user_params"
  step :save,                   with: "save_user"
  step :send_user_update_email, with: "send_user_update_email"
end
            
            
You can use dry-container for this(optional)
You can wrap operations
            
class UpdateUser
  include Dry::Transaction(container: ::Container)

  step :validate,               with: "validate_user_params"
  step :save,                   with: "save_user"
  step :send_user_update_email, with: "send_user_update_email"

  private

  def validate(input)
    adjusted_input = upcase_values(input)
  end

  def upcase_values(input)
    #...
  end
end
            
            
You can use dry-container for this(optional)
            
def validate(input)
  schema = Dry::Validation.Schema do
    required(:email).filled(:str?)
  end
  schema.call(input).to_monad
end
            
            
(optional) You can also use dry-validation, theres monads extension to make it seamless

Step notifications

You can use dry-events to subscribe to individual steps
            
NOTIFICATIONS = []
module UserCreationListener
  extend self

  def on_step(event)
    NOTIFICATIONS << "Started creation of #{user[:email]}"
  end
end

create_user = CreateUser.new
create_user.subscribe(create: UserCreationListener)
create_user.call(name: "joe", email: "joe@doe.com")

NOTIFICATIONS 
# => ["Started creation of joe@doe.com"]
            
            

Not only step

map

any output is considered successful and returned as Success(output)

check

the operation returns a boolean. True values return the original input as Success(input). Any other values return the original input as Failure(input).

try

the operation may raise an exception in an error case. This is caught and returned as Failure(exception). The output is otherwise returned as Success(output).

tee

the operation interacts with some external system and has no meaningful output. The original input is passed through and returned as Success(input).
Custom adapters - build one yourself

More sophisticated example

Let's say we want to transfer ownership of some project
  • We need to fetch the project entity
  • We need to fetch the user we want to transfer project to
  • We need to check if the receiver is eligible to own it
  • We need to check if it can be transfered
  • We want to wrap the operation itself in db transaction - project not owned by anyone is no good
            
class TransferProject
  include Dry::Transaction

  step  :fetch_project # Returns Success(output) or Failure(:project_not_found)
  step  :fetch_user
  check :owner_can_release_ownership? # Returns true/false
  check :user_can_own_project? # Returns true/false
  step :transfer_project_ownership # Returns Success(input) or Failure(:project_transfer_error)
  step :notify_user # Returns Success(input) or Failure(:cannot_notify_user)
end
            
            
You can wrap the dry-transaction in db transaction using around step
            
include Dry::Transaction

around :transaction
#...
def transaction(input, &block)
  puts '#### Transaction START ####'
  result = block.(Success(input))
  puts '#### Transaction END ####'
  result
  # transaction do
rescue TransactionError
  Failure(:db_error)
end

            
            
Output:
            
#### Transaction START ####
Transfered the project to Joe Doe
Notified user Joe Doe
#### Transaction END
transfered the project
            
            

Questions?

You get a cookie for each question

Links

https://fsharpforfunandprofit.com/rop/ https://www.morozov.is/2018/05/27/do-notation-ruby.html https://dry-rb.org/gems/dry-transaction/
fb.me/akra.net
akra.net
Aplikuj na: praca@akra.net

Co nas wyróżnia?

  • PROJEKTY(różne branże, projekty zarówno PL jak Zagraniczne)
  • Indywidualny pakiet szkoleniowy
  • Elastyczne godziny pracy i dbałość o Work-Life-Balance
  • Indywidualne podejście (Ty decydujesz jaką formę współpracy przyjmiemy)
  • Super Atmosferę
  • Kontakt z Nowoczesnymi Technologiami

Ponadto mamy to, co wszyscy czyli:

  • Pracujemy w Agile
  • Mamy: pakiet sportowy, medyczny, naukę języków obcych
  • Imprezy integracyjne, bilety do kina i takie takie ( ͡~ ͜ʖ ͡°)

KOGO SZUKAMY??

Być może Ciebie? ( ͡~ ͜ʖ ͡°)

Jeśli:
  • sprawnie komunikujesz się po Angielsku
  • jesteś doświadczonym Specjalistą w Ruby on Rails albo React.JS czy Node.JS
  • jesteś proaktywny, dociekliwy a zdalne ogarnianie projektu to dla Ciebie przysłowiowa „bułka z masłem”
to … ZAPRASZAMY!!

Zapraszamy!

Nasza Siedziba mieści się w Krakowie, biura mamy w:
  • Olsztynie
  • Wrocławiu
  • Zabrzu
Zdziwiony? No to koniecznie musisz nas odwiedzić. Wszystkich Niedowiarków zapraszamy na kawę!