flowy 0.1.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.
data/README.md ADDED
@@ -0,0 +1,873 @@
1
+ # Flowy
2
+
3
+ **Flowy** is a lightweight Ruby gem for building clean, composable service objects using the Result pattern (also known as Railway Oriented Programming).
4
+
5
+ Instead of raising exceptions or returning `true`/`false`, your service methods return typed `Success` or `Failure` objects that carry data or error information.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'flowy'
13
+ ```
14
+
15
+ ## Core objects
16
+
17
+ ### `Flowy::Success`
18
+
19
+ Represents a successful outcome. Carries a `data` hash (the result payload) and an optional `warnings` array.
20
+
21
+ ```ruby
22
+ result = Flowy::Success.new(data: { user_id: 1 }, warnings: ['email not verified'])
23
+
24
+ result.success? # => true
25
+ result.failure? # => false
26
+ result.data # => { user_id: 1 }
27
+ result.warnings # => ['email not verified']
28
+ result.to_hash
29
+ # => { success: true, data: { user_id: 1 }, warnings: ['email not verified'] }
30
+ ```
31
+
32
+ You can also build one via the factory:
33
+
34
+ ```ruby
35
+ Flowy::Result.success(data: { id: 1 })
36
+ ```
37
+
38
+ #### Merging two successes
39
+
40
+ `+` performs a deep merge of the `data` hashes:
41
+
42
+ ```ruby
43
+ a = Flowy::Success.new(data: { x: 1, meta: { a: 1 } })
44
+ b = Flowy::Success.new(data: { y: 2, meta: { b: 2 } })
45
+ (a + b).data # => { x: 1, y: 2, meta: { a: 1, b: 2 } }
46
+ ```
47
+
48
+ #### `merge_data`
49
+
50
+ Returns a **new** `Success` with data deep-merged. Accepts either a hash or a block receiving the current data:
51
+
52
+ ```ruby
53
+ result.merge_data(role: :admin)
54
+ result.merge_data { |d| { count: d[:items].size } }
55
+ ```
56
+
57
+ ---
58
+
59
+ ### `Flowy::Failure`
60
+
61
+ Represents a failed outcome. Carries a typed `error_code` symbol and optional contextual fields.
62
+
63
+ ```ruby
64
+ result = Flowy::Failure.new(
65
+ error_code: :payment_declined,
66
+ error_data: { gateway: 'stripe', amount: 99 },
67
+ error_title: 'Payment declined',
68
+ error_description: 'The card was declined by the issuer'
69
+ )
70
+
71
+ result.failure? # => true
72
+ result.success? # => false
73
+ result.error_code # => :payment_declined
74
+ result.error_data # => { gateway: 'stripe', amount: 99 }
75
+ result.error_title # => 'Payment declined'
76
+ result.error_description # => 'The card was declined by the issuer'
77
+ result.to_hash
78
+ # => { success: false, error_code: :payment_declined, error_data: {...},
79
+ # error_title: 'Payment declined', error_description: '...' }
80
+ ```
81
+
82
+ You can also build one via the factory:
83
+
84
+ ```ruby
85
+ Flowy::Result.failure(error_code: :not_found, error_title: 'Not Found')
86
+ ```
87
+
88
+ #### `merge_data`
89
+
90
+ Returns a **new** `Failure` with `error_data` deep-merged. All other attributes (including `parent_failure`) are preserved:
91
+
92
+ ```ruby
93
+ result.merge_data(context: 'CreateUser')
94
+ result.merge_data { |d| d.merge(retryable: false) }
95
+ ```
96
+
97
+ #### `is?` — error_code predicate
98
+
99
+ Convenience predicate for matching `error_code`. Equivalent to `failure.error_code == code` but reads as a sentence at the call site:
100
+
101
+ ```ruby
102
+ failure = Flowy::Failure.new(error_code: :not_found)
103
+
104
+ failure.is?(error_code: :not_found) # => true
105
+ failure.is?(error_code: :other) # => false
106
+ ```
107
+
108
+ #### Chaining nested failures with `parent_failure`
109
+
110
+ When a service wraps a failure from a downstream service, set `parent_failure:` to preserve the full error history:
111
+
112
+ ```ruby
113
+ inner = Flowy::Failure.new(error_code: :stripe_error)
114
+ outer = Flowy::Failure.new(error_code: :charge_failed, parent_failure: inner)
115
+
116
+ outer.failures_chain # => [inner, outer]
117
+ ```
118
+
119
+ `failures_chain` traverses the chain from root to leaf, giving you the complete error trail for logging or debugging.
120
+
121
+ #### Raising a failure as an exception with `raise!`
122
+
123
+ Convert a `Failure` into a `Flowy::Error` and raise it. Useful when a failure must propagate through code that does not handle results (e.g. a callback, a background job framework, or a boundary where exceptions are expected):
124
+
125
+ ```ruby
126
+ result = PaymentService.new.call(data)
127
+ result.raise!
128
+ # => raises Flowy::Error (code: :payment_declined, title: '...', detail: '...', meta: {...})
129
+ ```
130
+
131
+ The raised `Flowy::Error` is a `StandardError`, so it can be rescued normally:
132
+
133
+ ```ruby
134
+ begin
135
+ PaymentService.new.call(data).raise!
136
+ rescue Flowy::Error => e
137
+ e.code # => :payment_declined
138
+ e.to_failure # => back to a Flowy::Failure
139
+ end
140
+ ```
141
+
142
+ `raise!` is a **no-op on `Success`** — it returns `self` unchanged, making it safe to attach unconditionally:
143
+
144
+ ```ruby
145
+ ServiceB.new.call(data)
146
+ .raise! # raises only if Failure
147
+ .and_then { |r| do_more(r.data) } # continues only if Success
148
+ ```
149
+
150
+ #### Wrapping failures from nested services with `map_failure`
151
+
152
+ When service A calls service B, you can translate B's failure into A's own vocabulary while automatically preserving the original as `parent_failure`:
153
+
154
+ ```ruby
155
+ # Block form — full control
156
+ PaymentService.new.call(data)
157
+ .map_failure { |f|
158
+ Flowy::Failure.new(
159
+ error_code: :charge_failed,
160
+ error_data: { reason: f.error_code },
161
+ error_description: 'Payment could not be completed'
162
+ # parent_failure is set automatically when omitted
163
+ )
164
+ }
165
+
166
+ # Shorthand form — no block needed
167
+ PaymentService.new.call(data)
168
+ .map_failure(error_code: :charge_failed, error_data: { source: :payment_service })
169
+ ```
170
+
171
+ `map_failure` is a **no-op on `Success`** — it returns `self` unchanged, making it safe to attach unconditionally:
172
+
173
+ ```ruby
174
+ ServiceB.new.call(data)
175
+ .map_failure(error_code: :service_b_failed)
176
+ .and_then { |r| success(data: r.data.merge(done: true)) }
177
+ .on_failure { |r| puts r.failures_chain.map(&:error_code).inspect }
178
+ # => [:original_b_error, :service_b_failed]
179
+ ```
180
+
181
+ ---
182
+
183
+ ### `Flowy::Error`
184
+
185
+ A `StandardError` subclass that bridges Flowy's result objects with Ruby's exception system. Use it when you need to raise an exception carrying the same structured data as a `Failure`.
186
+
187
+ ```ruby
188
+ error = Flowy::Error.new(
189
+ code: :payment_declined,
190
+ title: 'Payment declined',
191
+ detail: 'The card was declined by the issuer',
192
+ meta: { gateway: 'stripe' }
193
+ )
194
+
195
+ raise error
196
+ ```
197
+
198
+ `Flowy::Error` is also rescuable as a standard `StandardError`.
199
+
200
+ #### Building from a `Failure`
201
+
202
+ ```ruby
203
+ failure = Flowy::Failure.new(
204
+ error_code: :not_found,
205
+ error_title: 'Not found',
206
+ error_description: 'Record does not exist',
207
+ error_data: { id: 42 }
208
+ )
209
+
210
+ error = Flowy::Error.initialize_from_failure(failure: failure)
211
+ error.code # => :not_found
212
+ error.title # => 'Not found'
213
+ error.detail # => 'Record does not exist'
214
+ error.meta # => { id: 42 }
215
+ ```
216
+
217
+ #### Converting back to a `Failure`
218
+
219
+ ```ruby
220
+ error.to_failure # => Flowy::Failure
221
+ error.to_hash # => same structure as Failure#to_hash
222
+ ```
223
+
224
+ ---
225
+
226
+ ### `Flowy::Result` — the union type
227
+
228
+ Both `Success` and `Failure` include `Flowy::Result`, enabling uniform type-checking:
229
+
230
+ ```ruby
231
+ result.is_a?(Flowy::Result) # => true for both Success and Failure
232
+ ```
233
+
234
+ Factory methods:
235
+
236
+ ```ruby
237
+ Flowy::Result.success(data: { id: 1 })
238
+ Flowy::Result.failure(error_code: :not_found, error_title: 'Not Found')
239
+ ```
240
+
241
+ #### `Result.wrap` — adapter for exception-raising code
242
+
243
+ Executes a block and automatically converts its outcome into a `Success` or `Failure`. Useful for integrating third-party libraries or any code that raises exceptions:
244
+
245
+ ```ruby
246
+ # Plain value → Success(data: { value: <User> })
247
+ result = Flowy::Result.wrap { User.find(id) }
248
+
249
+ # Existing Success/Failure → forwarded unchanged
250
+ result = Flowy::Result.wrap { some_service.call }
251
+
252
+ # Custom error_code and rescue classes
253
+ result = Flowy::Result.wrap(
254
+ rescue: [ActiveRecord::RecordNotFound],
255
+ error_code: :not_found,
256
+ error_title: 'Resource not found'
257
+ ) { User.find(id) }
258
+
259
+ result.on_success { |r| puts r.data[:value] }
260
+ .on_failure { |r| puts r.error_code } # => :not_found
261
+ ```
262
+
263
+ On failure the generated `Flowy::Failure` contains:
264
+ - `error_code` — `:wrapped_error` by default or the value passed via `error_code:`
265
+ - `error_data` — `{ error_class: '...', message: '...' }`
266
+ - `error_description` — the exception message
267
+
268
+ ---
269
+
270
+ ### Shared result methods
271
+
272
+ Both `Success` and `Failure` share the following chainable interface:
273
+
274
+ #### `on_success` / `on_failure`
275
+
276
+ Yields `self` only when the type matches; always returns `self`:
277
+
278
+ ```ruby
279
+ OrderService.new.call
280
+ .on_success { |r| render json: r.data }
281
+ .on_failure { |r| render json: r.to_hash, status: :unprocessable_entity }
282
+ ```
283
+
284
+ #### `and_then` / `or_else`
285
+
286
+ `and_then` pipes a `Success` into the next step; short-circuits on `Failure`:
287
+
288
+ ```ruby
289
+ validate(params)
290
+ .and_then { |r| persist(r.data) }
291
+ .and_then { |r| notify(r.data) }
292
+ ```
293
+
294
+ `or_else` is the symmetric counterpart — runs only on `Failure`, allowing recovery:
295
+
296
+ ```ruby
297
+ fetch_from_cache(id)
298
+ .or_else { fetch_from_db(id) }
299
+ ```
300
+
301
+ Both methods require the block to return a `Flowy::Success` or `Flowy::Failure`.
302
+
303
+ #### `tap`
304
+
305
+ Yields `self` for side-effects (logging, telemetry) without modifying it:
306
+
307
+ ```ruby
308
+ OrderService.new.call
309
+ .tap { |r| Rails.logger.info(r.to_hash) }
310
+ .on_success { |r| render json: r.data }
311
+ .on_failure { |r| render json: r.to_hash, status: :unprocessable_entity }
312
+ ```
313
+
314
+ ---
315
+
316
+ ## Service objects with `Flowy::Concern`
317
+
318
+ Include `Flowy::Concern` in any class to get `success`, `failure`, and `run_steps`:
319
+
320
+ ```ruby
321
+ class OrderService
322
+ include Flowy::Concern
323
+
324
+ def call
325
+ run_steps(
326
+ starting_data: { order_id: 42 },
327
+ steps: [:validate, :reserve_stock, :charge_payment]
328
+ )
329
+ end
330
+
331
+ private
332
+
333
+ def validate(previous_result:)
334
+ return failure(error_code: :invalid_order) unless valid?
335
+ success(data: previous_result.data)
336
+ end
337
+
338
+ def reserve_stock(previous_result:)
339
+ success(data: previous_result.data.merge(reserved: true))
340
+ end
341
+
342
+ def charge_payment(previous_result:)
343
+ success(data: previous_result.data.merge(charged: true))
344
+ end
345
+ end
346
+ ```
347
+
348
+ ### Step pipeline with `run_steps`
349
+
350
+ `run_steps` executes an ordered list of steps sequentially. Each step must return a `Success` or `Failure`. The pipeline short-circuits as soon as any step returns a `Failure`.
351
+
352
+ Steps can be:
353
+ - **Symbol** — name of an instance method on the service
354
+ - **Lambda / Proc** — any callable that accepts `previous_result:`
355
+
356
+ #### Step method signatures
357
+
358
+ Flowy inspects the keyword parameters declared by each step method and builds the call arguments automatically. You can choose the style that best communicates the method's contract:
359
+
360
+ ```ruby
361
+ # 1. Classic — receives the full result object
362
+ def persist(previous_result:)
363
+ user = User.create!(previous_result.data[:params])
364
+ success(data: { user: user })
365
+ end
366
+
367
+ # 2. Data-keys — declares exactly which data keys it needs (self-documenting)
368
+ def notify(user:)
369
+ UserMailer.welcome(user).deliver_later
370
+ success
371
+ end
372
+
373
+ # 3. Mixed — data keys + full result when both are needed
374
+ def charge(order_id:, amount:, previous_result:)
375
+ # use order_id and amount directly; inspect previous_result.warnings if needed
376
+ success(data: previous_result.data.merge(charged: true))
377
+ end
378
+
379
+ # 4. With ** rest — captures any remaining data keys not declared explicitly
380
+ def forward(required_key:, **rest)
381
+ success(data: rest.merge(required_key: required_key))
382
+ end
383
+ ```
384
+
385
+ **Required vs optional keyword parameters**
386
+
387
+ - `keyreq` (e.g. `def step(n:)`) — Flowy raises an `ArgumentError` with a descriptive message if the key is absent from `result.data`.
388
+ - `key` with a default (e.g. `def step(label: 'default')`) — Flowy passes the value only when the key exists in `result.data`; otherwise Ruby uses the declared default.
389
+
390
+ **Reserved keyword: `previous_result`**
391
+
392
+ `previous_result` is a reserved parameter name. When a step method declares `previous_result:`, Flowy always passes the full `Flowy::Result` object, regardless of whether `previous_result` exists as a key in `result.data`. Avoid using `:previous_result` as a data key — it will be shadowed by the Result object (or, if the step does not declare `previous_result:`, will leak into the `**rest` hash).
393
+
394
+ ```ruby
395
+ class CreateUser
396
+ include Flowy::Concern
397
+
398
+ def call(params)
399
+ run_steps(
400
+ starting_data: { params: params },
401
+ steps: [:validate, :persist, :notify],
402
+ rescue_errors: true # converts uncaught exceptions to Failure
403
+ )
404
+ end
405
+
406
+ private
407
+
408
+ def validate(params:) # receives params directly from data
409
+ return failure(error_code: :invalid_params) if params.empty?
410
+ success(data: { params: params })
411
+ end
412
+
413
+ def persist(params:) # only needs params
414
+ user = User.create!(params)
415
+ success(data: { user: user })
416
+ end
417
+
418
+ def notify(user:, previous_result:) # data key + full result
419
+ UserMailer.welcome(user).deliver_later
420
+ success(data: previous_result.data)
421
+ end
422
+ end
423
+ ```
424
+
425
+ ### Declarative step pipeline with `.step` / `.tap_step`
426
+
427
+ Steps can be declared at class level. `run_steps` uses them automatically when no explicit `steps:` array is passed:
428
+
429
+ ```ruby
430
+ class CreateUser
431
+ include Flowy::Concern
432
+
433
+ step :validate
434
+ tap_step :log_audit # side-effect only; return value is ignored
435
+ step :persist
436
+ step :notify
437
+
438
+ def call(params)
439
+ run_steps(starting_data: { params: params })
440
+ end
441
+
442
+ private
443
+
444
+ def log_audit(**data) # receives all data keys via **rest
445
+ Rails.logger.info("[CreateUser] #{data.keys}")
446
+ # no need to return a result
447
+ end
448
+
449
+ # ... other step methods
450
+ end
451
+ ```
452
+
453
+ ### Granular exception handling with `rescue:` / `on_error:`
454
+
455
+ Declare which exception classes a step can raise and how to handle them:
456
+
457
+ ```ruby
458
+ step :persist, rescue: [ActiveRecord::RecordInvalid], on_error: :handle_db_error
459
+
460
+ # Without on_error, the exception is converted to a generic Failure:
461
+ step :persist, rescue: [ActiveRecord::RecordInvalid]
462
+ # => error_code: :step_raised_error, error_data: { step: :persist, message: '...' }
463
+
464
+ def handle_db_error(error, previous_result:)
465
+ failure(
466
+ error_code: :persistence_failed,
467
+ error_data: { message: error.message, params: previous_result.data[:params] }
468
+ )
469
+ end
470
+ ```
471
+
472
+ ### Step hooks: `before_step`, `after_step`, `around_step`
473
+
474
+ Flowy provides three composable hook types that fire around every step execution without touching the step implementations themselves.
475
+
476
+ | Hook | Fires | Can modify result? | Block signature |
477
+ |---|---|---|---|
478
+ | `before_step` | just **before** the step | ✗ side-effect only | `\|step_name, previous_result\|` |
479
+ | `after_step` | just **after** the step | ✗ side-effect only | `\|step_name, result\|` |
480
+ | `around_step` | **wraps** the step | ✓ must return a `Flowy::Result` | `\|step_name, previous_result, &call\|` |
481
+
482
+ Hooks can be registered at three scopes, applied in this order per step:
483
+
484
+ ```
485
+ global before → class before → per-step before
486
+ global around [ class around [ per-step around [ step ] ] ]
487
+ per-step after → class after → global after
488
+ ```
489
+
490
+ #### 1. Global hooks — `Flowy::Concern.<hook>`
491
+
492
+ Run for **every** service class that includes `Flowy::Concern`. Ideal for cross-cutting concerns such as tracing, metrics, and audit logging.
493
+
494
+ ```ruby
495
+ # config/initializers/flowy.rb
496
+ Flowy::Concern.before_step do |step_name, previous_result|
497
+ Current.audit_log << { step: step_name, at: Time.now }
498
+ end
499
+
500
+ Flowy::Concern.after_step do |step_name, result|
501
+ StatsD.increment("flowy.#{step_name}.#{result.success? ? 'success' : 'failure'}")
502
+ end
503
+
504
+ Flowy::Concern.around_step do |step_name, previous_result, &call|
505
+ OpenTelemetry::Tracer.in_span("flowy.#{step_name}") { call.() }
506
+ end
507
+ ```
508
+
509
+ Remove all global hooks (e.g. in test teardowns):
510
+
511
+ ```ruby
512
+ Flowy::Concern.clear_global_hooks!
513
+ ```
514
+
515
+ #### 2. Class-level hooks — declared inside the service class
516
+
517
+ Run only for the service class they are declared on.
518
+
519
+ ```ruby
520
+ class CreateUser
521
+ include Flowy::Concern
522
+
523
+ before_step do |step_name, previous_result|
524
+ Rails.logger.debug "[CreateUser] starting #{step_name}"
525
+ end
526
+
527
+ after_step do |step_name, result|
528
+ Rails.logger.debug "[CreateUser] #{step_name} → #{result.success? ? '✓' : '✗'}"
529
+ end
530
+
531
+ around_step do |step_name, previous_result, &call|
532
+ t0 = Time.now
533
+ result = call.()
534
+ Rails.logger.info "[CreateUser] #{step_name} (#{((Time.now - t0) * 1000).round}ms)"
535
+ result
536
+ end
537
+
538
+ step :validate
539
+ step :persist
540
+ step :notify
541
+ end
542
+ ```
543
+
544
+ #### 3. Per-step hooks — inline on `step` / `tap_step`
545
+
546
+ Scoped to a **single step**. Accept either a **Symbol** (name of an instance method) or any **callable** (lambda / proc).
547
+
548
+ ```ruby
549
+ class CreateOrder
550
+ include Flowy::Concern
551
+
552
+ step :validate,
553
+ before_step: :log_start # Symbol → instance method
554
+
555
+ step :charge,
556
+ before_step: ->(name, prev) { Tracer.start(name) },
557
+ after_step: ->(name, res) { Tracer.finish(name, res.success?) },
558
+ around_step: :enforce_idempotency
559
+
560
+ step :persist,
561
+ rescue: [ActiveRecord::RecordInvalid],
562
+ on_error: :handle_db_error,
563
+ after_step: ->(name, res) { Rails.logger.info "persist: #{res.success?}" }
564
+
565
+ tap_step :audit_trail,
566
+ before_step: ->(name, _prev) { AuditLog.open(name) },
567
+ after_step: ->(name, _res) { AuditLog.close(name) }
568
+
569
+ private
570
+
571
+ def log_start(step_name, previous_result)
572
+ Rails.logger.info "Starting #{step_name}"
573
+ end
574
+
575
+ def enforce_idempotency(step_name, previous_result, &call)
576
+ IdempotencyGuard.wrap(step_name) { call.() }
577
+ end
578
+
579
+ # ...
580
+ end
581
+ ```
582
+
583
+ Per-step `around_step` can also short-circuit by returning a `Failure` without calling `call.()`:
584
+
585
+ ```ruby
586
+ step :charge, around_step: ->(name, prev, &_call) {
587
+ return Flowy::Result.failure(error_code: :dry_run) if DryRun.active?
588
+ _call.()
589
+ }
590
+ ```
591
+
592
+ #### Notes
593
+
594
+ - `after_step` receives `previous_result` (not the step's raw return) for `tap_step`s, because tap-steps always forward the previous result.
595
+ - Multiple hooks of the same scope and type run in **registration order**.
596
+ - `around_step` blocks **must** return a `Flowy::Result`; a `TypeError` is raised otherwise.
597
+
598
+ ---
599
+
600
+ ## `Flowy::Pipeline` — composable pipelines as first-class objects
601
+
602
+ `Flowy::Pipeline` is an **immutable, composable** pipeline that lives outside any service class. It can be built with a fluent DSL, stored as a constant, passed as a value, composed with `>>`, and embedded inside a `Flowy::Concern`.
603
+
604
+ ### Linear pipeline
605
+
606
+ ```ruby
607
+ PROCESS = Flowy::Pipeline.new
608
+ .step(:validate) { |prev| ValidateOrder.call(prev.data) }
609
+ .step(:persist) { |prev| PersistOrder.call(prev.data) }
610
+ .step(:notify) { |prev| NotifyUser.call(prev.data) }
611
+
612
+ result = PROCESS.call(starting_data: { order_id: 42 })
613
+ ```
614
+
615
+ ### Symbolic steps — resolved against a `context:`
616
+
617
+ A `step` can also be declared as a bare Symbol without a block. At execution time the method is resolved against the `context:` object passed to `#call`. The resolved method must accept `previous_result:` and return a `Flowy::Result`.
618
+
619
+ ```ruby
620
+ PROCESS = Flowy::Pipeline.new
621
+ .step(:validate)
622
+ .step(:persist)
623
+ .step(:notify)
624
+
625
+ class OrderService
626
+ def validate(previous_result:); ...; end
627
+ def persist(previous_result:); ...; end
628
+ def notify(previous_result:); ...; end
629
+ end
630
+
631
+ PROCESS.call(starting_data: { order_id: 42 }, context: OrderService.new)
632
+ ```
633
+
634
+ Calling a symbolic-step pipeline without a `context:` raises `ArgumentError`. Symbolic and block steps can be freely mixed in the same pipeline.
635
+
636
+ ### `tap_step` — side-effects without altering the flow
637
+
638
+ ```ruby
639
+ pipeline = Flowy::Pipeline.new
640
+ .step(:persist) { |prev| PersistOrder.call(prev.data) }
641
+ .tap_step(:audit) { |prev| AuditLog.record(prev.data) } # return value is ignored
642
+ .step(:notify) { |prev| NotifyUser.call(prev.data) }
643
+ ```
644
+
645
+ ### Conditional branching
646
+
647
+ Dispatches to a different sub-pipeline based on a key in `previous_result.data` (or the return value of a lambda).
648
+
649
+ #### Dispatch via Symbol key
650
+
651
+ ```ruby
652
+ PAYMENT = Flowy::Pipeline.new
653
+ .step(:reserve) { |prev| ReserveStock.call(prev.data) }
654
+ .branch(on: :payment_method) do |b|
655
+ b.when(:stripe) { Flowy::Pipeline.new.step(:charge) { |p| StripeCharge.call(p.data) } }
656
+ b.when(:paypal) { Flowy::Pipeline.new.step(:charge) { |p| PayPalCharge.call(p.data) } }
657
+ b.otherwise { Flowy::Pipeline.new.step(:charge) { |p| DefaultCharge.call(p.data) } }
658
+ end
659
+ .step(:notify) { |prev| NotifyUser.call(prev.data) }
660
+
661
+ result = PAYMENT.call(starting_data: { order_id: 1, payment_method: :stripe })
662
+ ```
663
+
664
+ `on: :payment_method` reads `previous_result.data[:payment_method]` and routes to the matching branch. If no branch matches and `otherwise` is not defined, a `Failure` with `error_code: :unmatched_branch` is returned.
665
+
666
+ #### Dispatch via Lambda (arbitrary logic)
667
+
668
+ ```ruby
669
+ .branch(on: ->(data) { data[:amount] > 1000 ? :high_value : :standard }) do |b|
670
+ b.when(:high_value) { Flowy::Pipeline.new.step(:premium_flow) { |p| ... } }
671
+ b.when(:standard) { Flowy::Pipeline.new.step(:normal_flow) { |p| ... } }
672
+ end
673
+ ```
674
+
675
+ ### Composition with `>>`
676
+
677
+ Concatenates two or more pipelines into a new immutable pipeline:
678
+
679
+ ```ruby
680
+ CHECKOUT = Flowy::Pipeline.new.step(:validate) { ... }.step(:reserve) { ... }
681
+ PAYMENT = Flowy::Pipeline.new.step(:charge) { ... }
682
+ FULFILLMENT = Flowy::Pipeline.new.step(:ship) { ... }.step(:notify) { ... }
683
+
684
+ FULL_ORDER = CHECKOUT >> PAYMENT >> FULFILLMENT
685
+
686
+ result = FULL_ORDER.call(starting_data: { order_id: 42 })
687
+ ```
688
+
689
+ ### Integration with `Flowy::Concern`
690
+
691
+ A `Flowy::Pipeline` can be used directly as a step, both inline in `run_steps` and in the `.step` DSL:
692
+
693
+ ```ruby
694
+ SUB_PIPELINE = Flowy::Pipeline.new
695
+ .step(:enrich) { |prev| EnrichData.call(prev.data) }
696
+
697
+ # Inline
698
+ class OrderService
699
+ include Flowy::Concern
700
+
701
+ def call
702
+ run_steps(
703
+ starting_data: { order_id: 1 },
704
+ steps: [SUB_PIPELINE, :notify]
705
+ )
706
+ end
707
+ end
708
+
709
+ # Via DSL
710
+ class OrderService
711
+ include Flowy::Concern
712
+
713
+ step SUB_PIPELINE
714
+ step :notify
715
+
716
+ def call = run_steps(starting_data: { order_id: 1 })
717
+ end
718
+ ```
719
+
720
+ ### Introspection
721
+
722
+ ```ruby
723
+ pipeline.steps
724
+ # => [
725
+ # { type: :step, name: :validate },
726
+ # { type: :branch, name: :"branch(payment_method)", on: :payment_method, branches: {...}, otherwise: [...] },
727
+ # { type: :step, name: :notify }
728
+ # ]
729
+
730
+ pipeline.size # => 3
731
+ pipeline.empty? # => false
732
+ ```
733
+
734
+ ### `#call` options
735
+
736
+ | Option | Type | Default | Description |
737
+ |---|---|---|---|
738
+ | `starting_data` | Hash | `{}` | Initial data wrapped in a `Success` |
739
+ | `rescue_errors` | Boolean | `false` | Converts uncaught `StandardError`s into a `Failure` with `error_code: :step_raised_error` |
740
+ | `context` | Object | `nil` | Optional object passed to the block as a second argument (useful when the pipeline is embedded inside a service instance) |
741
+
742
+ ---
743
+
744
+ ## API reference
745
+
746
+ ### `Flowy::Success`
747
+ | Method | Description |
748
+ |---|---|
749
+ | `data` | Hash with result payload |
750
+ | `warnings` | Array of warning messages |
751
+ | `success?` | Always `true` |
752
+ | `failure?` | Always `false` |
753
+ | `to_hash` | Serialized result |
754
+ | `+(other)` | Deep-merges two `Success` objects |
755
+ | `on_success` { \|result\| } | Yields `self` and returns `self`; no-op on `Failure` |
756
+ | `on_failure` { } | No-op on `Success`; yields on `Failure` |
757
+ | `and_then` { \|result\| } | Yields `self`, returns the block's result; no-op on `Failure` |
758
+ | `or_else` { } | No-op on `Success`; yields on `Failure` |
759
+ | `merge_data(hash)` / `merge_data { }` | Returns a new `Success` with data deep-merged |
760
+ | `map_failure` / `map_failure(error_code:, ...)` | No-op on `Success` |
761
+ | `raise!` | No-op on `Success`; raises `Flowy::Error` on `Failure` |
762
+ | `tap` { \|result\| } | Yields `self` for side-effects; always returns `self` unchanged |
763
+
764
+ ### `Flowy::Failure`
765
+ | Method | Description |
766
+ |---|---|
767
+ | `error_code` | Symbol identifying the error |
768
+ | `error_data` | Hash with contextual error data |
769
+ | `error_title` | Optional human-readable title |
770
+ | `error_description` | Optional human-readable description |
771
+ | `parent_failure` | Optional link to the originating failure |
772
+ | `success?` | Always `false` |
773
+ | `failure?` | Always `true` |
774
+ | `to_hash` | Serialized result |
775
+ | `failures_chain` | Array of chained failures from root to leaf |
776
+ | `is?(error_code:)` | `true` if `self.error_code == error_code` |
777
+ | `on_failure` { \|result\| } | Yields `self` and returns `self`; no-op on `Success` |
778
+ | `on_success` { } | No-op on `Failure`; yields on `Success` |
779
+ | `or_else` { \|result\| } | Yields `self`, returns the block's result; no-op on `Success` |
780
+ | `and_then` { } | No-op on `Failure`; yields on `Success` |
781
+ | `merge_data(hash)` / `merge_data { }` | Returns a new `Failure` with `error_data` deep-merged |
782
+ | `map_failure { \|f\| }` | Transforms `self` into a new `Failure`; sets `parent_failure: self` automatically |
783
+ | `map_failure(error_code:, error_data:, error_title:, error_description:)` | Shorthand — builds the wrapping `Failure` without a block |
784
+ | `raise!` | Raises a `Flowy::Error` built from `self`; no-op on `Success` |
785
+ | `tap` { \|result\| } | Yields `self` for side-effects; always returns `self` unchanged |
786
+
787
+ ### `Flowy::Error`
788
+ | Method / attribute | Description |
789
+ |---|---|
790
+ | `code` | Symbol identifying the error (maps to `error_code`) |
791
+ | `title` | Optional human-readable title |
792
+ | `detail` | Optional human-readable description |
793
+ | `meta` | Optional hash with contextual data |
794
+ | `.initialize_from_failure(failure:)` | Builds a `Flowy::Error` from a `Flowy::Failure` |
795
+ | `#to_failure` | Converts back to a `Flowy::Failure` |
796
+ | `#to_hash` | Same structure as `Failure#to_hash` |
797
+
798
+ ### `Flowy::Result`
799
+ | Method | Description |
800
+ |---|---|
801
+ | `Result.success(data:, warnings:)` | Factory — builds a `Flowy::Success` |
802
+ | `Result.failure(error_code:, error_data:, error_title:, error_description:, parent_failure:)` | Factory — builds a `Flowy::Failure` |
803
+ | `Result.wrap(rescue:, error_code:, error_title:) { }` | Wraps block outcome; forwards existing result objects unchanged |
804
+
805
+ ### `Flowy::Concern`
806
+
807
+ **Instance helpers:**
808
+
809
+ - `success(data:, warnings:)`
810
+ - `failure(error_code:, error_data:, error_title:, error_description:)`
811
+ - `run_steps(starting_data:, steps:, rescue_errors: false)`
812
+
813
+ **Class-level DSL:**
814
+
815
+ | Macro | Description |
816
+ |---|---|
817
+ | `step :name` | Registers a step in the class pipeline |
818
+ | `step :name, rescue: [ExcClass], on_error: :handler` | Step with granular exception handling |
819
+ | `step :name, before_step: :method_or_lambda` | Per-step before-hook (Symbol or callable) |
820
+ | `step :name, after_step: :method_or_lambda` | Per-step after-hook (Symbol or callable) |
821
+ | `step :name, around_step: :method_or_lambda` | Per-step around-hook (Symbol or callable) |
822
+ | `tap_step :name` | Side-effect step; also accepts `before_step:`, `after_step:`, `around_step:` |
823
+ | `before_step { \|step_name, previous_result\| }` | Class-level before-hook (all steps) |
824
+ | `after_step { \|step_name, result\| }` | Class-level after-hook (all steps) |
825
+ | `around_step { \|step_name, previous_result, &call\| }` | Class-level around-hook (all steps) |
826
+
827
+ **Module-level (global) DSL:**
828
+
829
+ | Method | Description |
830
+ |---|---|
831
+ | `Flowy::Concern.before_step { \|step_name, previous_result\| }` | Global before-hook for all service classes |
832
+ | `Flowy::Concern.after_step { \|step_name, result\| }` | Global after-hook for all service classes |
833
+ | `Flowy::Concern.around_step { \|step_name, previous_result, &call\| }` | Global around-hook for all service classes |
834
+ | `Flowy::Concern.clear_global_hooks!` | Removes all global hooks (before, after, around) |
835
+
836
+ **`run_steps` options:**
837
+
838
+ | Option | Type | Default | Description |
839
+ |---|---|---|---|
840
+ | `starting_data` | Hash | `{}` | Initial data wrapped in a `Success` |
841
+ | `steps` | Array\|nil | `nil` | Explicit step list (overrides class DSL when provided) |
842
+ | `rescue_errors` | Boolean | `false` | When `true`, converts uncaught `StandardError`s to a `Failure` with `error_code: :step_raised_error` |
843
+
844
+ **Step method keyword dispatch:**
845
+
846
+ Flowy inspects each Symbol step method's declared keyword parameters and builds the call accordingly:
847
+
848
+ | Parameter type | Behaviour |
849
+ |---|---|
850
+ | `previous_result:` | Receives the full `Flowy::Result` object |
851
+ | `key:` (required, no default) | Resolved from `result.data[key]`; raises `ArgumentError` if the key is absent |
852
+ | `key: default` (optional) | Resolved from `result.data[key]` when present; otherwise Ruby uses the declared default |
853
+ | `**rest` | Receives all remaining data keys not declared explicitly |
854
+
855
+ ### `Flowy::Pipeline`
856
+
857
+ | Method | Description |
858
+ |---|---|
859
+ | `#step(name) { \|prev\| }` | Appends a step; the block must return a `Flowy::Result` |
860
+ | `#step(:name)` (no block) | Appends a symbolic step resolved against `context:` at call time; the method must accept `previous_result:` |
861
+ | `#tap_step(name) { \|prev\| }` | Appends a side-effect step; the return value is ignored |
862
+ | `#branch(on:) { \|b\| }` | Appends a branch node; `on:` is a Symbol or Lambda |
863
+ | `#>>(other)` | Composes two pipelines sequentially; returns a new Pipeline |
864
+ | `#call(starting_data:, rescue_errors:, context:)` | Executes the pipeline |
865
+ | `#steps` | Returns the step list for introspection |
866
+ | `#size` | Number of top-level steps (including branch nodes) |
867
+ | `#empty?` | `true` when there are no steps |
868
+
869
+ ---
870
+
871
+ ## License
872
+
873
+ MIT