From 7dd4a15b7b6113e0161865919183fa0b94d4155a Mon Sep 17 00:00:00 2001 From: Stephen Margheim Date: Wed, 3 Jun 2026 17:01:37 +0200 Subject: [PATCH] Expose Servus::Result as a top-level primary concern Add `Servus::Result` with class-method factories `.success(data = nil)` and `.failure(message = nil, data:, type:)` so the result object can be used from any code, not just inside a service class. `Servus::Base#success` / `#failure` delegate to `Result.success` / `.failure` for a single source of truth. `Servus::Support::Response` remains as a silent alias for backwards compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 19 +++++ gem/lib/servus.rb | 4 + gem/lib/servus/base.rb | 10 +-- gem/lib/servus/result.rb | 99 ++++++++++++++++++++++ gem/lib/servus/support/response.rb | 85 ++----------------- gem/spec/servus/result_spec.rb | 127 +++++++++++++++++++++++++++++ site/.vitepress/config.ts | 102 ++++++++++++----------- site/core/responses.md | 103 ++++++++++++++++------- 8 files changed, 386 insertions(+), 163 deletions(-) create mode 100644 gem/lib/servus/result.rb create mode 100644 gem/spec/servus/result_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c01ee3e..716b85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [Unreleased] + +### Added + +- **`Servus::Result` as a top-level primary concern**: The result object is now exposed + at `Servus::Result` and is usable from any code, not just inside a service. New class-method + factories mirror the `Servus::Base` DSL: + + ```ruby + Servus::Result.success(user_id: 1) + Servus::Result.failure("Card declined", type: Servus::Support::Errors::BadRequestError) + Servus::Result.failure("Declined", data: { reason: "insufficient_funds" }) + ``` + + `Servus::Base#success` and `#failure` now delegate to `Servus::Result.success` / `.failure`, + giving a single source of truth for result construction. `Servus::Support::Response` remains + as a silent alias of `Servus::Result` — existing code referencing the old constant continues + to work unchanged. + ## [0.5.2] - 2026-05-25 ### Fixed diff --git a/gem/lib/servus.rb b/gem/lib/servus.rb index a534cbb..a4d7f70 100644 --- a/gem/lib/servus.rb +++ b/gem/lib/servus.rb @@ -20,6 +20,10 @@ module Servus; end # Support require_relative 'servus/support/logger' require_relative 'servus/support/data_object' + +# Result (top-level primary concern; loaded after DataObject which it wraps) +require_relative 'servus/result' + require_relative 'servus/support/response' require_relative 'servus/support/validator' require_relative 'servus/support/errors' diff --git a/gem/lib/servus/base.rb b/gem/lib/servus/base.rb index 34a32d4..eace434 100644 --- a/gem/lib/servus/base.rb +++ b/gem/lib/servus/base.rb @@ -55,7 +55,8 @@ class Base # Support class aliases Logger = Servus::Support::Logger Emitter = Servus::Events::Emitter - Response = Servus::Support::Response + Result = Servus::Result + Response = Servus::Support::Response # alias of Servus::Result, kept for back-compat Validator = Servus::Support::Validator # Creates a successful response with the provided data. @@ -78,9 +79,9 @@ class Base # end # # @see #failure - # @see Servus::Support::Response + # @see Servus::Result def success(data) - Response.new(true, data, nil) + Result.success(data) end # Creates a failure response with an error. @@ -121,8 +122,7 @@ def success(data) # @see #error! # @see Servus::Support::Errors def failure(message = nil, data: nil, type: Servus::Support::Errors::ServiceError) - error = type.new(message) - Response.new(false, data, error) + Result.failure(message, data: data, type: type) end # Logs an error and raises an exception, halting service execution. diff --git a/gem/lib/servus/result.rb b/gem/lib/servus/result.rb new file mode 100644 index 0000000..b390eaa --- /dev/null +++ b/gem/lib/servus/result.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Servus + # Encapsulates the result of an operation: either successful data or an error, + # never both. Use {.success}/{.failure} from anywhere — inside a {Servus::Base} + # service ({Servus::Base#success} / {Servus::Base#failure} delegate here) or + # from plain Ruby code that wants the same success/failure shape. + # + # Use {#success?} to determine which path to take when handling results. + # + # @example Inside a service + # class MyService < Servus::Base + # def call + # return failure("Invalid amount") if @amount <= 0 + # + # success(transaction_id: charge.id) + # end + # end + # + # @example Outside a service + # def import_rows(rows) + # return Servus::Result.failure("no rows") if rows.empty? + # + # Servus::Result.success(imported: rows.count) + # end + # + # @example Handling a result + # result = import_rows(rows) + # if result.success? + # puts "Imported: #{result.data.imported}" + # else + # puts "Error: #{result.error.message}" + # end + class Result + # @return [Object, nil] the data returned on success (nil on failure unless + # structured failure data was attached via {.failure}) + attr_reader :data + + # @return [Servus::Support::Errors::ServiceError, nil] the error returned on + # failure (nil on success) + attr_reader :error + + # Builds a successful result wrapping the given data. + # + # @param data [Object, nil] the data to return (Hashes are wrapped in a + # {Servus::Support::DataObject} for accessor-style access) + # @return [Servus::Result] + # + # @example + # Servus::Result.success(user_id: 123, status: "active") + def self.success(data = nil) + new(true, data, nil) + end + + # Builds a failure result with an error. + # + # @param message [String, nil] error message; falls back to the error type's + # default when nil + # @param data [Object, nil] optional structured data to attach to the failure + # @param type [Class] error class to instantiate (must inherit from + # {Servus::Support::Errors::ServiceError}) + # @return [Servus::Result] + # + # @example Default error type + # Servus::Result.failure("User not found") + # + # @example Custom error type + # Servus::Result.failure("Bad input", type: Servus::Support::Errors::BadRequestError) + # + # @example With structured failure data + # Servus::Result.failure("Card declined", data: { reason: "insufficient_funds" }) + def self.failure(message = nil, data: nil, type: Servus::Support::Errors::ServiceError) + new(false, data, type.new(message)) + end + + # @note Prefer {.success} or {.failure}. Direct construction is supported + # for advanced cases (e.g. wrapping an existing error instance). + # + # @param success [Boolean] true for successful results, false for failures + # @param data [Object, nil] the result data (nil for failures by default) + # @param error [Servus::Support::Errors::ServiceError, nil] the error + # (nil for successes) + def initialize(success, data, error) + @success = success + @data = Servus::Support::DataObject.wrap(data) + @error = error + end + + # @return [Boolean] true if the operation succeeded, false if it failed + def success? + @success + end + + # @return [Boolean] true if the operation failed, false if it succeeded + def failure? + !@success + end + end +end diff --git a/gem/lib/servus/support/response.rb b/gem/lib/servus/support/response.rb index a70ffdf..700539b 100644 --- a/gem/lib/servus/support/response.rb +++ b/gem/lib/servus/support/response.rb @@ -1,85 +1,12 @@ # frozen_string_literal: true +require_relative '../result' + module Servus module Support - # Encapsulates the result of a service execution. - # - # Response objects are returned by all service calls and contain either - # successful data or an error, never both. Use {#success?} to determine - # which path to take when handling results. - # - # @example Handling a successful response - # result = MyService.call(user_id: 123) - # if result.success? - # puts "Data: #{result.data}" - # puts "Error: #{result.error}" # => nil - # end - # - # @example Handling a failed response - # result = MyService.call(user_id: -1) - # unless result.success? - # puts "Error: #{result.error.message}" - # puts "Data: #{result.data}" # => nil - # end - # - # @example Pattern matching in controllers - # result = MyService.call(params) - # if result.success? - # render json: result.data, status: :ok - # else - # render json: result.error.message, status: :unprocessable_entity - # end - # - # @see Servus::Base#success - # @see Servus::Base#failure - class Response - # [Object] The data returned by the service - attr_reader :data - - # [Servus::Support::Errors::ServiceError] The error returned by the service - attr_reader :error - - # Creates a new response object. - # - # @note This is typically called by {Servus::Base#success} or {Servus::Base#failure} - # rather than being instantiated directly. - # - # @param success [Boolean] true for successful responses, false for failures - # @param data [Object, nil] the result data (nil for failures) - # @param error [Servus::Support::Errors::ServiceError, nil] the error (nil for successes) - # - # @api private - def initialize(success, data, error) - @success = success - @data = DataObject.wrap(data) - @error = error - end - - # Checks if the service execution was successful. - # - # @return [Boolean] true if the service succeeded, false if it failed - # - # @example - # result = MyService.call(params) - # if result.success? - # # Handle success - result.data is available - # else - # # Handle failure - result.error is available - # end - def success? - @success - end - - # Checks if the service execution failed. - # - # @return [Boolean] true if the service failed, false if it succeeded - # - # @example - # result = MyService.call(params) - # return render_error(result.error.message) if result.failure? - def failure? - !@success - end - end + # Backwards-compatible alias for {Servus::Result}. The canonical class is + # {Servus::Result}; this constant is kept so existing references continue + # to resolve. + Response = Servus::Result end end diff --git a/gem/spec/servus/result_spec.rb b/gem/spec/servus/result_spec.rb new file mode 100644 index 0000000..022e620 --- /dev/null +++ b/gem/spec/servus/result_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Result do + describe '.success' do + it 'returns a successful Result' do + result = described_class.success({ user_id: 1 }) + + expect(result).to be_a(described_class) + expect(result.success?).to be true + expect(result.failure?).to be false + expect(result.error).to be_nil + end + + it 'wraps Hash data in a DataObject for accessor-style access' do + result = described_class.success({ user: 'Alice', token: 'abc123' }) + + expect(result.data).to be_a(Servus::Support::DataObject) + expect(result.data.user).to eq('Alice') + expect(result.data.token).to eq('abc123') + end + + it 'allows nil data without arguments' do + result = described_class.success + + expect(result.success?).to be true + expect(result.data).to be_nil + end + + it 'passes non-Hash data through unchanged' do + result = described_class.success('plain string') + + expect(result.data).to eq('plain string') + expect(result.data).not_to be_a(Servus::Support::DataObject) + end + end + + describe '.failure' do + it 'returns a failure Result with a default ServiceError' do + result = described_class.failure('Boom') + + expect(result.failure?).to be true + expect(result.success?).to be false + expect(result.error).to be_a(Servus::Support::Errors::ServiceError) + expect(result.error.message).to eq('Boom') + expect(result.data).to be_nil + end + + it 'uses the error type default message when message is omitted' do + result = described_class.failure(type: Servus::Support::Errors::NotFoundError) + + expect(result.error).to be_a(Servus::Support::Errors::NotFoundError) + expect(result.error.message).not_to be_nil + end + + it 'accepts a custom error type' do + result = described_class.failure('Bad input', type: Servus::Support::Errors::BadRequestError) + + expect(result.error).to be_a(Servus::Support::Errors::BadRequestError) + expect(result.error.message).to eq('Bad input') + end + + it 'attaches structured failure data when data: is given' do + result = described_class.failure('Declined', data: { reason: 'insufficient_funds' }) + + expect(result.failure?).to be true + expect(result.data).to be_a(Servus::Support::DataObject) + expect(result.data.reason).to eq('insufficient_funds') + expect(result.error.message).to eq('Declined') + end + end + + describe 'direct construction' do + it 'supports building a Result with a pre-existing error instance' do + error = Servus::Support::Errors::ConflictError.new('duplicate') + result = described_class.new(false, nil, error) + + expect(result.failure?).to be true + expect(result.error).to equal(error) + end + end + + describe 'backwards-compatible alias' do + it 'exposes the same class as Servus::Support::Response' do + expect(Servus::Support::Response).to equal(described_class) + end + + it 'accepts construction via the legacy constant' do + result = Servus::Support::Response.new(true, { ok: true }, nil) + + expect(result).to be_a(described_class) + expect(result.success?).to be true + expect(result.data.ok).to be true + end + end + + describe 'inside Servus::Base' do + it 'returns a Servus::Result from a service call' do + service_class = Class.new(Servus::Base) do + def call + success(value: 42) + end + end + stub_const('ResultSpec::Successful::Service', service_class) + + result = service_class.call + + expect(result).to be_a(described_class) + expect(result.success?).to be true + expect(result.data.value).to eq(42) + end + + it 'returns a Servus::Result for failures' do + service_class = Class.new(Servus::Base) do + def call + failure('Nope', type: Servus::Support::Errors::NotFoundError) + end + end + stub_const('ResultSpec::Failing::Service', service_class) + + result = service_class.call + + expect(result).to be_a(described_class) + expect(result.failure?).to be true + expect(result.error).to be_a(Servus::Support::Errors::NotFoundError) + end + end +end diff --git a/site/.vitepress/config.ts b/site/.vitepress/config.ts index df464af..f8d437c 100644 --- a/site/.vitepress/config.ts +++ b/site/.vitepress/config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from 'vitepress'; +import { defineConfig } from "vitepress"; export default defineConfig({ - base: '/servus/', - title: 'Servus', - description: 'A disciplined service-object pattern for Ruby and Rails', - lang: 'en-US', + base: "/servus/", + title: "Servus", + description: "A disciplined service-object pattern for Ruby and Rails", + lang: "en-US", cleanUrls: true, lastUpdated: true, appearance: false, @@ -14,86 +14,90 @@ export default defineConfig({ }, }, themeConfig: { - siteTitle: 'Servus', + siteTitle: "Servus", search: { - provider: 'local', + provider: "local", }, nav: [ - { text: 'Home', link: '/' }, - { text: 'Quick Start', link: '/getting-started/' }, - { text: 'Core Concepts', link: '/core/service-objects' }, - { text: 'Features', link: '/features/schema-validation' }, - { text: 'Rails', link: '/rails/controllers' }, - { text: 'Testing', link: '/testing/services' }, - { text: 'Reference', link: '/reference/generators' }, + { text: "Home", link: "/" }, + { text: "Quick Start", link: "/getting-started/" }, + { text: "Core Concepts", link: "/core/service-objects" }, + { text: "Features", link: "/features/schema-validation" }, + { text: "Rails", link: "/rails/controllers" }, + { text: "Testing", link: "/testing/services" }, + { text: "Reference", link: "/reference/generators" }, ], sidebar: [ { - text: 'Introduction', + text: "Introduction", items: [ - { text: 'Home', link: '/' }, - { text: 'Quick Start', link: '/getting-started/' }, - { text: 'The Servus Mental Model', link: '/getting-started/mental-model' }, + { text: "Home", link: "/" }, + { text: "Quick Start", link: "/getting-started/" }, + { + text: "The Servus Mental Model", + link: "/getting-started/mental-model", + }, ], }, { - text: 'Core Concepts', + text: "Core Concepts", items: [ - { text: 'Service Objects', link: '/core/service-objects' }, - { text: 'Call Chain', link: '/core/call-chain' }, - { text: 'Responses', link: '/core/responses' }, - { text: 'Composition', link: '/core/composition' }, + { text: "Service Objects", link: "/core/service-objects" }, + { text: "Call Chain", link: "/core/call-chain" }, + { text: "Results", link: "/core/responses" }, + { text: "Composition", link: "/core/composition" }, ], }, { - text: 'Features', + text: "Features", items: [ - { text: 'Schema Validation', link: '/features/schema-validation' }, - { text: 'Error Handling', link: '/features/error-handling' }, - { text: 'Async Execution', link: '/features/async-execution' }, - { text: 'Logging', link: '/features/logging' }, - { text: 'Events', link: '/features/event-bus' }, - { text: 'Guards', link: '/features/guards' }, - { text: 'Lazy Resolvers', link: '/features/lazy-resolvers' }, + { text: "Schema Validation", link: "/features/schema-validation" }, + { text: "Error Handling", link: "/features/error-handling" }, + { text: "Async Execution", link: "/features/async-execution" }, + { text: "Logging", link: "/features/logging" }, + { text: "Events", link: "/features/event-bus" }, + { text: "Guards", link: "/features/guards" }, + { text: "Lazy Resolvers", link: "/features/lazy-resolvers" }, ], }, { - text: 'Rails Integration', + text: "Rails Integration", items: [ - { text: 'Controllers', link: '/rails/controllers' }, - { text: 'Generators', link: '/rails/generators' }, - { text: 'Configuration', link: '/rails/configuration' }, - { text: 'Autoloading', link: '/rails/autoloading' }, + { text: "Controllers", link: "/rails/controllers" }, + { text: "Generators", link: "/rails/generators" }, + { text: "Configuration", link: "/rails/configuration" }, + { text: "Autoloading", link: "/rails/autoloading" }, ], }, { - text: 'Testing', + text: "Testing", items: [ - { text: 'Testing Services', link: '/testing/services' }, - { text: 'Testing Guards', link: '/testing/guards' }, - { text: 'Testing Events', link: '/testing/events' }, + { text: "Testing Services", link: "/testing/services" }, + { text: "Testing Guards", link: "/testing/guards" }, + { text: "Testing Events", link: "/testing/events" }, ], }, { - text: 'Reference', + text: "Reference", items: [ - { text: 'Generators', link: '/reference/generators' }, - { text: 'Dry Initializer', link: '/reference/dry-initializer' }, + { text: "Generators", link: "/reference/generators" }, + { text: "Dry Initializer", link: "/reference/dry-initializer" }, ], }, ], - socialLinks: [{ icon: 'github', link: 'https://github.com/zarpay/servus' }], + socialLinks: [{ icon: "github", link: "https://github.com/zarpay/servus" }], outline: { level: [2, 3], - label: 'On this page', + label: "On this page", }, docFooter: { - prev: 'Previous page', - next: 'Next page', + prev: "Previous page", + next: "Next page", }, footer: { - message: 'Developed at and used extensively by ZAR', - copyright: 'Released under the MIT License', + message: + 'Developed at and used extensively by ZAR', + copyright: "Released under the MIT License", }, }, }); diff --git a/site/core/responses.md b/site/core/responses.md index 46d2302..5281c81 100644 --- a/site/core/responses.md +++ b/site/core/responses.md @@ -1,16 +1,70 @@ -# Responses +# Result -Every Servus service returns a `Response` object with three properties: `success?`, `data`, and `error`. The shape is always the same — callers never have to guess how to inspect the outcome. +`Servus::Result` is the value object that represents the outcome of an operation: either a `success?` carrying data, or a `failure?` carrying an error. Never both. -## Success +Every Servus service returns a `Result`. You can also construct one directly from any plain Ruby code that wants the same shape — there is no requirement to wrap your logic in a service class first. -When a service calls `success(...)`, the caller receives a `Response` with the data wrapped in a `DataObject`: +```ruby +Servus::Result.success(user_id: 1) +# => # error=nil> + +Servus::Result.failure("Card declined", type: Servus::Support::Errors::BadRequestError) +# => #> +``` + +`Servus::Support::Response` is kept as an alias so existing code referencing the old constant keeps working unchanged. + +## Outside a service + +Anywhere in your codebase — controllers, jobs, plain POROs, scripts — you can produce and consume a `Result`: + +```ruby +def import_rows(rows) + return Servus::Result.failure("no rows") if rows.empty? + + imported = rows.map { |row| Row.create!(row) } + Servus::Result.success(imported: imported.count) +end + +result = import_rows(rows) + +if result.success? + puts "Imported #{result.data.imported}" +else + puts "Error: #{result.error.message}" +end +``` + +`Servus::Result.success(data = nil)` takes any value. Hashes are wrapped in a `DataObject` (see below); everything else passes through unchanged. + +`Servus::Result.failure(message = nil, data: nil, type: ServiceError)` mirrors the service-level `failure` DSL — same arguments, same defaults. + +## Inside a service + +Inside a `Servus::Base` service, the `success` and `failure` DSL methods are sugar over `Servus::Result.success` / `.failure`: + +```ruby +class Treasury::TransferGold::Service < Servus::Base + def call + return failure("Cannot transfer to the same account") if @from == @to + + @from.withdraw!(@gold_dragons) + @to.deposit!(@gold_dragons) + + success( + transferred: @gold_dragons, + from_balance: @from.balance, + to_balance: @to.balance, + ) + end +end +``` ```ruby result = Treasury::TransferGold::Service.call( from_account: crown_account, to_account: night_watch_account, - gold_dragons: 50 + gold_dragons: 50, ) result.success? # => true @@ -20,9 +74,11 @@ result.data.to_balance # => 550 result.error # => nil ``` -### DataObject +The return value is a `Servus::Result` — the same type you'd build by hand outside a service. + +## DataObject -Any hash passed to `success(...)` is deeply wrapped in a `DataObject`. This means nested values are accessible as methods at any depth: +Any hash given to `success` / `Result.success` is deeply wrapped in a `DataObject`. Nested values are accessible as methods at any depth: ```ruby # Bracket access still works @@ -36,23 +92,7 @@ result.data.transfer.to # => "night_watch" result.data.entries.first.amount # => 50 ``` -## Failure - -When a service calls `failure(...)`, the caller receives a `Response` with an error and optionally structured data: - -```ruby -result = Treasury::TransferGold::Service.call( - from_account: crown_account, - to_account: crown_account, - gold_dragons: 50 -) - -result.success? # => false -result.error.message # => "Cannot transfer to the same account" -result.data # => nil (unless data: was passed to failure) -``` - -### Failure with structured data +## Failure with structured data `failure` accepts an optional `data:` keyword for attaching structured information to the failure: @@ -65,7 +105,9 @@ result.data.reason # => "insufficient_funds" result.data.decline_code # => "do_not_honor" ``` -### Error types +The same shape works from `Servus::Result.failure` outside a service. + +## Error types All errors inherit from `ServiceError` and map to HTTP status codes: @@ -89,6 +131,7 @@ Use the `type:` keyword to specify which error class a failure should use: ```ruby failure("Account not found", type: NotFoundError) +Servus::Result.failure("Account not found", type: Servus::Support::Errors::NotFoundError) ``` ::: info api_error @@ -104,21 +147,21 @@ This pairs well with Rails controller helpers — the error carries its own HTTP ## error! -`error!` is for exceptional situations that should halt execution immediately. Unlike `failure`, it raises an exception: +`error!` is for exceptional situations inside a service that should halt execution immediately. Unlike `failure`, it raises an exception: ```ruby -# failure returns a Response — execution continues +# failure returns a Result — execution continues return failure("Insufficient funds") # error! raises — execution stops error!("Database corrupted", type: InternalServerError) ``` -Use `failure` for expected business conditions. Use `error!` when something is genuinely wrong and the service cannot continue. +Use `failure` for expected business conditions. Use `error!` when something is genuinely wrong and the service cannot continue. `error!` is a service-only DSL; outside a service, raise the error class directly. ## Composition -If a downstream service fails and the calling service has no better context to add, return that response unchanged: +If a downstream service fails and the calling service has no better context to add, return that result unchanged: ```ruby def call @@ -129,4 +172,4 @@ def call end ``` -Responses travel through a workflow without re-wrapping. The original error type, message, and status code are preserved for the eventual caller. +Results travel through a workflow without re-wrapping. The original error type, message, and status code are preserved for the eventual caller.