functional-light-service 6.2.0 → 6.3.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/lib/functional-light-service.rb +6 -36
  3. metadata +14 -307
  4. data/.github/workflows/project-build.yml +0 -71
  5. data/.gitignore +0 -24
  6. data/.rspec +0 -3
  7. data/.rubocop.yml +0 -101
  8. data/.solargraph.yml +0 -11
  9. data/AUDIT-functional-light-service.md +0 -352
  10. data/Appraisals +0 -7
  11. data/CHANGELOG.md +0 -292
  12. data/CODE_OF_CONDUCT.md +0 -22
  13. data/Gemfile +0 -4
  14. data/LICENSE +0 -22
  15. data/README.md +0 -1663
  16. data/Rakefile +0 -12
  17. data/VERSION +0 -1
  18. data/audit/bench.rb +0 -99
  19. data/audit/verify_findings.rb +0 -172
  20. data/functional-light-service.gemspec +0 -35
  21. data/gemfiles/dry_inflector_0_2_1.gemfile +0 -5
  22. data/gemfiles/i18n_1_8_11.gemfile +0 -5
  23. data/lib/functional-light-service/action.rb +0 -145
  24. data/lib/functional-light-service/configuration.rb +0 -36
  25. data/lib/functional-light-service/context/key_verifier.rb +0 -124
  26. data/lib/functional-light-service/context.rb +0 -208
  27. data/lib/functional-light-service/deprecations.rb +0 -26
  28. data/lib/functional-light-service/errors.rb +0 -9
  29. data/lib/functional-light-service/functional/enum.rb +0 -286
  30. data/lib/functional-light-service/functional/maybe.rb +0 -21
  31. data/lib/functional-light-service/functional/monad.rb +0 -77
  32. data/lib/functional-light-service/functional/null.rb +0 -88
  33. data/lib/functional-light-service/functional/option.rb +0 -100
  34. data/lib/functional-light-service/functional/result.rb +0 -129
  35. data/lib/functional-light-service/functional/sequencer.rb +0 -144
  36. data/lib/functional-light-service/i18n/localization_adapter.rb +0 -50
  37. data/lib/functional-light-service/localization_adapter.rb +0 -39
  38. data/lib/functional-light-service/localization_map.rb +0 -9
  39. data/lib/functional-light-service/organizer/execute.rb +0 -16
  40. data/lib/functional-light-service/organizer/iterate.rb +0 -30
  41. data/lib/functional-light-service/organizer/reduce_case.rb +0 -50
  42. data/lib/functional-light-service/organizer/reduce_if.rb +0 -19
  43. data/lib/functional-light-service/organizer/reduce_if_else.rb +0 -23
  44. data/lib/functional-light-service/organizer/reduce_until.rb +0 -22
  45. data/lib/functional-light-service/organizer/reduce_while.rb +0 -31
  46. data/lib/functional-light-service/organizer/scoped_reducable.rb +0 -15
  47. data/lib/functional-light-service/organizer/with_callback.rb +0 -28
  48. data/lib/functional-light-service/organizer/with_reducer.rb +0 -81
  49. data/lib/functional-light-service/organizer/with_reducer_factory.rb +0 -20
  50. data/lib/functional-light-service/organizer/with_reducer_log_decorator.rb +0 -111
  51. data/lib/functional-light-service/organizer.rb +0 -129
  52. data/lib/functional-light-service/testing/context_factory.rb +0 -48
  53. data/lib/functional-light-service/testing.rb +0 -3
  54. data/lib/functional-light-service/version.rb +0 -5
  55. data/resources/fail_actions.png +0 -0
  56. data/resources/light-service.png +0 -0
  57. data/resources/organizer_and_actions.png +0 -0
  58. data/resources/skip_actions.png +0 -0
  59. data/spec/acceptance/add_numbers_spec.rb +0 -11
  60. data/spec/acceptance/after_actions_spec.rb +0 -87
  61. data/spec/acceptance/around_each_spec.rb +0 -19
  62. data/spec/acceptance/before_actions_spec.rb +0 -115
  63. data/spec/acceptance/custom_log_from_organizer_spec.rb +0 -61
  64. data/spec/acceptance/deprecation_warnings_spec.rb +0 -82
  65. data/spec/acceptance/fail_spec.rb +0 -52
  66. data/spec/acceptance/log_from_organizer_spec.rb +0 -154
  67. data/spec/acceptance/message_localization_spec.rb +0 -119
  68. data/spec/acceptance/organizer/add_aliases_spec.rb +0 -28
  69. data/spec/acceptance/organizer/add_to_context_spec.rb +0 -54
  70. data/spec/acceptance/organizer/around_each_with_reduce_if_spec.rb +0 -42
  71. data/spec/acceptance/organizer/context_failure_and_skipping_spec.rb +0 -90
  72. data/spec/acceptance/organizer/execute_spec.rb +0 -67
  73. data/spec/acceptance/organizer/iterate_spec.rb +0 -44
  74. data/spec/acceptance/organizer/reduce_case_spec.rb +0 -65
  75. data/spec/acceptance/organizer/reduce_if_else_spec.rb +0 -60
  76. data/spec/acceptance/organizer/reduce_if_spec.rb +0 -89
  77. data/spec/acceptance/organizer/reduce_until_spec.rb +0 -49
  78. data/spec/acceptance/organizer/reduce_while_spec.rb +0 -96
  79. data/spec/acceptance/organizer/with_callback_spec.rb +0 -113
  80. data/spec/acceptance/organizer_entry_point_spec.rb +0 -35
  81. data/spec/acceptance/rollback_spec.rb +0 -183
  82. data/spec/acceptance/skip_all_remaining_spec.rb +0 -139
  83. data/spec/acceptance/testing/context_factory_spec.rb +0 -54
  84. data/spec/action_expected_keys_spec.rb +0 -63
  85. data/spec/action_expects_and_promises_spec.rb +0 -97
  86. data/spec/action_optional_expected_keys_spec.rb +0 -107
  87. data/spec/action_promised_keys_spec.rb +0 -126
  88. data/spec/action_spec.rb +0 -97
  89. data/spec/context/inspect_spec.rb +0 -52
  90. data/spec/context_spec.rb +0 -301
  91. data/spec/examples/amount_spec.rb +0 -77
  92. data/spec/examples/controller_spec.rb +0 -63
  93. data/spec/examples/validate_address_spec.rb +0 -38
  94. data/spec/i18n_localization_adapter_spec.rb +0 -83
  95. data/spec/lib/deterministic/class_mixin_spec.rb +0 -24
  96. data/spec/lib/deterministic/currify_spec.rb +0 -90
  97. data/spec/lib/deterministic/monad_axioms.rb +0 -46
  98. data/spec/lib/deterministic/monad_spec.rb +0 -47
  99. data/spec/lib/deterministic/null_spec.rb +0 -65
  100. data/spec/lib/deterministic/option_spec.rb +0 -140
  101. data/spec/lib/deterministic/result/failure_spec.rb +0 -65
  102. data/spec/lib/deterministic/result/result_map_spec.rb +0 -155
  103. data/spec/lib/deterministic/result/result_shared.rb +0 -25
  104. data/spec/lib/deterministic/result/success_spec.rb +0 -41
  105. data/spec/lib/deterministic/result_spec.rb +0 -63
  106. data/spec/lib/deterministic/sequencer_spec.rb +0 -506
  107. data/spec/lib/edge_cases_spec.rb +0 -156
  108. data/spec/lib/enum_spec.rb +0 -114
  109. data/spec/lib/native_pattern_matching_spec.rb +0 -74
  110. data/spec/localization_adapter_spec.rb +0 -66
  111. data/spec/organizer/with_reducer_spec.rb +0 -56
  112. data/spec/organizer_key_aliases_spec.rb +0 -29
  113. data/spec/organizer_spec.rb +0 -115
  114. data/spec/readme_spec.rb +0 -45
  115. data/spec/sample/calculates_order_tax_action_spec.rb +0 -16
  116. data/spec/sample/calculates_tax_spec.rb +0 -30
  117. data/spec/sample/looks_up_tax_percentage_action_spec.rb +0 -55
  118. data/spec/sample/provides_free_shipping_action_spec.rb +0 -25
  119. data/spec/sample/tax/calculates_order_tax_action.rb +0 -10
  120. data/spec/sample/tax/calculates_tax.rb +0 -11
  121. data/spec/sample/tax/looks_up_tax_percentage_action.rb +0 -28
  122. data/spec/sample/tax/provides_free_shipping_action.rb +0 -11
  123. data/spec/spec_helper.rb +0 -32
  124. data/spec/support.rb +0 -1
  125. data/spec/test_doubles.rb +0 -656
  126. data/spec/testing/context_factory/iterate_spec.rb +0 -39
  127. data/spec/testing/context_factory/reduce_if_spec.rb +0 -40
  128. data/spec/testing/context_factory/reduce_until_spec.rb +0 -40
  129. data/spec/testing/context_factory/with_callback_spec.rb +0 -38
  130. data/spec/testing/context_factory_spec.rb +0 -76
data/README.md DELETED
@@ -1,1663 +0,0 @@
1
- # FunctionalLightService
2
-
3
- [![Gem Version](https://img.shields.io/gem/v/functional-light-service.svg)](https://rubygems.org/gems/functional-light-service)
4
- [![CI Tests](https://github.com/sphynx79/functional-light-service/actions/workflows/project-build.yml/badge.svg)](https://github.com/sphynx79/functional-light-service/actions/workflows/project-build.yml)
5
- [![Codecov](https://codecov.io/gh/sphynx79/functional-light-service/branch/master/graph/badge.svg)](https://app.codecov.io/gh/sphynx79/functional-light-service)
6
- [![License](https://img.shields.io/badge/license-MIT-green.svg)](http://opensource.org/licenses/MIT)
7
- [![Download Count](https://img.shields.io/gem/dt/functional-light-service)](https://rubygems.org/gems/functional-light-service)
8
-
9
- ## Table of Content
10
-
11
- * [Requirements](#requirements)
12
- * [Installation](#installation)
13
- * [Why FunctionalLightService?](#why-functionallightservice?)
14
- * [Stopping the Series of Actions](#stopping-the-series-of-actions)
15
- * [Failing the Context](#failing-the-context)
16
- * [Skipping the Rest of the Actions](#skipping-the-rest-of-the-actions)
17
- * [Benchmarking Actions with Around Advice](#benchmarking-actions-with-around-advice)
18
- * [Before and After Action Hooks](#before-and-after-action-hooks)
19
- * [Key Aliases](#key-aliases)
20
- * [Logging](#logging)
21
- * [Error Codes](#error-codes)
22
- * [Action Rollback](#action-rollback)
23
- * [Localizing Messages](#localizing-messages)
24
- * [Logic in Organizers](#logic-in-organizers)
25
- * [ContextFactory for Faster Action Testing](#contextfactory-for-faster-action-testing)
26
- * [Functional programming](#functional-programming)
27
- * [Pattern](#pattern)
28
- * [Usage](#functional-usage)
29
- * [Result: Success & Failure](#functional-usage-success-failure)
30
- * [Result Chaining](#functional-usage-chaining)
31
- * [Sequencing (do-notation)](#functional-usage-sequencing)
32
- * [Complex Example in a Builder Action](#functional-usage-complex-action)
33
- * [Pattern matching](#functional-usage-pattern-matching)
34
- * [Option](#functional-usage-option)
35
- * [Coercion](#functional-usage-coercion)
36
- * [Enum](#functional-usage-enum)
37
- * [Maybe](#functional-usage-maybe)
38
- * [Usage](#usage)
39
-
40
- ## Requirements
41
-
42
- This gem requires ruby >= 3.1 (tested up to ruby 4.0)
43
-
44
- ## Installation
45
-
46
- Add this line to your application's Gemfile:
47
-
48
- ```bash
49
- gem 'functional-light-service'
50
- ```
51
-
52
- And then execute:
53
-
54
- ```bash
55
- $ bundle
56
- ```
57
-
58
- Or install it yourself as:
59
-
60
- ```bash
61
- $ gem install functional-light-service
62
- ```
63
-
64
- ## Why FunctionalLightService?
65
-
66
- While studying functional programming in Ruby, I discovered the fantastic gem **Deterministic**, which made it much easier to write Ruby code in a functional style.
67
- By leveraging its `in_sequence` method, I can chain a series of actions:
68
-
69
- - If every step completes without raising an exception, the call returns a `Success()` monad.
70
- - If any step fails, the remaining actions are skipped and a `Failure()` monad is returned.
71
-
72
- I writing this code:
73
-
74
- ```ruby
75
- class Foo
76
- include Deterministic::Prelude
77
-
78
- def call(input)
79
- result = in_sequence do
80
- get(:sanitized_input) { sanitize(input) }
81
- and_then { validate(sanitized_input) }
82
- and_then { connect_db }
83
- get(:user) { get_user(sanitized_input) }
84
- and_yield { print_response(user) }
85
- end
86
- logger.warn(result.value) if result.failure?
87
- rescue StandardError => e
88
- logger.fatal(e.message)
89
- end
90
-
91
- def sanitize(input)
92
- sanitized_input = {}
93
- sanitized_input[:name] = input[:name].downcase
94
- sanitized_input[:password] = input[:password].downcase
95
- Success(sanitized_input)
96
- end
97
-
98
- def validate(sanitized_input)
99
- try! do
100
- raise "Not allow empty name" if sanitized_input[:name].empty?
101
- raise "Not allow empty password" if sanitized_input[:password].empty?
102
- end.map_err { |n| Failure(n.message) }
103
- end
104
-
105
- def connect_db
106
- try! do
107
- raise "Error connection to db" if rand(0..1) == 1
108
- end.map_err { |n| Failure(n.message) }
109
- end
110
-
111
- def get_user(sanitized_input)
112
- user = FAKEDB.find do |_k, v|
113
- sanitized_input[:name] == v[:name] && sanitized_input[:password] == v[:password]
114
- end
115
- user.nil? ? Failure("Name or password error") : Success(user)
116
- end
117
-
118
- def print_response(user)
119
- Success(logger.info("Login successful id: #{user[0]} name: #{user[1][:name]}"))
120
- end
121
- end
122
-
123
- Foo.new.call(:name => "foo", :password => "bar")
124
- ```
125
-
126
- While refactoring my codebase, I needed each action to live in a well‑defined context.
127
- That’s when I discovered the excellent gem **LightService**. It gives me exactly what I was looking for:
128
-
129
- - a clean separation between business concerns and orchestration logic
130
- - a simple way to arrange actions in a pipeline
131
- - the freedom to place every action in its own class, each with its own contextual data
132
-
133
- ```ruby
134
- class Foo
135
- extend LightService::Organizer
136
-
137
- def self.call(name: "", password: "")
138
- result = with(:name => name, :password => password).reduce(actions)
139
- logger.warn(result.message) if result.failure?
140
- end
141
-
142
- def self.actions
143
- [
144
- Sanitize,
145
- Validate,
146
- ConnectDb,
147
- GetUser,
148
- PrintResponse
149
- ]
150
- end
151
- end
152
-
153
- class Sanitize
154
- extend LightService::Action
155
- expects :name, :password
156
- promises :sanitized_input
157
-
158
- executed do |ctx|
159
- sanitized_input = {}
160
- sanitized_input[:name] = ctx.name.downcase
161
- sanitized_input[:password] = ctx.password.downcase
162
- ctx.sanitized_input = sanitized_input
163
- end
164
- end
165
-
166
- class Validate
167
- extend LightService::Action
168
- expects :sanitized_input
169
-
170
- executed do |ctx|
171
- ctx.fail_and_return!("Not allow empty name") if ctx.sanitized_input[:name].empty?
172
- ctx.fail_and_return!("Not allow empty password") if ctx.sanitized_input[:password].empty?
173
- end
174
- end
175
-
176
- class ConnectDb
177
- extend LightService::Action
178
-
179
- executed do |ctx|
180
- raise "Error connection to db"
181
- rescue StandardError => e
182
- ctx.fail!(e.message) if rand(0..1) == 1
183
- end
184
-
185
- # private_class_method :..
186
- end
187
-
188
- class GetUser
189
- extend LightService::Action
190
- expects :sanitized_input
191
- promises :user
192
-
193
- executed do |ctx|
194
- user = FAKEDB.find do |_k, v|
195
- ctx.sanitized_input[:name] == v[:name] && ctx.sanitized_input[:password] == v[:password]
196
- end
197
- ctx.fail_and_return!("Name or password error") if user.nil?
198
- ctx.user = user
199
- end
200
- end
201
-
202
- class PrintResponse
203
- extend LightService::Action
204
- expects :user
205
-
206
- executed do |ctx|
207
- logger.info("Login successful id: #{ctx.user[0]} name: #{ctx.user[1][:name]}")
208
- end
209
- end
210
-
211
- Foo.call(:name => "foo", :password => "bar")
212
- ```
213
-
214
- The switch to **LightService** came at a price: I missed the functional‑programming super‑powers that **Deterministic** had given me.
215
- So I asked myself, *why not enjoy the best of both worlds?*
216
- That question led me to create **this gem**. Now I can keep all the conveniences LightService offers—action pipelines, clear contexts—while still coding in a fully functional style with expressive monads.
217
-
218
- ```ruby
219
- class Foo
220
- extend FunctionalLightService::Organizer
221
-
222
- def self.call(name: "", password: "")
223
- result = with(:name => name, :password => password).reduce(actions)
224
- logger.warn(result.message) if result.failure?
225
- end
226
-
227
- def self.actions
228
- [
229
- Sanitize,
230
- Validate,
231
- ConnectDb,
232
- GetUser,
233
- PrintResponse
234
- ]
235
- end
236
- end
237
-
238
- class Sanitize
239
- extend FunctionalLightService::Action
240
- expects :name, :password
241
- promises :sanitized_input
242
-
243
- executed do |ctx|
244
- name = ctx.name
245
- password = ctx.password
246
- ctx.sanitized_input = downcase(name, password).value
247
- end
248
-
249
- def self.downcase(name, password)
250
- ctx.try! do
251
- {
252
- :name => name.downcase,
253
- :password => password.downcase
254
- }
255
- end.map_err { ctx.fail!("Error nel method downcase") }
256
- end
257
-
258
- private_class_method :downcase
259
- end
260
-
261
- class Validate
262
- extend FunctionalLightService::Action
263
- expects :sanitized_input
264
-
265
- executed do |ctx|
266
- validate_params(ctx.sanitized_input).match do
267
- None() { ctx.Success(0) }
268
- Some() { |errors| ctx.fail_and_return!(errors) }
269
- end
270
- end
271
-
272
- def self.validate_params(params)
273
- return ctx.Some("Not allow empty name") if ctx.Option.any?(params[:name]).none?
274
- return ctx.Some("Not allow empty password") if ctx.Option.any?(params[:password]).none?
275
-
276
- ctx.None
277
- end
278
-
279
- private_class_method :validate_params
280
- end
281
-
282
- class ConnectDb
283
- extend FunctionalLightService::Action
284
-
285
- executed do |ctx|
286
- ctx.try! do
287
- raise "Error connection to db" if rand(0..1) == 1
288
- end.map_err { |n| ctx.fail!(n.message) }
289
- end
290
- end
291
-
292
- class GetUser
293
- extend FunctionalLightService::Action
294
- expects :sanitized_input
295
- promises :user
296
-
297
- executed do |ctx|
298
- user = Success(ctx.sanitized_input[:name]) >> method(:fetch_name) >> method(:check_password)
299
- ctx.user = user.value
300
- end
301
-
302
- def self.fetch_name(name)
303
- records = FAKEDB.select { |_k, v| name == v[:name] }
304
- ctx.fail_and_return!("Name not found in DB") if records.empty?
305
-
306
- Success(records)
307
- end
308
-
309
- def self.check_password(records)
310
- record = records.select { |_k, v| ctx.sanitized_input[:password] == v[:password] }
311
- return ctx.fail_and_return!("Password is not correct") if record.empty?
312
-
313
- Success(record)
314
- end
315
-
316
- private_class_method :fetch_name, :check_password
317
- end
318
-
319
- class PrintResponse
320
- extend FunctionalLightService::Action
321
- expects :user
322
-
323
- executed do |ctx|
324
- id = ctx.user.keys[0]
325
- name = ctx.user.values[0][:name]
326
- logger.info("Login successful id: #{id} name: #{name}")
327
- end
328
- end
329
-
330
- Foo.call(:name => "foo", :password => "bar")
331
- ```
332
-
333
- ## Stopping the Series of Actions
334
-
335
- When everything goes smoothly, the organizer returns a **successful** context.
336
- You can check it like this:
337
-
338
- ```ruby
339
- class SomeController < ApplicationController
340
- def index
341
- result_context = SomeOrganizer.call(current_user.id)
342
-
343
- if result_context.success?
344
- redirect_to foo_path, :notice => "Everything went OK! Thanks!"
345
- else
346
- flash[:error] = result_context.message
347
- render :action => "new"
348
- end
349
- end
350
- end
351
- ```
352
-
353
- Sometimes, though, things don’t go as planned — an external API is down or a business rule fails.
354
- In those cases, you can short‑circuit the pipeline in two ways:
355
-
356
- 1. **Fail the context** – aborts execution and returns a `Failure()` monad with an error message.
357
- 2. **Skip the remaining actions** – stops further actions but keeps the context successful, allowing graceful exits without raising an error.
358
-
359
- ### Failing the Context
360
-
361
- When an action hits an unrecoverable error, call `context.fail!` to mark the context as failed (`context.failure? #=> true`) and abort the pipeline.
362
- You can pass an optional message to describe what went wrong:
363
-
364
- ```ruby
365
- context.fail!("Validation failed")
366
- ```
367
-
368
- If you also need to leave the executed block immediately, you have two options:
369
-
370
- - next context – after fail!, simply return the context.
371
- - context.fail_and_return!(msg) – a one‑liner that sets the failure state and exits the block.
372
-
373
- Here is an example:
374
-
375
- ```ruby
376
- class SubmitsOrderAction
377
- extend FunctionalLightService::Action
378
- expects :order, :mailer
379
-
380
- executed do |context|
381
- unless context.order.submit_order_successful?
382
- context.fail_and_return!("Failed to submit the order")
383
- end
384
-
385
- # This won't be executed
386
- context.mailer.send_order_notification!
387
- end
388
- end
389
- ```
390
-
391
- ![fail-actions](https://raw.githubusercontent.com/sphynx79/functional-light-service/master/resources/fail_actions.png)
392
-
393
- In the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd had a failure, that pushed the context into a failure state and the 4th action was skipped.
394
-
395
- ### Skipping the rest of the actions
396
-
397
- To short‑circuit the pipeline without marking the context as failed, call
398
- `context.skip_remaining!`. It behaves like `fail!`, but the context
399
- remains **successful**, so downstream code can still treat the result as OK.
400
-
401
- Typical use case: you run the first few actions, perform a check, and if everything
402
- is already fine you can avoid processing the rest.
403
-
404
- ```ruby
405
- class ChecksOrderStatusAction
406
- extend FunctionalLightService::Action
407
- expects :order
408
-
409
- executed do |context|
410
- if context.order.send_notification?
411
- context.skip_remaining!("Everything is good, no need to execute the rest of the actions")
412
- end
413
- end
414
- end
415
- ```
416
-
417
- ![skip-actions](https://raw.githubusercontent.com/sphynx79/functional-light-service/master/resources/skip_actions.png)
418
-
419
- In the example above, the organizer invokes four actions.
420
- The first two run successfully; the third calls skip_remaining!, so the fourth is never executed, yet the overall context stays successful.
421
-
422
- ### Skipping everything, including nested scopes
423
-
424
- `skip_remaining!` is scoped: constructs like `reduce_if` or `iterate` reset it
425
- at their boundary, so it only exits the **current** scope. When you need to stop
426
- the whole organizer from inside a nested construct, use
427
- `context.skip_all_remaining!` — it is never reset, so every remaining step (in
428
- the current scope and in the outer ones) is skipped while the context stays
429
- successful:
430
-
431
- ```ruby
432
- class StopsEverythingAction
433
- extend FunctionalLightService::Action
434
- expects :item
435
-
436
- executed do |context|
437
- if context.item.poison_pill?
438
- context.skip_all_remaining!("Poison pill found, stopping the pipeline")
439
- end
440
- end
441
- end
442
- ```
443
-
444
- ## Benchmarking Actions with Around Advice
445
-
446
- When you need to profile a pipeline, adding timing code inside every single
447
- action clutters your business logic.
448
- Instead, use the organizer’s `around_each` hook, which wraps each action call
449
- as it is reduced in order.
450
-
451
- ```ruby
452
- class LogDuration
453
- def self.call(context)
454
- start_time = Time.now
455
- result = yield # run the wrapped action
456
- duration = Time.now - start_time
457
- FunctionalLightService::Configuration.logger.info(
458
- :action => context.current_action,
459
- :duration => duration
460
- )
461
-
462
- result
463
- end
464
- end
465
-
466
- class CalculatesTax
467
- extend FunctionalLightService::Organizer
468
-
469
- def self.call(order)
470
- with(:order => order).around_each(LogDuration).reduce(
471
- LooksUpTaxPercentageAction,
472
- CalculatesOrderTaxAction,
473
- ProvidesFreeShippingAction
474
- )
475
- end
476
- end
477
- ```
478
-
479
- Any object you pass to around_each must implement:
480
-
481
- ```ruby
482
- def self.call(context, &block)
483
- # …before logic…
484
- result = yield # executes the action
485
- # …after logic…
486
- result
487
- end
488
- ```
489
-
490
- This design lets you measure—or audit—every action without polluting
491
- the actions themselves.
492
-
493
- ## Before and After Action Hooks
494
-
495
- Sometimes you need to run code **right before** or **right after** each action.
496
- FunctionalLightService lets you do that with the `before_actions` and `after_actions` hooks.
497
- Each hook accepts one (or many) lambdas that will be invoked by the organizer, keeping
498
- instrumentation neatly separated from business logic.
499
-
500
- ### Example without hooks
501
-
502
- ```ruby
503
- class SomeOrganizer
504
- extend FunctionalLightService::Organizer
505
-
506
- def self.call(ctx)
507
- with(ctx).reduce(actions)
508
- end
509
-
510
- def self.actions
511
- [
512
- OneAction,
513
- TwoAction,
514
- ThreeAction
515
- ]
516
- end
517
- end
518
-
519
- class TwoAction
520
- extend FunctionalLightService::Action
521
- expects :user, :logger
522
-
523
- executed do |ctx|
524
- # Logging information
525
- if ctx.user.role == 'admin'
526
- ctx.logger.info('admin is doing something')
527
- end
528
-
529
- ctx.user.do_something
530
- end
531
- end
532
- ```
533
-
534
- Logging overwhelms the real work in TwoAction.
535
- Let’s move that concern into hooks.
536
-
537
- ### Option 1 — declare hooks inside the organizer
538
-
539
- ```ruby
540
- class SomeOrganizer
541
- extend FunctionalLightService::Organizer
542
- before_actions (lambda do |ctx|
543
- if ctx.current_action == TwoAction
544
- return unless ctx.user.role == 'admin'
545
- ctx.logger.info('admin is doing something')
546
- end
547
- end)
548
- after_actions (lambda do |ctx|
549
- if ctx.current_action == TwoAction
550
- return unless ctx.user.role == 'admin'
551
- ctx.logger.info('admin is DONE doing something')
552
- end
553
- end)
554
-
555
- def self.call(ctx)
556
- with(ctx).reduce(actions)
557
- end
558
-
559
- def self.actions
560
- [
561
- OneAction,
562
- TwoAction,
563
- ThreeAction
564
- ]
565
- end
566
- end
567
-
568
- class TwoAction
569
- extend FunctionalLightService::Action
570
- expects :user
571
-
572
- executed do |ctx|
573
- ctx.user.do_something
574
- end
575
- end
576
- ```
577
-
578
- Now TwoAction is pure business logic.
579
- Because ctx.current_action holds the class of the action being run, the hooks fire
580
- only for TwoAction, not OneAction or ThreeAction.
581
-
582
- ### Option 2 — attach hooks from the outside
583
-
584
- ```ruby
585
- SomeOrganizer.before_actions =
586
- lambda do |ctx|
587
- if ctx.current_action == TwoAction
588
- return unless ctx.user.role == 'admin'
589
- ctx.logger.info('admin is doing something')
590
- end
591
- end
592
- ```
593
-
594
- These ideas are originally from Aspect Oriented Programming, read more about them [here](https://en.wikipedia.org/wiki/Aspect-oriented_programming).
595
-
596
- ## Expects and Promises
597
-
598
- Two handy macros define the contract of every action:
599
-
600
- | Macro | Purpose |
601
- | ---------- | --------------------------------------------------------------- |
602
- | `expects` | Declares which keys **must** be present before the action runs. |
603
- | `promises` | Declares which keys **must** exist after the action finishes. |
604
-
605
- If either rule is violated, FunctionalLightService raises a dedicated exception.
606
-
607
- ### Basic usage
608
-
609
- ```ruby
610
- class FooAction
611
- extend FunctionalLightService::Action
612
-
613
- expects :baz
614
- promises :bar
615
-
616
- executed do |context|
617
- baz = context.fetch(:baz) # guaranteed to be present
618
- context[:bar] = baz + 2 # fulfils the promise
619
- end
620
- end
621
- ```
622
-
623
- ### Built‑in readers and writers
624
-
625
- The macros do more than validation:
626
- expects adds an accessor reader, so you can reference keys directly.
627
- promises adds an accessor writer, so you can assign without touching the hash.
628
- Refactored, the action is cleaner:
629
-
630
- ```ruby
631
- class FooAction
632
- extend FunctionalLightService::Action
633
-
634
- expects :baz
635
- promises :bar
636
-
637
- executed do |context|
638
- context.bar = context.baz + 2
639
- end
640
- end
641
- ```
642
-
643
- Want to see it in practice? Check out [this spec](spec/action_expects_and_promises_spec.rb) test file.
644
-
645
- ### Default values for expected keys
646
-
647
- An expected key can declare a `default`, used when the key is missing from the
648
- context (also when the action runs inside an organizer). The default can be a
649
- static value or a lambda receiving the context:
650
-
651
- ```ruby
652
- class GreetsSomeoneAction
653
- extend FunctionalLightService::Action
654
-
655
- expects :name
656
- expects :greeting, :default => "Hello"
657
- expects :message, :default => ->(ctx) { "#{ctx[:greeting]}, #{ctx[:name]}!" }
658
-
659
- executed do |context|
660
- puts context.message
661
- end
662
- end
663
-
664
- GreetsSomeoneAction.execute(:name => "Rick") # ⇒ "Hello, Rick!"
665
- ```
666
-
667
- Note that `expects` accepts a single key when a default is given, and any
668
- keyword other than `default` raises `UnusableExpectKeyDefaultError` at class
669
- definition time. Keys already reachable through an alias are considered
670
- present, so their default is not applied.
671
-
672
- ## Key Aliases
673
-
674
- Need to wire together actions that use different key names?
675
- Declare key mappings once in the organizer with the `aliases` macro and every
676
- action can read or write the value under its preferred name.
677
-
678
- ```ruby
679
- class AnOrganizer
680
- extend FunctionalLightService::Organizer
681
-
682
- aliases :my_key => :key_alias
683
-
684
- def self.call(order)
685
- with(:order => order).reduce(
686
- AnAction,
687
- AnotherAction,
688
- )
689
- end
690
- end
691
-
692
- class AnAction
693
- extend FunctionalLightService::Action
694
- promises :my_key
695
-
696
- executed do |context|
697
- context.my_key = "value"
698
- end
699
- end
700
-
701
- class AnotherAction
702
- extend FunctionalLightService::Action
703
- expects :key_alias
704
-
705
- executed do |context|
706
- context.key_alias # => "value"
707
- end
708
- end
709
- ```
710
-
711
- ## Logging
712
-
713
- Turning on logging is the easiest way to see what happens inside a pipeline:
714
- which organizer is called, which actions run, which keys appear in the context, and when something goes wrong.
715
-
716
- Logging is **disabled by default**. Enable it in your app’s configuration:
717
-
718
- ```ruby
719
- FunctionalLightService::Configuration.logger = Logger.new(STDOUT)
720
- ```
721
-
722
- To silence it, point the logger at nil or /dev/null:
723
-
724
- ```ruby
725
- FunctionalLightService::Configuration.logger = Logger.new('/dev/null')
726
- ```
727
-
728
- Run an organizer and you’ll see output like:
729
-
730
- ```bash
731
- I, [DATE] INFO -- : [FunctionalLightService] - calling organizer <TestDoubles::MakesTeaAndCappuccino>
732
- I, [DATE] INFO -- : [FunctionalLightService] - keys in context: :tea, :milk, :coffee
733
- I, [DATE] INFO -- : [FunctionalLightService] - executing <TestDoubles::MakesTeaWithMilkAction>
734
- I, [DATE] INFO -- : [FunctionalLightService] - expects: :tea, :milk
735
- I, [DATE] INFO -- : [FunctionalLightService] - promises: :milk_tea
736
- I, [DATE] INFO -- : [FunctionalLightService] - keys in context: :tea, :milk, :coffee, :milk_tea
737
- I, [DATE] INFO -- : [FunctionalLightService] - executing <TestDoubles::MakesLatteAction>
738
- I, [DATE] INFO -- : [FunctionalLightService] - expects: :coffee, :milk
739
- I, [DATE] INFO -- : [FunctionalLightService] - promises: :latte
740
- I, [DATE] INFO -- : [FunctionalLightService] - keys in context: :tea, :milk, :coffee, :milk_tea, :latte
741
- ```
742
-
743
- The log provides a blueprint of the series of actions. You can see what organizer is invoked, what actions
744
- are called in what order, what do the expect and promise and most importantly what keys you have in the context
745
- after each action is executed.
746
-
747
- Failures are logged at WARN level:
748
-
749
- ```bash
750
- W, [DATE] WARN -- : [FunctionalLightService] - :-((( <TestDoubles::MakesLatteAction> has failed...
751
- W, [DATE] WARN -- : [FunctionalLightService] - context message: Can't make a latte from a milk that's too hot!
752
- ```
753
-
754
- Skipping the remaining actions is also reported:
755
-
756
- ```bash
757
- I, [DATE] INFO -- : [FunctionalLightService] - calling organizer <TestDoubles::MakesCappuccinoSkipsAddsTwo>
758
- I, [DATE] INFO -- : [FunctionalLightService] - keys in context: :milk, :coffee
759
- I, [DATE] INFO -- : [FunctionalLightService] - ;-) <TestDoubles::MakesLatteAction> has decided to skip the rest of the actions
760
- I, [DATE] INFO -- : [FunctionalLightService] - context message: Can't make a latte with a fatty milk like that!
761
- ```
762
-
763
- Need different log destinations per organizer? Override the global logger:
764
-
765
- ```ruby
766
- class FooOrganizer
767
- extend FunctionalLightService::Organizer
768
- log_with Logger.new("/my/special.log")
769
- end
770
- ```
771
-
772
- ## Error Codes
773
-
774
- Sometimes you need more structure than a free‑text error message.
775
- fail! and fail_and_return! accept an error_code: keyword so you can branch on well‑defined codes later.
776
-
777
- ```ruby
778
- class FooAction
779
- extend FunctionalLightService::Action
780
-
781
- executed do |context|
782
- result = external_service.call
783
-
784
- unless result.success?
785
- context.fail!(
786
- "Service call failed",
787
- error_code: 1001
788
- )
789
- end
790
-
791
- unless entity.save
792
- context.fail!(
793
- "Saving the entity failed",
794
- error_code: 2001
795
- )
796
- end
797
- end
798
- end
799
- ```
800
-
801
- Organizers or downstream actions can then react to specific codes:
802
-
803
- ```ruby
804
- result = FooOrganizer.call
805
-
806
- case result.error_code
807
- when 1001 then retry_later
808
- when 2001 then alert_ops_team
809
- end
810
- ```
811
-
812
- ## Action Rollback
813
-
814
- Sometimes an action must **undo** its work if a later step fails.
815
- Example: one action saves records to the database, the next calls an external
816
- API. If the API call blows up, you want to delete the records you just saved.
817
- That’s exactly what the `rolled_back` macro is for.
818
-
819
- ```ruby
820
- class SaveEntities
821
- extend FunctionalLightService::Action
822
- expects :user
823
-
824
- executed do |context|
825
- context.user.save!
826
- end
827
-
828
- rolled_back do |context|
829
- context.user.destroy
830
- end
831
- end
832
- ```
833
-
834
- Trigger a rollback by calling context.fail_with_rollback!.
835
- Rollback begins with the failing action and walks back through the already
836
- executed actions in reverse order.
837
-
838
- ```ruby
839
- class CallExternalApi
840
- extend FunctionalLightService::Action
841
-
842
- executed do |context|
843
- api_call_result = SomeAPI.save_user(context.user)
844
-
845
- context.fail_with_rollback!("Error when calling external API") if api_call_result.failure?
846
- end
847
- end
848
- ```
849
-
850
- Declaring rolled_back is optional. If an action makes no persistent changes,
851
- there’s nothing to undo—skip it.
852
-
853
- ### Using rollbackable actions standalone
854
-
855
- When an action is executed outside an organizer via .execute, any
856
- fail_with_rollback! will raise a FailWithRollbackError (an organizer needs
857
- the exception to traverse the chain).
858
-
859
- If you don’t want to wrap the call in begin … rescue, check whether the
860
- action is running inside an organizer:
861
-
862
- ```ruby
863
- class FooAction
864
- extend FunctionalLightService::Action
865
-
866
- executed do |context|
867
- # context.organized_by will be nil if run from an action,
868
- # or will be the class name if run from an organizer
869
- if context.organized_by.nil?
870
- context.fail!
871
- else
872
- context.fail_with_rollback!
873
- end
874
- end
875
- end
876
- ```
877
-
878
- For a full example, see [this acceptance test](spec/acceptance/rollback_spec.rb)
879
-
880
- ## Localizing Messages
881
-
882
- Symbols passed to `fail!`/`succeed!` are looked up through a localization
883
- adapter. Two adapters ship with the gem:
884
-
885
- - **Built-in adapter** (default): resolves messages from
886
- `FunctionalLightService::LocalizationMap.instance`, a plain hash keyed by
887
- `Configuration.locale` (default `:en`) — no extra dependency needed:
888
-
889
- ```ruby
890
- FunctionalLightService::LocalizationMap.instance[:en] = {
891
- :foo_action => {
892
- :light_service => {
893
- :failures => { :exceeded_api_limit => "Exceeded API limit" },
894
- :successes => { :api_call_ok => "All good" }
895
- }
896
- }
897
- }
898
- ```
899
-
900
- - **I18n adapter**: selected automatically when your application loads the
901
- `i18n` gem (it is no longer a runtime dependency of this gem — add it to
902
- your own Gemfile if you want I18n-backed lookups).
903
-
904
- If your app needs something more advanced, you can swap in a custom
905
- localization adapter.
906
-
907
- ```ruby
908
- class FooAction
909
- extend FunctionalLightService::Action
910
-
911
- executed do |context|
912
- unless service_call.success?
913
- context.fail!(:exceeded_api_limit)
914
-
915
- # The failure message used here equates to:
916
- # I18n.t(:exceeded_api_limit, scope: "foo_action.light_service.failures")
917
- end
918
- end
919
- end
920
- ```
921
-
922
- ### Nested classes
923
-
924
- Look‑ups follow ActiveSupport’s underscore, just like Rails models inside modules:
925
-
926
- ```ruby
927
- module PaymentGateway
928
- class CaptureFunds
929
- extend FunctionalLightService::Action
930
-
931
- executed do |context|
932
- context.fail!(:funds_not_available) if api_service.failed?
933
- # resolves to:
934
- # I18n.t(:funds_not_available,
935
- # scope: "payment_gateway/capture_funds.light_service.failures")
936
- end
937
- end
938
- end
939
- ```
940
-
941
- ### Interpolation variables
942
-
943
- Pass a hash for dynamic values:
944
-
945
- ```ruby
946
- module PaymentGateway
947
- class CaptureFunds
948
- extend FunctionalLightService::Action
949
-
950
- executed do |context|
951
- if api_service.failed?
952
- context.fail!(:funds_not_available, last_four: "1234")
953
- end
954
- end
955
- end
956
- end
957
- ```
958
-
959
- ```yaml
960
- # en.yml
961
- payment_gateway:
962
- capture_funds:
963
- light_service:
964
- failures:
965
- funds_not_available: "Unable to process your payment for account ending in %{last_four}"
966
- ```
967
-
968
- ### Custom adapter
969
-
970
- Need a different lookup scheme? Subclass the built‑in adapter and set it in the
971
- configuration:
972
-
973
- ```ruby
974
- # config/initializers/light_service.rb
975
- FunctionalLightService::Configuration.localization_adapter = MyLocalizer.new
976
-
977
- # lib/my_localizer.rb
978
- class MyLocalizer < FunctionalLightService::I18n::LocalizationAdapter
979
- # change default scope to: "light_service.failures.<class_path>"
980
- def i18n_scope_from_class(action_class, type)
981
- "light_service.#{type.pluralize}.#{action_class.name.underscore}"
982
- end
983
- end
984
- ```
985
-
986
- ### Retrieving the message
987
-
988
- After an action halts with fail! or succeed!, read the translated text via:
989
-
990
- ```ruby
991
- result = FooAction.execute(baz: 1)
992
- puts result.message # ⇒ "Exceeded API limit" (or localized equivalent)
993
- ```
994
-
995
- ## Logic in Organizers
996
-
997
- The Organizer - Action combination works really well for simple use cases. However, as business logic gets more complex, or when FunctionalLightService is used in an ETL workflow, the code that routes the different organizers becomes very complex and imperative. Let's look at a piece of code that does basic data transformations:
998
-
999
- ```ruby
1000
- class ExtractsTransformsLoadsData
1001
- def self.run(connection)
1002
- context = RetrievesConnectionInfo.call(connection)
1003
- context = PullsDataFromRemoteApi.call(context)
1004
-
1005
- retrieved_items = context.retrieved_items
1006
- if retrieved_items.empty?
1007
- NotifiesEngineeringTeamAction.execute(context)
1008
- end
1009
-
1010
- retrieved_items.each do |item|
1011
- context[:item] = item
1012
- TransformsData.call(context)
1013
- end
1014
-
1015
- context = LoadsData.call(context)
1016
-
1017
- SendsNotifications.call(context)
1018
- end
1019
- end
1020
- ```
1021
-
1022
- ### Declarative version
1023
-
1024
- ```ruby
1025
- class ExtractsTransformsLoadsData
1026
- extend FunctionalLightService::Organizer
1027
-
1028
- def self.call(connection)
1029
- with(:connection => connection).reduce(actions)
1030
- end
1031
-
1032
- def self.actions
1033
- [
1034
- RetrievesConnectionInfo,
1035
- PullsDataFromRemoteApi,
1036
- reduce_if(->(ctx) { ctx.retrieved_items.empty? }, [
1037
- NotifiesEngineeringTeamAction
1038
- ]),
1039
- iterate(:retrieved_items, [
1040
- TransformsData
1041
- ]),
1042
- LoadsData,
1043
- SendsNotifications
1044
- ]
1045
- end
1046
- end
1047
- ```
1048
-
1049
- The declarative style is shorter, easier to scan, and keeps flow control out of
1050
- your actions.
1051
-
1052
- ### Organizer constructs
1053
-
1054
- | Construct | Declarative “equivalent” | What it does (in one line) |
1055
- | ------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------- |
1056
- | [reduce_until](spec/acceptance/organizer/reduce_until_spec.rb) | `until` loop | Keeps reducing the listed steps **until** the lambda returns `true`. |
1057
- | [reduce_while](spec/acceptance/organizer/reduce_while_spec.rb) | `while` guard | Checks the lambda **before each step** and stops as soon as it returns `false`. |
1058
- | [reduce_if](spec/acceptance/organizer/reduce_if_spec.rb) | `if` | Reduces its sub‑steps **only if** the lambda returns `true`. |
1059
- | [reduce_if_else](spec/acceptance/organizer/reduce_if_else_spec.rb) | `if/else` | Reduces the first list of steps when the lambda is `true`, the second one otherwise. |
1060
- | [reduce_case](spec/acceptance/organizer/reduce_case_spec.rb) | `case/when` | Dispatches to the steps matching a context value (`:value`, `:when`, `:else` kwargs). |
1061
- | [iterate](spec/acceptance/organizer/iterate_spec.rb) | `each` loop | Loops over a collection key; each element is exposed under the **singular** name. |
1062
- | [execute](spec/acceptance/organizer/execute_spec.rb) | one‑off lambda | Runs an inline lambda or block for quick context tweaks (add keys, transform values, etc.). |
1063
- | [with_callback](spec/acceptance/organizer/with_callback_spec.rb) | streaming callback | Defers execution like a SAX parser—great for huge inputs without loading everything in RAM. |
1064
- | [add_to_context](spec/acceptance/organizer/add_to_context_spec.rb) | N/A (context inject) | Injects key–value pairs into the context (defining accessors) before the next steps run. |
1065
- | [add_aliases](spec/acceptance/organizer/add_aliases_spec.rb) | key aliasing | Creates an alias so actions can read/write the same value under different names. |
1066
-
1067
- All ten are covered by acceptance tests in spec/acceptance/organizer/*_spec.rb.
1068
-
1069
- **Tip**: When iterating, the collection must already be in the context.
1070
- iterate(:items) expects context[:items]; it then places each element under
1071
- context.item for the inner actions.
1072
-
1073
- ```ruby
1074
- iterate(:items, [ProcessItem])
1075
- # Inside ProcessItem → context.item
1076
- ```
1077
-
1078
- Need a quick context mutation? Use execute, with a lambda or a block:
1079
-
1080
- ```ruby
1081
- execute(->(c) { c[:some_values] = c.some_hash.values })
1082
- # or
1083
- execute { |c| c[:some_values] = c.some_hash.values }
1084
- ```
1085
-
1086
- Need to branch on a context value? Use reduce_case:
1087
-
1088
- ```ruby
1089
- reduce_case :value => :status,
1090
- :when => {
1091
- :active => [NotifiesUserAction],
1092
- :archived => [ArchivesRecordAction]
1093
- },
1094
- :else => [RaisesUnknownStatusAction]
1095
- ```
1096
-
1097
- ## ContextFactory for Faster Action Testing
1098
-
1099
- As workflows grow more complex, building a realistic
1100
- `FunctionalLightService::Context` for unit tests can become painful.
1101
- Factory objects help, but the data you assemble by hand may still differ
1102
- from what earlier actions really produce—especially in ETL pipelines where
1103
- each step mutates the context.
1104
-
1105
- ### Example pipeline:
1106
-
1107
- ```ruby
1108
- class SomeOrganizer
1109
- extend FunctionalLightService::Organizer
1110
-
1111
- def self.call(ctx)
1112
- with(ctx).reduce(actions)
1113
- end
1114
-
1115
- def self.actions
1116
- [
1117
- ETL::ParsesPayloadAction,
1118
- ETL::BuildsEnititiesAction,
1119
- ETL::SetsUpMappingsAction,
1120
- ETL::SavesEntitiesAction,
1121
- ETL::SendsNotificationAction
1122
- ]
1123
- end
1124
- end
1125
- ```
1126
-
1127
- You should test your workflow from the outside, invoking the organizer’s `call` method and verify that the data was properly created or updated in your data store. However, sometimes you need to zoom into one action, and setting up the context to test it is tedious work. This is where `ContextFactory` can be helpful.
1128
-
1129
- ### Enter ContextFactory
1130
-
1131
- FunctionalLightService::Testing::ContextFactory can generate a
1132
- pre-populated context that mirrors real runtime data, letting you focus on
1133
- the behaviour you want to test.
1134
-
1135
- ```ruby
1136
- require "spec_helper"
1137
- require "light-service/testing"
1138
-
1139
- RSpec.describe ETL::SetsUpMappingsAction do
1140
- let(:context) do
1141
- FunctionalLightService::Testing::ContextFactory
1142
- .make_from(SomeOrganizer) # build the full pipeline
1143
- .for(described_class) # stop right before our action
1144
- .with(payload: File.read("spec/data/payload.json"))
1145
- end
1146
-
1147
- it "sets up mappings correctly" do
1148
- result = described_class.execute(context)
1149
- expect(result).to be_success
1150
- end
1151
- end
1152
- ```
1153
-
1154
- No more 20-line fixture setup—just a realistic context ready to go.
1155
-
1156
- If your organizer contains additional logic in its own call method,
1157
- create a test-only organizer inside your specs.
1158
- See [acceptance test](spec/acceptance/testing/context_factory_spec.rb#L4-L11) for a full example.
1159
-
1160
- ## Functional Programming
1161
-
1162
- FunctionalLightService lets you write **confident**, side-effect-aware Ruby by
1163
- offering monads and algebraic data types (ADTs) you can compose and pattern-match
1164
- without boilerplate.
1165
-
1166
- ### Pattern Overview
1167
-
1168
- | Monad / ADT | When to use it | Typical flow control |
1169
- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
1170
- | **Result** (`Success / Failure`) | An operation can **succeed or fail** and the *value matters* either way. | Short-circuit on the first `Failure`. |
1171
- | **Option** (`Some / None`) | An operation may return **a value or nothing**, and *why it’s missing doesn’t matter*. Think collections or cache hits. | Run every step, keep only the `Some` results. |
1172
- | **Maybe** | Wrap any object that *might be `nil`* to avoid endless `nil?` checks. | Chain safe calls; `Null` swallows method calls. |
1173
- | **Enums** (custom ADTs) | Define your own tagged unions when the built-ins don’t fit. | Full pattern-matching support. |
1174
-
1175
- ### Usage
1176
-
1177
- ### Result – `Success / Failure` <a name="functional-usage-success-failure"></a>
1178
-
1179
- ```ruby
1180
- Success(1).to_s # => "1"
1181
- Success(Success(1)) # => Success(1)
1182
-
1183
- Failure(1).to_s # => "1"
1184
- Failure(Failure(1)) # => Failure(1)
1185
- ```
1186
-
1187
- #### Mapping and binding
1188
-
1189
- ```ruby
1190
- Success(1).fmap { |v| v + 1 } # => Success(2)
1191
- Failure(1).bind { |v| Success(v - 1) } # => Success(0)
1192
-
1193
- Success(1).map { |n| Success(n + 1) } # => Success(2)
1194
- Failure(1).map_err { |n| Success(n + 1) } # => Success(2)
1195
- ```
1196
-
1197
- #### Flow helpers
1198
-
1199
- ```ruby
1200
- Success(1).and Success(2) # => Success(2)
1201
- Success(1).and_then { Success(2) } # => Success(2)
1202
-
1203
- Failure(1).or Success(99) # => Success(99)
1204
- Failure(1).or_else { |n| Success(n + 1) } # => Success(2)
1205
- ```
1206
-
1207
- #### Exception capturing
1208
-
1209
- ```ruby
1210
- include FunctionalLightService::Prelude::Result
1211
-
1212
- try! { 1 } # => Success(1)
1213
- try! { raise "hell" } # => Failure(#<RuntimeError: hell>)
1214
- try! { risky_call } # => Success(result) or Failure(err)
1215
- ```
1216
-
1217
- ### Result Chaining <a name="functional-usage-chaining"></a>
1218
-
1219
- You can easily chain the execution of several operations. Here we got some nice function composition.
1220
- The method must be a unary function, i.e. it always takes one parameter - the context, which is passed from call to call.
1221
-
1222
- The following aliases are defined
1223
-
1224
- ```ruby
1225
- alias :>> :map
1226
- alias :<< :pipe
1227
- ```
1228
-
1229
- This allows the composition of procs or lambdas and thus allow a clear definiton of a pipeline.
1230
-
1231
- ```ruby
1232
- Success(params) >>
1233
- validate >>
1234
- build_request << log >>
1235
- send << log >>
1236
- build_response
1237
- ```
1238
-
1239
- ### Sequencing (do-notation) – `in_sequence` <a name="functional-usage-sequencing"></a>
1240
-
1241
- When a pipeline needs the intermediate values of earlier steps, chaining alone
1242
- gets awkward. `in_sequence` (ported from the [deterministic](https://github.com/pzol/deterministic)
1243
- gem, MIT License) gives you a do-notation style block: each step returns a
1244
- `Result`, the sequence short-circuits on the first `Failure`, and values bound
1245
- with `get`/`let` are available to all subsequent steps by name.
1246
-
1247
- ```ruby
1248
- class DownloadRemit
1249
- include FunctionalLightService::Prelude
1250
-
1251
- def call(row)
1252
- in_sequence do
1253
- get(:url) { extract_url(row) } # binds the Success value to :url
1254
- get(:file) { fetch(url) } # :url is available here
1255
- let(:name) { File.basename(url) } # binds a plain (non-Result) value
1256
- and_then { validate(file) } # step without binding
1257
- observe { logger.info("got #{name}") } # side effect, return value ignored
1258
- and_yield { Success(name) } # final result of the sequence
1259
- end
1260
- end
1261
- end
1262
- ```
1263
-
1264
- * `get(:name) { ... }` – runs a step returning a `Result`; on `Success` binds the
1265
- unwrapped value to `name`, on `Failure` stops the sequence and returns it.
1266
- * `let(:name) { ... }` – binds the block's plain return value (no `Result` involved).
1267
- * `and_then { ... }` – runs a step returning a `Result` without binding its value.
1268
- * `observe { ... }` – runs a side effect; its return value is ignored.
1269
- * `and_yield { ... }` – mandatory final step; its `Result` is the value of the
1270
- whole `in_sequence` block.
1271
-
1272
- #### Complex Example in a Builder Action <a name="functional-usage-complex-action"></a>
1273
-
1274
- ```ruby
1275
- class Foo
1276
- extend FunctionalLightService::Action
1277
- expects :params
1278
- alias :m :method
1279
-
1280
- executed do |ctx|
1281
- Success(ctx.params) >> m(:validate) >> m(:send)
1282
- end
1283
-
1284
- def self.validate(params)
1285
- # do stuff
1286
- Success(validate_and_cleansed_params)
1287
- end
1288
-
1289
- def self.send(clean_params)
1290
- # do stuff
1291
- Success(result)
1292
- end
1293
- end
1294
-
1295
- class Bar
1296
- extend FunctionalLightService::Organizer
1297
-
1298
- def self.call(params)
1299
- with(:params => params).reduce(Foo)
1300
- end
1301
- end
1302
-
1303
- Bar.call # Success(3)
1304
- ```
1305
-
1306
- Chaining works with blocks (`#map` is an alias for `#>>`)
1307
-
1308
- ```ruby
1309
- Success(1).map {|ctx| Success(ctx + 1)}
1310
- ```
1311
-
1312
- it also works with lambdas
1313
-
1314
- ```ruby
1315
- Success(1) >> ->(ctx) { Success(ctx + 1) } >> ->(ctx) { Success(ctx + 1) }
1316
- ```
1317
-
1318
- and it will break the chain of execution, when it encounters a `Failure` on its way
1319
-
1320
- ```ruby
1321
- def works(ctx)
1322
- Success(1)
1323
- end
1324
-
1325
- def breaks(ctx)
1326
- Failure(2)
1327
- end
1328
-
1329
- def never_executed(ctx)
1330
- Success(99)
1331
- end
1332
-
1333
- Success(0) >> method(:works) >> method(:breaks) >> method(:never_executed) # Failure(2)
1334
- ```
1335
-
1336
- `#map` aka `#>>` will not catch any exceptions raised. If you want automatic exception handling, the `#try` aka `#>=` will catch an error and wrap it with a failure
1337
-
1338
- ```ruby
1339
- def error(ctx)
1340
- raise "error #{ctx}"
1341
- end
1342
-
1343
- Success(1) >= method(:error) # Failure(RuntimeError(error 1))
1344
- ```
1345
-
1346
- ### Pattern matching <a name="functional-usage-pattern-matching"></a>
1347
-
1348
- Now that you have some result, you want to control flow by providing patterns.
1349
- `#match` can match by
1350
-
1351
- * success, failure, result or any
1352
- * values
1353
- * lambdas
1354
- * classes
1355
-
1356
- ```ruby
1357
- Success(1).match do
1358
- Success() { |s| "success #{s}"}
1359
- Failure() { |f| "failure #{f}"}
1360
- end # => "success 1"
1361
- ```
1362
-
1363
- Note1: the variant's inner value(s) have been unwrapped, and passed to the block.
1364
-
1365
- Note2: only the __first__ matching pattern block will be executed, so order __can__ be important.
1366
-
1367
- Note3: you can omit block parameters if you don't use them, or you can use `_` to signify that you don't care about their values. If you specify parameters, their number must match the number of values in the variant.
1368
-
1369
- The result returned will be the result of the __first__ `#try` or `#let`. As a side note, `#try` is a monad, `#let` is a functor.
1370
-
1371
- Guards
1372
-
1373
- ```ruby
1374
- Success(1).match do
1375
- Success(where { s == 1 }) { |s| "Success #{s}" }
1376
- end # => "Success 1"
1377
- ```
1378
-
1379
- Note1: the guard has access to variable names defined by the block arguments.
1380
-
1381
- Note2: the guard is not evaluated using the enclosing context's `self`; if you need to call methods on the enclosing scope, you must specify a receiver.
1382
-
1383
- Also you can match the result class
1384
-
1385
- ```ruby
1386
- Success([1, 2, 3]).match do
1387
- Success(where { s.is_a?(Array) }) { |s| s.first }
1388
- end # => 1
1389
- ```
1390
-
1391
- If no match was found a `NoMatchError` is raised, so make sure you always cover all possible outcomes.
1392
-
1393
- ```ruby
1394
- Success(1).match do
1395
- Failure() { |f| "you'll never get me" }
1396
- end # => NoMatchError
1397
- ```
1398
-
1399
- Matches must be exhaustive, otherwise an error will be raised, showing the variants which have not been covered.
1400
-
1401
- ### Option <a name="functional-usage-option"></a>
1402
-
1403
- ```ruby
1404
- Some(1).some? # #=> true
1405
- Some(1).none? # #=> false
1406
- None.some? # #=> false
1407
- None.none? # #=> true
1408
- ```
1409
-
1410
- Maps an `Option` with the value `a` to the same `Option` with the value `b`.
1411
-
1412
- ```ruby
1413
- Some(1).fmap { |n| n + 1 } # => Some(2)
1414
- None.fmap { |n| n + 1 } # => None
1415
- ```
1416
-
1417
- Maps a `Result` with the value `a` to another `Result` with the value `b`.
1418
-
1419
- ```ruby
1420
- Some(1).map { |n| Some(n + 1) } # => Some(2)
1421
- Some(1).map { |n| None } # => None
1422
- None.map { |n| Some(n + 1) } # => None
1423
- ```
1424
-
1425
- Get the inner value or provide a default for a `None`. Calling `#value` on a `None` will raise a `NoMethodError`
1426
-
1427
- ```ruby
1428
- Some(1).value # => 1
1429
- Some(1).value_or(2) # => 1
1430
- None.value # => NoMethodError
1431
- None.value_or(0) # => 0
1432
- ```
1433
-
1434
- Add the inner values of option using `+`.
1435
-
1436
- ```ruby
1437
- Some(1) + Some(1) # => Some(2)
1438
- Some([1]) + Some(1) # => TypeError: No implicit conversion
1439
- None + Some(1) # => Some(1)
1440
- Some(1) + None # => Some(1)
1441
- Some([1]) + None + Some([2]) # => Some([1, 2])
1442
- ```
1443
-
1444
- ### Coercion <a name="functional-usage-coercion"></a>
1445
-
1446
- ```ruby
1447
- Option.any?(nil) # => None
1448
- Option.any?([]) # => None
1449
- Option.any?({}) # => None
1450
- Option.any?(1) # => Some(1)
1451
-
1452
- Option.some?(nil) # => None
1453
- Option.some?([]) # => Some([])
1454
- Option.some?({}) # => Some({})
1455
- Option.some?(1) # => Some(1)
1456
-
1457
- Option.try! { 1 } # => Some(1)
1458
- Option.try! { raise "error"} # => None
1459
-
1460
- Some(1).match {
1461
- Some(where { s == 1 }) { |s| s + 1 }
1462
- Some() { |s| 1 }
1463
- None() { 0 }
1464
- } # => 2
1465
- ```
1466
-
1467
- ### Maybe <a name="functional-usage-maybe"></a>
1468
-
1469
- The simplest NullObject wrapper there can be. It adds `#some?` and `#null?` to `Object` though.
1470
-
1471
- ```ruby
1472
- require 'functional-light-service/functional/maybe' # you need to do this explicitly
1473
- Maybe(nil).foo # => Null
1474
- Maybe(nil).foo.bar # => Null
1475
- Maybe({a: 1})[:a] # => 1
1476
-
1477
- Maybe(nil).null? # => true
1478
- Maybe({}).null? # => false
1479
-
1480
- Maybe(nil).some? # => false
1481
- Maybe({}).some? # => true
1482
- ```
1483
-
1484
- ### Enums (custom ADTs) <a name="functional-usage-enum"></a>
1485
-
1486
- All the above are implemented using enums, see their definition, for more details.
1487
-
1488
- ```ruby
1489
- Threenum = FunctionalLightService::enum {
1490
- Nullary()
1491
- Unary(:a)
1492
- Binary(:a, :b)
1493
- }
1494
-
1495
- Threenum.variants # => [:Nullary, :Unary, :Binary]
1496
- ```
1497
-
1498
- Initialize
1499
-
1500
- ```ruby
1501
- n = Threenum.Nullary # => Threenum::Nullary.new()
1502
- n.value # => Error
1503
-
1504
- u = Threenum.Unary(1) # => Threenum::Unary.new(1)
1505
- u.value # => 1
1506
-
1507
- b = Threenum::Binary(2, 3) # => Threenum::Binary(2, 3)
1508
- b.value # => { a:2, b: 3 }
1509
- ```
1510
-
1511
- Pattern matching
1512
-
1513
- ```ruby
1514
- Threenum::Unary(5).match {
1515
- Nullary() { 0 }
1516
- Unary() { |u| u }
1517
- Binary() { |a, b| a + b }
1518
- } # => 5
1519
-
1520
- # or
1521
- t = Threenum::Unary(5)
1522
- Threenum.match(t) {
1523
- Nullary() { 0 }
1524
- Unary() { |u| u }
1525
- Binary() { |a, b| a + b }
1526
- } # => 5
1527
- ```
1528
-
1529
- If you want to return the whole matched object, you'll need to pass a reference to the object (second case). Note that `self` refers to the scope enclosing the `match` call.
1530
-
1531
- ```ruby
1532
- def drop(n)
1533
- match {
1534
- Cons(where { n > 0 }) { |h, t| t.drop(n - 1) }
1535
- Cons() { |_, _| self }
1536
- Nil() { raise EmptyListError }
1537
- }
1538
- end
1539
- ```
1540
-
1541
- See the linked list implementation in the specs for more examples
1542
-
1543
- With guard clauses
1544
-
1545
- ```ruby
1546
- Threenum::Unary(5).match {
1547
- Nullary() { 0 }
1548
- Unary() { |u| u }
1549
- Binary(where { a.is_a?(Fixnum) && b.is_a?(Fixnum) }) { |a, b| a + b }
1550
- Binary() { |a, b| raise "Expected a, b to be numbers" }
1551
- } # => 5
1552
- ```
1553
-
1554
- #### Add methods with impl
1555
-
1556
- ```ruby
1557
- FunctionalLightService::impl(Threenum) {
1558
- def sum
1559
- match {
1560
- Nullary() { 0 }
1561
- Unary() { |u| u }
1562
- Binary() { |a, b| a + b }
1563
- }
1564
- end
1565
-
1566
- def +(other)
1567
- match {
1568
- Nullary() { other.sum }
1569
- Unary() { |a| self.sum + other.sum }
1570
- Binary() { |a, b| self.sum + other.sum }
1571
- }
1572
- end
1573
- }
1574
-
1575
- Threenum.Nullary + Threenum.Unary(1) # => Unary(1)
1576
- ```
1577
-
1578
- All matches must be exhaustive; otherwise NoMatchError is raised.
1579
-
1580
- ## Usage <a name="usage"></a>
1581
-
1582
- Based on the refactoring example above, just create an organizer object that calls the
1583
- actions in order and write code for the actions. That's it.
1584
-
1585
- For further examples, please visit the project's [Wiki](https://github.com/sphynx79/functional-light-service/wiki).
1586
-
1587
- ## Upgrading to 6.0
1588
-
1589
- Version 6.0 requires **Ruby >= 3.1** and ships a few breaking changes plus new guarantees.
1590
- They come from a full technical audit (see `AUDIT-functional-light-service.md`).
1591
-
1592
- ### Breaking changes
1593
-
1594
- - **`Context#fetch` now honours the `Hash#fetch` contract**: `fetch(:missing)` without a
1595
- default raises `KeyError` (it used to return `nil`) and fetch never writes to the
1596
- context anymore.
1597
- - **Aliases are pure alternative names**: reads *and* writes on an alias resolve to the
1598
- original key. `assign_aliases` no longer copies values, so `to_h` contains only the
1599
- original keys.
1600
- - **Key collisions raise**: declaring `expects :size` (or any key that clashes with an
1601
- existing `Hash`/`Context` method) raises `ReservedKeysInContextError` instead of
1602
- silently returning the wrong value. Access such data via `ctx[:size]` instead.
1603
- - **`Some(nil)` raises `ArgumentError`**: absence is expressed with `None`.
1604
- - **`Context#outcome` is read-only**: use `succeed!`/`fail!` to change the outcome.
1605
- - The infrastructure keys `:_aliases`, `:_before_actions` and `:_after_actions` are
1606
- reserved and cannot be used in `expects`/`promises`.
1607
-
1608
- ### New guarantees and features
1609
-
1610
- - **Declarative hooks are stable**: `before_actions`/`after_actions` declared on an
1611
- organizer now apply to *every* call (they used to disappear after the first one).
1612
- - **Rollback is complete** even when the same action class appears more than once in
1613
- the pipeline.
1614
- - **Native pattern matching**: every enum variant supports `case/in`:
1615
-
1616
- ```ruby
1617
- case result
1618
- in FunctionalLightService::Result::Success[value] then value
1619
- in FunctionalLightService::Result::Failure[error] then handle(error)
1620
- end
1621
- ```
1622
-
1623
- For hot paths prefer `case/in` (or `success?`/`value`) over the `match` DSL: it is
1624
- roughly two orders of magnitude faster.
1625
- - **`skip_remaining!` is scoped**: inside `iterate`/`reduce_if`/`reduce_until` it skips
1626
- the remaining *steps of the current sub-pipeline* (for `iterate`: of the current item),
1627
- then the outer flow continues. The outcome message set by `skip_remaining!` is preserved.
1628
- - **Deprecations** (still working, warn once on stderr): `Maybe()`/`Null` (use
1629
- `Option`), `Result#>=` (use `try`), `Result#<<` (use `pipe`), `Result#+`/`Option#+`.
1630
- Silence them with `FunctionalLightService::Deprecations.silenced = true`.
1631
-
1632
- ### Threading contract
1633
-
1634
- A `Context` is a per-call object: create it inside each organizer call (which is what
1635
- `with` does) and do not share a live context between threads. Class-level state
1636
- (hooks, aliases, logger) is read-only at call time, so calling the same organizer from
1637
- multiple threads (Puma, Sidekiq) is safe.
1638
-
1639
- ## Contributing
1640
-
1641
- 1. Fork it
1642
- 2. Create your feature branch (`git checkout -b my-new-feature`)
1643
- 3. Commit your changes (`git commit -am 'Added some feature'`)
1644
- 4. Push to the branch (`git push origin my-new-feature`)
1645
- 5. Create new Pull Request
1646
-
1647
- Huge thanks to the [contributors](https://github.com/sphynx79/functional-light-service/graphs/contributors)!
1648
-
1649
- ## Changelog
1650
-
1651
- Follow the changelog in this [document](https://github.com/sphynx79/functional-light-service/blob/master/CHANGELOG.md).
1652
-
1653
- ## Thank You
1654
-
1655
- A very special thank you to [Attila Domokos](https://github.com/adomokos) for
1656
- his fantastic work on [LightService](https://github.com/adomokos/light-service).
1657
- A very special thank you to [Piotr Zolnierek](https://github.com/pzol) for
1658
- his fantastic work on [Deterministic](https://github.com/pzol/deterministic).
1659
- FunctionalLightService is inspired heavily by the concepts put to code by Attila and add some functionality taken from the excellent work of mario Piotr.
1660
-
1661
- ## License
1662
-
1663
- FunctionalLightService is released under the [MIT License](http://www.opensource.org/licenses/MIT).