From 3b0642db88675ef707d58a8e18dcd87e9d1c15b4 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 19 May 2026 18:15:05 +0300 Subject: [PATCH] Implement ServiceResult#bind to allow chaining successful service call operations. --- app/services/service_result.rb | 13 ++++++ spec/services/service_result_spec.rb | 59 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/app/services/service_result.rb b/app/services/service_result.rb index 4e581be3770..6ca9f5ec9a9 100644 --- a/app/services/service_result.rb +++ b/app/services/service_result.rb @@ -245,6 +245,19 @@ class ServiceResult self end + ## + # Chains a subsequent service call if this result is successful, short-circuiting on failure. + # Useful for composing several potentially-failing operations, returning the last successful + # ServiceResult or the first failing one. + # + # @yield block to be called on success. + # @yieldparam result [Object, nil] the result of the service call. + # @yieldreturn [ServiceResult] the next ServiceResult in the chain. + # @return [ServiceResult] the block's ServiceResult on success, or self on failure. + def bind + success? ? yield(result) : self + end + ## # Iterates exactly once, passing the result to the block, if the service call # succeeded. diff --git a/spec/services/service_result_spec.rb b/spec/services/service_result_spec.rb index 62b3ffad8ec..bbc560de810 100644 --- a/spec/services/service_result_spec.rb +++ b/spec/services/service_result_spec.rb @@ -244,4 +244,63 @@ RSpec.describe ServiceResult, type: :model do it { is_expected.to include(info: message) } end end + + describe "#bind" do + context "when successful" do + let(:first) { described_class.success(result: 42) } + + it "calls the block with the result and returns the block's ServiceResult" do + returned = first.bind { |r| described_class.success(result: r * 2) } + + expect(returned).to be_success + expect(returned.result).to eq(84) + end + + it "returns the failure from the block when the block fails" do + failure = described_class.failure(message: "step 2 failed") + + returned = first.bind { failure } + + expect(returned).to be(failure) + expect(returned.message).to eq("step 2 failed") + end + end + + context "when failed" do + let(:first) { described_class.failure(message: "step 1 failed") } + + it "returns self without calling the block" do + expect { |b| first.bind(&b) }.not_to yield_control + + expect(first.bind { described_class.success }).to be(first) + end + end + + context "when chaining multiple calls" do + it "returns the last success when all succeed" do + result = described_class.success(result: 1) + .bind { |r| described_class.success(result: r + 1) } + .bind { |r| described_class.success(result: r + 1) } + + expect(result).to be_success + expect(result.result).to eq(3) + end + + it "short-circuits at the first failure" do + second_called = false + failure = described_class.failure(message: "failed") + + result = described_class.success(result: 1) + .bind { failure } + .bind do + second_called = true + described_class.success + end + + expect(result).to be(failure) + expect(result.message).to eq("failed") + expect(second_called).to be(false) + end + end + end end