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