[dry-transactions] Useful step adapters

We’ve been using dry-transactions a greenfield rewrite of our app for the last 6 months, and we’ve discovered several useful step types we created that I wanted to share with everyone else. I’d love to share the code for these, but I’m not sure how useful it’d be to others, since several of these had to be created as class methods, and we had to add a few things to a base transaction class.

The vast majority of our transaction & steps accept and return a symbol-keyed Hash object, and each step uses kwargs. Each transaction starts with a validate step, and then our most common steps are merge to build up the hash, and tap to trigger side-effects that we still care about failures.

tap

It works similarly to tee, in that on Success it will just return the input to the method and discard any return value. If the method returns a Failure, however, the step fails with that Failure.

tap :notify

def notify(message:)
  FlakeyClient.new.post(message)
rescue FlakeyClient::Error => ex
  Failure(ex)
end

validate

Takes a block, which is a dry-validation schema. If the validation succeeds, it takes the output of the validation result as Success, otherwise it wraps the validation errors in Failure. For more details on validations, see “Validations” below. Also, unlike the built-in types, this step also has no corresponding method implementation.

validate do
  required(:user).filled
  required(:account).filled
end

authorize

Uses Pundit in a similar fashion to the controller helper to find a valid policy and authorize the user with it. It requires that its input be a symbol-key hash with a user: and the object to be authorized. The name of this key is provided as the argument to the authorize step. Also takes an optional argument for the policy query method to use.

authorize :message

merge

Since most of our steps take keyword-args, it is often useful for steps to add more kwargs to the hash to be passed into the next step. If the return value of the method is a hash, that hash is merged into the input hash as the step result. If the method returns another value, then the input hash gets a new key that matches the step name with the return value as the value. If the method returns nil, then the step result is the input hash.

merge :user
merge :lookup_metadata

# input: { user_id: 42, name: "Chuck" }
def user(user_id:, **)
  User.find(user_id)
end
# output: { user_id: 42, name: "Chuck", user: #<User id:42> }

def lookup_metadata(user:,.**)
  resp = APIClient.get_email(user.client_id)
  { email: resp["user_email"], fists: resp["fists"]["items"].size }
end
# output: { user_id: 42, name: "Chuck", user: #<User id:42>, email: "chuck@example", fists: 2 }

use

Calls another transaction. When that transaction is Success and it returns a hash, it gets merged into the input, otherwise it returns the result.

use MyOtherTransaction

async

Works like use, but instead of doing .call on the argument, does .perform_later. We’ve implemented the perform_later method for our transactions so they’ll quack like any other ActiveJob.

async SendEmailJob
async NotifyFrontendTransaction
5 Likes

Kudos, Paul! It’s fantastic to see what you’ve been able to accomplish with this range of step adapters. I’m continually amazed by how much people can do with dry-transaction :slight_smile:

If you’d be willing to share the code, even in something like a gist, I think that’d be great. I’m sure it could help others, and I’d certainly be interested to take a look, personally.

1 Like

I’m really curious about the implementation! It would be great if you could share it :slight_smile:

@paul :beer: for sharing

I wondered for a while around authorize in dry-transaction:

authorization at least to some level of complexity might be done way before dry-transaction black-box object call (routing middleware etc.)

… in the other hand more complex authorization rules might involve authorize in the dry-transaction flow… great job

I had some free time, so I had to try to implement some of those step adapters: https://github.com/nicolas-besnard/step_adapters :slight_smile:

1 Like

@timriley On holiday this week, but I was able to copy the code into a gist via iPad: https://gist.github.com/paul/b29b366d87880f91d8bb881dedddfcdb

I hope this is useful to others, and welcome any feedback.

It would be nice if, in a future version of dry-transaction, all of these could be step adapters. Right now, adapters accept only a symbol arg, and a few of these read nicer when they accept a Class (use, async) or a block (validate).

Maybe I’ll have some time later to make it a gem with tests and a README.

2 Likes

dry-transaction has been immensely helpful for me as well. One custom step I recently built was to automatically convert a Faraday response into a Result

class ReplicateToken
	include Dry::Transaction(container: Operations)

	http :send_token
	step :document, with: "json_api.validate_document"

	private

	def send_token(options)
		domain = options.delete(:domain) { return Failure(:missing_domain) }

		options.fetch(:token)   { return Failure(:missing_token)   }
		options.fetch(:content) { return Failure(:missing_content) }

		Request.create_challenge(domain, type: "http-01", **options.slice(:token, :content))
	end
end

Where Request.create_challenge returns a Faraday::Response or raises Faraday::Error