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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/check-docs.md +1 -0
  3. data/.claude/commands/consistency-check.md +1 -0
  4. data/.claude/commands/fine-tooth-comb.md +1 -0
  5. data/.claude/commands/red-green-refactor.md +5 -0
  6. data/.claude/settings.json +15 -0
  7. data/.rubocop.yml +18 -2
  8. data/.yardopts +6 -0
  9. data/CHANGELOG.md +47 -0
  10. data/CLAUDE.md +10 -0
  11. data/IDEAS.md +5 -0
  12. data/READme.md +300 -47
  13. data/Rakefile +33 -0
  14. data/builds/servus-0.1.3.gem +0 -0
  15. data/builds/servus-0.1.4.gem +0 -0
  16. data/builds/servus-0.1.5.gem +0 -0
  17. data/docs/core/1_overview.md +77 -0
  18. data/docs/core/2_architecture.md +120 -0
  19. data/docs/core/3_service_objects.md +121 -0
  20. data/docs/current_focus.md +569 -0
  21. data/docs/features/1_schema_validation.md +119 -0
  22. data/docs/features/2_error_handling.md +121 -0
  23. data/docs/features/3_async_execution.md +81 -0
  24. data/docs/features/4_logging.md +64 -0
  25. data/docs/features/5_event_bus.md +244 -0
  26. data/docs/guides/1_common_patterns.md +90 -0
  27. data/docs/guides/2_migration_guide.md +175 -0
  28. data/docs/integration/1_configuration.md +104 -0
  29. data/docs/integration/2_testing.md +287 -0
  30. data/docs/integration/3_rails_integration.md +99 -0
  31. data/docs/yard/Servus/Base.html +1645 -0
  32. data/docs/yard/Servus/Config.html +582 -0
  33. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  34. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  35. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  36. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  37. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  38. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  39. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  40. data/docs/yard/Servus/Extensions/Async.html +141 -0
  41. data/docs/yard/Servus/Extensions.html +117 -0
  42. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  43. data/docs/yard/Servus/Generators.html +115 -0
  44. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  45. data/docs/yard/Servus/Helpers.html +115 -0
  46. data/docs/yard/Servus/Railtie.html +134 -0
  47. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  48. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  49. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  50. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  51. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  52. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  53. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  54. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  55. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  56. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  57. data/docs/yard/Servus/Support/Errors.html +140 -0
  58. data/docs/yard/Servus/Support/Logger.html +856 -0
  59. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  60. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  61. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  62. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  63. data/docs/yard/Servus/Support/Response.html +574 -0
  64. data/docs/yard/Servus/Support/Validator.html +1150 -0
  65. data/docs/yard/Servus/Support.html +119 -0
  66. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  67. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  68. data/docs/yard/Servus/Testing.html +142 -0
  69. data/docs/yard/Servus.html +343 -0
  70. data/docs/yard/_index.html +535 -0
  71. data/docs/yard/class_list.html +54 -0
  72. data/docs/yard/css/common.css +1 -0
  73. data/docs/yard/css/full_list.css +58 -0
  74. data/docs/yard/css/style.css +503 -0
  75. data/docs/yard/file.1_common_patterns.html +154 -0
  76. data/docs/yard/file.1_configuration.html +115 -0
  77. data/docs/yard/file.1_overview.html +142 -0
  78. data/docs/yard/file.1_schema_validation.html +188 -0
  79. data/docs/yard/file.2_architecture.html +157 -0
  80. data/docs/yard/file.2_error_handling.html +190 -0
  81. data/docs/yard/file.2_migration_guide.html +242 -0
  82. data/docs/yard/file.2_testing.html +227 -0
  83. data/docs/yard/file.3_async_execution.html +145 -0
  84. data/docs/yard/file.3_rails_integration.html +160 -0
  85. data/docs/yard/file.3_service_objects.html +191 -0
  86. data/docs/yard/file.4_logging.html +135 -0
  87. data/docs/yard/file.ErrorHandling.html +190 -0
  88. data/docs/yard/file.READme.html +674 -0
  89. data/docs/yard/file.architecture.html +157 -0
  90. data/docs/yard/file.async_execution.html +145 -0
  91. data/docs/yard/file.common_patterns.html +154 -0
  92. data/docs/yard/file.configuration.html +115 -0
  93. data/docs/yard/file.error_handling.html +190 -0
  94. data/docs/yard/file.logging.html +135 -0
  95. data/docs/yard/file.migration_guide.html +242 -0
  96. data/docs/yard/file.overview.html +142 -0
  97. data/docs/yard/file.rails_integration.html +160 -0
  98. data/docs/yard/file.schema_validation.html +188 -0
  99. data/docs/yard/file.service_objects.html +191 -0
  100. data/docs/yard/file.testing.html +227 -0
  101. data/docs/yard/file_list.html +119 -0
  102. data/docs/yard/frames.html +22 -0
  103. data/docs/yard/index.html +674 -0
  104. data/docs/yard/js/app.js +344 -0
  105. data/docs/yard/js/full_list.js +242 -0
  106. data/docs/yard/js/jquery.js +4 -0
  107. data/docs/yard/method_list.html +542 -0
  108. data/docs/yard/top-level-namespace.html +110 -0
  109. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  110. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  111. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  112. data/lib/generators/servus/service/service_generator.rb +68 -1
  113. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  114. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  115. data/lib/generators/servus/service/templates/service.rb.erb +102 -5
  116. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  117. data/lib/servus/base.rb +275 -58
  118. data/lib/servus/config.rb +83 -17
  119. data/lib/servus/event_handler.rb +275 -0
  120. data/lib/servus/events/bus.rb +137 -0
  121. data/lib/servus/events/emitter.rb +162 -0
  122. data/lib/servus/events/errors.rb +10 -0
  123. data/lib/servus/extensions/async/call.rb +50 -18
  124. data/lib/servus/extensions/async/errors.rb +23 -3
  125. data/lib/servus/extensions/async/ext.rb +10 -2
  126. data/lib/servus/extensions/async/job.rb +30 -9
  127. data/lib/servus/helpers/controller_helpers.rb +73 -37
  128. data/lib/servus/railtie.rb +16 -0
  129. data/lib/servus/support/errors.rb +135 -45
  130. data/lib/servus/support/rescuer.rb +189 -36
  131. data/lib/servus/support/response.rb +49 -7
  132. data/lib/servus/support/validator.rb +147 -19
  133. data/lib/servus/testing/example_builders.rb +133 -0
  134. data/lib/servus/testing/example_extractor.rb +309 -0
  135. data/lib/servus/testing/matchers.rb +88 -0
  136. data/lib/servus/testing.rb +19 -0
  137. data/lib/servus/version.rb +1 -1
  138. data/lib/servus.rb +6 -0
  139. 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
+ ```