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