-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prototype: Service Objects #270
Conversation
But intermediate steps return a monad
That should eventually be moved outside of the main transaction code through extensibility
@waiting-for-dev that's fantastic, simple yet powerful. Something I would like to explore in the future is the ability to change the order of the operations (or add one at some specific position) without overriding the whole service. I think that could be interesting to chain changes to services from different places, for example, two different extensions that want to add operations to the same service. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really like the approach taken here! Chaining of operations looks pretty powerful and I like the DSL overall :)
core/lib/spree/transaction.rb
Outdated
def transaction(db: true, &block) | ||
@block = block | ||
@db = db |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if the db: true
parameter isn't a smell this should actually be two methods in the future - a db transaction
and a block of aggregated operation within a service look different to me.
The first is atomic, ie can be treated as a single operation, while the second is not atomic in all scenarios - it would be highly dependent on the operations chained; anything with side-effects that is not the last call would break this.
Am I overthinking this? 😅 if so, feel free to ignore!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey, @cesartalves, thanks for looking into it 🙌
I think both db: true
& db: false
should be considered the same in terms of atomicity. In a purely functional programming language, that would be the case. However, in Ruby (and most other languages, FWIW), we're free to do whatever we want. For instance, it's also true that you can open an ActiveRecord::Base.transaction
block, create a file within, and roll it back. The file will still exist after rolling back, so there we had a side effect. The transaction
block opens a business transaction, and the db
parameter can make it also a database transaction.
I agree that a boolean parameter is a smell most of the time, as the caller already knows which path to take. Ideally, we could have two separate methods, like transaction
& transaction_with_db
. However, given how implicit database transactions are in Rails, I think it's better to default to the safe side. We could also have transaction
& transaction_without_db
, but it looks ugly 🙂 I'm open to discussing it further.
core/app/services/spree/save_user.rb
Outdated
save_record(user, attributes) | ||
update_user_roles(user, role_ids, ability) if role_ids | ||
update_user_stock_locations(user, stock_location_ids, ability) if stock_location_ids |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The magic introduced to access operations with underscore names as opposed to specifying the classes in the registry is pretty interesting 💡
Thank you, @kennyadsl! Yeah, I agree that would be an excellent feature for Solidus extensions! I already pointed out something similar in the "Improvements section":
We'd need to find a good balance between flexibility and maintainability. IMO having |
@waiting-for-dev I really like the approach that has been taken for this. I feel that having services as a composition of operations right in the core of Solidus will encourage the same pattern to be used in other parts of clients' codebases. I think that this approach will help a ton the debugging when things go wrong. |
Thanks, @nirebu! In terms of debugging, I'm thinking that it'd be much better to be explicit about what dependencies are included, instead of blindly delegating to the registry of operations. The API could look something like: class CreateUserTransaction
include Spree::Transaction[
:create_user,
:send_welcome_email
]
transaction do |attributes|
user = create_user(attributes)
send_welcome_email(user)
end
end I think it won't be something difficult to achieve (famous last words 🙂 ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's some really interesting stuff in here. Thanks for kicking of this conversation, Marc. Before I critique anything here, I just want to be clear that I'm in favour of adopting a service layer in Solidus that stores can leverage as an extension point. I think this is a great idea.
That said, I do have some concerns about much of the direction of this PR, but nothing that I think is too intrinsic. My main worry is that the metaprogramming in this PR wouldn't exactly "be the least disruptive to new contributors".
Stuff I like:
services
helper at the controller layer for accessing operations.- The lightweight monad-style of returning
Spree::Result
. - How this adds a reasonable way to access existing Solidus functionality without a ton of glue.
Stuff I'm not so sure about:
-
The naming of
Spree::Transaction
. This prototype has us callingservices
to access "transactions". Seems like a disconnect. While "Service" isn't completely free of this, "Transaction" is extremely overloaded in the web/database context, so I'm hesitant to add a new kind of transaction, especially since this also involves database transactions. -
Spree::Transaction.transaction
: The DSL method doesn't seem like it gives us much here, except thedb
argument, which we can also pass in when includingSpree::Transaction
. I'm not sure what this adds. I would just make this a normal method.* -
Multiple Registries: Do we really need multiple registries? What's the goal here? I feel like Solidus should provide a unified and extensible service layer, not multiple. I'd prefer something where we provide the single Solidus registry (that potentially has multiple resolvers) and supports overriding and adding additional resolvers.
-
The metaprogramming in
Spree::Transaction
: Including it calls a separate method that generates a new module that contains its own ownincluded
hook that sets everything up and includes that. 🥴It took me a second to understand, but I get why you did this. It's not exactly intuitive code and I really hope there's some less obtuse way of accomplishing the same thing... though I sadly am not currently sure what it would be.
*Upon further discussion with @adammathys, I've realized what Spree::Transaction.transaction
stuff gives us.
To summarize: I really like most of what this gets us, but the file containing |
Thanks. I like where this is going a lot. I second everything what Jared already said (really a great written comment, Jared). Have you thought about adopting dry-rb libraries (especially We have huge success with it in our custom frontend we wrote for Solidus at CandleScience and are happy to share some of the ideas and commands we wrote. The benefit would be that we do not need to maintain that crazy meta programming that is necessary to make this work in Solidus and rely on the great community that stands behind dry-rb. |
Many thanks for looking into it, @jarednorman & @tvdeyen! ❤️
I highly agree here. I'll come up with a better name. We can probably go with
Makes sense. I want to revisit that stuff, regardless. We want to be explicit about what is included as a dependency. That means a single registry will be enough, and there'll be no need to separate between registries for operations vs. services.
I wanted to leave implementation details out of scope. The code is far from being production ready. It's just a POC to show that API can be build.
Yes, I've thought about it a lot 🙂 However, notice that at this point, there's no dry-rb library for that. It used to be dry-transaction, but it has been deprecated. dry-monad's do notation would fit part of it, but it'd fall short of allowing the extensibility we need for operations and to provide instrumentation. Also, I think it'd be good to depend on a monad interface instead of an implementation. But I do want to make it compatible with dry-rb. |
Gotcha. I think my main concern is how tricky it might be to accomplish something that's gets us all this great stuff without the implementation being too spooky.
I think I agree with maintaining our own implementation here, give this. That side of things is relatively straightforward and if we can come up with an implementation that's easier to grok, what we have here seems both simple to use and pretty powerful. |
I'm in the process of creating the actual implementation for the prototype. In the process, I've realized that it's possible to transform the proposed API: class CreateUserTransaction
include Spree::Transaction
transaction do |attributes|
user = create_user(attributes)
send_welcome_email(user)
end
end into class CreateUserService < Spree::Service
def call(attributes)
service do
user = create_user(attributes)
send_welcome_email(user)
end
end
end The caller wouldn't need to do anything else than before: services[:create_user_service].call(user_params) That would address @jarednorman feedback:
And would allow using any method in the class as regular, so probably it's preferable. |
BTW, progress can be followed at https://github.com/nebulab/kwork/ |
@waiting-for-dev I like that! I was thinking yesterday about how this all might help us improve the architecture and more so extensibility of the order updater. This could be a really big improvement here. |
I just added a new commit adopting the external library. Everything is still WIP, but the current interface looks like this: class CreateUserTransaction < Spree::Transaction
operations :create_user,
:send_welcome_email
def call(attributes)
transaction do
user = create_user(attributes)
send_welcome_email(user)
end
end
end |
092ae05
to
085338a
Compare
Closing, as the prototype is now completed. |
This PR is a prototype with a candidate API for introducing a service layer on Solidus. It's not intended to be merged but serves as a starting point to discuss how it can be built.
Assumptions
Introducing a service layer will benefit the platform's architecture, leading to Solidus's more straightforward evolution and extensibility.
Goals
Out of scope
Approach
We aggregate concepts from Railway Programming, functional programming, and monadic composition and shape them into an idiomatic DSL in Ruby where the previous buzzwords are not needed to explain the API behavior in a simple way.
Description
A service object is designed as a chain of operations (a business transaction), running by default within a database transaction.
Each operation needs to return a
Spree::Result
instance, which may be of two sub-types:Spree::Result::Success
(which wraps a returned value) orSpree::Result::Failure
(which wraps an error). The chain is halted whenever a failure is returned, and subsequent operations are not run. Thus, the final result is the last operation's success or the first encountered failure.Although each operation returns a
Spree::Result
instance, thetransaction
blocks allow us to work with them as if we only considered the happy path. The otherwise returnedSpree::Result::Success
instances are unwrapped into the underlying result value, while the transaction ends whenever a failure is returned. E.g.:In the previous example, the
create_user
operation might return a#<Spree::Result::Success...@result = #<Spree::User...>
or a#<Spree::Result::Failure...@error = #<ActiveModel::Errors...>
. However, in the case of success, theuser
variable would be bound to theSpree::User
instance. In case of failure, the execution flow wouldn't reach thesend_welcome_email
step.Each operation may be anything responding to
#call
, taking any arguments, and, as said, returning aSpree::Result
. E.g.:The controller accesses registered services through a
services
helper method. Its responsibility is reduced to:We could simplify the controller flow by adding pattern matching behavior to the
Spree::Result
object. However, we first need to remove support for Ruby v2.5 & v2.6 (which are already deprecated on Solidus):Everything registered within
core/app/services
is registered as a service, while all withincore/app/services/operations
is available as operations to be used (we probably would need to adjust this convention and not have operations nested within the services).The interesting part for users is that they could register their own services and replace ours (also with something that is not a
Spree::Transaction
, as long as it returns aSpree::Result
).The most powerful help would be that they could inject individual operations without overriding the whole service. They would be able to register in
config/initializers/spree.rb
(API open to be revisited):Example in the prototype
As said offline, we've chosen the service to udpate a user from the backend for the PoC. We can also obtain good insight by studying the test file for
Spree::Transaction
.As we can see running
backend/spec/controllers/spree/admin/users_controller_spec.rb
, backward compatibility is maintained when it comes to the HTTP request/response cycle.Possible improvements
after:
,before:
hooks to inject operations between the already defined in the transaction (danger of going too wild)Open questions