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.