[Dry::Transaction] How I can add steps which will be called on fail and on any cases?


#1

Is it exists the way to add some steps what will be called on fail and on any cases?


#2

Hi @badenkov, this isn’t something we support as part of the dry-transaction class-level DSL. It’s an interesting feature, though, and I’d be happy to consider it – perhaps you could share your use case here?

That said, it is the kind of thing you could implement manually, although it may feel a bit awkward. All failure values are wrapped in StepFailure objects, which hold the value and the associated failed step object, so you could do potentially override #call, call super, then act on the result and do something if a failure occurred on a specific step.

Hope this helps — I’d be happy to hear any other thoughts you have on this!


#3

On the first look, it is rollback transaction if something was wrong. Or canceling some changes which were done in before steps. I’m working on an app, which using external tool (terraform), and I need to cancel all work if somewhere will fail.
We are using our own analog of dry-transaction, but I like dry-transaction more, and I just check the possibility to use it.
I will use your advice and will write result if I will get some result.
Thank you!


#4

Hello @timriley. I thought about that, and I didn’t find a way to use dry-transaction.
IMO there are all necessary on the language level. It is more convenient approach for me to use something like that:

class SomeCompoundOperation
  def initialize(operation1:, operation2:, operation3: )
    @operation1 = operation1
    @operation2 = operation2
    @operation3 = operation3
  end

  def call(input)
    begin
      in_transaction do
        output1 = @operation1.call(input)
        output2 = @operation2.call(output1)
        output3 = @operation3.call(output2)
    
        output3
      end
    rescue SomeException => e
      ...
    rescue => e
      ...
    ensure
      ...
    end
  end
end

Also in dry-transation case, I concerned that all input and output each step should match for step expectation. But if I build transaction from the container, I can’t be sure in that completely. I like to have it more obvious.

Thanks all for dry-rb libraries.


#5

@badenkov, hello! We faced same problem. We need to handle several error cases on external api calls. Assuming that we have :call_api operation that can return predefined errors (instance of error class) as monadic value, we can do next things:

  • first one is to adjust step operation (I guess it is what @timriley mentioned earlier in this topic):
class MyTransaction
  include Dry::Transaction(container: Container)

  API_ERRORS = [
    MyFirstError,
    MySecondError,
    MyNthError
  ].freeze

  step :validate
  step :call_api
  step :persist

  def call_api(input)
    super.or do |error|
      if error.class.in?(API_ERRORS)
        # do something
      end
      Left(error)
    end
  end
end
  • second approach is to use Dry::Matcher and wrap it into new abstraction (I name it command in my example):
# transaction itself
class MyTransaction
  include Dry::Transaction(container: Container)

  step :validate
  step :call_api
  step :persist
end

# command injecting transaction from some container
class MyCommand
  include Import[transaction: 'transactions.my_transaction']

  def call(input)
    transaction.call(input) do |m|
      m.failure :call_api do |error|
        if error.class.in?(API_ERRORS)
          # do something
        end
        fail error
      end
      m.success { |value| value }
    end
  end
end

# then we can register instance of command as interface to our logic
MyContainer.register('commands.my_command') { MyCommand.new }

I like second approach more since it’s not involved in hacking super() operation, but i’am not sure that is right way to use EitherMatcher, do we allow to put logic outside transaction etc…
We still cannot decide which approach to choose, maybe someone knows a better way.


#6

@iarie Thanks for sharing your experience.
I decided won’t use transactions (and same approaches like a trailblazer’s operations) for next reasons:

class MyTransaction
  include Dry::Transaction(container: Container)

  step :validate
  step :call_api
  step :persist
end

Here I don’t see a lot of important for me as for developer information: what this steps recieve and what this steps return.
Also it is complicated to create reusable operations - because every moment I should think about all cases where this operation will be used, what it will get, and what it will return, and what it will work properly with another operations toghether.

As I wrote above, IMHO more easier just to write:

def some_operation
  result = validate(input)
  if result.success?
    call_api(result.output) # will raise exception if something important went wrong
    persist(result.output) # will raise exception if something important went wrong
  end
rescue => e
  # Here handle if errors
end

I don’t see the problem to write more code. I see the problem when this code complicated (maybe easy, but not simple - see the talk of Rich Hickey), when not obvious, when hide important details. I like and use dry-rb gems, I’m going to use rom-rb, but I have a doubt about dry-transactions.


#7

@badenkov if i understand you correctly, your example is basically the same as what Dry is proposing, but you can’t take advantage of dependency injections without making some additional work, for example injectable transaction with custom monadic-like steps will look somewhat like this in plain ruby:

class PoroTransaction

  def initalize(step1: Step1.new, step2: Step2.new, step3: Step3.new)
    @step1 = step1
    @step2 = step2
    @step3 = step3
  end

  def call(input)
    first_result = @step1.call(input)
    if result.success?
      second_result = @step2.call(first_result)

      if second_result.success?
        third_result = @step3.call(third_result)
      end
    end
  end
end

Though it is simple example i already foresee troubles with handling operations flow.
Using Dry you won’t have to maintain all this parts, you have monads to, pattern matching, injector build and ready to use, plus pub/sub wisper as bonus from the box. I think if you need adjustments between step operations it’s not a big deal, still i’d like to see some proposals or best practices how to correctly put fail-case logic in transaction.


#9

Hi @badenkov,

For what it’s worth. I don’t use transactions like this either:

I much more often have operation classes that look like your second example, where collaborators are injected and I use them as required and handle flow control logic myself.

I usually reserve transactions for when I want to chain together larger, higher-level blocks of functionality.