senro_usecaser 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbfc07ede41cc295ceb0a019774a480744b226e88edc46f72a6447d59375f4e4
4
- data.tar.gz: b86aaade8ccc9f2479f18d0f3bd5204203718ce26740eb3a51b5cccf1ed06798
3
+ metadata.gz: 3a40e2440b8f5f624de415bc556c1fb3aed16cf6f5afdef5a6c1b36d8b71242f
4
+ data.tar.gz: ffd39400b4f7ebb5e89cd6e27aff1f41e5cfc4410584170498dd3f0a821b7821
5
5
  SHA512:
6
- metadata.gz: 2ce95671ffefc951140c3625db940f1b077fe8bf40138d790b25067ab010a087d9f96b6c2e31bab2819ea89f0b2e6e905c70288f9409e24782cd73a0bca832cd
7
- data.tar.gz: aa3b27b6eff694929f8cfc33ca47e5d7fb450eeb4ac5da9fecba2023cc70393a2edfa1b033e89d0fe0f0e1abe5cda3817841e8b92e04294062c0cd4a02ef8879
6
+ metadata.gz: 26c5ba7d471feb89eb69353f16ae5a86ba6da67bf0fc057982a8ad48f8022f5a88ccc55ee566fa243e74a13957e7a3e824525985acd3975a82ae935e60853ef8
7
+ data.tar.gz: 4e27567ba9ef646b358f781c136e9840fcc57d644ea93d844925cde8d975f62665ec91694cdc6f447d2c020995373b3951e667ced56345ee3c6820cfb08e8f54
data/.rubocop.yml CHANGED
@@ -58,6 +58,10 @@ Metrics/PerceivedComplexity:
58
58
  Exclude:
59
59
  - "lib/senro_usecaser/base.rb"
60
60
 
61
+ Metrics/AbcSize:
62
+ Exclude:
63
+ - "lib/senro_usecaser/base.rb"
64
+
61
65
  # AroundBlock is a false positive for our DSL
62
66
  RSpec/AroundBlock:
63
67
  Enabled: false
data/README.md CHANGED
@@ -935,6 +935,661 @@ class Admin::CreateUserUseCase < SenroUsecaser::Base
935
935
  end
936
936
  ```
937
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
+
938
1593
  ##### Input/Output Validation
939
1594
 
940
1595
  Use `extend_with` to integrate validation libraries like ActiveModel::Validations: