Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions gem/lib/servus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 5 additions & 5 deletions gem/lib/servus/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
99 changes: 99 additions & 0 deletions gem/lib/servus/result.rb
Original file line number Diff line number Diff line change
@@ -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
85 changes: 6 additions & 79 deletions gem/lib/servus/support/response.rb
Original file line number Diff line number Diff line change
@@ -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
127 changes: 127 additions & 0 deletions gem/spec/servus/result_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading