[dry-transaction] Operation ignoring input

So far, all available step adapters take as input the output of the previous step (or the original input for the first step in the stack). Sometimes, it is useful to have a step which just doesn’t need to use the previous output at all. For example (ok, quite contrived):

step :validate_contact_info
step :send_contact_request_email
tee :update_db_with_just_a_count_of_contact_requests

I don’t think this feature should translate to another adapter, because it is more something that happens between adapters . Taking Haskell as example, all available adapters are variations of >>=, but there is no possibility to do a >>.

-- In dry-transaction we can do this:
Right 1 >>= \x -> return (x + 1)
-- Right 2

-- But, for this:
Right 1 >> Right 2
-- Right 2

-- we are forced to do:
Right 1 >>= \_ -> Right 2
-- Right 2

What do you think?

Yeah, I can see how something like this could be useful.

We could have the tee step adapter to an arity check on the operation’s #call method, and if has no params, then it could be called without them? What do you think?

Thanks for your reply @timriley.

I think it would not be enough because it would be coupling input and output. I mean, you can have an operation where output is meaningful (so, it’s not a tee) but input is not needed. Right now, map, check, try and tee are all about output embellishment, which doesn’t have anything to do with input. All step adapters are varieties of bind (>>=), but there is no way to do what is usually known as then or sequence (>>).

However, thinking it twice, dry-transaction is not exactly a DSL for bind. Should it be, each step would be able to access all previous steps outputs, and not just the output from last step. Being more of a data pipeline, I think it makes sense that all steps must accept it as input.

What bothers me surely is that operations defined as classes can not be seen as completely isolated, because they need to know they will be used in a transaction: they need to accept an input argument even if they are not going to use it.

The arity check you say could be a solution, but I should be added to any step. However, is it compatible with additional step arguments feature?

Another solution would be change dry-transaction to be really a DSL for bind/then. If so, you should provide a list of all steps outputs that would be taken as inputs. It would be more powerful, but also more verbose:

step :validate_contact_info
step :persist_contact_info, input: [:validate_contact_info]
step :send_contact_request_email, input: [:validate_contact_info]
step :update_db_with_just_a_count_of_contact_requests, input: []

Hmm, if we limit the scope of our discussion to the arity check at this point, my feeling is this should be specific to the tee step adapter only, because tee already passes the preceding step’s output. If we added a zero-arity check to the other step adapters, then we lose the “railway” effect, because the preceding step’s output just gets lost. Does that make sense?

I like this idea! It feels like it could be a nice way to bring dry-transaction a little closer to what you can achieve otherwise using dry-monads’ native “do” notation directly.

Plus, it feels like it should be possible to build this without interfering with what we already provide as standard behaviour (i.e. if the input: option is missing, then the input for a step just remains as the preceding step step’s output).

If you wanted to play around with something like this in a PR I’d definitely be open to finding a way to get such a feature in.

Cool! I’ll definitely try a PR :slight_smile:

In fact, I was thinking yesterday about all this stuff and came up with some ideas that, as you say, could be nice to implement, and better of all they would be backwards compatible.

As you say, the abstraction that dry-transaction would fit is a layer on top of monad binding. This layer would allow us to develop isolated operations which would not need to agree with the binding requirement: step adapters would fit the gap and wrap an output into the expected Either type.

So, better talk with examples, here are some of the API changes proposals. Surely, for some of them it would be better to implement them in dry-monads and just use from there in dry-transaction:

Possibility for a step to take as input several outputs from previous steps

step_3 :foo, input: [:step_1, :step_2]

Default input: [] or input: :previous means “take the previous output”

Possibility for a step to take no input

With input: nil, input: [] or a type: :then option.

Ability to change the output type of each step and the whole transaction to Maybe

include Dry::Transaction(output: Maybe)

This is useful for transactions where the kind of error message is not important. It just matters whether it was successful or not.

In fact, it could be done with any other monad type.

New step adapters

  • Transforms Maybe to Either.
  • Transforms Either to Maybe.

What do you think?

What we’ve been doing with nearly all our transaction & steps is the object that gets passed from one step to the next is always a symbol-keyed Hash. Using Ruby’s keyword arg method syntax, it not too bad to ignore the fields you don’t care about.

step :validate_contact_info
step :persist_contact_info
step :send_contact_request_email
step :update_db_with_just_a_count_of_contact_requests

def validate_contact_info(name:, number:, **kwargs)
  # ...
  Success(kwargs.merge(contact: Contact.new(name: name, number: number)))
end

def persist_contact_info(contact:, **kwargs)
  # ...
  Success(kwargs)
end

def send_contact_request_email(contact:, **kwargs)
  # ...
end

def update_db(count:, **kwargs)
  # ...
end

To make this simpler, we’ve added a merge step adapter that, if the input and output of a step are both hashes, then it merges the output into the input.