Dry-transaction injection


#1

How to inject an object (like a repository) in a transaction ?
I want to do this, for unit testing the transaction


#2

Hi @xero88, can you please share an example of your transaction class?


#3

For now, here the transaction, that create an article

require 'dry-transaction'

class CreateArticle
  include Dry::Transaction

  step :validate
  step :persist

  def initialize(input)
    super(input)
    @repository = MyApp.instance.container['repositories.articles']
  end

  def validate(input)
    validation = ArticleSchema.(input)
    if validation.success?
      Success(input)
    else
      Failure(validation.errors)
    end
  end

  def persist(input)
    article = @repository.create(input)
    Success(article)
  end
end

And I call it in the controller :

 transaction = CreateArticle.new
      transaction.(article_params) do |result|
        result.success do |article|
          render json: article, status: :created
        end

        result.failure :validate do |errors|
          render json: errors, status: :unprocessable_entity
        end
      end

The think that I want to do is to move out of the transaction this part :

@repository = MyApp.instance.container[‘repositories.articles’]

I want to inject from the controller the repository, something like that :

  transaction = CreateArticle.new(repository)

#4

I want to do the same as in dry-validation :

CompanySchema.with(company_repository: @repository).call(input)


#5

dry-transaction isn’t designed out of the box to automatically handle injection of arbitrary dependencies — it’s designed to help you compose multiple operations to run as steps in a sequence, and it will help you inject those objects.

e.g.

class MyTransaction
  step :one_thing
  step :another_thing
end

# inject operation object for "one_thing" step
my_trans = MyTransaction.new(one_thing: -> input { do_something })
my_trans.("input")

Now, injecting other things into a transaction object is a different matter. I’ve never tried it myself, which is why I couldn’t give you a quick answer to your questions.

I’ve just looked into it now, and it seems like it’s now possible with the initialize that dry-transaction provides. I’m going to file a couple of issues for this, since it’s something that I would like to make possible, eventually.

Sorry this doesn’t help you in the short term, though.

May I make one suggestion, though? I don’t think you need a transaction in this case. I certainly wouldn’t use one for an object like the one you shared. I think it’s perfectly fine to build up that kind of logic by hand, for example:

require "dry-auto_inject"
require "dry-monads"

Import = Dry::AutoInject(MyContainer)

class CreateArticle
  # dry-auto_inject can take care of importing this for you
  include Import[article_repo: "repositories.articles"]

  include Dry::Monads::Result::Mixin

  def call(input)
    validation = ArticleSchema.(input)

    if validation.success?
      article = article_repo.create(validation.to_h)
      Success(article)
    else
      Failure(validation.errors)
    end
  end
end

This is exactly like how I build up standard CRUD operation objects in the apps I build. I think it’s more obvious than the transaction approach and will more easily support adding in nuanced operation-specific behaviour in future.


#6

In addition to this: I think the dry-transaction docs need to be reworked to show better examples of the kinds of things that I feel are appropriate for transactions, like piecing together higher-level operations from an application, rather than low-level steps like validate/process/persist, etc. These are hangovers from when I first put the docs together and I haven’t had the time to update them.


#7

Thank you for your answer.

I have an alternative way, I don’t know if this is a good way, but it’s working :

  transaction = Company::CreateCompany.new
  transaction.({repository: @repository, input: company_params})

I added a step “injection” and injected my repository. Now with that way, I can mock my repository and test my transaction in isolation.

class Company::CreateCompany
  include Dry::Transaction

  step :injection
  step :validate
  step :persist

  def injection(input)
    @repository = input[:repository]
    Success(input[:input])
  rescue => e
    Failure(e)
  end

  def validate(input)
     ...
  end

  def persist(input)
    ...
  end
end

Do you think this is a good way ? Maybe I can do a new page of the documentation about testing transactions.


#8

While that may work for you, I definitely wouldn’t encourage it, because this mixes user data (the company_params), which vary at run time, with dependencies (the repository) which shouldn’t vary at run time, and should really remain static for the lifetime of the object.

The whole idea behind the functional objects that we encourage is to separate data and behaviour, not to mix them together as #call params like you’re doing here.

Mixing them compromises the design of your objects, and will only make them more difficult to understand over time, particularly across the breadth of a full application.

I would really encourage you to look at the second example from my previous reply, and to consider this as the approach for building your simple CRUD operation objects.


#9

Ok, I tried your example, it’s seams to work.

And can you provide the rspec test with it ? How mock the repository in your exemple ?


#10

Got the response on the gitter :

require 'spec_helper'

RSpec.describe Company::CreateCompany, type: :helper do
  let(:company_repository){
    double(CompanyRepository, is_name_available?: true, create: Company.new(nil))
  }

  let(:transaction) { described_class.new(company_repository: company_repository) }

  it 'successful return the entity Company returned by repository' do
    company_input = { name: 'CompanyName', company_type: 'client', legal_form: 'SA' }
    result = transaction.(company_input)

    expect(result.success?).to be(true)
  end

end

#11

You can do normal / expected injection, without dropping into using Dry::AutoInject by overriding initialize:

class CreateArticle
  attr_reader :steps
  def initialiize(repo:, **steps)
    super(steps)
    @repo = repo
  end
end

That should get you what I think you’re looking for


#12

@timriley I’m also anxiously awaiting some better docs and examples for using this library. I came here from Ryan Bigg’s Exploding Rails book, and he also used the step :validate; step :persist pattern.

I really like the declarative “series of steps” to perform, rather than having a long #call method with lots “abort early” checks:

def call(input)
  return "oops" unless valid?(input)
  params = munge_input(input)
  owner = find_owner(params)
  return "other oops" unless owner
  thing = create_thing(owner, params)
  deliver_notification(owner, thing)
rescue FailedToCreate => ex
  return "some error"
end

I like how that can just become:

check :valid
map :munge 
try :create, catch: FailedToCreate
tee :notify

I don’t particularly want to create Operations for each of those, since most of them are actually 3-5 lines of code and not useful or reusable outside this transaction, and that would create more boilerplate LOC to set up the Containers and registration and indirection. (There’s also a lot of friction on my team about including all that, since its such a large departure from the familiar.)

I think I’m probably using transactions in totally the wrong way, since in my mind it is a mechanism to break up a long #call and reduce boilerplate. But, from what you’re saying in this thread, how I (and several others) are attempting to use it isn’t how it was intended at all?


#13

Hi @paul, that’s a valid use of the library, you should feel free to continue.


#14

@paul Yep it is an absolutely valid use.

If you want here is an interesting read about dry-transaction and dry-monads https://www.morozov.is/2018/05/27/do-notation-ruby.html