functional-light-service 6.1.0 → 6.3.0

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