servus 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
  3. data/lib/generators/servus/guard/guard_generator.rb +1 -1
  4. data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
  5. data/lib/generators/servus/service/service_generator.rb +1 -1
  6. data/lib/servus/base.rb +46 -3
  7. data/lib/servus/config.rb +71 -3
  8. data/lib/servus/events/bus.rb +29 -0
  9. data/lib/servus/events/emitter.rb +15 -0
  10. data/lib/servus/guard.rb +7 -6
  11. data/lib/servus/guards/falsey_guard.rb +3 -3
  12. data/lib/servus/guards/presence_guard.rb +4 -4
  13. data/lib/servus/guards/state_guard.rb +4 -5
  14. data/lib/servus/guards/truthy_guard.rb +3 -3
  15. data/lib/servus/helpers/controller_helpers.rb +40 -0
  16. data/lib/servus/support/errors.rb +16 -0
  17. data/lib/servus/support/lockdown.rb +94 -0
  18. data/lib/servus/support/logger.rb +16 -0
  19. data/lib/servus/support/validator.rb +65 -34
  20. data/lib/servus/testing/example_builders.rb +52 -0
  21. data/lib/servus/testing/matchers.rb +99 -0
  22. data/lib/servus/version.rb +1 -1
  23. data/lib/servus.rb +1 -0
  24. metadata +7 -111
  25. data/.claude/commands/check-docs.md +0 -1
  26. data/.claude/commands/consistency-check.md +0 -1
  27. data/.claude/commands/fine-tooth-comb.md +0 -1
  28. data/.claude/commands/red-green-refactor.md +0 -5
  29. data/.claude/settings.json +0 -24
  30. data/.rspec +0 -3
  31. data/.rubocop.yml +0 -27
  32. data/.yardopts +0 -6
  33. data/CHANGELOG.md +0 -169
  34. data/CLAUDE.md +0 -10
  35. data/IDEAS.md +0 -5
  36. data/LICENSE.txt +0 -21
  37. data/READme.md +0 -856
  38. data/Rakefile +0 -45
  39. data/docs/core/1_overview.md +0 -81
  40. data/docs/core/2_architecture.md +0 -120
  41. data/docs/core/3_service_objects.md +0 -154
  42. data/docs/features/1_schema_validation.md +0 -161
  43. data/docs/features/2_error_handling.md +0 -129
  44. data/docs/features/3_async_execution.md +0 -81
  45. data/docs/features/4_logging.md +0 -64
  46. data/docs/features/5_event_bus.md +0 -244
  47. data/docs/features/6_guards.md +0 -356
  48. data/docs/features/7_lazy_resolvers.md +0 -238
  49. data/docs/features/guards_naming_convention.md +0 -540
  50. data/docs/guides/1_common_patterns.md +0 -90
  51. data/docs/guides/2_migration_guide.md +0 -225
  52. data/docs/integration/1_configuration.md +0 -154
  53. data/docs/integration/2_testing.md +0 -304
  54. data/docs/integration/3_rails_integration.md +0 -99
  55. data/docs/yard/Servus/Base.html +0 -1645
  56. data/docs/yard/Servus/Config.html +0 -582
  57. data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
  58. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
  59. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
  60. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
  61. data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
  62. data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
  63. data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
  64. data/docs/yard/Servus/Extensions/Async.html +0 -141
  65. data/docs/yard/Servus/Extensions.html +0 -117
  66. data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
  67. data/docs/yard/Servus/Generators.html +0 -115
  68. data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
  69. data/docs/yard/Servus/Helpers.html +0 -115
  70. data/docs/yard/Servus/Railtie.html +0 -134
  71. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
  72. data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
  73. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
  74. data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
  75. data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
  76. data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
  77. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
  78. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
  79. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
  80. data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
  81. data/docs/yard/Servus/Support/Errors.html +0 -140
  82. data/docs/yard/Servus/Support/Logger.html +0 -856
  83. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
  84. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
  85. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
  86. data/docs/yard/Servus/Support/Rescuer.html +0 -267
  87. data/docs/yard/Servus/Support/Response.html +0 -574
  88. data/docs/yard/Servus/Support/Validator.html +0 -1150
  89. data/docs/yard/Servus/Support.html +0 -119
  90. data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
  91. data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
  92. data/docs/yard/Servus/Testing.html +0 -142
  93. data/docs/yard/Servus.html +0 -343
  94. data/docs/yard/_index.html +0 -535
  95. data/docs/yard/class_list.html +0 -54
  96. data/docs/yard/css/common.css +0 -1
  97. data/docs/yard/css/full_list.css +0 -58
  98. data/docs/yard/css/style.css +0 -503
  99. data/docs/yard/file.1_common_patterns.html +0 -154
  100. data/docs/yard/file.1_configuration.html +0 -115
  101. data/docs/yard/file.1_overview.html +0 -142
  102. data/docs/yard/file.1_schema_validation.html +0 -188
  103. data/docs/yard/file.2_architecture.html +0 -157
  104. data/docs/yard/file.2_error_handling.html +0 -190
  105. data/docs/yard/file.2_migration_guide.html +0 -242
  106. data/docs/yard/file.2_testing.html +0 -227
  107. data/docs/yard/file.3_async_execution.html +0 -145
  108. data/docs/yard/file.3_rails_integration.html +0 -160
  109. data/docs/yard/file.3_service_objects.html +0 -191
  110. data/docs/yard/file.4_logging.html +0 -135
  111. data/docs/yard/file.ErrorHandling.html +0 -190
  112. data/docs/yard/file.READme.html +0 -674
  113. data/docs/yard/file.architecture.html +0 -157
  114. data/docs/yard/file.async_execution.html +0 -145
  115. data/docs/yard/file.common_patterns.html +0 -154
  116. data/docs/yard/file.configuration.html +0 -115
  117. data/docs/yard/file.error_handling.html +0 -190
  118. data/docs/yard/file.logging.html +0 -135
  119. data/docs/yard/file.migration_guide.html +0 -242
  120. data/docs/yard/file.overview.html +0 -142
  121. data/docs/yard/file.rails_integration.html +0 -160
  122. data/docs/yard/file.schema_validation.html +0 -188
  123. data/docs/yard/file.service_objects.html +0 -191
  124. data/docs/yard/file.testing.html +0 -227
  125. data/docs/yard/file_list.html +0 -119
  126. data/docs/yard/frames.html +0 -22
  127. data/docs/yard/index.html +0 -674
  128. data/docs/yard/js/app.js +0 -344
  129. data/docs/yard/js/full_list.js +0 -242
  130. data/docs/yard/js/jquery.js +0 -4
  131. data/docs/yard/method_list.html +0 -542
  132. data/docs/yard/top-level-namespace.html +0 -110
data/Rakefile DELETED
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
- require 'bundler/gem_tasks'
5
- require 'rspec/core/rake_task'
6
-
7
- # Constants
8
- GEM_NAME = 'servus'
9
- BUILDS_DIR = 'builds'
10
-
11
- RSpec::Core::RakeTask.new(:spec)
12
-
13
- require 'rubocop/rake_task'
14
-
15
- RuboCop::RakeTask.new
16
-
17
- # Build gem
18
- task :build do
19
- FileUtils.mkdir_p(BUILDS_DIR)
20
-
21
- # Build gem in current directory
22
- sh "gem build #{GEM_NAME}.gemspec"
23
-
24
- # Move to builds directory
25
- gem_file = Dir["#{GEM_NAME}-*.gem"].first
26
- if gem_file
27
- FileUtils.mv(gem_file, BUILDS_DIR)
28
- puts "Moved #{gem_file} to #{BUILDS_DIR}/"
29
- end
30
- end
31
-
32
- # Install gem locally
33
- task install: :build do
34
- gem_file = Dir["#{BUILDS_DIR}/#{GEM_NAME}-*.gem"].max_by { |f| File.mtime(f) }
35
- sh "gem install #{gem_file}"
36
- end
37
-
38
- # Publish gem to RubyGems.org
39
- task publish: :build do
40
- gem_file = Dir["#{BUILDS_DIR}/#{GEM_NAME}-*.gem"].max_by { |f| File.mtime(f) }
41
- puts "Publishing #{gem_file} to RubyGems.org..."
42
- sh "gem push #{gem_file}"
43
- end
44
-
45
- task default: %i[spec rubocop]
@@ -1,81 +0,0 @@
1
- # @title Core / 1. Overview
2
-
3
- # Servus Overview
4
-
5
- Servus is a lightweight framework for implementing service objects in Ruby applications. It extracts business logic from controllers and models into testable, single-purpose classes with built-in validation, error handling, and logging.
6
-
7
- ## Core Concepts
8
-
9
- ### The Service Pattern
10
-
11
- Services encapsulate one business operation. Each service inherits from `Servus::Base`, implements `initialize` and `call`, and returns a `Response` object indicating success or failure.
12
-
13
- ```ruby
14
- class ProcessPayment::Service < Servus::Base
15
- def initialize(user_id:, amount:)
16
- @user_id = user_id
17
- @amount = amount
18
- end
19
-
20
- def call
21
- user = User.find(@user_id)
22
- return failure("Insufficient funds") unless user.balance >= @amount
23
-
24
- user.update!(balance: user.balance - @amount)
25
- success(user: user, new_balance: user.balance)
26
- end
27
- end
28
-
29
- # Usage
30
- result = ProcessPayment::Service.call(user_id: 1, amount: 50)
31
- result.success? # => true
32
- result.data # => { user: #<User>, new_balance: 950 }
33
- ```
34
-
35
- ### Response Objects
36
-
37
- Services return `Response` objects instead of raising exceptions for business failures. This makes success and failure paths explicit and enables service composition without exception handling.
38
-
39
- ```ruby
40
- result = SomeService.call(params)
41
- if result.success?
42
- result.data # DataObject wrapping the success data
43
- result.data[:user] # bracket access
44
- result.data.user # accessor access
45
- result.data.user.email # nested accessor access
46
- else
47
- result.error # ServiceError instance
48
- result.error.message
49
- result.error.api_error # { code: :symbol, message: "string" }
50
- result.data # optional failure data (nil unless data: kwarg was used)
51
- end
52
- ```
53
-
54
- ### Optional Schema Validation
55
-
56
- Services can define JSON schemas for arguments and results. Validation happens automatically before/after execution but is entirely optional.
57
-
58
- ```ruby
59
- class Service < Servus::Base
60
- schema(
61
- arguments: {
62
- type: "object",
63
- required: ["user_id", "amount"],
64
- properties: {
65
- user_id: { type: "integer" },
66
- amount: { type: "number", minimum: 0.01 }
67
- }
68
- }
69
- )
70
- end
71
- ```
72
-
73
- ## When to Use Servus
74
-
75
- **Good fits**: Multi-step workflows, operations spanning multiple models, external API calls, background jobs, complex business logic.
76
-
77
- **Poor fits**: Simple CRUD, single-model operations, operations tightly coupled to one model.
78
-
79
- ## Framework Integration
80
-
81
- Servus core works in any Ruby application. Rails-specific features (async via ActiveJob, controller helpers, generators) are optional additions. Services work without any configuration - just inherit from `Servus::Base` and implement your logic.
@@ -1,120 +0,0 @@
1
- # @title Core / 2. Architecture
2
-
3
- # Architecture
4
-
5
- Servus wraps service execution with automatic validation, logging, and error handling. When you call `Service.call(**args)`, the framework orchestrates these concerns transparently.
6
-
7
- ## Execution Flow
8
-
9
- ```
10
- Arguments → Validation → Service#call → Result Validation → Event Emission → Logging → Response
11
- ↓ ↓ ↓ ↓
12
- ValidationError ValidationError EventHandlers Benchmark
13
- ```
14
-
15
- The framework intercepts the `.call` class method to inject cross-cutting concerns before and after your business logic runs. Your `call` instance method contains only business logic - validation, logging, event emission, and timing happen automatically.
16
-
17
- ## Core Components
18
-
19
- **Servus::Base** (`lib/servus/base.rb`): Foundation class providing `.call()` orchestration and response helpers (`success`, `failure`, `error!`)
20
-
21
- **Support::Response** (`lib/servus/support/response.rb`): Immutable result object with `success?`, `data`, and `error` attributes
22
-
23
- **Support::Validator** (`lib/servus/support/validator.rb`): JSON Schema validation for arguments (before execution) and results (after execution). Schemas are cached after first load.
24
-
25
- **Support::Logger** (`lib/servus/support/logger.rb`): Automatic logging at DEBUG (calls with args), INFO (success), WARN (failures), ERROR (exceptions)
26
-
27
- **Support::Rescuer** (`lib/servus/support/rescuer.rb`): Declarative exception handling via `rescue_from` class method
28
-
29
- **Support::Errors** (`lib/servus/support/errors.rb`): HTTP-aligned error hierarchy (ServiceError, NotFoundError, ValidationError, etc.)
30
-
31
- **Events::Emitter** (`lib/servus/events/emitter.rb`): DSL for declaring events that services emit on success/failure
32
-
33
- **Events::Bus** (`lib/servus/events/bus.rb`): Central event router using ActiveSupport::Notifications for thread-safe dispatch
34
-
35
- **EventHandler** (`lib/servus/event_handler.rb`): Base class for handlers that subscribe to events and invoke services
36
-
37
- ## Extension Points
38
-
39
- ### Schema Validation
40
-
41
- Use the `schema` DSL method to define JSON Schema validation for arguments and results:
42
-
43
- ```ruby
44
- class Service < Servus::Base
45
- schema(
46
- arguments: { type: "object", required: ["user_id"] },
47
- result: { type: "object", required: ["user"] }
48
- )
49
- end
50
- ```
51
-
52
- ### Declarative Error Handling
53
-
54
- Use `rescue_from` to convert exceptions into failures. Provide a custom error type or use a block for custom handling.
55
-
56
- ```ruby
57
- class Service < Servus::Base
58
- # Default error type
59
- rescue_from Net::HTTPError, Timeout::Error, use: ServiceUnavailableError
60
-
61
- # Custom handling with block
62
- rescue_from ActiveRecord::RecordInvalid do |exception|
63
- failure("Validation failed: #{exception.message}", type: ValidationError)
64
- end
65
- end
66
- ```
67
-
68
- ### Support Classes
69
-
70
- Create helper classes in `app/services/service_name/support/*.rb`. These are namespaced to your service.
71
-
72
- ```
73
- app/services/process_payment/
74
- ├── service.rb
75
- └── support/
76
- ├── payment_gateway.rb
77
- └── receipt_formatter.rb
78
- ```
79
-
80
- ## Async Execution
81
-
82
- `Service.call_async(**args)` enqueues execution via ActiveJob. The service runs identically whether called sync or async.
83
-
84
- ```ruby
85
- ProcessPayment::Service.call_async(
86
- user_id: 1,
87
- amount: 50,
88
- queue: :critical,
89
- wait: 5.minutes
90
- )
91
- ```
92
-
93
- ## Event-Driven Architecture
94
-
95
- Services can emit events that trigger downstream handlers. This decouples services from their side effects.
96
-
97
- ```ruby
98
- # Service emits events
99
- class CreateUser::Service < Servus::Base
100
- emits :user_created, on: :success
101
- end
102
-
103
- # Handler reacts to events
104
- class UserCreatedHandler < Servus::EventHandler
105
- handles :user_created
106
-
107
- invoke SendWelcomeEmail::Service, async: true do |payload|
108
- { user_id: payload[:user_id] }
109
- end
110
- end
111
- ```
112
-
113
- See {file:docs/features/5_event_bus.md Event Bus} for full documentation.
114
-
115
- ## Performance
116
-
117
- - Schema loading: Cached per class after first use
118
- - Validation overhead: ~1-5ms when schemas defined
119
- - Logging overhead: ~0.1ms per call
120
- - Total framework overhead: < 10ms per service call
@@ -1,154 +0,0 @@
1
- # @title Core / 3. Service Objects
2
-
3
- # Service Objects
4
-
5
- Service objects encapsulate one business operation into a testable, reusable class. They sit between controllers and models, handling orchestration logic that doesn't belong in either.
6
-
7
- ## The Pattern
8
-
9
- Services implement two methods: `initialize` (sets up dependencies) and `call` (executes business logic). All services return a `Response` object indicating success or failure.
10
-
11
- ```ruby
12
- module Users
13
- module Create
14
- class Service < Servus::Base
15
- def initialize(email:, name:)
16
- @email = email
17
- @name = name
18
- end
19
-
20
- def call
21
- return failure("Email taken") if User.exists?(email: @email)
22
-
23
- user = User.create!(email: @email, name: @name)
24
- send_welcome_email(user)
25
-
26
- success(user: user)
27
- end
28
- end
29
- end
30
- end
31
-
32
- # Usage
33
- result = Users::Create::Service.call(email: "user@example.com", name: "John")
34
- result.success? # => true
35
- result.data[:user] # => #<User>
36
- ```
37
-
38
- ## Accessing Response Data
39
-
40
- Response data can be accessed with bracket syntax or accessor-style methods. Both are fully supported — use whichever reads better in context.
41
-
42
- ```ruby
43
- result = Users::Create::Service.call(email: "user@example.com", name: "John")
44
-
45
- # Bracket access
46
- result.data[:user] # => #<User>
47
- result.data[:user].email # => "user@example.com"
48
-
49
- # Accessor access
50
- result.data.user # => #<User>
51
- result.data.user.email # => "user@example.com"
52
- ```
53
-
54
- Nested Hashes are wrapped automatically, so accessor chains work at any depth:
55
-
56
- ```ruby
57
- result = Orders::Create::Service.call(items: [...])
58
-
59
- result.data.order.shipping.address.city # => "Berlin"
60
- result.data[:order][:shipping] # also works
61
- ```
62
-
63
- Arrays of Hashes are also wrapped, so you can access elements naturally:
64
-
65
- ```ruby
66
- result.data.order.items.first.sku # => "A1"
67
- ```
68
-
69
- Non-Hash values like model instances, strings, and integers pass through unchanged — accessor access on those values uses the object's own methods.
70
-
71
- ## Service Composition
72
-
73
- Services can call other services. Use the returned Response to decide whether to continue or propagate the failure.
74
-
75
- ```ruby
76
- def call
77
- user_result = Users::Create::Service.call(user_params)
78
- return user_result unless user_result.success? # propogates result failure
79
-
80
- account_result = Accounts::Create::Service.call(
81
- user: user_result.data[:user],
82
- plan: @plan
83
- )
84
- return account_result unless account_result.success? # propogates result failure
85
-
86
- success(
87
- user: user_result.data[:user],
88
- account: account_result.data[:account]
89
- )
90
- end
91
- ```
92
-
93
- ## When to Extract to Services
94
-
95
- **Extract when**:
96
- - Logic spans multiple models
97
- - Complex conditional branching
98
- - External API calls
99
- - Background processing needed
100
- - Testing requires extensive setup
101
-
102
- **Don't extract when**:
103
- - Simple CRUD operations
104
- - Single-model updates
105
- - Logic naturally belongs in model
106
-
107
- ## Directory Structure
108
-
109
- Each service lives in its own namespace to avoid naming collisions and allow for support classes.
110
-
111
- ```
112
- app/services/
113
- ├── users/
114
- │ └── create/
115
- │ ├── service.rb
116
- │ └── support/
117
- │ └── welcome_email.rb
118
- └── orders/
119
- └── process/
120
- ├── service.rb
121
- └── support/
122
- ├── payment_gateway.rb
123
- └── inventory_updater.rb
124
- ```
125
-
126
- Support classes are private to their service - they should never be used by other services.
127
-
128
- ## Testing
129
-
130
- Services are designed for easy testing with explicit inputs and outputs.
131
-
132
- ```ruby
133
- RSpec.describe Users::Create::Service do
134
- describe ".call" do
135
- context "with valid params" do
136
- it "creates user" do
137
- result = described_class.call(email: "test@example.com", name: "Test")
138
- expect(result.success?).to be true
139
- expect(result.data[:user]).to be_persisted
140
- end
141
- end
142
-
143
- context "with duplicate email" do
144
- before { create(:user, email: "test@example.com") }
145
-
146
- it "returns failure" do
147
- result = described_class.call(email: "test@example.com", name: "Test")
148
- expect(result.success?).to be false
149
- expect(result.error.message).to eq("Email taken")
150
- end
151
- end
152
- end
153
- end
154
- ```
@@ -1,161 +0,0 @@
1
- # @title Features / 1. Schema Validation
2
-
3
- # Schema Validation
4
-
5
- Servus provides optional JSON Schema validation for service arguments and results. Validation is opt-in - services work fine without schemas.
6
-
7
- ## How It Works
8
-
9
- Define schemas using the `schema` DSL method (recommended) or as constants. The framework validates arguments before execution and results after execution. Invalid data raises `ValidationError`.
10
-
11
- ### Preferred: Schema DSL Method
12
-
13
- ```ruby
14
- class ProcessPayment::Service < Servus::Base
15
- schema(
16
- arguments: {
17
- type: "object",
18
- required: ["user_id", "amount"],
19
- properties: {
20
- user_id: { type: "integer", example: 123 },
21
- amount: { type: "number", minimum: 0.01, example: 100.0 }
22
- }
23
- },
24
- result: {
25
- type: "object",
26
- required: ["transaction_id", "new_balance"],
27
- properties: {
28
- transaction_id: { type: "string", example: "txn_abc123" },
29
- new_balance: { type: "number", example: 950.0 }
30
- }
31
- },
32
- failure: {
33
- type: "object",
34
- properties: {
35
- reason: { type: "string", example: "insufficient_funds" }
36
- }
37
- }
38
- )
39
- end
40
- ```
41
-
42
- **Pro tip:** Add `example` or `examples` keywords to your schemas. These values can be automatically extracted in tests using `servus_arguments_example()` and `servus_result_example()` helpers. See the [Testing documentation](../integration/testing.md#schema-example-helpers) for details.
43
-
44
- You can define just one schema if needed:
45
-
46
- ```ruby
47
- class SendEmail::Service < Servus::Base
48
- schema arguments: {
49
- type: "object",
50
- required: ["email", "subject"],
51
- properties: {
52
- email: { type: "string", format: "email" },
53
- subject: { type: "string" }
54
- }
55
- }
56
- end
57
- ```
58
-
59
- ### Alternative: Inline Constants
60
-
61
- Constants are still supported for backwards compatibility:
62
-
63
- ```ruby
64
- class ProcessPayment::Service < Servus::Base
65
- ARGUMENTS_SCHEMA = {
66
- type: "object",
67
- required: ["user_id", "amount"],
68
- properties: {
69
- user_id: { type: "integer" },
70
- amount: { type: "number", minimum: 0.01 }
71
- }
72
- }.freeze
73
-
74
- RESULT_SCHEMA = {
75
- type: "object",
76
- required: ["transaction_id", "new_balance"],
77
- properties: {
78
- transaction_id: { type: "string" },
79
- new_balance: { type: "number" }
80
- }
81
- }.freeze
82
-
83
- FAILURE_SCHEMA = {
84
- type: "object",
85
- properties: {
86
- reason: { type: "string" }
87
- }
88
- }.freeze
89
- end
90
- ```
91
-
92
- ## File-Based Schemas
93
-
94
- For complex schemas, use JSON files instead of inline definitions. Create files at:
95
- - `app/schemas/services/service_name/arguments.json`
96
- - `app/schemas/services/service_name/result.json`
97
- - `app/schemas/services/service_name/failure.json`
98
-
99
- ### Schema Lookup Precedence
100
-
101
- Servus checks for schemas in this order:
102
- 1. **schema DSL method** (if defined)
103
- 2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA, FAILURE_SCHEMA)
104
- 3. **JSON files** (in schema_root directory)
105
-
106
- Schemas are cached after first load for performance.
107
-
108
- ## Three Layers of Validation
109
-
110
- **Schema Validation** (Servus): Type safety and structure at service boundaries
111
-
112
- **Business Rules** (Service Logic): Domain-specific constraints during execution
113
-
114
- **Model Validation** (ActiveRecord): Database constraints before persistence
115
-
116
- Each layer has a different purpose - don't duplicate validation across layers.
117
-
118
- ## Failure Data Validation
119
-
120
- Services can optionally attach structured data to failure responses using the `data:` keyword argument on `failure()`. When a `failure` schema is defined, this data is validated against it — just like success results are validated against `result` schemas.
121
-
122
- ```ruby
123
- class ProcessPayment::Service < Servus::Base
124
- schema(
125
- failure: {
126
- type: "object",
127
- required: ["reason"],
128
- properties: {
129
- reason: { type: "string" },
130
- decline_code: { type: "string" }
131
- }
132
- }
133
- )
134
-
135
- def call
136
- return failure("Card declined", data: { reason: "insufficient_funds", decline_code: "do_not_honor" })
137
- end
138
- end
139
- ```
140
-
141
- Failure data validation is skipped when:
142
- - No `failure` schema is defined
143
- - The failure response has no data (`data: nil`, the default)
144
- - The response is a success
145
-
146
- ## Configuration
147
-
148
- Change the schema file location if needed:
149
-
150
- ```ruby
151
- # config/initializers/servus.rb
152
- Servus.configure do |config|
153
- config.schema_root = Rails.root.join('config/schemas')
154
- end
155
- ```
156
-
157
- Clear the schema cache during development when schemas change:
158
-
159
- ```ruby
160
- Servus::Support::Validator.clear_cache!
161
- ```
@@ -1,129 +0,0 @@
1
- # @title Features / 2. Error Handling
2
-
3
- # Error Handling
4
-
5
- Servus distinguishes between expected business failures (return failure) and unexpected system errors (raise exceptions). This separation makes error handling predictable and explicit.
6
-
7
- ## Failures vs Exceptions
8
-
9
- **Use `failure()`** for expected business conditions:
10
- - User not found
11
- - Insufficient balance
12
- - Invalid state transition
13
-
14
- **Use `error!()` or raise** for unexpected system errors:
15
- - Database connection failure
16
- - Nil reference error
17
- - External API timeout
18
-
19
- Failures return a Response object so callers can handle them. Exceptions halt execution and bubble up.
20
-
21
- ```ruby
22
- def call
23
- user = User.find_by(id: user_id)
24
- return failure("User not found", type: NotFoundError) unless user
25
- return failure("Insufficient funds") unless user.balance >= amount
26
-
27
- user.update!(balance: user.balance - amount) # Raises on system error
28
- success(user: user)
29
- end
30
- ```
31
-
32
- Failures can optionally carry structured data using the `data:` keyword argument. When a `failure` schema is defined on the service, this data is validated against it.
33
-
34
- ```ruby
35
- def call
36
- return failure("Approval required", data: { requires_human_approval: true, ai_approved: true })
37
- end
38
- ```
39
-
40
- ## Error Classes
41
-
42
- All error classes inherit from `ServiceError` and map to HTTP status codes. Use them for API-friendly errors.
43
-
44
- ```ruby
45
- # Built-in errors
46
- NotFoundError # 404
47
- BadRequestError # 400
48
- UnauthorizedError # 401
49
- ForbiddenError # 403
50
- ValidationError # 422
51
- InternalServerError # 500
52
- ServiceUnavailableError # 503
53
-
54
- # Usage
55
- failure("Resource not found", type: NotFoundError)
56
- error!("Database corrupted", type: InternalServerError) # Raises exception
57
- ```
58
-
59
- Each error has an `api_error` method returning `{ code: :symbol, message: "string" }` for JSON APIs.
60
-
61
- ## Declarative Exception Handling
62
-
63
- Use `rescue_from` to convert specific exceptions into failures. Original exception details are preserved in error messages.
64
-
65
- ```ruby
66
- class CallExternalApi::Service < Servus::Base
67
- rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
68
- rescue_from JSON::ParserError, use: BadRequestError
69
-
70
- def call
71
- response = http_client.get(url) # May raise
72
- data = JSON.parse(response.body) # May raise
73
- success(data: data)
74
- end
75
- end
76
-
77
- # If Net::HTTPError is raised, service returns:
78
- # Response(success: false, error: ServiceUnavailableError("[Net::HTTPError]: original message"))
79
- ```
80
-
81
- The `rescue_from` pattern keeps business logic clean while ensuring consistent error handling across services.
82
-
83
- ### Custom Error Handling with Blocks
84
-
85
- For more control over error handling, provide a block to `rescue_from`. The block receives the exception and can return either success or failure:
86
-
87
- ```ruby
88
- class ProcessPayment::Service < Servus::Base
89
- # Custom failure with error details
90
- rescue_from ActiveRecord::RecordInvalid do |exception|
91
- failure("Payment failed: #{exception.record.errors.full_messages.join(', ')}",
92
- type: ValidationError)
93
- end
94
-
95
- # Recover from certain errors with success
96
- rescue_from Stripe::CardError do |exception|
97
- if exception.code == 'card_declined'
98
- failure("Card was declined", type: BadRequestError)
99
- else
100
- # Log and continue for other card errors
101
- Rails.logger.warn("Stripe error: #{exception.message}")
102
- success(recovered: true, fallback_used: true)
103
- end
104
- end
105
-
106
- def call
107
- # Service logic that may raise exceptions
108
- end
109
- end
110
- ```
111
-
112
- The block has access to `success(data)` and `failure(message, data:, type:)` methods. This allows conditional error handling and even recovering from exceptions.
113
-
114
- ## Custom Errors
115
-
116
- Create domain-specific errors by inheriting from `ServiceError`:
117
-
118
- ```ruby
119
- class InsufficientFundsError < Servus::Support::Errors::ServiceError
120
- DEFAULT_MESSAGE = "Insufficient funds"
121
-
122
- def api_error
123
- { code: :insufficient_funds, message: message }
124
- end
125
- end
126
-
127
- # Usage
128
- failure("Account balance too low", type: InsufficientFundsError)
129
- ```