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
@@ -1,356 +0,0 @@
1
- # @title Features / 6. Guards
2
-
3
- # Guards
4
-
5
- Guards are reusable validation rules that halt service execution when conditions aren't met. They provide a declarative way to enforce preconditions with rich, API-friendly error responses.
6
-
7
- ## Why Guards?
8
-
9
- Instead of scattering validation logic throughout services:
10
-
11
- ```ruby
12
- # Without guards - repetitive and verbose
13
- def call
14
- return failure("User required", type: ValidationError) unless user
15
- return failure("User must be active", type: ValidationError) unless user.active?
16
- # ... business logic ...
17
- end
18
- ```
19
-
20
- Use guards for clean, declarative validation:
21
-
22
- ```ruby
23
- # With guards - clear and reusable
24
- def call
25
- enforce_presence!(user: user)
26
- enforce_truthy!(on: user, check: :active)
27
- # ... business logic ...
28
- end
29
- ```
30
-
31
- ## Built-in Guards
32
-
33
- Servus includes four guards by default:
34
-
35
- ### PresenceGuard
36
-
37
- Validates that all values are present (not nil or empty):
38
-
39
- ```ruby
40
- # Single value
41
- enforce_presence!(user: user)
42
-
43
- # Multiple values - all must be present
44
- enforce_presence!(user: user, account: account, device: device)
45
-
46
- # Works with strings, arrays, hashes
47
- enforce_presence!(email: email) # fails if nil or ""
48
- enforce_presence!(items: cart.items) # fails if nil or []
49
- enforce_presence!(data: response.body) # fails if nil or {}
50
- ```
51
-
52
- Error: `"user must be present (got nil)"` or `"email must be present (got \"\")"`
53
-
54
- ### TruthyGuard
55
-
56
- Validates that attribute(s) on an object are truthy:
57
-
58
- ```ruby
59
- # Single attribute
60
- enforce_truthy!(on: user, check: :active)
61
-
62
- # Multiple attributes - all must be truthy
63
- enforce_truthy!(on: user, check: [:active, :verified, :confirmed])
64
-
65
- # Conditional check
66
- if check_truthy?(on: subscription, check: :valid?)
67
- process_subscription
68
- end
69
- ```
70
-
71
- Error: `"User.active must be truthy (got false)"`
72
-
73
- ### FalseyGuard
74
-
75
- Validates that attribute(s) on an object are falsey:
76
-
77
- ```ruby
78
- # Single attribute - user must not be banned
79
- enforce_falsey!(on: user, check: :banned)
80
-
81
- # Multiple attributes - all must be falsey
82
- enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged])
83
-
84
- # Conditional check
85
- if check_falsey?(on: user, check: :suspended)
86
- allow_action
87
- end
88
- ```
89
-
90
- Error: `"User.banned must be falsey (got true)"`
91
-
92
- ### StateGuard
93
-
94
- Validates that an attribute matches an expected value or one of several allowed values:
95
-
96
- ```ruby
97
- # Single expected value
98
- enforce_state!(on: order, check: :status, is: :pending)
99
-
100
- # Multiple allowed values - any match passes
101
- enforce_state!(on: account, check: :status, is: [:active, :trial])
102
-
103
- # Conditional check
104
- if check_state?(on: order, check: :status, is: :shipped)
105
- send_tracking_email
106
- end
107
- ```
108
-
109
- Errors:
110
- - Single value: `"Order.status must be pending (got shipped)"`
111
- - Multiple values: `"Account.status must be one of active, trial (got suspended)"`
112
-
113
- ## Guard Methods
114
-
115
- Each guard defines two methods on `Servus::Guards`:
116
-
117
- - **Bang method (`!`)** - Throws on failure, halts execution
118
- - **Predicate method (`?`)** - Returns boolean, continues execution
119
-
120
- ```ruby
121
- # Bang method - use for preconditions that must pass
122
- enforce_presence!(user: user) # throws :guard_failure if nil
123
-
124
- # Predicate method - use for conditional logic
125
- if check_truthy?(on: account, check: :premium)
126
- apply_premium_discount
127
- else
128
- apply_standard_rate
129
- end
130
- ```
131
-
132
- ## Creating Custom Guards
133
-
134
- Define guards by inheriting from `Servus::Guard`:
135
-
136
- ```ruby
137
- # app/guards/sufficient_balance_guard.rb
138
- class SufficientBalanceGuard < Servus::Guard
139
- http_status 422
140
- error_code 'insufficient_balance'
141
-
142
- message 'Insufficient balance: need %<required>s, have %<available>s' do
143
- message_data
144
- end
145
-
146
- def test(account:, amount:)
147
- account.balance >= amount
148
- end
149
-
150
- private
151
-
152
- def message_data
153
- {
154
- required: kwargs[:amount],
155
- available: kwargs[:account].balance
156
- }
157
- end
158
- end
159
- ```
160
-
161
- This automatically defines `enforce_sufficient_balance!` and `check_sufficient_balance?` methods.
162
-
163
- ### Guard DSL
164
-
165
- **`http_status`** - HTTP status code for API responses (default: 422)
166
-
167
- ```ruby
168
- http_status 422 # Unprocessable Entity
169
- http_status 403 # Forbidden
170
- http_status 400 # Bad Request
171
- ```
172
-
173
- **`error_code`** - Machine-readable error code for API clients
174
-
175
- ```ruby
176
- error_code 'insufficient_balance'
177
- error_code 'daily_limit_exceeded'
178
- error_code 'account_locked'
179
- ```
180
-
181
- **`message`** - Human-readable error message with optional interpolation
182
-
183
- ```ruby
184
- # Static message
185
- message 'Amount must be positive'
186
-
187
- # With interpolation (uses Ruby's % formatting)
188
- message 'Balance: %<current>s, Required: %<required>s' do
189
- { current: account.balance, required: amount }
190
- end
191
- ```
192
-
193
- The message block has access to all kwargs passed to the guard via `kwargs`.
194
-
195
- **`test`** - The validation logic (must return boolean)
196
-
197
- ```ruby
198
- def test(account:, amount:)
199
- account.balance >= amount
200
- end
201
- ```
202
-
203
- ## Message Templates
204
-
205
- Guards support multiple message template formats:
206
-
207
- ### String with Interpolation
208
-
209
- ```ruby
210
- message 'Insufficient balance: need %<required>s, have %<available>s' do
211
- { required: amount, available: account.balance }
212
- end
213
- ```
214
-
215
- ### I18n Symbol
216
-
217
- ```ruby
218
- message :insufficient_balance
219
- # Looks up: I18n.t('guards.insufficient_balance')
220
- # Falls back to: "Insufficient balance" (humanized)
221
- ```
222
-
223
- ### Inline Translations
224
-
225
- ```ruby
226
- message(
227
- en: 'Insufficient balance',
228
- es: 'Saldo insuficiente',
229
- fr: 'Solde insuffisant'
230
- )
231
- ```
232
-
233
- ### Dynamic Proc
234
-
235
- ```ruby
236
- message -> { "Limit exceeded for #{limit_type} transfers" }
237
- ```
238
-
239
- ## Error Handling
240
-
241
- When a bang guard fails, it throws `:guard_failure` with a `GuardError`. Services automatically catch this and return a failure response:
242
-
243
- ```ruby
244
- class TransferService < Servus::Base
245
- def call
246
- enforce_state!(on: from_account, check: :status, is: :active)
247
- # If guard fails, execution stops here
248
- # Service returns: Response(success: false, error: GuardError)
249
-
250
- transfer_funds
251
- success(transfer: transfer)
252
- end
253
- end
254
- ```
255
-
256
- The `GuardError` includes all metadata:
257
-
258
- ```ruby
259
- error = guard.error
260
- error.message # "Account.status must be active (got suspended)"
261
- error.code # "invalid_state"
262
- error.http_status # 422
263
- ```
264
-
265
- ## Naming Convention
266
-
267
- Guard class names are converted to method names by stripping the `Guard` suffix and converting to snake_case:
268
-
269
- | Class Name | Bang Method | Predicate Method |
270
- |------------|-------------|------------------|
271
- | `SufficientBalanceGuard` | `enforce_sufficient_balance!` | `check_sufficient_balance?` |
272
- | `ValidAmountGuard` | `enforce_valid_amount!` | `check_valid_amount?` |
273
- | `AuthorizedGuard` | `enforce_authorized!` | `check_authorized?` |
274
-
275
- The built-in guards follow this pattern: `TruthyGuard` -> `enforce_truthy!` / `check_truthy?`.
276
-
277
- ## Rails Auto-Loading
278
-
279
- In Rails, guards in `app/guards/` are automatically loaded. Files must follow the `*_guard.rb` naming convention:
280
-
281
- ```
282
- app/guards/
283
- ├── sufficient_balance_guard.rb
284
- ├── valid_amount_guard.rb
285
- └── authorized_guard.rb
286
- ```
287
-
288
- ## Configuration
289
-
290
- Disable built-in guards if you want to define your own (you can have both):
291
-
292
- ```ruby
293
- Servus.configure do |config|
294
- config.include_default_guards = false # Default: true
295
- config.guards_dir = 'app/guards' # Default: 'app/guards'
296
- end
297
- ```
298
-
299
- ## Testing Guards
300
-
301
- Test guards in isolation:
302
-
303
- ```ruby
304
- RSpec.describe Servus::Guards::TruthyGuard do
305
- let(:user_class) do
306
- Struct.new(:active, :verified, keyword_init: true) do
307
- def self.name
308
- 'User'
309
- end
310
- end
311
- end
312
-
313
- describe '#test' do
314
- it 'passes when attribute is truthy' do
315
- user = user_class.new(active: true)
316
- guard = described_class.new(on: user, check: :active)
317
- expect(guard.test(on: user, check: :active)).to be true
318
- end
319
-
320
- it 'fails when attribute is falsey' do
321
- user = user_class.new(active: false)
322
- guard = described_class.new(on: user, check: :active)
323
- expect(guard.test(on: user, check: :active)).to be false
324
- end
325
- end
326
-
327
- describe '#error' do
328
- it 'returns GuardError with correct metadata' do
329
- user = user_class.new(active: false)
330
- guard = described_class.new(on: user, check: :active)
331
- error = guard.error
332
-
333
- expect(error.code).to eq('must_be_truthy')
334
- expect(error.message).to include('User', 'active', 'false')
335
- expect(error.http_status).to eq(422)
336
- end
337
- end
338
- end
339
- ```
340
-
341
- Test guards in service integration:
342
-
343
- ```ruby
344
- RSpec.describe TransferService do
345
- it 'fails when account is not active' do
346
- result = described_class.call(
347
- from_account: suspended_account,
348
- to_account: recipient,
349
- amount: 100
350
- )
351
-
352
- expect(result).to be_failure
353
- expect(result.error.code).to eq('invalid_state')
354
- end
355
- end
356
- ```
@@ -1,238 +0,0 @@
1
- # @title Features / 7. Lazy Resolvers
2
-
3
- # Lazy Resolvers
4
-
5
- Services often accept record IDs as inputs and query for the record inside `call`. This is necessary for async execution — ActiveJob serializes arguments, so you can't pass ActiveRecord objects through a job. But when calling synchronously with an already-loaded record, re-querying is wasteful.
6
-
7
- The `lazily` DSL solves this. A service declares what records it needs, and the resolver handles both cases transparently: pass an ID and it queries; pass an instance and it skips the query.
8
-
9
- ## The Problem
10
-
11
- Without `lazily`, you write this pattern repeatedly:
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) # Always queries, even if caller had the record
22
- # ...
23
- end
24
- end
25
- ```
26
-
27
- The caller can't pass a loaded record — `user_id:` expects an integer. And if you change the param to `user:`, it breaks async execution.
28
-
29
- ## Basic Usage
30
-
31
- ```ruby
32
- class ProcessPayment::Service < Servus::Base
33
- lazily :user, finds: User
34
-
35
- def initialize(user:, amount:)
36
- @user = user
37
- @amount = amount
38
- end
39
-
40
- def call
41
- return failure("Insufficient funds") unless user.balance >= @amount
42
-
43
- user.update!(balance: user.balance - @amount)
44
- success(user: user, new_balance: user.balance)
45
- end
46
- end
47
- ```
48
-
49
- The param is named `user:` — callers pass whatever they have:
50
-
51
- ```ruby
52
- # Sync with a loaded record — no query
53
- ProcessPayment::Service.call(user: current_user, amount: 50)
54
-
55
- # Async with an ID — resolves via User.find(123)
56
- ProcessPayment::Service.call_async(user: user.id, amount: 50)
57
-
58
- # Sync with an ID — also works
59
- ProcessPayment::Service.call(user: 123, amount: 50)
60
- ```
61
-
62
- ## DSL Signature
63
-
64
- ```ruby
65
- lazily :name, finds: ModelClass # default: ModelClass.find(value)
66
- lazily :name, finds: ModelClass, by: :column # ModelClass.find_by!(column: value)
67
- ```
68
-
69
- | Parameter | Description |
70
- |-----------|-------------|
71
- | `:name` | The keyword argument name and the accessor method name |
72
- | `finds:` | The model class constant (e.g., `User`, `Account`) |
73
- | `by:` | Lookup column. Defaults to `:id`. When set, uses `.find_by!` instead of `.find` |
74
-
75
- ## Resolution Behavior
76
-
77
- The resolver checks the input type and acts accordingly:
78
-
79
- | Input | Behavior |
80
- |-------|----------|
81
- | Instance of target class | Returned directly — no query |
82
- | Integer, String, or other scalar | Resolved via `.find(value)` or `.find_by!(column: value)` |
83
- | Array | Resolved via `.where(column => values)` |
84
- | `nil` | Raises `NotFoundError` immediately |
85
-
86
- Resolution is **lazy** — it only happens when the accessor method is first called inside `call`, not during `initialize`. If a service never calls the accessor, no query is made.
87
-
88
- Resolution is **memoized** — the resolved record is written back to the instance variable. Subsequent calls return the same object without re-querying.
89
-
90
- ## Custom Column Lookup
91
-
92
- Use `by:` to look up by a column other than `:id`:
93
-
94
- ```ruby
95
- class FindAccount::Service < Servus::Base
96
- lazily :account, finds: Account, by: :uuid
97
-
98
- def initialize(account:)
99
- @account = account
100
- end
101
-
102
- def call
103
- success(account: account)
104
- end
105
- end
106
-
107
- # Resolves via Account.find_by!(uuid: "abc-def-123")
108
- FindAccount::Service.call(account: "abc-def-123")
109
-
110
- # Passes through an Account instance directly
111
- FindAccount::Service.call(account: loaded_account)
112
- ```
113
-
114
- The `by:` column accepts any value type — strings, integers, UUIDs — whatever the column expects.
115
-
116
- ## Array Input
117
-
118
- When the input is an Array, the resolver uses `.where` instead of `.find`:
119
-
120
- ```ruby
121
- class BulkNotify::Service < Servus::Base
122
- lazily :users, finds: User
123
-
124
- def initialize(users:, message:)
125
- @users = users
126
- @message = message
127
- end
128
-
129
- def call
130
- users.each { |u| notify(u, @message) }
131
- success(notified: users.count)
132
- end
133
- end
134
-
135
- # Resolves via User.where(id: [1, 2, 3])
136
- BulkNotify::Service.call(users: [1, 2, 3], message: "Hello")
137
- ```
138
-
139
- Arrays with a custom `by:` column use `.where(column => values)`.
140
-
141
- Empty arrays return an empty relation — no error is raised.
142
-
143
- ## Multiple Resolvers
144
-
145
- A service can declare multiple resolvers. Each resolves independently and memoizes separately:
146
-
147
- ```ruby
148
- class TransferFunds::Service < Servus::Base
149
- lazily :sender, finds: User
150
- lazily :receiver, finds: User
151
- lazily :account, finds: Account, by: :uuid
152
-
153
- def initialize(sender:, receiver:, account:, amount:)
154
- @sender = sender
155
- @receiver = receiver
156
- @account = account
157
- @amount = amount
158
- end
159
-
160
- def call
161
- # Each resolver triggers independently on first access
162
- success(from: sender.name, to: receiver.name, account: account.uuid)
163
- end
164
- end
165
- ```
166
-
167
- ## Error States
168
-
169
- ### Nil Input
170
-
171
- Raises `Servus::Extensions::Lazily::Errors::NotFoundError` immediately. The error message includes the param name and target class:
172
-
173
- ```
174
- Couldn't find User (user was nil)
175
- ```
176
-
177
- This is always a bug at the call site — the resolver never silently returns nil.
178
-
179
- ### Missing Record
180
-
181
- `.find` and `.find_by!` raise `ActiveRecord::RecordNotFound` as usual. The resolver does not catch or wrap these errors — they propagate normally.
182
-
183
- ```ruby
184
- # Raises ActiveRecord::RecordNotFound: Couldn't find User with 'id'=999
185
- ProcessPayment::Service.call(user: 999, amount: 50)
186
- ```
187
-
188
- Use `rescue_from` if you want to convert these to failure responses:
189
-
190
- ```ruby
191
- class ProcessPayment::Service < Servus::Base
192
- lazily :user, finds: User
193
- rescue_from ActiveRecord::RecordNotFound, use: NotFoundError
194
-
195
- # ...
196
- end
197
- ```
198
-
199
- ### Empty Array
200
-
201
- Returns an empty ActiveRecord relation. No error is raised — this is intentional for batch operations where an empty set is valid.
202
-
203
- ## Async Compatibility
204
-
205
- The `lazily` pattern is designed for services that run both synchronously and asynchronously:
206
-
207
- ```ruby
208
- # Controller (sync) — pass the loaded record
209
- def create
210
- run_service(ProcessPayment::Service, user: current_user, amount: params[:amount])
211
- end
212
-
213
- # Background job (async) — pass the ID
214
- ProcessPayment::Service.call_async(user: user.id, amount: 50)
215
- ```
216
-
217
- Both paths use the same service code. The resolver handles the difference.
218
-
219
- ## dry-initializer Compatibility
220
-
221
- `lazily` works alongside dry-initializer. The resolver defines its accessor on a prepended module, which takes priority over dry-initializer's generated method. It reads from the `@name` instance variable that dry-initializer sets:
222
-
223
- ```ruby
224
- class ProcessPayment::Service < Servus::Base
225
- option :user
226
- option :amount
227
-
228
- lazily :user, finds: User
229
-
230
- def call
231
- success(user: user, charged: amount)
232
- end
233
- end
234
- ```
235
-
236
- ## Requirements
237
-
238
- `lazily` is an ActiveRecord extension. It loads automatically via Railtie when ActiveRecord is present in your application. It is not available in pure Ruby applications without ActiveRecord.