Coming soon in dry-transaction: class-based transactions

Hello, dry-rb community!

For a little while now, Gustavo Caso and I have been working on a big re-orientation to dry-transaction: replacing the anonymous transaction builder with class-based transactions.

The work has been going on in PR #58 and is based on the specification for class-based that I wrote in Issue #52 (most of which I’ll share with you now as rationale).

What was problematic with the previous behavior

After working with dry-transaction for a while, and across a number of apps, I see a few issues in its design:

  • The Dry.Transaction constructor creates objects with an ancestry that app authors have no control over, so there’s no way to add behaviour to all an app’s transactions (this is important in our apps at Icelab, because we use a little #to_queue/#call_from_queue protocol for running operations in background jobs._ - Also because of this, there’s no place to put transaction-specific logic for adjusting values in-between a transaction’s steps, which can be useful for tying together steps with slightly differing input/output expectations.
  • The constructor returning anonymous objects means we need some sort of harness for building and capturing transactions (i.e. registering in our container) in each of our apps.
  • Having to build and register transactions like this means that their definition ends up sitting far apart from other parts of the codebase that they relate to, and their registration ends up complicating the app’s boot process.

We think that class-based transactions will give us ways to solve all of these.

What is a class-based transaction?

Instead of building a transaction like this:

	my_trans = Dry.Transaction(MyContainer) do
	  step :one, "operations.one"
	  step :two, "operations.two"
	end

We can now define transactions in classes, like this:

	class MyTransaction
	  include Dry::Transaction(container: MyContainer)
	  
	  step :one, "operations.one"
	  step :two, "operations.two"
	end
	
	my_trans = MyTransaction.new

This allows us to inject replacement dependencies to make testing easier:

	# Provide an explicit operation for step one
	MyTransaction.new(one: different_operation)

For larger apps, we can provide a per-container transaction mixin for easy reuse, too:

	# In container setup
	module Main
	  Transaction = Dry::Transaction(container: Main::Container)
	end
	
	# Elsewhere
	module Main
	  class AnotherTransaction
	    include Main::Transaction
	    
	    step :one
	    step :two
	    # …
	  end
	end

Or even a base class:

	module Main
	  class Transaction
	    def self.inherited(klass)
	      # mix Dry::Transaction module into subclasses
	    end
	
	    # Can provide custom call logic if needed
	    # def call
	    # end
	    
	    # And shared API to transaction objects
	    def to_queue(input)
	      [input.id]
	    end
	    
	    def call_from_queue(input_id)
	      input = find_from_id(input_id)
	      call(input)
	    end
	  end
	end

Another benefit from class-based transactions is that the application author can add extra, local, steps if they needed to:

	class MyTransaction
	  include Main::Transaction
	  
	  step :one
	  step :prepare_two
	  step :two
	  
	  # Can mix local methods with injected step operations
	  def prepare_two(input)
	    # do something with output of one
	  end
	end

Or even wrap the existing steps:

	class MyTransaction
	  include Main::Transaction
	  
	  step :one
	  step :two
	  
	  def one(input)
	    changed_input = do_something_with(input)
	    super(changed_input)
	  end
	end

And all the other stuff that we get with Ruby’s standard class behaviour.

We think this approach would make dry-transaction easier and more flexible to work with, would make it fit more naturally into larger apps, and handle a variety of different real-world scenarios, many of which we couldn’t even predict right now.

What else is changing?

In building class-based transactions, we’ve been able to add another nice feature along the way:

The container is now optional! Yes, that’s right. While dry-transaction previously required a container to hold all of a transaction’s operations, now that we have classes and instance methods at our disposal, we can work without a container too:

	class MyTransaction
	  include Dry::Transaction() # no container? no problems!
	
	  step :one # will look for matching instance method
	
	  def one(input)
	    # go to work
	  end
	end

However, we’ve also removed some areas of functionality that no longer made sense with class-based transactions:

  • You can no longer extend existing transactions with #prepend, #append, #insert, or #remove. Since transactions will now be instances of your own classes, with their own different behaviors, there’s no predictable way to combine the behaviors of two different classes. If you need the ability to add/remove steps, I suggest you create separate transactions for the different behaviours you need to offer, or build into your own transaction class a way to skip steps based on input or step arguments.
  • Blocks in step definitions are no longer accepted. If you want to wrap a step with some local behavior, you can now use an instance method.
  • There is no longer an option for configuring the result matcher block - we now use Dry::Transaction::ResultMatcher by default. If you want to provide your own matcher, you can do this by overriding #call in your transaction classes and using your own matcher when a block is given.

If you depend on any of these features and none of the alternative approaches will suit you, please let us know in the comments.

We feel that this adjustment to dry-transaction will make it a lot more approachable and useful to many people, and we’re excited to get it out! We’d just like to make sure you’ve had some notice and opportunity to comment before we finalize the release (which will come in a couple of weeks or so).

6 Likes

Really excited about this @timriley, it was great to work and help on this issue.