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
Avoid #steps
boilerplate by prepending on #call
#9
Conversation
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. Users can inherit from, e.g., `Dry::Operation[prepend: :run]` to decorate the `#run` method (multiple methods are allowed by passing an array), or `Dry::Operation[prepend: false]` to disable the behavior: ```ruby class CreateUser < Dry::Operation[prepend: :run] def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` ```ruby class CreateUser < Dry::Operation[prepend: :false] def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Notice that as a consequence of this change, we're "polluting" the ancestry chain of the descendant classes with the prepender logic: ```ruby Class.new(Dry::Operation).ancestors # [#<Module:0x00007f8e523c1698>, <- Actual prepender # #<Class:0x00007f8e523c1918>, <- Descendant # #<Dry::Operation::Prepender:0x00007f8e523c17d8>, <- Stateful module keeping the prepender options # Dry::Operation, # Dry::Operation::Mixin, # ...] ``` When creating a descendant from a descendant, the prepender logic is duplicated to override the decorated method from the nested descendant (which goes before the first prepender in the ancestry): ```ruby Class.new(Class.new(Dry::Operation)).ancestors # [#<Module:0x00007f8e52331408>, <- Actual prepender # #<Class:0x00007f8e52331688>, <- Nested descendant # #<Dry::Operation::Prepender:0x00007f8e52331548>, <- Stateful module keeping the prepender options # #<Module:0x00007f8e52331868>, <- Previous prepender # #<Class:0x00007f8e52331ae8>, <- Descendant # #<Dry::Operation::Prepender:0x00007f8e523319a8>, <- Previous stateful module keeping the prepender options # Dry::Operation, # Dry::Operation::Mixin, # ...] ``` Also, notice that `Dry::Operation[]` will return an anonymous `Class` so it can be inherited from, so `Dry::Operation` won't appear in the ancestry chain: ```ruby Class.new(Dry::Operation[prepend: false]).ancestors # [#<Class:0x00007f8e5233eec8>, <- Descendant # #<Class:0x00007f8e5233f328>, <- Anonymous class returned by Dry::Operation[] # Dry::Operation::Mixin, # ...] ``` To accomodate the above, the steps logic has been moved to a `Dry::Operation::Mixin` module so it can be reused both by descendants from `Dry::Operation` and `Dry::Operation[]`. It's worth it to highlight all that complexity, although it should be transparent to the user. As said, it's a trade-off we consider worth it to improve developer experience.
Thanks for this, @waiting-for-dev! I hope to get onto this sometime in the coming week, but I did want to give some early feedback on one aspect: I'd rather we avoid the whole dynamic subclassing pattern here.
If you want to look for an analog within the dry-rb ecosystem for API inspiration, perhaps dry-struct is a good one, in that it offers a couple of class-level methods for controlling its overall behaviour. Keen to hear your thoughts :) |
Thanks for your feedback, @timriley 🙌 I get your points and they make a lot of sense. However, we're in a tricky situation here, as I see no way to use dry-operation through inheritance + defaulting to decorate
I think 3 is the best solution, but I'm very interested in hearing your thoughts 🙂 |
@waiting-for-dev All good points, thanks for explaining them :) I've been thinking about this, and I think there might still be some options here for us. So far, we've been thinking of
Both of these would occur after the user has had the chance to call some class-level method for customising the class' behaviour. I think What do you think? |
We could even have .method_added undef itself after it finds the relevant method (which will usually the first user-defined method in typical usage). |
Those are great calls, @timriley!
I think that wouldn't be too late, as we can always use
I agree! I'll give it a try and get back with my findings 🙂 |
@timriley, one thing that could be very surprising is having the class-level method ignored when called after the method definition. E:g.: class CreateUser < Dry::Operation
def run
# ...
end
wrap :run
end We don't have that problem either with anonymous inheritance or mixin inclusion. IMO that's a big trade-off, as shipping a DSL that depends on global (load order) state is quite brittle. What are your thoughts on that? |
@waiting-for-dev I'm not personally concerned about this. We can document this limitation. And most people would never run into this anyway, since "the top of the class" is where class-level configuration tends to go anyway. We could also possibly combine this with a check at the time of the first call to |
Just came to my mind we can also check |
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. After discarding dynamic inheritance because of DX concerns (see #9), we opt for implementing a couple of class-level methods to tweak the defaults. `.operate_on` allows to customize the method to decorate. E.g., this is how we decorate `#run` instead of `#call`: ```ruby class CreateUser < Dry::Operation operate_on :run # Several methods can be passed as arguments def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` On the other hand, `.skip_prepending` allows to opt out of the default `#call` decoration: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` To have `#call` decorated by default but still be something configurable, we need to rely on Ruby's `.method_added` hook. Notice that for any other method specified by `.operate_on` we could just include the prepender module and avoid going through the hook. However, we opt for still using the hook to have a single way of doing things. Both `.operate_on` and `.skip_prepending` tweaks are inherited by subclasses, so it's possible to do something like: ```ruby class BaseOperation < Dry::Operation operate_on :run end class CreateUser < BaseOperation def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` Both methods raise an exception when called after any method has been prepended. This is to avoid misunderstandings like trying to skip prepending after the `.method_added` hook has been triggered: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end skip_prepending # At this point, `#call` would have already been prepended # ... end ``` Similarly, `.operate_on` raises an exception when called after the method has already been defined. ```ruby class CreateUser < Dry::Operation def run(input) attributes = step validate(input) step create(attributes) end operate_on :run # At this point, `.method_added` won't be called for `#run` # ... end ``` Those checks are reset when a subclass is defined to allow for redefinitions or changes in the configuration.
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. After discarding dynamic inheritance because of DX concerns (see #9), we opt for implementing a couple of class-level methods to tweak the defaults. `.operate_on` allows to customize the method to decorate. E.g., this is how we decorate `#run` instead of `#call`: ```ruby class CreateUser < Dry::Operation operate_on :run # Several methods can be passed as arguments def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` On the other hand, `.skip_prepending` allows to opt out of the default `#call` decoration: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` To have `#call` decorated by default but still be something configurable, we need to rely on Ruby's `.method_added` hook. Notice that for any other method specified by `.operate_on` we could just include the prepender module and avoid going through the hook. However, we opt for still using the hook to have a single way of doing things. Both `.operate_on` and `.skip_prepending` tweaks are inherited by subclasses, so it's possible to do something like: ```ruby class BaseOperation < Dry::Operation operate_on :run end class CreateUser < BaseOperation def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` Both methods raise an exception when called after any method has been prepended. This is to avoid misunderstandings like trying to skip prepending after the `.method_added` hook has been triggered: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end skip_prepending # At this point, `#call` would have already been prepended # ... end ``` Similarly, `.operate_on` raises an exception when called after the method has already been defined. ```ruby class CreateUser < Dry::Operation def run(input) attributes = step validate(input) step create(attributes) end operate_on :run # At this point, `.method_added` won't be called for `#run` # ... end ``` Those checks are reset when a subclass is defined to allow for redefinitions or changes in the configuration.
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. After discarding dynamic inheritance because of DX concerns (see #9), we opt for implementing a couple of class-level methods to tweak the defaults. `.operate_on` allows to customize the method to decorate. E.g., this is how we decorate `#run` instead of `#call`: ```ruby class CreateUser < Dry::Operation operate_on :run # Several methods can be passed as arguments def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` On the other hand, `.skip_prepending` allows to opt out of the default `#call` decoration: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` To have `#call` decorated by default but still be something configurable, we need to rely on Ruby's `.method_added` hook. Notice that for any other method specified by `.operate_on` we could just include the prepender module and avoid going through the hook. However, we opt for still using the hook to have a single way of doing things. Both `.operate_on` and `.skip_prepending` tweaks are inherited by subclasses, so it's possible to do something like: ```ruby class BaseOperation < Dry::Operation operate_on :run end class CreateUser < BaseOperation def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` Both methods raise an exception when called after any method has been prepended. This is to avoid misunderstandings like trying to skip prepending after the `.method_added` hook has been triggered: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end skip_prepending # At this point, `#call` would have already been prepended # ... end ``` Similarly, `.operate_on` raises an exception when called after the method has already been defined. ```ruby class CreateUser < Dry::Operation def run(input) attributes = step validate(input) step create(attributes) end operate_on :run # At this point, `.method_added` won't be called for `#run` # ... end ``` Those checks are reset when a subclass is defined to allow for redefinitions or changes in the configuration.
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. After discarding dynamic inheritance because of DX concerns (see #9), we opt for implementing a couple of class-level methods to tweak the defaults. `.operate_on` allows to customize the method to decorate. E.g., this is how we decorate `#run` instead of `#call`: ```ruby class CreateUser < Dry::Operation operate_on :run # Several methods can be passed as arguments def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` On the other hand, `.skip_prepending` allows to opt out of the default `#call` decoration: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` To have `#call` decorated by default but still be something configurable, we need to rely on Ruby's `.method_added` hook. Notice that for any other method specified by `.operate_on` we could just include the prepender module and avoid going through the hook. However, we opt for still using the hook to have a single way of doing things. Both `.operate_on` and `.skip_prepending` tweaks are inherited by subclasses, so it's possible to do something like: ```ruby class BaseOperation < Dry::Operation operate_on :run end class CreateUser < BaseOperation def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` Both methods raise an exception when called after any method has been prepended. This is to avoid misunderstandings like trying to skip prepending after the `.method_added` hook has been triggered: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end skip_prepending # At this point, `#call` would have already been prepended # ... end ``` Similarly, `.operate_on` raises an exception when called after the method has already been defined. ```ruby class CreateUser < Dry::Operation def run(input) attributes = step validate(input) step create(attributes) end operate_on :run # At this point, `.method_added` won't be called for `#run` # ... end ``` Those checks are reset when a subclass is defined to allow for redefinitions or changes in the configuration.
We get rid of the necessity to wrap the operations' flow with the `#steps`'s block by prepending a module that decorates the `#call` method. If before we had to do: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` Now we can do: ```ruby class CreateUser < Dry::Operation def call(input) attributes = step validate(input) step create(attributes) end # ... end ``` We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. After discarding dynamic inheritance because of DX concerns (see #9), we opt for implementing a couple of class-level methods to tweak the defaults. `.operate_on` allows to customize the method to decorate. E.g., this is how we decorate `#run` instead of `#call`: ```ruby class CreateUser < Dry::Operation operate_on :run # Several methods can be passed as arguments def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` On the other hand, `.skip_prepending` allows to opt out of the default `#call` decoration: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attributes = step validate(input) step create(attributes) end end # ... end ``` To have `#call` decorated by default but still be something configurable, we need to rely on Ruby's `.method_added` hook. Notice that for any other method specified by `.operate_on` we could just include the prepender module and avoid going through the hook. However, we opt for still using the hook to have a single way of doing things. Both `.operate_on` and `.skip_prepending` tweaks are inherited by subclasses, so it's possible to do something like: ```ruby class BaseOperation < Dry::Operation operate_on :run end class CreateUser < BaseOperation def run(input) attributes = step validate(input) step create(attributes) end # ... end ``` Both methods raise an exception when called after any method has been prepended. This is to avoid misunderstandings like trying to skip prepending after the `.method_added` hook has been triggered: ```ruby class CreateUser < Dry::Operation def call(input) steps do attributes = step validate(input) step create(attributes) end end skip_prepending # At this point, `#call` would have already been prepended # ... end ``` Similarly, `.operate_on` raises an exception when called after the method has already been defined. ```ruby class CreateUser < Dry::Operation def run(input) attributes = step validate(input) step create(attributes) end operate_on :run # At this point, `.method_added` won't be called for `#run` # ... end ``` Those checks are reset when a subclass is defined to allow for redefinitions or changes in the configuration.
We get rid of the necessity to wrap the operations' flow with the
#steps
's block by prepending a module that decorates the#call
method.If before we had to do:
Now we can do:
We want to provide that as the default behavior to improve the ergonomics of the library. However, we also want to provide a way to customize or opt out of this magic behavior. Users can inherit from, e.g.,
Dry::Operation[prepend: :run]
to decorate the#run
method (multiple methods are allowed by passing an array), orDry::Operation[prepend: false]
to disable the behavior:Notice that as a consequence of this change, we're "polluting" the ancestry chain of the descendant classes with the prepender logic:
When creating a descendant from a descendant, the prepender logic is duplicated to override the decorated method from the nested descendant (which goes before the first prepender in the ancestry):
Also, notice that
Dry::Operation[]
will return an anonymousClass
so it can be inherited from, soDry::Operation
won't appear in the ancestry chain:To accomodate the above, the steps logic has been moved to a
Dry::Operation::Mixin
module so it can be reused both by descendants fromDry::Operation
andDry::Operation[]
.It's worth it to highlight all that complexity, although it should be transparent to the user. As said, it's a trade-off we consider worth it to improve developer experience.