How to inject an object (like a repository) in a transaction ?
I want to do this, for unit testing the transaction
Hi @xero88, can you please share an example of your transaction class?
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)
I want to do the same as in dry-validation :
CompanySchema.with(company_repository: @repository).call(input)
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.
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.
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.
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.
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 ?
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
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
@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 Operation
s 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?
Hi @paul, thatâs a valid use of the library, you should feel free to continue.
@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