operandi 5.0.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
data/docs/errors.md ADDED
@@ -0,0 +1,331 @@
1
+ # Errors
2
+
3
+ Errors are a natural part of every application. This guide explores how to handle errors within Operandi, drawing parallels to ActiveModel errors.
4
+
5
+ ## Error Structure
6
+
7
+ Operandi errors follow a structure similar to ActiveModel errors. Here's a simplified example:
8
+
9
+ ```ruby
10
+ {
11
+ email: ["must be a valid email"],
12
+ password: ["is too short", "must contain at least one number"]
13
+ }
14
+ ```
15
+
16
+ ## Adding Errors
17
+
18
+ To add an error to your service, use the `errors.add` method.
19
+
20
+ {% hint style="info" %}
21
+ By default, adding an error marks the service as failed, preventing subsequent steps from executing. This behavior can be customized in the configuration for individual services and errors.
22
+ {% endhint %}
23
+
24
+ ```ruby
25
+ class ParsePage < ApplicationService
26
+ # Arguments
27
+ arg :url, type: String
28
+ # ...
29
+
30
+ # Steps
31
+ step :validate
32
+ step :parse
33
+ # ...
34
+
35
+ private
36
+
37
+ def validate
38
+ # Multiple errors can be added with the same key
39
+ errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
40
+ errors.add(:url, "must be a secure link") unless url.start_with?("https")
41
+ end
42
+
43
+ # ...
44
+ end
45
+ ```
46
+
47
+ ## Quick Error with `fail!`
48
+
49
+ The `fail!` method is a shortcut for adding an error to the `:base` key:
50
+
51
+ ```ruby
52
+ class ParsePage < ApplicationService
53
+ def validate
54
+ fail!("URL is required") if url.blank?
55
+ end
56
+ end
57
+ ```
58
+
59
+ This is equivalent to:
60
+
61
+ ```ruby
62
+ errors.add(:base, "URL is required")
63
+ ```
64
+
65
+ ## Reading Errors
66
+
67
+ To check if a service has errors, you can use the `#failed?` method. You can also use methods like `errors.any?` to inspect errors.
68
+
69
+ ```ruby
70
+ class ParsePage < ApplicationService
71
+ def parse
72
+ nodes.each do |node|
73
+ if node.nil? || (node.respond_to?(:empty?) && node.empty?)
74
+ errors.add(:base, "Node #{node} is blank")
75
+ else
76
+ parse_node(node)
77
+ end
78
+ end
79
+
80
+ if failed? # or errors.any?
81
+ puts "Not all nodes were parsed"
82
+ end
83
+ end
84
+ end
85
+ ```
86
+
87
+ You can access errors outside the service using the `#errors` method.
88
+
89
+ ```ruby
90
+ service = ParsePage.run(url: "rubygems")
91
+
92
+ if service.failed?
93
+ puts service.errors
94
+ puts service.errors[:url]
95
+ puts service.errors.to_h # Returns errors as a hash
96
+ end
97
+ ```
98
+
99
+ ## Adding Warnings
100
+
101
+ Sometimes, you may want to add a warning instead of an error. Warnings are similar to errors but they do not mark the service as failed. By default they also do not stop execution and do not roll back the transaction (both behaviors can be configured globally or per-message).
102
+
103
+ ```ruby
104
+ class ParsePage < ApplicationService
105
+ def validate
106
+ errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
107
+ warnings.add(:url, "should be a secure link") unless url.start_with?("https")
108
+ end
109
+ end
110
+ ```
111
+
112
+ ```ruby
113
+ service = ParsePage.run(url: "http://rubygems.org")
114
+
115
+ if service.warnings.any?
116
+ puts service.warnings
117
+ puts service.warnings[:url]
118
+ puts service.warnings.to_h # Returns warnings as a hash
119
+ end
120
+ ```
121
+
122
+ ## Copying Errors
123
+
124
+ ### From ActiveRecord Models
125
+
126
+ Use `errors.copy_from` (or its alias `errors.from_record`) to copy errors from an ActiveRecord model:
127
+
128
+ ```ruby
129
+ class User::Create < ApplicationService
130
+ def create_user
131
+ self.user = User.new(attributes)
132
+
133
+ unless user.save
134
+ errors.copy_from(user) # Copies all validation errors from the user model
135
+ end
136
+ end
137
+ end
138
+ ```
139
+
140
+ ### From Another Service
141
+
142
+ Copy errors from a child service that wasn't run in the same context:
143
+
144
+ ```ruby
145
+ class Order::Process < ApplicationService
146
+ def process_payment
147
+ payment_service = Payment::Charge.run(amount:, card:)
148
+
149
+ if payment_service.failed?
150
+ errors.copy_from(payment_service)
151
+ end
152
+ end
153
+ end
154
+ ```
155
+
156
+ ## Converting Errors to Hash
157
+
158
+ Use `errors.to_h` to get a hash representation of all errors:
159
+
160
+ ```ruby
161
+ service = User::Create.run(email: "invalid")
162
+
163
+ if service.failed?
164
+ service.errors.to_h
165
+ # => { email: ["is invalid"], password: ["can't be blank"] }
166
+ end
167
+ ```
168
+
169
+ ## Per-Message Options
170
+
171
+ When adding errors, you can control behavior on a per-message basis:
172
+
173
+ ### Control Break Behavior
174
+
175
+ ```ruby
176
+ def validate
177
+ # This error won't stop subsequent steps from running
178
+ errors.add(:warning_field, "has a minor issue", break: false)
179
+
180
+ # This error WILL stop execution (default behavior)
181
+ errors.add(:critical_field, "is completely invalid")
182
+ end
183
+ ```
184
+
185
+ ### Control Rollback Behavior
186
+
187
+ ```ruby
188
+ def process
189
+ # This error won't trigger a transaction rollback
190
+ errors.add(:notification, "failed to send", rollback: false)
191
+
192
+ # This error WILL rollback (default behavior when use_transactions is true)
193
+ errors.add(:payment, "failed to process")
194
+ end
195
+ ```
196
+
197
+ ## Checking for Errors and Warnings
198
+
199
+ Operandi provides convenient methods to check error/warning states:
200
+
201
+ ```ruby
202
+ service = MyService.run(args)
203
+
204
+ # Check if service has any errors
205
+ service.failed? # => true/false
206
+ service.success? # => true/false (opposite of failed?)
207
+ service.errors? # => true/false (same as errors.any?)
208
+
209
+ # Check if service has any warnings
210
+ service.warnings? # => true/false (same as warnings.any?)
211
+ ```
212
+
213
+ By following these guidelines, you can effectively manage errors and warnings in Operandi, ensuring a smoother and more robust application experience.
214
+
215
+ ## Exception Classes
216
+
217
+ Operandi defines several exception classes for different error scenarios:
218
+
219
+ | Exception | Description |
220
+ |-----------|-------------|
221
+ | `Operandi::Error` | Base exception class for all Operandi errors |
222
+ | `Operandi::RuntimeError` | Raised while a service is running; exposes the service instance through `service` |
223
+ | `Operandi::ArgTypeError` | Raised when type validation fails; exposes the associated service class through `service_class` |
224
+ | `Operandi::ReservedNameError` | Raised when using a reserved name for arguments, outputs, or steps |
225
+ | `Operandi::InvalidNameError` | Raised when using an invalid name format |
226
+ | `Operandi::NoStepsError` | Raised when a service has no steps defined and no `run` method |
227
+ | `Operandi::MissingTypeError` | Raised when defining an argument or output without a `type` option when `require_arg_type` or `require_output_type` is enabled |
228
+ | `Operandi::StopExecution` | Control flow exception raised by `stop_immediately!` to halt execution without rollback |
229
+ | `Operandi::FailExecution` | Control flow exception raised by `fail_immediately!` to halt execution and rollback transactions |
230
+
231
+ ### RuntimeError
232
+
233
+ Runtime failures caused by service errors, warnings, or invalid step execution raise
234
+ `Operandi::RuntimeError`. The exception inherits from `Operandi::Error` and provides
235
+ the concrete service instance that failed:
236
+
237
+ ```ruby
238
+ begin
239
+ MyService.run!(name: "John")
240
+ rescue Operandi::RuntimeError => error
241
+ error.service # => the MyService instance
242
+ end
243
+ ```
244
+
245
+ Errors raised while defining a service continue to use `Operandi::Error`.
246
+
247
+ ### ArgTypeError
248
+
249
+ Argument and output type failures raise `Operandi::ArgTypeError`. It inherits from
250
+ `Operandi::Error` and identifies the associated service class without requiring a
251
+ service instance:
252
+
253
+ ```ruby
254
+ begin
255
+ MyService.run(name: 123)
256
+ rescue Operandi::ArgTypeError => error
257
+ error.service_class # => MyService
258
+ end
259
+ ```
260
+
261
+ ### MissingTypeError
262
+
263
+ This exception is raised when you define an argument or output without a `type` option. Since `require_arg_type` and `require_output_type` are enabled by default, all arguments and outputs must have a type.
264
+
265
+ ```ruby
266
+ class MyService < ApplicationService
267
+ arg :name # => raises Operandi::MissingTypeError
268
+ end
269
+ ```
270
+
271
+ To fix this, add a `type` option to all arguments and outputs:
272
+
273
+ ```ruby
274
+ class MyService < ApplicationService
275
+ arg :name, type: String
276
+ output :result, type: Hash
277
+ end
278
+ ```
279
+
280
+ If you need to disable type enforcement for legacy services, you can use the `config` method:
281
+
282
+ ```ruby
283
+ class LegacyService < ApplicationService
284
+ config require_arg_type: false, require_output_type: false
285
+
286
+ arg :data # Allowed when require_arg_type is disabled
287
+ output :result # Allowed when require_output_type is disabled
288
+ end
289
+ ```
290
+
291
+ ### NoStepsError
292
+
293
+ This exception is raised when you attempt to execute a service that has no steps defined and no `run` method as a fallback:
294
+
295
+ ```ruby
296
+ class EmptyService < ApplicationService
297
+ # No steps defined and no run method
298
+ end
299
+
300
+ EmptyService.run # => raises Operandi::NoStepsError
301
+ ```
302
+
303
+ To fix this, either define at least one step or implement a `run` method:
304
+
305
+ ```ruby
306
+ # Option 1: Define steps
307
+ class MyService < ApplicationService
308
+ step :do_work
309
+
310
+ private
311
+
312
+ def do_work
313
+ # ...
314
+ end
315
+ end
316
+
317
+ # Option 2: Use run method
318
+ class MyService < ApplicationService
319
+ private
320
+
321
+ def run
322
+ # ...
323
+ end
324
+ end
325
+ ```
326
+
327
+ ## What's next?
328
+
329
+ Learn about callbacks to add logging, benchmarking, and other cross-cutting concerns to your services.
330
+
331
+ [Next: Callbacks](callbacks.md)
@@ -0,0 +1,250 @@
1
+ # Rails Generators
2
+
3
+ Operandi includes Rails generators to help you quickly set up and create services in your Rails application. These generators follow Rails conventions and integrate seamlessly with your Rails workflow.
4
+
5
+ ## Install Generator
6
+
7
+ The install generator sets up Operandi in your Rails application by creating the base `ApplicationService` class and configuration files.
8
+
9
+ ### Usage
10
+
11
+ ```bash
12
+ bin/rails generate operandi:install
13
+ ```
14
+
15
+ ### What It Creates
16
+
17
+ The install generator creates the following files:
18
+
19
+ 1. **`app/services/application_service.rb`** - Base service class for your application
20
+ ```ruby
21
+ class ApplicationService < Operandi::Base
22
+ # Add common arguments, callbacks, or helpers shared across all services.
23
+ #
24
+ # Example: Add a context argument for the current user
25
+ # arg :current_user, type: User, optional: true, context: true
26
+ end
27
+ ```
28
+
29
+ 2. **`config/initializers/operandi.rb`** - Configuration file (unless `--skip-initializer` is used)
30
+ This file contains the global configuration for Operandi in your Rails application.
31
+
32
+ 3. **`spec/services/application_service_spec.rb`** - RSpec test file (if RSpec is detected and `--skip-spec` is not used)
33
+
34
+ ### Options
35
+
36
+ - `--skip-initializer` - Skip creating the initializer file
37
+ - `--skip-spec` - Skip creating the spec file
38
+
39
+ ### Examples
40
+
41
+ ```bash
42
+ # Standard installation
43
+ bin/rails generate operandi:install
44
+
45
+ # Skip initializer
46
+ bin/rails generate operandi:install --skip-initializer
47
+
48
+ # Skip spec file
49
+ bin/rails generate operandi:install --skip-spec
50
+ ```
51
+
52
+ ## Service Generator
53
+
54
+ The service generator creates a new service class that inherits from `ApplicationService`. It supports namespaced services and can pre-populate arguments, steps, and outputs.
55
+
56
+ ### Usage
57
+
58
+ ```bash
59
+ bin/rails generate operandi:service NAME [options]
60
+ ```
61
+
62
+ ### What It Creates
63
+
64
+ The service generator creates:
65
+
66
+ 1. **Service file** - `app/services/{name}.rb`
67
+ 2. **Spec file** - `spec/services/{name}_spec.rb` (if RSpec is detected and `--skip-spec` is not used)
68
+
69
+ ### Options
70
+
71
+ - `--args` - List of arguments for the service (space-separated)
72
+ - `--steps` - List of steps for the service (space-separated)
73
+ - `--outputs` - List of outputs for the service (space-separated)
74
+ - `--skip-spec` - Skip creating the spec file
75
+ - `--parent` - Parent class (default: `ApplicationService`)
76
+
77
+ ### Examples
78
+
79
+ #### Basic Service
80
+
81
+ Create a simple service without any predefined structure:
82
+
83
+ ```bash
84
+ bin/rails generate operandi:service user/create
85
+ ```
86
+
87
+ This creates:
88
+ ```ruby
89
+ # app/services/user/create.rb
90
+ class User::Create < ApplicationService
91
+ # step :step_a
92
+ # step :step_b
93
+
94
+ private
95
+
96
+ # def step_a
97
+ # # TODO: Implement service logic
98
+ # end
99
+
100
+ # def step_b
101
+ # # TODO: Implement service logic
102
+ # end
103
+ end
104
+ ```
105
+
106
+ #### Service with Arguments, Steps, and Outputs
107
+
108
+ Create a fully structured service:
109
+
110
+ ```bash
111
+ bin/rails generate operandi:service CreateOrder \
112
+ --args=user product quantity \
113
+ --steps=validate_stock create_order send_confirmation \
114
+ --outputs=order
115
+ ```
116
+
117
+ This creates:
118
+ ```ruby
119
+ # app/services/create_order.rb
120
+ class CreateOrder < ApplicationService
121
+ # Arguments
122
+ arg :user
123
+ arg :product
124
+ arg :quantity
125
+
126
+ # Steps
127
+ step :validate_stock
128
+ step :create_order
129
+ step :send_confirmation
130
+
131
+ # Outputs
132
+ output :order
133
+
134
+ private
135
+
136
+ def validate_stock
137
+ # TODO: Implement validate_stock
138
+ end
139
+
140
+ def create_order
141
+ # TODO: Implement create_order
142
+ end
143
+
144
+ def send_confirmation
145
+ # TODO: Implement send_confirmation
146
+ end
147
+ end
148
+ ```
149
+
150
+ #### Namespaced Service
151
+
152
+ Create a service within a namespace:
153
+
154
+ ```bash
155
+ bin/rails generate operandi:service payment/process \
156
+ --args=order payment_method \
157
+ --steps=validate_payment charge_card update_order \
158
+ --outputs=transaction
159
+ ```
160
+
161
+ This creates:
162
+ ```ruby
163
+ # app/services/payment/process.rb
164
+ class Payment::Process < ApplicationService
165
+ # Arguments
166
+ arg :order
167
+ arg :payment_method
168
+
169
+ # Steps
170
+ step :validate_payment
171
+ step :charge_card
172
+ step :update_order
173
+
174
+ # Outputs
175
+ output :transaction
176
+
177
+ private
178
+
179
+ def validate_payment
180
+ # TODO: Implement validate_payment
181
+ end
182
+
183
+ def charge_card
184
+ # TODO: Implement charge_card
185
+ end
186
+
187
+ def update_order
188
+ # TODO: Implement update_order
189
+ end
190
+ end
191
+ ```
192
+
193
+ #### Custom Parent Class
194
+
195
+ Create a service that inherits from a custom parent class:
196
+
197
+ ```bash
198
+ bin/rails generate operandi:service admin/reports/generate \
199
+ --parent=AdminService \
200
+ --args=start_date end_date \
201
+ --steps=fetch_data generate_report \
202
+ --outputs=report
203
+ ```
204
+
205
+ ## RSpec Integration
206
+
207
+ Both generators automatically detect if RSpec is installed in your Rails application by checking for the presence of the `spec/` directory. If RSpec is detected, the generators will create corresponding spec files with basic test structure.
208
+
209
+ ### Example Spec File
210
+
211
+ ```ruby
212
+ # spec/services/user/create_spec.rb
213
+ require "rails_helper"
214
+
215
+ RSpec.describe User::Create do
216
+ describe ".run" do
217
+ it "creates a user" do
218
+ service = described_class.run(...)
219
+ expect(service).to be_successful
220
+ end
221
+ end
222
+ end
223
+ ```
224
+
225
+ You can skip spec file generation with the `--skip-spec` option:
226
+
227
+ ```bash
228
+ bin/rails generate operandi:service user/create --skip-spec
229
+ ```
230
+
231
+ ## Best Practices
232
+
233
+ 1. **Run the install generator first** - Always run `operandi:install` before creating individual services to set up the base `ApplicationService` class.
234
+
235
+ 2. **Use namespaces** - Organize related services under namespaces (e.g., `User::Create`, `Payment::Process`) to keep your services organized.
236
+
237
+ 3. **Start with structure** - Use `--args`, `--steps`, and `--outputs` options to create a skeleton for your service, then fill in the implementation.
238
+
239
+ 4. **Keep it simple** - Don't over-specify. If you're not sure about the exact steps, create a basic service and add them as you develop.
240
+
241
+ 5. **Follow conventions** - Use descriptive names for services that indicate the action being performed (e.g., `CreateOrder`, `User::Authenticate`, `Payment::Refund`).
242
+
243
+ ## Next Steps
244
+
245
+ After generating your services, learn more about:
246
+
247
+ - [Arguments](arguments.md) - Define and validate service inputs
248
+ - [Steps](steps.md) - Organize service logic into steps
249
+ - [Outputs](outputs.md) - Define service outputs
250
+ - [Testing](testing.md) - Write comprehensive tests for your services
data/docs/outputs.md ADDED
@@ -0,0 +1,150 @@
1
+ # Outputs
2
+
3
+ Outputs are the results of a service.
4
+
5
+ ## TL;DR
6
+
7
+ - Define outputs using the `output` keyword in the service class
8
+ - Outputs can have default values
9
+ - Outputs can be validated by type (validated when the service succeeds)
10
+
11
+ ## Define Outputs
12
+
13
+ You define outputs using the `output` keyword in the service class.
14
+
15
+ ```ruby
16
+ class AI::Chat < ApplicationService
17
+ output :messages
18
+ output :cost
19
+ end
20
+ ```
21
+
22
+ ## Write Outputs
23
+
24
+ Outputs function similarly to instance variables created with `attr_accessor`.
25
+
26
+ ```ruby
27
+ class AI::Chat < ApplicationService
28
+ # Steps
29
+ step :chat
30
+
31
+ # Outputs
32
+ output :messages
33
+ output :cost
34
+
35
+ private
36
+
37
+ def chat
38
+ self.messages = ["Hello!", "Hi, how are you?"]
39
+ self.cost = 0.0013
40
+ end
41
+ end
42
+ ```
43
+
44
+ To set outputs programmatically, use the `output.set` method or hash syntax.
45
+
46
+ ```ruby
47
+ class AI::Chat < ApplicationService
48
+ # ...
49
+
50
+ def chat
51
+ output.set(:messages, ["Hello!", "Hi, how are you?"])
52
+ output.set(:cost, 0.0013)
53
+
54
+ # Or use hash syntax
55
+
56
+ output[:messages] = ["Hello!", "Hi, how are you?"]
57
+ output[:cost] = 0.0013
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Type Validation
63
+
64
+ You can specify the type of output using the `type` option. The output type will be validated when the service successfully completes.
65
+
66
+ ```ruby
67
+ class AI::Chat < ApplicationService
68
+ output :messages, type: Array
69
+ output :cost, type: Float
70
+ end
71
+ ```
72
+
73
+ You can specify multiple allowed types using an array.
74
+
75
+ ```ruby
76
+ class AI::Chat < ApplicationService
77
+ output :result, type: [String, Hash]
78
+ end
79
+ ```
80
+
81
+ ### Type Enforcement (Enabled by Default)
82
+
83
+ By default, all outputs must have a `type` option. This helps catch type-related bugs early and makes your services self-documenting.
84
+
85
+ ```ruby
86
+ class MyService < ApplicationService
87
+ output :result, type: Hash # ✓ Valid
88
+ output :data # ✗ Raises MissingTypeError
89
+ end
90
+ ```
91
+
92
+ To disable type enforcement for outputs in a specific service:
93
+
94
+ ```ruby
95
+ class LegacyService < ApplicationService
96
+ config require_output_type: false
97
+
98
+ output :data # Allowed when require_output_type is disabled
99
+ end
100
+ ```
101
+
102
+ See the [Configuration documentation](configuration.md) for more details.
103
+
104
+ ### Sorbet Runtime Types
105
+
106
+ Outputs support [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation:
107
+
108
+ ```ruby
109
+ require "sorbet-runtime"
110
+
111
+ class AI::Chat < ApplicationService
112
+ output :messages, type: T::Array[Hash]
113
+ output :total_tokens, type: T::Utils.coerce(Integer)
114
+ output :metadata, type: T.nilable(Hash), optional: true
115
+ end
116
+ ```
117
+
118
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
119
+
120
+ ## Default Values
121
+
122
+ Set default values for outputs using the `default` option. The default value will be automatically set before the execution of steps.
123
+
124
+ ```ruby
125
+ class AI::Chat < ApplicationService
126
+ output :cost, default: 0.0
127
+ end
128
+ ```
129
+
130
+ ## Removing Inherited Outputs
131
+
132
+ When inheriting from a parent service, you can remove outputs using `remove_output`:
133
+
134
+ ```ruby
135
+ class BaseReportService < ApplicationService
136
+ output :report
137
+ output :debug_info
138
+ end
139
+
140
+ class ProductionReportService < BaseReportService
141
+ # Don't expose debug info in production
142
+ remove_output :debug_info
143
+ end
144
+ ```
145
+
146
+ ## What's Next?
147
+
148
+ Next, learn about context.
149
+
150
+ [Next: Context](context.md)