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.
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:
Dry.Transactionconstructor 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
#call_from_queueprotocol 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
#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::ResultMatcherby default. If you want to provide your own matcher, you can do this by overriding
#callin 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).