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.
- checksums.yaml +4 -4
- data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
- data/lib/generators/servus/guard/guard_generator.rb +1 -1
- data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
- data/lib/generators/servus/service/service_generator.rb +1 -1
- data/lib/servus/base.rb +46 -3
- data/lib/servus/config.rb +71 -3
- data/lib/servus/events/bus.rb +29 -0
- data/lib/servus/events/emitter.rb +15 -0
- data/lib/servus/guard.rb +7 -6
- data/lib/servus/guards/falsey_guard.rb +3 -3
- data/lib/servus/guards/presence_guard.rb +4 -4
- data/lib/servus/guards/state_guard.rb +4 -5
- data/lib/servus/guards/truthy_guard.rb +3 -3
- data/lib/servus/helpers/controller_helpers.rb +40 -0
- data/lib/servus/support/errors.rb +16 -0
- data/lib/servus/support/lockdown.rb +94 -0
- data/lib/servus/support/logger.rb +16 -0
- data/lib/servus/support/validator.rb +65 -34
- data/lib/servus/testing/example_builders.rb +52 -0
- data/lib/servus/testing/matchers.rb +99 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +1 -0
- metadata +7 -111
- data/.claude/commands/check-docs.md +0 -1
- data/.claude/commands/consistency-check.md +0 -1
- data/.claude/commands/fine-tooth-comb.md +0 -1
- data/.claude/commands/red-green-refactor.md +0 -5
- data/.claude/settings.json +0 -24
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/.yardopts +0 -6
- data/CHANGELOG.md +0 -169
- data/CLAUDE.md +0 -10
- data/IDEAS.md +0 -5
- data/LICENSE.txt +0 -21
- data/READme.md +0 -856
- data/Rakefile +0 -45
- data/docs/core/1_overview.md +0 -81
- data/docs/core/2_architecture.md +0 -120
- data/docs/core/3_service_objects.md +0 -154
- data/docs/features/1_schema_validation.md +0 -161
- data/docs/features/2_error_handling.md +0 -129
- data/docs/features/3_async_execution.md +0 -81
- data/docs/features/4_logging.md +0 -64
- data/docs/features/5_event_bus.md +0 -244
- data/docs/features/6_guards.md +0 -356
- data/docs/features/7_lazy_resolvers.md +0 -238
- data/docs/features/guards_naming_convention.md +0 -540
- data/docs/guides/1_common_patterns.md +0 -90
- data/docs/guides/2_migration_guide.md +0 -225
- data/docs/integration/1_configuration.md +0 -154
- data/docs/integration/2_testing.md +0 -304
- data/docs/integration/3_rails_integration.md +0 -99
- data/docs/yard/Servus/Base.html +0 -1645
- data/docs/yard/Servus/Config.html +0 -582
- data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
- data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
- data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
- data/docs/yard/Servus/Extensions/Async.html +0 -141
- data/docs/yard/Servus/Extensions.html +0 -117
- data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
- data/docs/yard/Servus/Generators.html +0 -115
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
- data/docs/yard/Servus/Helpers.html +0 -115
- data/docs/yard/Servus/Railtie.html +0 -134
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
- data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
- data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
- data/docs/yard/Servus/Support/Errors.html +0 -140
- data/docs/yard/Servus/Support/Logger.html +0 -856
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
- data/docs/yard/Servus/Support/Rescuer.html +0 -267
- data/docs/yard/Servus/Support/Response.html +0 -574
- data/docs/yard/Servus/Support/Validator.html +0 -1150
- data/docs/yard/Servus/Support.html +0 -119
- data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
- data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
- data/docs/yard/Servus/Testing.html +0 -142
- data/docs/yard/Servus.html +0 -343
- data/docs/yard/_index.html +0 -535
- data/docs/yard/class_list.html +0 -54
- data/docs/yard/css/common.css +0 -1
- data/docs/yard/css/full_list.css +0 -58
- data/docs/yard/css/style.css +0 -503
- data/docs/yard/file.1_common_patterns.html +0 -154
- data/docs/yard/file.1_configuration.html +0 -115
- data/docs/yard/file.1_overview.html +0 -142
- data/docs/yard/file.1_schema_validation.html +0 -188
- data/docs/yard/file.2_architecture.html +0 -157
- data/docs/yard/file.2_error_handling.html +0 -190
- data/docs/yard/file.2_migration_guide.html +0 -242
- data/docs/yard/file.2_testing.html +0 -227
- data/docs/yard/file.3_async_execution.html +0 -145
- data/docs/yard/file.3_rails_integration.html +0 -160
- data/docs/yard/file.3_service_objects.html +0 -191
- data/docs/yard/file.4_logging.html +0 -135
- data/docs/yard/file.ErrorHandling.html +0 -190
- data/docs/yard/file.READme.html +0 -674
- data/docs/yard/file.architecture.html +0 -157
- data/docs/yard/file.async_execution.html +0 -145
- data/docs/yard/file.common_patterns.html +0 -154
- data/docs/yard/file.configuration.html +0 -115
- data/docs/yard/file.error_handling.html +0 -190
- data/docs/yard/file.logging.html +0 -135
- data/docs/yard/file.migration_guide.html +0 -242
- data/docs/yard/file.overview.html +0 -142
- data/docs/yard/file.rails_integration.html +0 -160
- data/docs/yard/file.schema_validation.html +0 -188
- data/docs/yard/file.service_objects.html +0 -191
- data/docs/yard/file.testing.html +0 -227
- data/docs/yard/file_list.html +0 -119
- data/docs/yard/frames.html +0 -22
- data/docs/yard/index.html +0 -674
- data/docs/yard/js/app.js +0 -344
- data/docs/yard/js/full_list.js +0 -242
- data/docs/yard/js/jquery.js +0 -4
- data/docs/yard/method_list.html +0 -542
- data/docs/yard/top-level-namespace.html +0 -110
data/docs/features/6_guards.md
DELETED
|
@@ -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.
|