Skip to content

Commit f07b0cb

Browse files
Call a global #on_failure hook when the flow fails
Most of the time, individual operations are responsible for handling their own failures. However, there are cases when it makes sense to handle failures globally, for example, when you want to log the error for a given flow. When using the raw behavior, i.e., when we don't prepend around methods, that can be easily handled manually: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attrs = step validate(input) step persist(attrs) user end.tap do |result| log_failure(result.failure) if result.failure? end end # ... end ``` However, by automatically wrapping around `#steps` we gain focus on the happy path, but we lose the ability to handle global failures. Because of that, we introduce an `#on_failure` hook that is only called when using the prepended behavior on a failing flow. The method accepts the unwrapped failure extracted from the result object. ```ruby class CreateUser < Dry::Operation def call(input) attrs = step validate(input) step persist(attrs) user end private def on_failure(failure) log_failure(failure) end # ... end ``` `#on_failure` can also take a second optional argument, which will be assigned to the prepended method's name. That's useful when we're defining more than one flow in a single class. ```ruby class UserFlows < Dry::Operation operate_on :create_user, :delete_user # ... private def on_failure(failure, flow_name) case flow_name when :create_user # ... when :delete_user # ... end end # ... end ``` The calling of the hook is done via an injected result handler lambda. At some point, we might want to make this behavior configurable.
1 parent d8813a8 commit f07b0cb

File tree

4 files changed

+194
-7
lines changed

4 files changed

+194
-7
lines changed

lib/dry/operation.rb

+28-3
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ module Dry
5353
#
5454
# Under the hood, the `#call` method is decorated to allow skipping the rest
5555
# of its execution when a failure is encountered. You can choose to use another
56-
# method with {ClassContext#operate_on}:
56+
# method with {ClassContext#operate_on} (which also accepts a list of methods):
5757
#
5858
# ```ruby
5959
# class MyOperation < Dry::Operation
60-
# operate_on :run
60+
# operate_on :run # or operate_on :run, :call
6161
#
6262
# def run(input)
6363
# attrs = step validate(input)
@@ -70,8 +70,31 @@ module Dry
7070
# end
7171
# ```
7272
#
73+
# As you can see, the aforementioned behavior allows you to write your flow
74+
# in a linear fashion. Failures are mostly handled locally by each individual
75+
# operation. However, you can also define a global failure handler by defining
76+
# an `#on_failure` method. It will be called with the wrapped failure value
77+
# and, in the case of accepting a second argument, the name of the method that
78+
# defined the flow:
79+
#
80+
# ```ruby
81+
# class MyOperation < Dry::Operation
82+
# def call(input)
83+
# attrs = step validate(input)
84+
# user = step persist(attrs)
85+
# step notify(user)
86+
# user
87+
# end
88+
#
89+
# def on_failure(user) # or def on_failure(failure, method_name)
90+
# log_failure(user)
91+
# end
92+
# end
93+
# ```
94+
#
7395
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
74-
# you manually need to wrap your flow within the {#steps} method.
96+
# you manually need to wrap your flow within the {#steps} method and manually
97+
# handle global failures.
7598
#
7699
# ```ruby
77100
# class MyOperation < Dry::Operation
@@ -83,6 +106,8 @@ module Dry
83106
# user = step persist(attrs)
84107
# step notify(user)
85108
# user
109+
# end.tap do |result|
110+
# log_failure(result.failure) if result.failure?
86111
# end
87112
# end
88113
#

lib/dry/operation/class_context/steps_method_prepender.rb

+27-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
# frozen_string_literal: true
22

3+
require "dry/operation/errors"
4+
35
module Dry
46
class Operation
57
module ClassContext
68
# @api private
79
class StepsMethodPrepender < Module
8-
def initialize(method:)
10+
FAILURE_HOOK_METHOD_NAME = :on_failure
11+
12+
RESULT_HANDLER = lambda do |result, instance, method|
13+
return if result.success? || !instance.methods.include?(FAILURE_HOOK_METHOD_NAME)
14+
15+
failure_hook = instance.method(FAILURE_HOOK_METHOD_NAME)
16+
case failure_hook.arity
17+
when 1
18+
failure_hook.(result.failure)
19+
when 2
20+
failure_hook.(result.failure, method)
21+
else
22+
raise WrongFailureHookArityError.new(hook: failure_hook)
23+
end
24+
end
25+
26+
def initialize(method:, result_handler: RESULT_HANDLER)
927
super()
1028
@method = method
29+
@result_handler = result_handler
1130
end
1231

1332
def included(klass)
@@ -18,9 +37,13 @@ def included(klass)
1837

1938
def mod
2039
@module ||= Module.new.tap do |mod|
21-
mod.define_method(@method) do |*args, **kwargs, &block|
22-
steps do
23-
super(*args, **kwargs, &block)
40+
module_exec(@result_handler) do |result_handler|
41+
mod.define_method(@method) do |*args, **kwargs, &block|
42+
steps do
43+
super(*args, **kwargs, &block)
44+
end.tap do |result|
45+
result_handler.(result, self, __method__)
46+
end
2447
end
2548
end
2649
end

lib/dry/operation/errors.rb

+10
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,15 @@ def initialize(methods:)
2222
MSG
2323
end
2424
end
25+
26+
# Defined failure hook has wrong arity
27+
class WrongFailureHookArityError < ::StandardError
28+
def initialize(hook:)
29+
super <<~MSG
30+
##{hook.name} must accept 1 (failure) or 2 (failure, method name) \
31+
arguments, but its arity is #{hook.arity}
32+
MSG
33+
end
34+
end
2535
end
2636
end

spec/integration/operations_spec.rb

+129
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,135 @@ def add_one(x) = Success(x + 1)
7070
).to eq(Success(2))
7171
end
7272

73+
context "#on_failure" do
74+
it "is called when prepending if a failure is returned" do
75+
klass = Class.new(Dry::Operation) do
76+
attr_reader :failure
77+
78+
def initialize
79+
super
80+
@failure = nil
81+
end
82+
83+
def call(x)
84+
step divide_by_zero(x)
85+
end
86+
87+
def divide_by_zero(_x) = Failure(:not_possible)
88+
89+
def on_failure(failure)
90+
@failure = failure
91+
end
92+
end
93+
instance = klass.new
94+
95+
instance.(1)
96+
97+
expect(
98+
instance.failure
99+
).to be(:not_possible)
100+
end
101+
102+
it "isn't called if a success is returned" do
103+
klass = Class.new(Dry::Operation) do
104+
attr_reader :failure
105+
106+
def initialize
107+
super
108+
@failure = nil
109+
end
110+
111+
def call(x)
112+
step add_one(x)
113+
end
114+
115+
def add_one(x) = Success(x + 1)
116+
117+
def on_failure(failure)
118+
@failure = failure
119+
end
120+
end
121+
instance = klass.new
122+
123+
instance.(1)
124+
125+
expect(
126+
instance.failure
127+
).to be(nil)
128+
end
129+
130+
it "is given the prepended method name when it accepts a second argument" do
131+
klass = Class.new(Dry::Operation) do
132+
attr_reader :method_name
133+
134+
def initialize
135+
super
136+
@method_name = nil
137+
end
138+
139+
def call(x)
140+
step divide_by_zero(x)
141+
end
142+
143+
def divide_by_zero(_x) = Failure(:not_possible)
144+
145+
def on_failure(_failure, method_name)
146+
@method_name = method_name
147+
end
148+
end
149+
instance = klass.new
150+
151+
instance.(1)
152+
153+
expect(
154+
instance.method_name
155+
).to be(:call)
156+
end
157+
158+
it "has its arity checked and a meaningful error is raised when not conforming" do
159+
klass = Class.new(Dry::Operation) do
160+
def call(x)
161+
step divide_by_zero(x)
162+
end
163+
164+
def divide_by_zero(_x) = Failure(:not_possible)
165+
166+
def on_failure(_failure, _method_name, _unknown); end
167+
end
168+
169+
expect { klass.new.(1) }.to raise_error(Dry::Operation::WrongFailureHookArityError, /arity is 3/)
170+
end
171+
172+
it "can be defined in a parent class" do
173+
klass = Class.new(Dry::Operation) do
174+
attr_reader :failure
175+
176+
def initialize
177+
super
178+
@failure = nil
179+
end
180+
181+
def on_failure(failure)
182+
@failure = failure
183+
end
184+
end
185+
qlass = Class.new(klass) do
186+
def call(x)
187+
step divide_by_zero(x)
188+
end
189+
190+
def divide_by_zero(_x) = Failure(:not_possible)
191+
end
192+
instance = qlass.new
193+
194+
instance.(1)
195+
196+
expect(
197+
instance.failure
198+
).to be(:not_possible)
199+
end
200+
end
201+
73202
context ".operate_on" do
74203
it "allows prepending around a method other than #call" do
75204
klass = Class.new(Dry::Operation) do

0 commit comments

Comments
 (0)