servus 0.1.3 → 0.1.5
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/.claude/commands/check-docs.md +1 -0
- data/.claude/commands/consistency-check.md +1 -0
- data/.claude/commands/fine-tooth-comb.md +1 -0
- data/.claude/commands/red-green-refactor.md +5 -0
- data/.claude/settings.json +15 -0
- data/.rubocop.yml +18 -2
- data/.yardopts +6 -0
- data/CHANGELOG.md +47 -0
- data/CLAUDE.md +10 -0
- data/IDEAS.md +5 -0
- data/READme.md +300 -47
- data/Rakefile +33 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/builds/servus-0.1.5.gem +0 -0
- data/docs/core/1_overview.md +77 -0
- data/docs/core/2_architecture.md +120 -0
- data/docs/core/3_service_objects.md +121 -0
- data/docs/current_focus.md +569 -0
- data/docs/features/1_schema_validation.md +119 -0
- data/docs/features/2_error_handling.md +121 -0
- data/docs/features/3_async_execution.md +81 -0
- data/docs/features/4_logging.md +64 -0
- data/docs/features/5_event_bus.md +244 -0
- data/docs/guides/1_common_patterns.md +90 -0
- data/docs/guides/2_migration_guide.md +175 -0
- data/docs/integration/1_configuration.md +104 -0
- data/docs/integration/2_testing.md +287 -0
- data/docs/integration/3_rails_integration.md +99 -0
- data/docs/yard/Servus/Base.html +1645 -0
- data/docs/yard/Servus/Config.html +582 -0
- data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
- data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
- data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
- data/docs/yard/Servus/Extensions/Async.html +141 -0
- data/docs/yard/Servus/Extensions.html +117 -0
- data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
- data/docs/yard/Servus/Generators.html +115 -0
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
- data/docs/yard/Servus/Helpers.html +115 -0
- data/docs/yard/Servus/Railtie.html +134 -0
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
- data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
- data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
- data/docs/yard/Servus/Support/Errors.html +140 -0
- data/docs/yard/Servus/Support/Logger.html +856 -0
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
- data/docs/yard/Servus/Support/Rescuer.html +267 -0
- data/docs/yard/Servus/Support/Response.html +574 -0
- data/docs/yard/Servus/Support/Validator.html +1150 -0
- data/docs/yard/Servus/Support.html +119 -0
- data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
- data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
- data/docs/yard/Servus/Testing.html +142 -0
- data/docs/yard/Servus.html +343 -0
- data/docs/yard/_index.html +535 -0
- data/docs/yard/class_list.html +54 -0
- data/docs/yard/css/common.css +1 -0
- data/docs/yard/css/full_list.css +58 -0
- data/docs/yard/css/style.css +503 -0
- data/docs/yard/file.1_common_patterns.html +154 -0
- data/docs/yard/file.1_configuration.html +115 -0
- data/docs/yard/file.1_overview.html +142 -0
- data/docs/yard/file.1_schema_validation.html +188 -0
- data/docs/yard/file.2_architecture.html +157 -0
- data/docs/yard/file.2_error_handling.html +190 -0
- data/docs/yard/file.2_migration_guide.html +242 -0
- data/docs/yard/file.2_testing.html +227 -0
- data/docs/yard/file.3_async_execution.html +145 -0
- data/docs/yard/file.3_rails_integration.html +160 -0
- data/docs/yard/file.3_service_objects.html +191 -0
- data/docs/yard/file.4_logging.html +135 -0
- data/docs/yard/file.ErrorHandling.html +190 -0
- data/docs/yard/file.READme.html +674 -0
- data/docs/yard/file.architecture.html +157 -0
- data/docs/yard/file.async_execution.html +145 -0
- data/docs/yard/file.common_patterns.html +154 -0
- data/docs/yard/file.configuration.html +115 -0
- data/docs/yard/file.error_handling.html +190 -0
- data/docs/yard/file.logging.html +135 -0
- data/docs/yard/file.migration_guide.html +242 -0
- data/docs/yard/file.overview.html +142 -0
- data/docs/yard/file.rails_integration.html +160 -0
- data/docs/yard/file.schema_validation.html +188 -0
- data/docs/yard/file.service_objects.html +191 -0
- data/docs/yard/file.testing.html +227 -0
- data/docs/yard/file_list.html +119 -0
- data/docs/yard/frames.html +22 -0
- data/docs/yard/index.html +674 -0
- data/docs/yard/js/app.js +344 -0
- data/docs/yard/js/full_list.js +242 -0
- data/docs/yard/js/jquery.js +4 -0
- data/docs/yard/method_list.html +542 -0
- data/docs/yard/top-level-namespace.html +110 -0
- data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
- data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
- data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
- data/lib/generators/servus/service/service_generator.rb +68 -1
- data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
- data/lib/generators/servus/service/templates/result.json.erb +8 -2
- data/lib/generators/servus/service/templates/service.rb.erb +102 -5
- data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
- data/lib/servus/base.rb +275 -58
- data/lib/servus/config.rb +83 -17
- data/lib/servus/event_handler.rb +275 -0
- data/lib/servus/events/bus.rb +137 -0
- data/lib/servus/events/emitter.rb +162 -0
- data/lib/servus/events/errors.rb +10 -0
- data/lib/servus/extensions/async/call.rb +50 -18
- data/lib/servus/extensions/async/errors.rb +23 -3
- data/lib/servus/extensions/async/ext.rb +10 -2
- data/lib/servus/extensions/async/job.rb +30 -9
- data/lib/servus/helpers/controller_helpers.rb +73 -37
- data/lib/servus/railtie.rb +16 -0
- data/lib/servus/support/errors.rb +135 -45
- data/lib/servus/support/rescuer.rb +189 -36
- data/lib/servus/support/response.rb +49 -7
- data/lib/servus/support/validator.rb +147 -19
- data/lib/servus/testing/example_builders.rb +133 -0
- data/lib/servus/testing/example_extractor.rb +309 -0
- data/lib/servus/testing/matchers.rb +88 -0
- data/lib/servus/testing.rb +19 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +6 -0
- metadata +135 -19
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
## Error Classes
|
|
33
|
+
|
|
34
|
+
All error classes inherit from `ServiceError` and map to HTTP status codes. Use them for API-friendly errors.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Built-in errors
|
|
38
|
+
NotFoundError # 404
|
|
39
|
+
BadRequestError # 400
|
|
40
|
+
UnauthorizedError # 401
|
|
41
|
+
ForbiddenError # 403
|
|
42
|
+
ValidationError # 422
|
|
43
|
+
InternalServerError # 500
|
|
44
|
+
ServiceUnavailableError # 503
|
|
45
|
+
|
|
46
|
+
# Usage
|
|
47
|
+
failure("Resource not found", type: NotFoundError)
|
|
48
|
+
error!("Database corrupted", type: InternalServerError) # Raises exception
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each error has an `api_error` method returning `{ code: :symbol, message: "string" }` for JSON APIs.
|
|
52
|
+
|
|
53
|
+
## Declarative Exception Handling
|
|
54
|
+
|
|
55
|
+
Use `rescue_from` to convert specific exceptions into failures. Original exception details are preserved in error messages.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class CallExternalApi::Service < Servus::Base
|
|
59
|
+
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
60
|
+
rescue_from JSON::ParserError, use: BadRequestError
|
|
61
|
+
|
|
62
|
+
def call
|
|
63
|
+
response = http_client.get(url) # May raise
|
|
64
|
+
data = JSON.parse(response.body) # May raise
|
|
65
|
+
success(data: data)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# If Net::HTTPError is raised, service returns:
|
|
70
|
+
# Response(success: false, error: ServiceUnavailableError("[Net::HTTPError]: original message"))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `rescue_from` pattern keeps business logic clean while ensuring consistent error handling across services.
|
|
74
|
+
|
|
75
|
+
### Custom Error Handling with Blocks
|
|
76
|
+
|
|
77
|
+
For more control over error handling, provide a block to `rescue_from`. The block receives the exception and can return either success or failure:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class ProcessPayment::Service < Servus::Base
|
|
81
|
+
# Custom failure with error details
|
|
82
|
+
rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
83
|
+
failure("Payment failed: #{exception.record.errors.full_messages.join(', ')}",
|
|
84
|
+
type: ValidationError)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Recover from certain errors with success
|
|
88
|
+
rescue_from Stripe::CardError do |exception|
|
|
89
|
+
if exception.code == 'card_declined'
|
|
90
|
+
failure("Card was declined", type: BadRequestError)
|
|
91
|
+
else
|
|
92
|
+
# Log and continue for other card errors
|
|
93
|
+
Rails.logger.warn("Stripe error: #{exception.message}")
|
|
94
|
+
success(recovered: true, fallback_used: true)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call
|
|
99
|
+
# Service logic that may raise exceptions
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The block has access to `success(data)` and `failure(message, type:)` methods. This allows conditional error handling and even recovering from exceptions.
|
|
105
|
+
|
|
106
|
+
## Custom Errors
|
|
107
|
+
|
|
108
|
+
Create domain-specific errors by inheriting from `ServiceError`:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class InsufficientFundsError < Servus::Support::Errors::ServiceError
|
|
112
|
+
DEFAULT_MESSAGE = "Insufficient funds"
|
|
113
|
+
|
|
114
|
+
def api_error
|
|
115
|
+
{ code: :insufficient_funds, message: message }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Usage
|
|
120
|
+
failure("Account balance too low", type: InsufficientFundsError)
|
|
121
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @title Features / 3. Async Execution
|
|
2
|
+
|
|
3
|
+
# Async Execution
|
|
4
|
+
|
|
5
|
+
Servus provides asynchronous execution via ActiveJob. Services run identically whether called sync or async - they're unaware of execution context.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Call `.call_async(**args)` instead of `.call(**args)` to execute in the background. The service is enqueued immediately and executed by a worker.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Synchronous
|
|
13
|
+
result = ProcessReport::Service.call(user_id: user.id, report_type: :monthly)
|
|
14
|
+
result.data[:report] # Available immediately
|
|
15
|
+
|
|
16
|
+
# Asynchronous
|
|
17
|
+
ProcessReport::Service.call_async(user_id: user.id, report_type: :monthly)
|
|
18
|
+
# Returns true if enqueued successfully
|
|
19
|
+
# Result not available (service hasn't run yet)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Services must accept JSON-serializable arguments for async execution (primitives, hashes, arrays, ActiveRecord objects via GlobalID). Complex objects like Procs won't work.
|
|
23
|
+
|
|
24
|
+
## Queue and Scheduling Options
|
|
25
|
+
|
|
26
|
+
Pass ActiveJob options to control execution:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
ProcessReport::Service.call_async(
|
|
30
|
+
user_id: user.id,
|
|
31
|
+
queue: :critical, # Specify queue
|
|
32
|
+
priority: 10, # Higher priority
|
|
33
|
+
wait: 5.minutes # Delay execution
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Result Handling
|
|
38
|
+
|
|
39
|
+
Async services can't return results to callers (the service hasn't executed yet). If you need results, implement persistence in the service:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class GenerateReport::Service < Servus::Base
|
|
43
|
+
def call
|
|
44
|
+
report_data = generate_report
|
|
45
|
+
|
|
46
|
+
# Persist result
|
|
47
|
+
Report.create!(
|
|
48
|
+
user_id: @user_id,
|
|
49
|
+
data: report_data,
|
|
50
|
+
status: 'completed'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Optionally notify user
|
|
54
|
+
UserMailer.report_ready(@user_id).deliver_now
|
|
55
|
+
|
|
56
|
+
success(data: report_data)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Controller creates placeholder, service updates it
|
|
61
|
+
report = Report.create!(user_id: user.id, status: 'pending')
|
|
62
|
+
GenerateReport::Service.call_async(user_id: user.id, report_id: report.id)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Error Handling
|
|
66
|
+
|
|
67
|
+
Failures (business logic) don't trigger retries - the job completes successfully but returns a failure Response.
|
|
68
|
+
|
|
69
|
+
Exceptions (system errors) trigger ActiveJob retry logic. Use `rescue_from` to convert transient errors into exceptions:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Service < Servus::Base
|
|
73
|
+
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## When to Use Async
|
|
78
|
+
|
|
79
|
+
**Good candidates**: Email sending, report generation, data imports, long-running API calls, cleanup tasks
|
|
80
|
+
|
|
81
|
+
**Poor candidates**: Operations requiring immediate feedback, fast operations (<100ms), critical path operations where user waits for result
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @title Features / 4. Logging
|
|
2
|
+
|
|
3
|
+
# Logging
|
|
4
|
+
|
|
5
|
+
Servus automatically logs all service executions with timing information. No instrumentation code needed in services.
|
|
6
|
+
|
|
7
|
+
## What Gets Logged
|
|
8
|
+
|
|
9
|
+
**Service calls** (DEBUG): Service class name and arguments
|
|
10
|
+
```
|
|
11
|
+
[Servus] Users::Create::Service called with {:email=>"user@example.com", :name=>"John"}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Successful completions** (INFO): Service class name and duration
|
|
15
|
+
```
|
|
16
|
+
[Servus] Users::Create::Service completed successfully in 0.0243s
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Failures** (WARN): Service class name, error type, message, and duration
|
|
20
|
+
```
|
|
21
|
+
[Servus] Users::Create::Service failed with NotFoundError: User not found (0.0125s)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Exceptions** (ERROR): Service class name, exception type, message, and duration
|
|
25
|
+
```
|
|
26
|
+
[Servus] Users::Create::Service raised ArgumentError: Missing required field (0.0089s)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Log Levels
|
|
30
|
+
|
|
31
|
+
Servus uses Rails.logger and respects application log level configuration:
|
|
32
|
+
|
|
33
|
+
- **DEBUG**: Shows arguments (use in development, hide in production to avoid logging sensitive data)
|
|
34
|
+
- **INFO**: Shows completions (normal operations)
|
|
35
|
+
- **WARN**: Shows business failures
|
|
36
|
+
- **ERROR**: Shows system exceptions
|
|
37
|
+
|
|
38
|
+
Set production log level to INFO to hide argument logging:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# config/environments/production.rb
|
|
42
|
+
config.log_level = :info
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Sensitive Data
|
|
46
|
+
|
|
47
|
+
Arguments are logged at DEBUG level. In production, either:
|
|
48
|
+
1. Set log level to INFO (recommended)
|
|
49
|
+
2. Use Rails parameter filtering: `config.filter_parameters += [:password, :ssn, :credit_card]`
|
|
50
|
+
3. Pass IDs instead of full objects: `Service.call(user_id: 1)` not `Service.call(user: user_object)`
|
|
51
|
+
|
|
52
|
+
## Integration with Logging Tools
|
|
53
|
+
|
|
54
|
+
The `[Servus]` prefix makes service logs easy to grep and filter:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Find all service calls
|
|
58
|
+
grep "\[Servus\]" production.log
|
|
59
|
+
|
|
60
|
+
# Find slow services
|
|
61
|
+
grep "completed" production.log | grep "Servus" | awk '{print $NF}' | sort -n
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Servus logs work with structured logging tools (Lograge, Datadog, Splunk) without modification.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @title Features / 5. Event Bus
|
|
2
|
+
|
|
3
|
+
# Event Bus
|
|
4
|
+
|
|
5
|
+
Servus includes an event-driven architecture for decoupling service logic from side effects. Services emit events, and EventHandlers subscribe to them and invoke downstream services.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
The Event Bus provides:
|
|
10
|
+
- **Emitters**: Services declare events they emit on success/failure
|
|
11
|
+
- **EventHandlers**: Subscribe to events and invoke services in response
|
|
12
|
+
- **Event Bus**: Routes events to registered handlers via ActiveSupport::Notifications
|
|
13
|
+
- **Payload Validation**: Optional JSON Schema validation for event payloads
|
|
14
|
+
|
|
15
|
+
## Service Event Emission
|
|
16
|
+
|
|
17
|
+
Services can emit events when they succeed or fail using the `emits` DSL:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class CreateUser::Service < Servus::Base
|
|
21
|
+
emits :user_created, on: :success
|
|
22
|
+
emits :user_creation_failed, on: :failure
|
|
23
|
+
|
|
24
|
+
def initialize(email:, name:)
|
|
25
|
+
@email = email
|
|
26
|
+
@name = name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
user = User.create!(email: @email, name: @name)
|
|
31
|
+
success(user: user)
|
|
32
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
33
|
+
failure(e.message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Custom Payloads
|
|
39
|
+
|
|
40
|
+
By default, success events receive `result.data` and failure events receive `result.error`. Customize with a block or method:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class CreateUser::Service < Servus::Base
|
|
44
|
+
# Block-based payload
|
|
45
|
+
emits :user_created, on: :success do |result|
|
|
46
|
+
{ user_id: result.data[:user].id, email: result.data[:user].email }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Method-based payload
|
|
50
|
+
emits :user_stats_updated, on: :success, with: :stats_payload
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def stats_payload(result)
|
|
55
|
+
{ user_count: User.count, latest_user_id: result.data[:user].id }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Trigger Types
|
|
61
|
+
|
|
62
|
+
- `:success` - Fires when service returns `success(...)`
|
|
63
|
+
- `:failure` - Fires when service returns `failure(...)`
|
|
64
|
+
- `:error!` - Fires when service calls `error!(...)` (before exception is raised)
|
|
65
|
+
|
|
66
|
+
## Event Handlers
|
|
67
|
+
|
|
68
|
+
EventHandlers live in `app/events/` and subscribe to events using a declarative DSL:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# app/events/user_created_handler.rb
|
|
72
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
73
|
+
handles :user_created
|
|
74
|
+
|
|
75
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
76
|
+
{ user_id: payload[:user_id], email: payload[:email] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
invoke TrackAnalytics::Service, async: true do |payload|
|
|
80
|
+
{ event: 'user_created', user_id: payload[:user_id] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Generator
|
|
86
|
+
|
|
87
|
+
Generate handlers with the Rails generator:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
rails g servus:event_handler user_created
|
|
91
|
+
# Creates:
|
|
92
|
+
# app/events/user_created_handler.rb
|
|
93
|
+
# spec/events/user_created_handler_spec.rb
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Invocation Options
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
100
|
+
handles :user_created
|
|
101
|
+
|
|
102
|
+
# Synchronous invocation (default)
|
|
103
|
+
invoke NotifyAdmin::Service do |payload|
|
|
104
|
+
{ message: "New user: #{payload[:email]}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Async via ActiveJob
|
|
108
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
109
|
+
{ user_id: payload[:user_id] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Async with specific queue
|
|
113
|
+
invoke SendWelcomeEmail::Service, async: true, queue: :mailers do |payload|
|
|
114
|
+
{ user_id: payload[:user_id] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Conditional invocation
|
|
118
|
+
invoke GrantPremiumRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
119
|
+
{ user_id: payload[:user_id] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
invoke SkipForPremium::Service, unless: ->(p) { p[:premium] } do |payload|
|
|
123
|
+
{ user_id: payload[:user_id] }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Emitting Events Directly
|
|
129
|
+
|
|
130
|
+
EventHandlers provide an `emit` class method for emitting events from controllers, jobs, or other code without a service:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class UsersController < ApplicationController
|
|
134
|
+
def create
|
|
135
|
+
user = User.create!(user_params)
|
|
136
|
+
UserCreatedHandler.emit({ user_id: user.id, email: user.email })
|
|
137
|
+
redirect_to user
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This is useful when the event source isn't a Servus service.
|
|
143
|
+
|
|
144
|
+
## Payload Schema Validation
|
|
145
|
+
|
|
146
|
+
Define JSON schemas to validate event payloads:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
150
|
+
handles :user_created
|
|
151
|
+
|
|
152
|
+
schema payload: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
required: ['user_id', 'email'],
|
|
155
|
+
properties: {
|
|
156
|
+
user_id: { type: 'integer' },
|
|
157
|
+
email: { type: 'string', format: 'email' }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
162
|
+
{ user_id: payload[:user_id], email: payload[:email] }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
When `emit` is called, the payload is validated against the schema before the event is dispatched.
|
|
168
|
+
|
|
169
|
+
## Handler Validation
|
|
170
|
+
|
|
171
|
+
Enable strict validation to catch handlers subscribing to non-existent events:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# config/initializers/servus.rb
|
|
175
|
+
Servus.configure do |config|
|
|
176
|
+
config.strict_event_validation = true # Default: true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Then manually validate (typically in a rake task or CI)
|
|
180
|
+
Servus::EventHandler.validate_all_handlers!
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This helps catch typos and orphaned handlers during development and CI.
|
|
184
|
+
|
|
185
|
+
## Best Practices
|
|
186
|
+
|
|
187
|
+
### Single Event Per Service
|
|
188
|
+
|
|
189
|
+
Services should emit one event per trigger representing their core concern:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Good - one event, handler coordinates reactions
|
|
193
|
+
class CreateUser::Service < Servus::Base
|
|
194
|
+
emits :user_created, on: :success
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
198
|
+
handles :user_created
|
|
199
|
+
|
|
200
|
+
invoke SendWelcomeEmail::Service, async: true
|
|
201
|
+
invoke TrackAnalytics::Service, async: true
|
|
202
|
+
invoke NotifySlack::Service, async: true
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Avoid - service doing too much coordination
|
|
206
|
+
class CreateUser::Service < Servus::Base
|
|
207
|
+
emits :send_welcome_email, on: :success
|
|
208
|
+
emits :track_user_analytics, on: :success
|
|
209
|
+
emits :notify_slack, on: :success
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Naming Conventions
|
|
214
|
+
|
|
215
|
+
- Events: Past tense describing what happened (`user_created`, `payment_processed`)
|
|
216
|
+
- Handlers: Event name + "Handler" suffix (`UserCreatedHandler`)
|
|
217
|
+
|
|
218
|
+
### Handler Location
|
|
219
|
+
|
|
220
|
+
Handlers live in `app/events/` and are auto-loaded by the Railtie:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
app/events/
|
|
224
|
+
├── user_created_handler.rb
|
|
225
|
+
├── payment_processed_handler.rb
|
|
226
|
+
└── order_completed_handler.rb
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Instrumentation
|
|
230
|
+
|
|
231
|
+
Events are instrumented via ActiveSupport::Notifications and appear in Rails logs:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Subscribe to events programmatically:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
|
|
241
|
+
event_name = name.sub('servus.events.', '')
|
|
242
|
+
Rails.logger.info "Event emitted: #{event_name}"
|
|
243
|
+
end
|
|
244
|
+
```
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @title Guides / 1. Common Patterns
|
|
2
|
+
|
|
3
|
+
# Common Patterns
|
|
4
|
+
|
|
5
|
+
Common architectural patterns for using Servus effectively.
|
|
6
|
+
|
|
7
|
+
## Parent-Child Services
|
|
8
|
+
|
|
9
|
+
When one service orchestrates multiple sub-operations, decide on transaction boundaries and error propagation.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class Orders::Checkout::Service < Servus::Base
|
|
13
|
+
def call
|
|
14
|
+
ActiveRecord::Base.transaction do
|
|
15
|
+
# Create order
|
|
16
|
+
order_result = Orders::Create::Service.call(order_params)
|
|
17
|
+
return order_result unless order_result.success?
|
|
18
|
+
|
|
19
|
+
# Charge payment
|
|
20
|
+
payment_result = Payments::Charge::Service.call(
|
|
21
|
+
user_id: @user_id,
|
|
22
|
+
amount: order_result.data[:order].total
|
|
23
|
+
)
|
|
24
|
+
return payment_result unless payment_result.success?
|
|
25
|
+
|
|
26
|
+
# Update inventory
|
|
27
|
+
inventory_result = Inventory::Reserve::Service.call(
|
|
28
|
+
order_id: order_result.data[:order].id
|
|
29
|
+
)
|
|
30
|
+
return inventory_result unless inventory_result.success?
|
|
31
|
+
|
|
32
|
+
success(order: order_result.data[:order])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Use parent transaction when**: All children must succeed or all roll back (atomic operation)
|
|
39
|
+
|
|
40
|
+
**Use child transactions when**: Children can succeed independently (partial success acceptable)
|
|
41
|
+
|
|
42
|
+
## Async with Result Persistence
|
|
43
|
+
|
|
44
|
+
Store async results in database for later retrieval:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Controller creates placeholder
|
|
48
|
+
report = Report.create!(user_id: user.id, status: 'pending')
|
|
49
|
+
GenerateReport::Service.call_async(report_id: report.id)
|
|
50
|
+
|
|
51
|
+
# Service updates record
|
|
52
|
+
class GenerateReport::Service < Servus::Base
|
|
53
|
+
def call
|
|
54
|
+
report = Report.find(@report_id)
|
|
55
|
+
data = generate_report_data
|
|
56
|
+
|
|
57
|
+
report.update!(data: data, status: 'completed')
|
|
58
|
+
success(report: report)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Idempotent Services
|
|
64
|
+
|
|
65
|
+
Use database constraints to make services idempotent:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class Users::Create::Service < Servus::Base
|
|
69
|
+
def call
|
|
70
|
+
# Unique constraint on email prevents duplicates
|
|
71
|
+
user = User.create!(email: @email, name: @name)
|
|
72
|
+
success(user: user)
|
|
73
|
+
rescue ActiveRecord::RecordNotUnique
|
|
74
|
+
user = User.find_by!(email: @email)
|
|
75
|
+
success(user: user) # Return existing user, not error
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or check for existing resources explicitly:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
def call
|
|
84
|
+
existing = User.find_by(email: @email)
|
|
85
|
+
return success(user: existing) if existing
|
|
86
|
+
|
|
87
|
+
user = User.create!(email: @email, name: @name)
|
|
88
|
+
success(user: user)
|
|
89
|
+
end
|
|
90
|
+
```
|