Before delving into how we support database (DB) transactions, let's first consider how we can handle operations that only pertain to the unhappy path. This consideration is important because, as we will see, it will become relevant when we reach the main topic.
Most of the time, the unhappy path is something managed by the caller (e.g., a controller rendering an error in case of failure). However, there are situations where it makes sense to encapsulate part of the unhappy path within the operations class. For instance, you might want to log the failure somewhere.
When relying on the vanilla #steps
method, the implementation is straightforward:
class CreateUser < Dry::Operation
def call(input)
steps do
attrs = step validate(input)
user = step persist(attrs)
assign_initial_roles(user)
step send_welcome_email(user)
user
end.tap do |result|
log_failure(result) if result.failure?
end
end
end
However, it is beneficial to automatically prepend the #steps
method in #call
for a couple of reasons:
- It reduces boilerplate.
- It consolidates the interface of the operation, ensuring it always returns a result object.
This leads us to a single option: calling a hook for the failure case:
class CreateUser < Dry::Operation
def call(input)
attrs = step validate(input)
user = step persist(attrs)
assign_initial_roles(user)
step send_welcome_email(user)
user
end
private
def on_failure(user)
log_failure(user)
end
end
Instead of allowing the registration of multiple hooks, it is better to allow a single one where users can dispatch to other methods if needed. This approach allows us to skip dealing with hook ordering and makes the flow more linear.
There is no need, at least for now, to make the hook method name configurable; on_failure
is sufficient.
It's worth noting that we now allow multiple methods to be prepended, as in operate_on :call, :run
. Therefore, we need a way to specify which hook to call for a given prepended method. We can achieve this by providing a second argument to on_failure
when the method is defined with arity 2:
def on_failure(result, method)
case method
when :call
do something()
when :run
do something_else()
end
end
Leaving aside the interface for now, we have two architectural options:
- Wrap the entire
#steps
call in a transaction:
class CreateUser < Dry::Operation
use_db_transaction
def call(input)
attrs = step validate(input)
user = step persist(attrs)
assign_initial_roles(user)
step send_welcome_email(user)
user
end
end
Benefits:
- It supports composing operation classes within a single transaction:
CreateUser.new >> CreatePost.new
Drawbacks:
- It wraps potentially expensive operations in a transaction, such as
send_welcome_email
in the example. - It is not optimized, though not critical, to wrap
validate
in a transaction.
We find the drawbacks to be unacceptable. If we were to support this option, we would need to use hooks for the setup and success cases:
class CreateUser < Dry::Operation
use_db_transaction
def call(input)
user = step persist(attrs)
assign_initial_roles(user)
user
end
private
def setup(input)
step validate(input)
end
def on success(user)
step send_welcome_email(user)
end
end
In this case, the introduced indirection is also considered unacceptable. While we need to support a hook for the on_failure
scenario, dry-operation
should prioritize readability when focusing on the happy path.
- Explicitly wrap the steps that need to run in a transaction:
class CreateUser < Dry::Operation
use_db_transaction
def call(input)
attrs = step validate(input)
transaction do
user = step persist(attrs)
assign_initial_roles(user)
end
step send_welcome_email(user)
user
end
end
Benefits:
- It is explicit.
- It enhances readability.
Drawbacks:
- It requires manual setup.
- It makes it impossible to compose operation classes within a single transaction.
In this case, the drawbacks are considered acceptable. There is no way to completely conceal the fact that we are dealing with a database transaction, and developers need to consider it. Furthermore, one of the key concepts of dry-operation
is the decoupling of individual operations. Therefore, we should encourage the composition of operations rather than groups of operations in the documentation.
A Dry::Operation.db_adapter
method could be sufficient to configure how Dry::Operation#transaction
works.
We can think of three ORM-style libraries we want to support: ROM
, Sequel
, and ActiveRecord
. Different libraries might require different options, and we can use different option names in any case. For example:
class CreateUser < Dry::Operation
db_adapter :rom, container: Deps[:rom], gateway: :default
# ...
end
- Support the
#on_failure
hook. - Support the
#transaction
method through.db_adapter
. - Support ROM
- Support AR
- Support Sequel
@waiting-for-dev Thanks for putting together those thoughts.
Prepend
steps
Agreed, as it will reduce the boilerplate.
on_failure
HookThis is not a good idea. IMO, each method should handle the potential errors locally.
Let me expand your example:
๐ There is a loss in code locality.
on_failure
will keep growing and knowing about other methods implementations.๐ Can't we leave the single methods to handle the exceptions and turn them into results?
Example:
Database Transactions
Do we need explicit support? Can the following scenario work?
๐ In this way, we save a lot of work, not just supporting the several db adapters but also keeping the state in a DSL, transmitting that state to the subclasses, and then resetting it when the app reloads.