senro_usecaser 0.2.0 → 0.4.1
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +23 -0
- data/README.md +850 -0
- data/examples/namespace_demo.rb +50 -15
- data/examples/order_system.rb +222 -34
- data/examples/sig/namespace_demo.rbs +35 -10
- data/examples/sig/order_system.rbs +196 -20
- data/lib/senro_usecaser/base.rb +387 -86
- data/lib/senro_usecaser/depends_on.rb +257 -0
- data/lib/senro_usecaser/hook.rb +28 -82
- data/lib/senro_usecaser/provider.rb +1 -1
- data/lib/senro_usecaser/retry_configuration.rb +131 -0
- data/lib/senro_usecaser/retry_context.rb +133 -0
- data/lib/senro_usecaser/version.rb +1 -1
- data/lib/senro_usecaser.rb +3 -0
- data/sig/generated/senro_usecaser/base.rbs +179 -37
- data/sig/generated/senro_usecaser/depends_on.rbs +197 -0
- data/sig/generated/senro_usecaser/hook.rbs +23 -35
- data/sig/generated/senro_usecaser/provider.rbs +1 -1
- data/sig/generated/senro_usecaser/retry_configuration.rbs +90 -0
- data/sig/generated/senro_usecaser/retry_context.rbs +101 -0
- data/sig/overrides.rbs +1 -2
- metadata +7 -1
data/README.md
CHANGED
|
@@ -442,6 +442,179 @@ end
|
|
|
442
442
|
|
|
443
443
|
The `**_rest` parameter in Input's initialize allows extra fields to be passed through pipeline steps without errors.
|
|
444
444
|
|
|
445
|
+
### Runtime Type Validation
|
|
446
|
+
|
|
447
|
+
In addition to static type checking with RBS, SenroUsecaser provides runtime type validation for Input and Output. This ensures that the actual values passed at runtime match the expected types.
|
|
448
|
+
|
|
449
|
+
#### Input Type Validation
|
|
450
|
+
|
|
451
|
+
The `input` declaration supports three patterns:
|
|
452
|
+
|
|
453
|
+
##### 1. Class Validation (Traditional)
|
|
454
|
+
|
|
455
|
+
When a Class is specified, input must be an instance of that class:
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
459
|
+
input CreateUserInput # Class
|
|
460
|
+
|
|
461
|
+
def call(input)
|
|
462
|
+
# input must be a CreateUserInput instance
|
|
463
|
+
success(input.name)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# OK
|
|
468
|
+
CreateUserUseCase.call(CreateUserInput.new(name: "Taro"))
|
|
469
|
+
|
|
470
|
+
# ArgumentError: Input must be an instance of CreateUserInput, got String
|
|
471
|
+
CreateUserUseCase.call("invalid")
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
##### 2. Interface Validation (Single Module)
|
|
475
|
+
|
|
476
|
+
When a Module is specified, input's class must include that module. This enables duck-typing with explicit interface contracts:
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
# Define interface
|
|
480
|
+
module HasUserId
|
|
481
|
+
def user_id
|
|
482
|
+
raise NotImplementedError
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# UseCase expects input that includes HasUserId
|
|
487
|
+
class FindUserUseCase < SenroUsecaser::Base
|
|
488
|
+
input HasUserId
|
|
489
|
+
|
|
490
|
+
#: (HasUserId) -> SenroUsecaser::Result[User]
|
|
491
|
+
def call(input)
|
|
492
|
+
user = User.find(input.user_id)
|
|
493
|
+
success(user)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Input class that implements the interface
|
|
498
|
+
class UserQuery
|
|
499
|
+
include HasUserId
|
|
500
|
+
|
|
501
|
+
attr_reader :user_id
|
|
502
|
+
|
|
503
|
+
def initialize(user_id:)
|
|
504
|
+
@user_id = user_id
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# OK - UserQuery includes HasUserId
|
|
509
|
+
FindUserUseCase.call(UserQuery.new(user_id: 123))
|
|
510
|
+
|
|
511
|
+
# ArgumentError: Input UserQuery must include HasUserId
|
|
512
|
+
class InvalidInput
|
|
513
|
+
attr_reader :user_id
|
|
514
|
+
def initialize(user_id:) = @user_id = user_id
|
|
515
|
+
end
|
|
516
|
+
FindUserUseCase.call(InvalidInput.new(user_id: 123))
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
##### 3. Multiple Interfaces Validation
|
|
520
|
+
|
|
521
|
+
Multiple Modules can be specified. The input must include ALL of them:
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
module HasUserId
|
|
525
|
+
def user_id = raise NotImplementedError
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
module HasEmail
|
|
529
|
+
def email = raise NotImplementedError
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# UseCase requires both interfaces
|
|
533
|
+
class NotifyUserUseCase < SenroUsecaser::Base
|
|
534
|
+
input HasUserId, HasEmail
|
|
535
|
+
|
|
536
|
+
#: ((HasUserId & HasEmail)) -> SenroUsecaser::Result[bool]
|
|
537
|
+
def call(input)
|
|
538
|
+
notify(input.user_id, input.email)
|
|
539
|
+
success(true)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Input class must include both modules
|
|
544
|
+
class NotificationRequest
|
|
545
|
+
include HasUserId
|
|
546
|
+
include HasEmail
|
|
547
|
+
|
|
548
|
+
attr_reader :user_id, :email
|
|
549
|
+
|
|
550
|
+
def initialize(user_id:, email:)
|
|
551
|
+
@user_id = user_id
|
|
552
|
+
@email = email
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# OK
|
|
557
|
+
NotifyUserUseCase.call(NotificationRequest.new(user_id: 123, email: "test@example.com"))
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
##### Interface Pattern in Pipelines
|
|
561
|
+
|
|
562
|
+
Interface validation is especially useful for sub-UseCases in pipelines. A parent UseCase's Input can include multiple interfaces, and each step only requires the interfaces it needs:
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
# Parent UseCase - Input includes both interfaces
|
|
566
|
+
class ProcessOrderUseCase < SenroUsecaser::Base
|
|
567
|
+
class Input
|
|
568
|
+
include HasUserId
|
|
569
|
+
include HasEmail
|
|
570
|
+
|
|
571
|
+
attr_reader :user_id, :email, :order_items
|
|
572
|
+
|
|
573
|
+
def initialize(user_id:, email:, order_items:)
|
|
574
|
+
@user_id = user_id
|
|
575
|
+
@email = email
|
|
576
|
+
@order_items = order_items
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
input Input
|
|
581
|
+
|
|
582
|
+
organize do
|
|
583
|
+
step FindUserUseCase # Only needs HasUserId
|
|
584
|
+
step NotifyUserUseCase # Needs HasUserId and HasEmail
|
|
585
|
+
step CreateOrderUseCase
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
#### Output Type Validation
|
|
591
|
+
|
|
592
|
+
When `output` is declared with a Class, the success result's value is validated:
|
|
593
|
+
|
|
594
|
+
```ruby
|
|
595
|
+
class UserOutput
|
|
596
|
+
attr_reader :user
|
|
597
|
+
def initialize(user:) = @user = user
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
class FindUserUseCase < SenroUsecaser::Base
|
|
601
|
+
input FindUserInput
|
|
602
|
+
output UserOutput # Class declaration enables validation
|
|
603
|
+
|
|
604
|
+
def call(input)
|
|
605
|
+
user = User.find(input.user_id)
|
|
606
|
+
success(UserOutput.new(user: user)) # OK
|
|
607
|
+
|
|
608
|
+
# TypeError: Output must be an instance of UserOutput, got User
|
|
609
|
+
# success(user) # Wrong! Must wrap in UserOutput
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Note:** When `output` is a Hash schema (e.g., `output({ user: User })`), validation is skipped for backwards compatibility.
|
|
615
|
+
|
|
616
|
+
**Note:** Type validation errors raise exceptions (`ArgumentError` for input, `TypeError` for output). See [`.call` vs `.call!`](#call-vs-call-1) for how exceptions are handled.
|
|
617
|
+
|
|
445
618
|
### Simplicity
|
|
446
619
|
|
|
447
620
|
Define UseCases with minimal boilerplate. Avoids over-abstraction and provides an intuitive API.
|
|
@@ -762,6 +935,661 @@ class Admin::CreateUserUseCase < SenroUsecaser::Base
|
|
|
762
935
|
end
|
|
763
936
|
```
|
|
764
937
|
|
|
938
|
+
##### Using DependsOn in Custom Classes
|
|
939
|
+
|
|
940
|
+
The `SenroUsecaser::DependsOn` module can be used in any class to enable the same dependency injection features available in UseCase and Hook classes. This is useful for services, repositories, or other application components that need DI support.
|
|
941
|
+
|
|
942
|
+
**Basic Usage (No initialize needed)**
|
|
943
|
+
|
|
944
|
+
When you extend `DependsOn`, a default `initialize` is provided automatically. If no container is passed, it uses `SenroUsecaser.container`:
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
class OrderService
|
|
948
|
+
extend SenroUsecaser::DependsOn
|
|
949
|
+
|
|
950
|
+
depends_on :order_repository, OrderRepository
|
|
951
|
+
depends_on :payment_gateway, PaymentGateway
|
|
952
|
+
depends_on :logger, Logger
|
|
953
|
+
|
|
954
|
+
# No initialize needed! Default is provided automatically.
|
|
955
|
+
|
|
956
|
+
def process_order(order_id)
|
|
957
|
+
order = order_repository.find(order_id)
|
|
958
|
+
logger.info("Processing order #{order_id}")
|
|
959
|
+
payment_gateway.charge(order.total)
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Usage - uses SenroUsecaser.container by default
|
|
964
|
+
service = OrderService.new
|
|
965
|
+
service.process_order(123)
|
|
966
|
+
|
|
967
|
+
# Or with explicit container
|
|
968
|
+
service = OrderService.new(container: custom_container)
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
**Custom initialize with super**
|
|
972
|
+
|
|
973
|
+
If you need additional parameters, define your own `initialize` and call `super`:
|
|
974
|
+
|
|
975
|
+
```ruby
|
|
976
|
+
class OrderService
|
|
977
|
+
extend SenroUsecaser::DependsOn
|
|
978
|
+
|
|
979
|
+
depends_on :order_repository, OrderRepository
|
|
980
|
+
attr_reader :default_currency
|
|
981
|
+
|
|
982
|
+
def initialize(default_currency: "JPY", container: nil)
|
|
983
|
+
super(container: container) # Handles dependency resolution
|
|
984
|
+
@default_currency = default_currency
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def process_order(order_id)
|
|
988
|
+
order = order_repository.find(order_id)
|
|
989
|
+
order.charge(currency: default_currency)
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# Uses SenroUsecaser.container by default
|
|
994
|
+
service = OrderService.new(default_currency: "USD")
|
|
995
|
+
service.default_currency # => "USD"
|
|
996
|
+
service.order_repository # => OrderRepository instance
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
**With Namespace**
|
|
1000
|
+
|
|
1001
|
+
```ruby
|
|
1002
|
+
class Admin::ReportService
|
|
1003
|
+
extend SenroUsecaser::DependsOn
|
|
1004
|
+
|
|
1005
|
+
namespace :admin
|
|
1006
|
+
depends_on :report_generator, ReportGenerator
|
|
1007
|
+
depends_on :logger, Logger # Falls back to root namespace
|
|
1008
|
+
|
|
1009
|
+
# No initialize needed!
|
|
1010
|
+
|
|
1011
|
+
def generate_monthly_report
|
|
1012
|
+
logger.info("Generating monthly report")
|
|
1013
|
+
report_generator.generate(:monthly)
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
**With Automatic Namespace Inference**
|
|
1019
|
+
|
|
1020
|
+
When `infer_namespace_from_module` is enabled, the namespace is automatically derived from the module structure:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
SenroUsecaser.configure do |config|
|
|
1024
|
+
config.infer_namespace_from_module = true
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
module Admin
|
|
1028
|
+
module Reports
|
|
1029
|
+
class ExportService
|
|
1030
|
+
extend SenroUsecaser::DependsOn
|
|
1031
|
+
|
|
1032
|
+
# No explicit namespace needed!
|
|
1033
|
+
# Automatically uses "admin::reports" namespace
|
|
1034
|
+
depends_on :exporter, Exporter # from admin::reports
|
|
1035
|
+
depends_on :storage, Storage # from admin (fallback)
|
|
1036
|
+
depends_on :logger, Logger # from root (fallback)
|
|
1037
|
+
|
|
1038
|
+
# No initialize needed!
|
|
1039
|
+
|
|
1040
|
+
def export(data)
|
|
1041
|
+
result = exporter.export(data)
|
|
1042
|
+
storage.save(result)
|
|
1043
|
+
logger.info("Export completed")
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
**Features provided by DependsOn:**
|
|
1051
|
+
|
|
1052
|
+
- `depends_on :name, Type` - Declare dependencies with optional type hints
|
|
1053
|
+
- `namespace :name` - Set explicit namespace for dependency resolution
|
|
1054
|
+
- `declared_namespace` - Get the declared namespace
|
|
1055
|
+
- `dependencies` - List of declared dependency names
|
|
1056
|
+
- `dependency_types` - Hash of dependency name to type
|
|
1057
|
+
- `copy_depends_on_to(subclass)` - Copy configuration to subclasses (for inheritance)
|
|
1058
|
+
|
|
1059
|
+
**Instance methods (via InstanceMethods module):**
|
|
1060
|
+
|
|
1061
|
+
- `initialize(container: nil)` - Default initialize that sets up dependency injection. Uses `SenroUsecaser.container` if no container is provided.
|
|
1062
|
+
- `resolve_dependencies` - Resolve all declared dependencies from the container
|
|
1063
|
+
- `effective_namespace` - Get the namespace used for resolution (declared or inferred)
|
|
1064
|
+
|
|
1065
|
+
**Custom initialize (full override):**
|
|
1066
|
+
|
|
1067
|
+
If you need complete control, you can manually set up the required instance variables:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
def initialize(extra:, container: nil)
|
|
1071
|
+
@_container = container || SenroUsecaser.container
|
|
1072
|
+
@_dependencies = {}
|
|
1073
|
+
@extra = extra
|
|
1074
|
+
resolve_dependencies
|
|
1075
|
+
end
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
##### on_failure Hook
|
|
1079
|
+
|
|
1080
|
+
The `on_failure` hook is called only when the UseCase execution results in a failure. Unlike `after` which is always called, `on_failure` provides a dedicated hook for error handling, logging, or recovery logic.
|
|
1081
|
+
|
|
1082
|
+
**Block Syntax**
|
|
1083
|
+
|
|
1084
|
+
```ruby
|
|
1085
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1086
|
+
depends_on :logger
|
|
1087
|
+
depends_on :error_notifier
|
|
1088
|
+
input Input
|
|
1089
|
+
|
|
1090
|
+
# on_failure block is executed in UseCase instance context
|
|
1091
|
+
# allowing access to dependencies
|
|
1092
|
+
on_failure do |input, result|
|
|
1093
|
+
logger.error("Failed to create user: #{result.errors.map(&:message).join(', ')}")
|
|
1094
|
+
error_notifier.notify(
|
|
1095
|
+
action: "create_user",
|
|
1096
|
+
input: input,
|
|
1097
|
+
errors: result.errors
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
def call(input)
|
|
1102
|
+
user = User.create!(name: input.name, email: input.email)
|
|
1103
|
+
success(user)
|
|
1104
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1105
|
+
failure(Error.new(code: :validation_error, message: e.message))
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
**Module Syntax (with extend_with)**
|
|
1111
|
+
|
|
1112
|
+
```ruby
|
|
1113
|
+
module ErrorLogging
|
|
1114
|
+
def self.on_failure(input, result)
|
|
1115
|
+
Rails.logger.error("UseCase failed: #{result.errors.first&.message}")
|
|
1116
|
+
end
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1120
|
+
extend_with ErrorLogging
|
|
1121
|
+
|
|
1122
|
+
def call(input)
|
|
1123
|
+
# ...
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
**Hook Class Syntax**
|
|
1129
|
+
|
|
1130
|
+
```ruby
|
|
1131
|
+
class ErrorNotificationHook < SenroUsecaser::Hook
|
|
1132
|
+
depends_on :error_notifier
|
|
1133
|
+
depends_on :logger
|
|
1134
|
+
|
|
1135
|
+
def on_failure(input, result)
|
|
1136
|
+
logger.error("UseCase failed with #{result.errors.size} error(s)")
|
|
1137
|
+
error_notifier.notify(
|
|
1138
|
+
errors: result.errors,
|
|
1139
|
+
input_class: input.class.name,
|
|
1140
|
+
timestamp: Time.current
|
|
1141
|
+
)
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1146
|
+
extend_with ErrorNotificationHook
|
|
1147
|
+
|
|
1148
|
+
def call(input)
|
|
1149
|
+
# ...
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
**Execution Order**
|
|
1155
|
+
|
|
1156
|
+
When a UseCase fails, hooks are executed in the following order:
|
|
1157
|
+
|
|
1158
|
+
1. `around` hooks (unwinding)
|
|
1159
|
+
2. `after` hooks (always called, regardless of success/failure)
|
|
1160
|
+
3. `on_failure` hooks (only called when `result.failure?` is true)
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1164
|
+
after do |input, result|
|
|
1165
|
+
puts "after: #{result.success? ? 'success' : 'failure'}"
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
on_failure do |input, result|
|
|
1169
|
+
puts "on_failure: handling error..."
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def call(input)
|
|
1173
|
+
failure(Error.new(code: :error, message: "Something went wrong"))
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# Output:
|
|
1178
|
+
# after: failure
|
|
1179
|
+
# on_failure: handling error...
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
**Use Cases for on_failure**
|
|
1183
|
+
|
|
1184
|
+
- **Error logging**: Log detailed error information for debugging
|
|
1185
|
+
- **Error notification**: Send alerts to monitoring systems (Sentry, Bugsnag, etc.)
|
|
1186
|
+
- **Cleanup operations**: Rollback partial state changes on failure
|
|
1187
|
+
- **Retry preparation**: Queue failed operations for retry
|
|
1188
|
+
- **Metrics**: Increment failure counters for observability
|
|
1189
|
+
|
|
1190
|
+
##### on_failure in Pipelines (Rollback Behavior)
|
|
1191
|
+
|
|
1192
|
+
When using `organize` pipelines, the `on_failure` hook provides **rollback behavior**. If a step fails, the `on_failure` hooks of all previously successful steps are executed in **reverse order**.
|
|
1193
|
+
|
|
1194
|
+
This enables compensation logic (Saga pattern) where each step can define how to undo its changes when a later step fails.
|
|
1195
|
+
|
|
1196
|
+
```ruby
|
|
1197
|
+
class CreateOrderUseCase < SenroUsecaser::Base
|
|
1198
|
+
input Input
|
|
1199
|
+
|
|
1200
|
+
on_failure do |input, result|
|
|
1201
|
+
# Called if a later step fails
|
|
1202
|
+
Order.find_by(id: input.order_id)&.destroy
|
|
1203
|
+
puts "Rolled back: order creation"
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def call(input)
|
|
1207
|
+
order = Order.create!(user_id: input.user_id)
|
|
1208
|
+
success(Output.new(order_id: order.id, user_id: input.user_id))
|
|
1209
|
+
end
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
class ReserveInventoryUseCase < SenroUsecaser::Base
|
|
1213
|
+
input Input
|
|
1214
|
+
|
|
1215
|
+
on_failure do |input, result|
|
|
1216
|
+
# Called if a later step fails
|
|
1217
|
+
Inventory.release(order_id: input.order_id)
|
|
1218
|
+
puts "Rolled back: inventory reservation"
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
def call(input)
|
|
1222
|
+
Inventory.reserve(order_id: input.order_id)
|
|
1223
|
+
success(input)
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
class ChargePaymentUseCase < SenroUsecaser::Base
|
|
1228
|
+
input Input
|
|
1229
|
+
|
|
1230
|
+
on_failure do |input, result|
|
|
1231
|
+
# Called when this step itself fails (no rollback needed for self)
|
|
1232
|
+
puts "Payment failed: #{result.errors.first&.message}"
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def call(input)
|
|
1236
|
+
# Payment fails
|
|
1237
|
+
failure(Error.new(code: :payment_failed, message: "Insufficient funds"))
|
|
1238
|
+
end
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1242
|
+
input Input
|
|
1243
|
+
|
|
1244
|
+
organize do
|
|
1245
|
+
step CreateOrderUseCase # Step 1: Success
|
|
1246
|
+
step ReserveInventoryUseCase # Step 2: Success
|
|
1247
|
+
step ChargePaymentUseCase # Step 3: Failure!
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
result = PlaceOrderUseCase.call(input)
|
|
1252
|
+
# Output (in order):
|
|
1253
|
+
# Payment failed: Insufficient funds <- ChargePaymentUseCase.on_failure
|
|
1254
|
+
# Rolled back: inventory reservation <- ReserveInventoryUseCase.on_failure
|
|
1255
|
+
# Rolled back: order creation <- CreateOrderUseCase.on_failure
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
**Execution Flow on Pipeline Failure:**
|
|
1259
|
+
|
|
1260
|
+
```
|
|
1261
|
+
Step A (success) → Step B (success) → Step C (failure)
|
|
1262
|
+
↓
|
|
1263
|
+
C.on_failure (failed step)
|
|
1264
|
+
↓
|
|
1265
|
+
B.on_failure (rollback)
|
|
1266
|
+
↓
|
|
1267
|
+
A.on_failure (rollback)
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
**Important Notes:**
|
|
1271
|
+
|
|
1272
|
+
1. **Reverse order**: `on_failure` hooks are called in reverse order of successful execution, ensuring proper cleanup sequence.
|
|
1273
|
+
|
|
1274
|
+
2. **Input context**: Each step's `on_failure` receives the input that was passed to that specific step (the output of the previous step), not the original pipeline input.
|
|
1275
|
+
|
|
1276
|
+
3. **Failed step included**: The step that failed also has its `on_failure` called (first, before rollback of previous steps).
|
|
1277
|
+
|
|
1278
|
+
4. **on_failure: :continue steps**: Steps marked with `on_failure: :continue` that fail will have their `on_failure` hook called, but won't trigger rollback of previous steps since the pipeline continues.
|
|
1279
|
+
|
|
1280
|
+
5. **Independent of on_failure_strategy**: The rollback behavior works consistently with `:stop`, `:continue`, and `:collect` strategies. For `:collect`, rollback occurs after all steps have been attempted.
|
|
1281
|
+
|
|
1282
|
+
```ruby
|
|
1283
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1284
|
+
organize do
|
|
1285
|
+
step CreateOrderUseCase
|
|
1286
|
+
step SendNotificationUseCase, on_failure: :continue # Optional step
|
|
1287
|
+
step ChargePaymentUseCase
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# If SendNotificationUseCase fails:
|
|
1292
|
+
# - SendNotificationUseCase.on_failure is called
|
|
1293
|
+
# - Pipeline continues (no rollback of CreateOrderUseCase)
|
|
1294
|
+
# - ChargePaymentUseCase executes
|
|
1295
|
+
|
|
1296
|
+
# If ChargePaymentUseCase fails:
|
|
1297
|
+
# - ChargePaymentUseCase.on_failure is called
|
|
1298
|
+
# - CreateOrderUseCase.on_failure is called (rollback)
|
|
1299
|
+
# - SendNotificationUseCase.on_failure is NOT called (it was optional and didn't cause the failure)
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
##### Retry on Failure
|
|
1303
|
+
|
|
1304
|
+
SenroUsecaser provides retry functionality similar to ActiveJob, allowing automatic retry of failed UseCases with configurable strategies.
|
|
1305
|
+
|
|
1306
|
+
###### Basic Retry Configuration
|
|
1307
|
+
|
|
1308
|
+
Use `retry_on` to configure automatic retry for specific error codes or exception types:
|
|
1309
|
+
|
|
1310
|
+
```ruby
|
|
1311
|
+
class FetchExternalDataUseCase < SenroUsecaser::Base
|
|
1312
|
+
input Input
|
|
1313
|
+
|
|
1314
|
+
# Retry up to 3 times for specific error codes
|
|
1315
|
+
retry_on :network_error, :timeout_error, attempts: 3
|
|
1316
|
+
|
|
1317
|
+
# Retry on specific exception types
|
|
1318
|
+
retry_on NetworkError, Timeout::Error, attempts: 5, wait: 1.second
|
|
1319
|
+
|
|
1320
|
+
def call(input)
|
|
1321
|
+
response = ExternalAPI.fetch(input.url)
|
|
1322
|
+
success(response)
|
|
1323
|
+
rescue Timeout::Error => e
|
|
1324
|
+
failure(Error.new(code: :timeout_error, message: e.message, cause: e))
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
###### Retry Options
|
|
1330
|
+
|
|
1331
|
+
| Option | Description | Default |
|
|
1332
|
+
|--------|-------------|---------|
|
|
1333
|
+
| `attempts` | Maximum number of retry attempts | 3 |
|
|
1334
|
+
| `wait` | Time to wait between retries (seconds or Duration) | 0 |
|
|
1335
|
+
| `backoff` | Backoff strategy (`:fixed`, `:linear`, `:exponential`) | `:fixed` |
|
|
1336
|
+
| `max_wait` | Maximum wait time when using backoff | 1 hour |
|
|
1337
|
+
| `jitter` | Add randomness to wait time (0.0 to 1.0) | 0 |
|
|
1338
|
+
|
|
1339
|
+
```ruby
|
|
1340
|
+
class ProcessPaymentUseCase < SenroUsecaser::Base
|
|
1341
|
+
input Input
|
|
1342
|
+
|
|
1343
|
+
# Exponential backoff: 1s, 2s, 4s, 8s... (capped at 30s)
|
|
1344
|
+
retry_on :gateway_error,
|
|
1345
|
+
attempts: 5,
|
|
1346
|
+
wait: 1.second,
|
|
1347
|
+
backoff: :exponential,
|
|
1348
|
+
max_wait: 30.seconds
|
|
1349
|
+
|
|
1350
|
+
# Linear backoff with jitter: 2s, 4s, 6s... (±20% randomness)
|
|
1351
|
+
retry_on :rate_limited,
|
|
1352
|
+
attempts: 10,
|
|
1353
|
+
wait: 2.seconds,
|
|
1354
|
+
backoff: :linear,
|
|
1355
|
+
jitter: 0.2
|
|
1356
|
+
|
|
1357
|
+
def call(input)
|
|
1358
|
+
PaymentGateway.charge(input.amount)
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
###### Backoff Strategies Explained
|
|
1364
|
+
|
|
1365
|
+
The `backoff` option controls how wait time increases between retry attempts:
|
|
1366
|
+
|
|
1367
|
+
**`:fixed` (default)** - Same wait time for every retry.
|
|
1368
|
+
|
|
1369
|
+
```
|
|
1370
|
+
wait: 2s
|
|
1371
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1372
|
+
Attempt 2 fails → wait 2s → Attempt 3
|
|
1373
|
+
Attempt 3 fails → wait 2s → Attempt 4
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
Best for: Temporary errors expected to recover quickly.
|
|
1377
|
+
|
|
1378
|
+
**`:linear`** - Wait time increases by a fixed amount each retry.
|
|
1379
|
+
|
|
1380
|
+
```
|
|
1381
|
+
wait: 2s (formula: wait × attempt)
|
|
1382
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1383
|
+
Attempt 2 fails → wait 4s → Attempt 3
|
|
1384
|
+
Attempt 3 fails → wait 6s → Attempt 4
|
|
1385
|
+
Attempt 4 fails → wait 8s → Attempt 5
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
Best for: Load-related errors where gradual recovery is expected.
|
|
1389
|
+
|
|
1390
|
+
**`:exponential`** - Wait time doubles each retry (most aggressive backoff).
|
|
1391
|
+
|
|
1392
|
+
```
|
|
1393
|
+
wait: 2s (formula: wait × 2^(attempt-1))
|
|
1394
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1395
|
+
Attempt 2 fails → wait 4s → Attempt 3
|
|
1396
|
+
Attempt 3 fails → wait 8s → Attempt 4
|
|
1397
|
+
Attempt 4 fails → wait 16s → Attempt 5
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
Best for: Rate limiting, server overload, external API throttling.
|
|
1401
|
+
|
|
1402
|
+
**Summary:**
|
|
1403
|
+
|
|
1404
|
+
| Strategy | Formula | Use Case |
|
|
1405
|
+
|----------|---------|----------|
|
|
1406
|
+
| `:fixed` | `wait` | Quick recovery expected |
|
|
1407
|
+
| `:linear` | `wait × attempt` | Gradual recovery expected |
|
|
1408
|
+
| `:exponential` | `wait × 2^(attempt-1)` | Rate limits, heavy load |
|
|
1409
|
+
|
|
1410
|
+
**`max_wait`** caps the wait time to prevent excessive delays with `:exponential`:
|
|
1411
|
+
|
|
1412
|
+
```ruby
|
|
1413
|
+
retry_on :api_error,
|
|
1414
|
+
wait: 1.second,
|
|
1415
|
+
backoff: :exponential,
|
|
1416
|
+
max_wait: 30.seconds # Never wait more than 30s
|
|
1417
|
+
|
|
1418
|
+
# Without max_wait, attempt 10 would wait 512 seconds (8.5 minutes)!
|
|
1419
|
+
# With max_wait: 30s, it caps at 30 seconds
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
**`jitter`** adds randomness to prevent thundering herd problem when many processes retry simultaneously:
|
|
1423
|
+
|
|
1424
|
+
```ruby
|
|
1425
|
+
retry_on :api_error,
|
|
1426
|
+
wait: 2.seconds,
|
|
1427
|
+
backoff: :exponential,
|
|
1428
|
+
jitter: 0.25 # ±25% randomness
|
|
1429
|
+
|
|
1430
|
+
# Attempt 2 wait: 4s ± 1s (3s to 5s)
|
|
1431
|
+
# Attempt 3 wait: 8s ± 2s (6s to 10s)
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
###### Manual Retry in on_failure
|
|
1435
|
+
|
|
1436
|
+
For more control, use `retry!` within an `on_failure` block:
|
|
1437
|
+
|
|
1438
|
+
```ruby
|
|
1439
|
+
class SendEmailUseCase < SenroUsecaser::Base
|
|
1440
|
+
input Input
|
|
1441
|
+
|
|
1442
|
+
on_failure do |input, result, context|
|
|
1443
|
+
if result.errors.any? { |e| e.code == :temporary_failure }
|
|
1444
|
+
# Retry with same input
|
|
1445
|
+
retry! if context.attempt < 3
|
|
1446
|
+
|
|
1447
|
+
# Or retry with modified input
|
|
1448
|
+
retry!(input: ModifiedInput.new(input, fallback: true))
|
|
1449
|
+
|
|
1450
|
+
# Or retry after delay
|
|
1451
|
+
retry!(wait: 5.seconds) if context.attempt < 5
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
def call(input)
|
|
1456
|
+
Mailer.send(input.to, input.subject, input.body)
|
|
1457
|
+
end
|
|
1458
|
+
end
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
###### Retry Context
|
|
1462
|
+
|
|
1463
|
+
The `on_failure` block receives a `context` object with retry information:
|
|
1464
|
+
|
|
1465
|
+
```ruby
|
|
1466
|
+
on_failure do |input, result, context|
|
|
1467
|
+
context.attempt # Current attempt number (1, 2, 3...)
|
|
1468
|
+
context.max_attempts # Maximum attempts configured (nil if unlimited)
|
|
1469
|
+
context.retried? # Whether this is a retry (attempt > 1)
|
|
1470
|
+
context.elapsed_time # Total time elapsed since first attempt
|
|
1471
|
+
context.last_error # The error from the previous attempt (if retried)
|
|
1472
|
+
end
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
###### Discard (Don't Retry)
|
|
1476
|
+
|
|
1477
|
+
Use `discard_on` to skip retry for specific errors:
|
|
1478
|
+
|
|
1479
|
+
```ruby
|
|
1480
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1481
|
+
input Input
|
|
1482
|
+
|
|
1483
|
+
# Always retry on these
|
|
1484
|
+
retry_on :database_error, attempts: 3
|
|
1485
|
+
|
|
1486
|
+
# Never retry on these (fail immediately)
|
|
1487
|
+
discard_on :validation_error, :duplicate_record
|
|
1488
|
+
|
|
1489
|
+
def call(input)
|
|
1490
|
+
User.create!(input.to_h)
|
|
1491
|
+
end
|
|
1492
|
+
end
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
###### Callbacks for Retry Events
|
|
1496
|
+
|
|
1497
|
+
```ruby
|
|
1498
|
+
class ProcessOrderUseCase < SenroUsecaser::Base
|
|
1499
|
+
input Input
|
|
1500
|
+
|
|
1501
|
+
retry_on :payment_error, attempts: 3
|
|
1502
|
+
|
|
1503
|
+
# Called before each retry attempt
|
|
1504
|
+
before_retry do |input, result, context|
|
|
1505
|
+
logger.warn("Retrying attempt #{context.attempt + 1}...")
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# Called when all retry attempts are exhausted
|
|
1509
|
+
after_retries_exhausted do |input, result, context|
|
|
1510
|
+
logger.error("All #{context.max_attempts} attempts failed")
|
|
1511
|
+
ErrorNotifier.notify(result.errors, attempts: context.attempt)
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
def call(input)
|
|
1515
|
+
# ...
|
|
1516
|
+
end
|
|
1517
|
+
end
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
###### Retry in Pipelines
|
|
1521
|
+
|
|
1522
|
+
When a step in a pipeline fails and retries, the behavior depends on the retry outcome:
|
|
1523
|
+
|
|
1524
|
+
```ruby
|
|
1525
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1526
|
+
organize do
|
|
1527
|
+
step CreateOrderUseCase
|
|
1528
|
+
step ChargePaymentUseCase # Has retry_on :gateway_error, attempts: 3
|
|
1529
|
+
step SendConfirmationUseCase
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
**Retry succeeds**: Pipeline continues to the next step normally.
|
|
1535
|
+
|
|
1536
|
+
```
|
|
1537
|
+
CreateOrder ✓ → ChargePayment ✗ → (retry) → ChargePayment ✓ → SendConfirmation ✓
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
**Retry exhausted**: Pipeline fails and rollback is triggered.
|
|
1541
|
+
|
|
1542
|
+
```
|
|
1543
|
+
CreateOrder ✓ → ChargePayment ✗ → (retry x3) → ChargePayment ✗ (exhausted)
|
|
1544
|
+
↓
|
|
1545
|
+
ChargePayment.on_failure
|
|
1546
|
+
↓
|
|
1547
|
+
CreateOrder.on_failure (rollback)
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
###### Combining retry_on with on_failure
|
|
1551
|
+
|
|
1552
|
+
`retry_on` is evaluated first. If retries are exhausted or the error is discarded, `on_failure` is called:
|
|
1553
|
+
|
|
1554
|
+
```ruby
|
|
1555
|
+
class ProcessPaymentUseCase < SenroUsecaser::Base
|
|
1556
|
+
input Input
|
|
1557
|
+
|
|
1558
|
+
retry_on :gateway_error, attempts: 3
|
|
1559
|
+
discard_on :invalid_card
|
|
1560
|
+
|
|
1561
|
+
on_failure do |input, result, context|
|
|
1562
|
+
if context.retried?
|
|
1563
|
+
# All retries exhausted
|
|
1564
|
+
logger.error("Payment failed after #{context.attempt} attempts")
|
|
1565
|
+
else
|
|
1566
|
+
# First failure (discarded or non-retryable error)
|
|
1567
|
+
logger.error("Payment failed immediately: #{result.errors.first&.code}")
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
def call(input)
|
|
1572
|
+
# ...
|
|
1573
|
+
end
|
|
1574
|
+
end
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
###### Execution Flow with Retry
|
|
1578
|
+
|
|
1579
|
+
```
|
|
1580
|
+
call(input)
|
|
1581
|
+
↓
|
|
1582
|
+
failure ←──────────────────────────────┐
|
|
1583
|
+
↓ │
|
|
1584
|
+
retry_on matches? ──yes──→ attempt < max?
|
|
1585
|
+
↓ no ↓ yes │ no
|
|
1586
|
+
↓ wait → retry ───┘
|
|
1587
|
+
↓ ↓
|
|
1588
|
+
└────────────────────────────┴──→ on_failure
|
|
1589
|
+
↓
|
|
1590
|
+
(rollback if pipeline)
|
|
1591
|
+
```
|
|
1592
|
+
|
|
765
1593
|
##### Input/Output Validation
|
|
766
1594
|
|
|
767
1595
|
Use `extend_with` to integrate validation libraries like ActiveModel::Validations:
|
|
@@ -1007,6 +1835,28 @@ end
|
|
|
1007
1835
|
|
|
1008
1836
|
Use `.call!` when you want to ensure all exceptions are captured as `Result.failure` without explicit rescue blocks in your UseCase.
|
|
1009
1837
|
|
|
1838
|
+
**Type validation errors** (from `input` and `output` declarations) also follow this pattern:
|
|
1839
|
+
|
|
1840
|
+
```ruby
|
|
1841
|
+
# With .call - type validation errors raise exceptions
|
|
1842
|
+
begin
|
|
1843
|
+
UseCase.call(invalid_input)
|
|
1844
|
+
rescue ArgumentError => e
|
|
1845
|
+
puts e.message # "Input SomeClass must include HasUserId"
|
|
1846
|
+
end
|
|
1847
|
+
|
|
1848
|
+
# With .call! - type validation errors become Result.failure
|
|
1849
|
+
result = UseCase.call!(invalid_input)
|
|
1850
|
+
result.failure? # => true
|
|
1851
|
+
result.errors.first.code # => :exception
|
|
1852
|
+
result.errors.first.message # => "Input SomeClass must include HasUserId"
|
|
1853
|
+
```
|
|
1854
|
+
|
|
1855
|
+
| Validation | Exception type | With `.call` | With `.call!` |
|
|
1856
|
+
|------------|---------------|--------------|---------------|
|
|
1857
|
+
| Input type | `ArgumentError` | Raises | `Result.failure` |
|
|
1858
|
+
| Output type | `TypeError` | Raises | `Result.failure` |
|
|
1859
|
+
|
|
1010
1860
|
#### Exception Handling in Pipelines
|
|
1011
1861
|
|
|
1012
1862
|
When using `.call!` with `organize` pipelines, the exception capture behavior is **chained** to all steps. This is especially useful with `on_failure: :collect`:
|