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).