hanikamu-operation 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,1028 @@
1
+ # Hanikamu::Operation
2
+
3
+ [![ci](https://github.com/Hanikamu/hanikamu-operation/actions/workflows/ci.yml/badge.svg)](https://github.com/Hanikamu/hanikamu-operation/actions/workflows/ci.yml)
4
+
5
+ A Ruby gem that extends [hanikamu-service](https://github.com/Hanikamu/hanikamu-service) with advanced operation patterns including distributed locking, database transactions, form validations, and guard conditions. Perfect for building robust, concurrent-safe business operations in Rails applications.
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Why Hanikamu::Operation?](#why-hanikamuoperation)
10
+ 2. [Quick Start](#quick-start)
11
+ 3. [Installation](#installation)
12
+ 4. [Setup](#setup)
13
+ 5. [Usage](#usage)
14
+ - [Basic Operation](#basic-operation)
15
+ - [Distributed Locking](#distributed-locking-with-within_mutex)
16
+ - [Database Transactions](#database-transactions-with-within_transaction)
17
+ - [Form Validations](#form-validations)
18
+ - [Guard Conditions](#guard-conditions)
19
+ - [Block Requirements](#block-requirements)
20
+ - [Complete Example](#complete-example-combining-all-features)
21
+ 6. [Error Handling](#error-handling)
22
+ 7. [Best Practices](#best-practices)
23
+ 8. [Configuration Reference](#configuration-reference)
24
+ 9. [Testing](#testing)
25
+ 10. [Development](#development)
26
+ 11. [Contributing](#contributing)
27
+ 12. [License](#license)
28
+ 13. [Credits](#credits)
29
+
30
+ ## Why Hanikamu::Operation?
31
+
32
+ `hanikamu-operation` builds upon the service object pattern established by `hanikamu-service`, adding critical infrastructure concerns that complex business operations require:
33
+
34
+ ### Core Principles from hanikamu-service
35
+
36
+ - **Single Responsibility**: Each operation encapsulates one business transaction
37
+ - **Type Safety**: Input validation via dry-struct type checking
38
+ - **Monadic Error Handling**: `.call` returns `Success` or `Failure` monads; `.call!` raises exceptions
39
+ - **Clean Architecture**: Business logic isolated from models and controllers
40
+ - **Predictable Interface**: All operations follow the same `.call` / `.call!` pattern
41
+
42
+ ### Extended Operation Capabilities
43
+
44
+ Building on this foundation, `hanikamu-operation` adds:
45
+
46
+ - **Distributed Locking**: Prevent race conditions across multiple processes/servers using Redis locks (Redlock algorithm)
47
+ - **Database Transactions**: Wrap operations in ActiveRecord transactions with automatic rollback
48
+ - **Form Validations**: ActiveModel validations on the operation itself
49
+ - **Guard Conditions**: Pre-execution business rule validation (e.g., permissions, state checks)
50
+ - **Block Requirements**: Enforce callback patterns for operations that need them
51
+
52
+ ### When to Use
53
+
54
+ Use `Hanikamu::Operation` (instead of plain `Hanikamu::Service`) when your business logic requires:
55
+
56
+ - **Concurrency Control**: Multiple users/processes might execute the same operation simultaneously
57
+ - **Transactional Integrity**: Multiple database changes must succeed/fail atomically
58
+ - **Complex Validation**: Both input validation AND business rule validation
59
+ - **State Guards**: Pre-conditions that determine if the operation can proceed
60
+ - **Critical Sections**: Code that must not be interrupted or run concurrently
61
+
62
+ ## Quick Start
63
+
64
+ **1. Install the gem**
65
+
66
+ ```ruby
67
+ # Gemfile
68
+ gem 'hanikamu-operation', '~> 0.1.0'
69
+ ```
70
+
71
+ ```bash
72
+ bundle install
73
+ ```
74
+
75
+ **2. Configure Redis (required for distributed locking)**
76
+
77
+ ```ruby
78
+ # config/initializers/hanikamu_operation.rb
79
+ require 'redis-client'
80
+
81
+ Hanikamu::Operation.configure do |config|
82
+ config.redis_client = RedisClient.new(url: ENV.fetch('REDIS_URL'))
83
+ end
84
+ ```
85
+
86
+ **3. Create an operation**
87
+
88
+ ```ruby
89
+ class Payments::ChargeOperation < Hanikamu::Operation
90
+ attribute :user_id, Types::Integer
91
+ validates :user_id, presence: true
92
+
93
+ within_mutex(:mutex_lock)
94
+ within_transaction(:base)
95
+
96
+ def execute
97
+ user = User.find(user_id)
98
+ user.charge!
99
+ response user: user
100
+ end
101
+
102
+ private
103
+
104
+ def mutex_lock
105
+ "user:#{user_id}:charge"
106
+ end
107
+ end
108
+ ```
109
+
110
+ **4. Call the operation**
111
+
112
+ ```ruby
113
+ # Raises exceptions on failure
114
+ Payments::ChargeOperation.call!(user_id: current_user.id)
115
+
116
+ # Returns Success/Failure monad
117
+ result = Payments::ChargeOperation.call(user_id: current_user.id)
118
+ if result.success?
119
+ user = result.success.user
120
+ else
121
+ errors = result.failure
122
+ end
123
+ ```
124
+
125
+ ## Installation
126
+
127
+ Add to your application's Gemfile:
128
+
129
+ ```ruby
130
+ gem 'hanikamu-operation', '~> 0.1.0'
131
+ ```
132
+
133
+ Then execute:
134
+
135
+ ```bash
136
+ bundle install
137
+ ```
138
+
139
+ ## Setup
140
+
141
+ ### Rails Application Setup Guide
142
+
143
+ Follow these steps to integrate Hanikamu::Operation into a Rails application:
144
+
145
+ **Step 1: Add the gem to your Gemfile**
146
+
147
+ ```ruby
148
+ # Gemfile
149
+ gem 'hanikamu-operation', '~> 0.1.0'
150
+ gem 'redis-client', '~> 0.22' # Required for distributed locking
151
+ ```
152
+
153
+ ```bash
154
+ bundle install
155
+ ```
156
+
157
+ **Step 2: Define your Types module**
158
+
159
+ ```ruby
160
+ # app/types.rb
161
+ module Types
162
+ include Dry.Types()
163
+ end
164
+ ```
165
+
166
+ **Step 3: Create the initializer**
167
+
168
+ ```ruby
169
+ # config/initializers/hanikamu_operation.rb
170
+ require 'redis-client'
171
+
172
+ Hanikamu::Operation.configure do |config|
173
+ config.redis_client = RedisClient.new(
174
+ url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
175
+ reconnect_attempts: 3,
176
+ timeout: 1.0
177
+ )
178
+ end
179
+ ```
180
+
181
+ **Step 4: Add Redis to your development environment**
182
+
183
+ For Docker Compose:
184
+
185
+ ```yaml
186
+ # docker-compose.yml
187
+ services:
188
+ redis:
189
+ image: redis:7-alpine
190
+ ports:
191
+ - "6379:6379"
192
+ volumes:
193
+ - redis_data:/data
194
+
195
+ web:
196
+ # ... your Rails app config
197
+ environment:
198
+ REDIS_URL: redis://redis:6379/0
199
+ depends_on:
200
+ - redis
201
+
202
+ volumes:
203
+ redis_data:
204
+ ```
205
+
206
+ For local development without Docker:
207
+
208
+ ```bash
209
+ # macOS with Homebrew
210
+ brew install redis
211
+ brew services start redis
212
+
213
+ # Your .env file
214
+ REDIS_URL=redis://localhost:6379/0
215
+ ```
216
+
217
+ **Step 5: Create your first operation**
218
+
219
+ ```bash
220
+ # Create operations directory
221
+ mkdir -p app/operations/users
222
+ ```
223
+
224
+ ```ruby
225
+ # app/operations/users/create_user_operation.rb
226
+ module Users
227
+ class CreateUserOperation < Hanikamu::Operation
228
+ attribute :email, Types::String
229
+ attribute :password, Types::String
230
+
231
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
232
+ validates :password, length: { minimum: 8 }
233
+
234
+ within_transaction(:base)
235
+
236
+ def execute
237
+ user = User.create!(
238
+ email: email,
239
+ password: password
240
+ )
241
+
242
+ response user: user
243
+ end
244
+ end
245
+ end
246
+ ```
247
+
248
+ **Step 6: Use in your controller**
249
+
250
+ ```ruby
251
+ # app/controllers/users_controller.rb
252
+ class UsersController < ApplicationController
253
+ def create
254
+ result = Users::CreateUserOperation.call(user_params)
255
+
256
+ if result.success?
257
+ user = result.success.user
258
+ render json: { user: user }, status: :created
259
+ else
260
+ error = result.failure
261
+ case error
262
+ when Hanikamu::Operation::FormError
263
+ render json: { errors: error.errors.full_messages }, status: :unprocessable_entity
264
+ else
265
+ render json: { error: error.message }, status: :internal_server_error
266
+ end
267
+ end
268
+ end
269
+
270
+ private
271
+
272
+ def user_params
273
+ params.require(:user).permit(:email, :password)
274
+ end
275
+ end
276
+ ```
277
+
278
+ **Step 7: Configure for production**
279
+
280
+ Set your Redis URL in production (Heroku, AWS, etc.):
281
+
282
+ ```bash
283
+ # Heroku
284
+ heroku addons:create heroku-redis:mini
285
+ # REDIS_URL is automatically set
286
+
287
+ # Or set manually
288
+ heroku config:set REDIS_URL=redis://your-redis-host:6379/0
289
+ ```
290
+
291
+ ### Detailed Configuration Options
292
+
293
+ If you need more control, create a detailed initializer:
294
+
295
+ ```ruby
296
+ # config/initializers/hanikamu_operation.rb
297
+ require 'redis-client'
298
+
299
+ Hanikamu::Operation.configure do |config|
300
+ # Required: Redis client for distributed locking
301
+ config.redis_client = RedisClient.new(
302
+ url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
303
+ reconnect_attempts: 3,
304
+ timeout: 1.0
305
+ )
306
+
307
+ # Optional: Customize Redlock settings (these are the defaults)
308
+ config.mutex_expire_milliseconds = 1500 # Lock TTL
309
+ config.redlock_retry_count = 6 # Number of retry attempts
310
+ config.redlock_retry_delay = 500 # Milliseconds between retries
311
+ config.redlock_retry_jitter = 50 # Random jitter to prevent thundering herd
312
+ config.redlock_timeout = 0.1 # Redis command timeout
313
+
314
+ # Optional: Add errors to whitelist (Redlock::LockError is always included by default)
315
+ config.whitelisted_errors = [CustomBusinessError]
316
+ end
317
+ ```
318
+
319
+ ### Redis Setup by Environment
320
+
321
+ **For Development (Docker Compose)**:
322
+
323
+ ```yaml
324
+ # docker-compose.yml
325
+ services:
326
+ redis:
327
+ image: redis:7-alpine
328
+ volumes:
329
+ - redis_data:/data
330
+ networks:
331
+ - app_network
332
+
333
+ app:
334
+ # ... your app config
335
+ environment:
336
+ REDIS_URL: redis://redis:6379/0
337
+ depends_on:
338
+ - redis
339
+ networks:
340
+ - app_network
341
+
342
+ volumes:
343
+ redis_data:
344
+
345
+ networks:
346
+ app_network:
347
+ ```
348
+
349
+ **For Production**:
350
+
351
+ Use a managed Redis service (AWS ElastiCache, Heroku Redis, Redis Labs, etc.) and set the `REDIS_URL` environment variable.
352
+
353
+ ## Usage
354
+
355
+ Hanikamu::Operation provides five key features that you can combine as needed:
356
+
357
+ | Feature | Declaration | Purpose |
358
+ |---------|-------------|----------|
359
+ | **Input Attributes** | `attribute :name, Type` | Define typed input parameters |
360
+ | **Form Validations** | `validates :field, ...` | Validate input values (format, presence, etc.) |
361
+ | **Guard Conditions** | `guard do ... end` | Validate business rules and state before execution |
362
+ | **Distributed Locking** | `within_mutex(:method)` | Prevent concurrent execution of the same resource |
363
+ | **Database Transactions** | `within_transaction(:base)` | Wrap execution in an atomic database transaction |
364
+ | **Block Requirement** | `block true` | Require a block to be passed to the operation |
365
+
366
+ ### Basic Operation
367
+
368
+ ```ruby
369
+ module Types
370
+ include Dry.Types()
371
+ end
372
+
373
+ class CreatePayment < Hanikamu::Operation
374
+ attribute :user_id, Types::Integer
375
+ attribute :amount_cents, Types::Integer
376
+ attribute :payment_method_id, Types::String
377
+
378
+ validates :amount_cents, numericality: { greater_than: 0 }
379
+
380
+ def execute
381
+ payment = Payment.create!(
382
+ user_id: user_id,
383
+ amount_cents: amount_cents,
384
+ payment_method_id: payment_method_id,
385
+ status: 'completed'
386
+ )
387
+
388
+ response payment: payment
389
+ end
390
+ end
391
+
392
+ # Usage
393
+ result = CreatePayment.call!(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
394
+ # => #<struct payment=#<Payment...>>
395
+
396
+ # Or with monadic interface
397
+ result = CreatePayment.call(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
398
+ if result.success?
399
+ payment = result.success.payment
400
+ else
401
+ error = result.failure
402
+ end
403
+ ```
404
+
405
+ ### Distributed Locking with `within_mutex`
406
+
407
+ Prevent race conditions by ensuring only one process can execute the operation for a specific resource at a time. Uses the Redlock algorithm for distributed systems safety.
408
+
409
+ ```ruby
410
+ class ProcessSubscriptionRenewal < Hanikamu::Operation
411
+ attribute :subscription_id, Types::Integer
412
+
413
+ # The :mutex_lock method will be called to generate the lock identifier
414
+ within_mutex(:mutex_lock, expire_milliseconds: 3000)
415
+
416
+ def execute
417
+ subscription = Subscription.find(subscription_id)
418
+ subscription.renew!
419
+ subscription.charge_payment!
420
+
421
+ response subscription: subscription
422
+ end
423
+
424
+ private
425
+
426
+ # This method returns the Redis lock key
427
+ # Must be unique per resource you want to lock
428
+ def mutex_lock
429
+ "subscription:#{subscription_id}:renewal"
430
+ end
431
+ end
432
+
433
+ # If another process holds the lock, this raises Redlock::LockError
434
+ ProcessSubscriptionRenewal.call!(subscription_id: 456)
435
+ ```
436
+
437
+ **How it works**:
438
+ 1. `within_mutex(:method_name)` tells the operation which method to call for the lock key
439
+ 2. Before `execute` runs, the operation calls your method (e.g., `mutex_lock`) to get a unique string
440
+ 3. It attempts to acquire a distributed lock using that key
441
+ 4. If successful, `execute` runs and the lock is released afterward
442
+ 5. If the lock can't be acquired, raises `Redlock::LockError`
443
+ 6. Locks automatically expire after `expire_milliseconds` (default: 1500ms) to prevent deadlocks
444
+
445
+ **Key points**:
446
+ - The method name (`:mutex_lock`) can be anything you want
447
+ - The method must return a string that uniquely identifies the resource being locked
448
+ - Use different lock keys for different types of operations on the same resource
449
+ - Common pattern: `"resource_type:#{id}:operation_name"`
450
+
451
+ **Common patterns**:
452
+ ```ruby
453
+ # Lock by resource ID
454
+ within_mutex(:mutex_lock)
455
+
456
+ def mutex_lock
457
+ "stream:#{stream_id}:processing"
458
+ end
459
+
460
+ # Lock by multiple attributes
461
+ within_mutex(:mutex_lock)
462
+
463
+ def mutex_lock
464
+ "user:#{user_id}:account:#{account_id}:transfer"
465
+ end
466
+ ```
467
+
468
+ ### Database Transactions with `within_transaction`
469
+
470
+ Ensure multiple database changes succeed or fail together atomically. If any database operation raises an exception, all changes are rolled back.
471
+
472
+ ```ruby
473
+ class TransferFunds < Hanikamu::Operation
474
+ attribute :from_account_id, Types::Integer
475
+ attribute :to_account_id, Types::Integer
476
+ attribute :amount_cents, Types::Integer
477
+
478
+ validates :amount_cents, numericality: { greater_than: 0 }
479
+
480
+ # Wrap the execute method in a database transaction
481
+ within_transaction(:base)
482
+
483
+ def execute
484
+ from_account = Account.lock.find(from_account_id)
485
+ to_account = Account.lock.find(to_account_id)
486
+
487
+ from_account.withdraw!(amount_cents)
488
+ to_account.deposit!(amount_cents)
489
+
490
+ response(
491
+ from_account: from_account,
492
+ to_account: to_account
493
+ )
494
+ end
495
+ end
496
+
497
+ # Both withdraw and deposit happen atomically
498
+ # If either fails, both are rolled back
499
+ TransferFunds.call!(from_account_id: 1, to_account_id: 2, amount_cents: 10000)
500
+ ```
501
+
502
+ **Transaction Options**:
503
+ - `within_transaction(:base)` - Use `ActiveRecord::Base.transaction` (most common)
504
+ - `within_transaction(User)` - Use a specific model's transaction (useful for multiple databases)
505
+
506
+ **Important**: Use transactions when you have multiple database writes that must succeed or fail together. Without a transaction, if the second write fails, the first write remains in the database.
507
+
508
+ ### Form Validations
509
+
510
+ Validate input values using familiar ActiveModel validations. These run after type checking but before guards and execution.
511
+
512
+ ```ruby
513
+ class RegisterUser < Hanikamu::Operation
514
+ attribute :email, Types::String
515
+ attribute :password, Types::String
516
+ attribute :age, Types::Integer
517
+
518
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
519
+ validates :password, length: { minimum: 8 }
520
+ validates :age, numericality: { greater_than_or_equal_to: 18 }
521
+
522
+ def execute
523
+ user = User.create!(email: email, password: password, age: age)
524
+ response user: user
525
+ end
526
+ end
527
+
528
+ # Invalid inputs raise FormError
529
+ RegisterUser.call!(email: 'invalid', password: '123', age: 15)
530
+ # => Hanikamu::Operation::FormError: Email is invalid, Password is too short, Age must be >= 18
531
+
532
+ # With monadic interface
533
+ result = RegisterUser.call(email: 'invalid', password: '123', age: 15)
534
+ result.failure.errors.full_messages
535
+ # => ["Email is invalid", "Password is too short (minimum is 8 characters)", "Age must be greater than or equal to 18"]
536
+ ```
537
+
538
+ **When to use**: Validate input format, presence, length, format, or value ranges. If correcting the input arguments could make the operation succeed, use form validations.
539
+
540
+ ### Guard Conditions
541
+
542
+ Validate business rules, permissions, and system state before execution. Unlike form validations (which check input values), guards check whether the operation can proceed given the current state of your system.
543
+
544
+ ```ruby
545
+ class PublishArticle < Hanikamu::Operation
546
+ attribute :article_id, Types::Integer
547
+ attribute :user_id, Types::Integer
548
+
549
+ # Define guard conditions using a block
550
+ guard do
551
+ # Access operation attributes directly using delegates
552
+ delegates :article_id, :user_id
553
+
554
+ validate :user_must_be_author
555
+ validate :article_must_be_draft
556
+
557
+ def article
558
+ @article ||= Article.find(article_id)
559
+ end
560
+
561
+ def user
562
+ @user ||= User.find(user_id)
563
+ end
564
+
565
+ def user_must_be_author
566
+ errors.add(:user, "must be the article author") unless article.user_id == user.id
567
+ end
568
+
569
+ def article_must_be_draft
570
+ errors.add(:article, "must be in draft status") unless article.draft?
571
+ end
572
+ end
573
+
574
+ def execute
575
+ article = Article.find(article_id)
576
+ article.update!(status: 'published', published_at: Time.current)
577
+
578
+ response article: article
579
+ end
580
+ end
581
+
582
+ # Raises GuardError if guards fail
583
+ PublishArticle.call!(article_id: 999, user_id: 1)
584
+ # => Hanikamu::Operation::GuardError: User must be the article author, Article must be in draft status
585
+ ```
586
+
587
+ **Form Validations vs Guards**:
588
+
589
+ | | Form Validations | Guards |
590
+ |---|---|---|
591
+ | **Purpose** | Validate input values | Validate system state and business rules |
592
+ | **Example** | Email format, password length | User permissions, resource status |
593
+ | **Error** | `FormError` | `GuardError` |
594
+ | **When** | After type check, before guards | After validations, before execution |
595
+ | **Can succeed later?** | Yes, by correcting inputs | Maybe, if system state changes |
596
+
597
+ **When to use guards**: Check permissions, verify resource state, enforce business rules that depend on the current state of your system (not just the input values).
598
+
599
+ ### Block Requirements
600
+
601
+ Some operations need to yield data back to the caller (for batch processing, streaming, etc.). Use `block true` to enforce that a block is provided.
602
+
603
+ ```ruby
604
+ class BatchProcessRecords < Hanikamu::Operation
605
+ attribute :record_ids, Types::Array.of(Types::Integer)
606
+
607
+ block true # Callers must provide a block
608
+
609
+ def execute(&block)
610
+ record_ids.each do |id|
611
+ record = Record.find(id)
612
+ yield record # Pass each record to the caller
613
+ end
614
+
615
+ response processed_count: record_ids.size
616
+ end
617
+ end
618
+
619
+ # Valid usage with a block
620
+ BatchProcessRecords.call!(record_ids: [1, 2, 3]) do |record|
621
+ puts "Processing #{record.id}"
622
+ end
623
+
624
+ # Calling without a block raises an error
625
+ BatchProcessRecords.call!(record_ids: [1, 2, 3])
626
+ # => Hanikamu::Operation::MissingBlockError: This service requires a block to be called
627
+ ```
628
+
629
+ **When to use**: Batch processors, iterators, or any operation where the caller needs to handle each item individually.
630
+
631
+ ### Complete Example: Combining All Features
632
+
633
+ ```ruby
634
+ class CheckoutOrder < Hanikamu::Operation
635
+ attribute :order_id, Types::Integer
636
+ attribute :user_id, Types::Integer
637
+ attribute :payment_method_id, Types::String
638
+
639
+ # Form validation
640
+ validates :payment_method_id, presence: true
641
+
642
+ # Guard conditions using a block
643
+ guard do
644
+ # Shortcut helper to delegate to the operation instance
645
+ delegates :order_id, :user_id
646
+
647
+ validate :user_owns_order
648
+ validate :order_not_checked_out
649
+ validate :sufficient_inventory
650
+
651
+ def order
652
+ @order ||= Order.find(order_id)
653
+ end
654
+
655
+ def user
656
+ @user ||= User.find(user_id)
657
+ end
658
+
659
+ def user_owns_order
660
+ errors.add(:order, "does not belong to user") unless order.user_id == user.id
661
+ end
662
+
663
+ def order_not_checked_out
664
+ errors.add(:order, "already checked out") if order.checked_out?
665
+ end
666
+
667
+ def sufficient_inventory
668
+ order.line_items.each do |item|
669
+ if item.product.stock < item.quantity
670
+ errors.add(:base, "Insufficient stock for #{item.product.name}")
671
+ end
672
+ end
673
+ end
674
+ end
675
+
676
+ # Distributed lock to prevent double-checkout
677
+ within_mutex(:mutex_lock, expire_milliseconds: 5000)
678
+
679
+ # Database transaction for atomicity
680
+ within_transaction(:base)
681
+
682
+ def execute
683
+ order = Order.find(order_id)
684
+
685
+ # Decrease inventory
686
+ order.line_items.each do |item|
687
+ item.product.decrement!(:stock, item.quantity)
688
+ end
689
+
690
+ # Process payment
691
+ payment = Payment.create!(
692
+ order: order,
693
+ user_id: user_id,
694
+ amount_cents: order.total_cents,
695
+ payment_method_id: payment_method_id
696
+ )
697
+
698
+ # Mark order as checked out
699
+ order.update!(
700
+ status: 'completed',
701
+ checked_out_at: Time.current
702
+ )
703
+
704
+ response order: order, payment: payment
705
+ end
706
+
707
+ private
708
+
709
+ def mutex_lock
710
+ "order:#{order_id}:checkout"
711
+ end
712
+ end
713
+
714
+ # Usage
715
+ result = CheckoutOrder.call(
716
+ order_id: 789,
717
+ user_id: 123,
718
+ payment_method_id: 'pm_abc'
719
+ )
720
+
721
+ if result.success?
722
+ order = result.success.order
723
+ payment = result.success.payment
724
+ # Send confirmation email, etc.
725
+ else
726
+ # Handle FormError, GuardError, or other failures
727
+ errors = result.failure
728
+ end
729
+ ```
730
+
731
+ ## Error Handling
732
+
733
+ ### Understanding Validation Layers
734
+
735
+ Operations validate at three distinct levels, each serving a specific purpose:
736
+
737
+ **1. Type Validation (Dry::Struct::Error)**
738
+ - Validates that input arguments are of the correct type
739
+ - Raised automatically by dry-struct before the operation executes
740
+ - Example: Passing a string when an integer is expected
741
+
742
+ **2. Form Validation (Hanikamu::Operation::FormError)**
743
+ - Validates input argument values and basic business rules
744
+ - Raised when the provided values don't meet criteria
745
+ - **Key principle**: Correcting the arguments may allow the operation to succeed
746
+ - Examples: Missing required fields, invalid format, duplicate values, out-of-range numbers
747
+
748
+ **3. Guard Validation (Hanikamu::Operation::GuardError)**
749
+ - Validates system state and pre-conditions
750
+ - Raised when arguments are valid but the system state prevents execution
751
+ - **Key principle**: The operation cannot proceed due to current state, regardless of argument changes
752
+ - Examples: Resource already processed, insufficient permissions, preconditions not met
753
+
754
+ ### Error Types Reference
755
+
756
+ | Error Class | When Raised | Contains |
757
+ |-------------|-------------|----------|
758
+ | `Dry::Struct::Error` | Type validation fails (wrong argument types) | Type error details |
759
+ | `Hanikamu::Operation::FormError` | Input validation fails (ActiveModel validations) | `errors` - ActiveModel::Errors object |
760
+ | `Hanikamu::Operation::GuardError` | Guard validation fails (business rules/state) | `errors` - ActiveModel::Errors object |
761
+ | `Hanikamu::Operation::MissingBlockError` | Block required but not provided | Standard error message |
762
+ | `Hanikamu::Operation::ConfigurationError` | Redis client not configured | Configuration instructions |
763
+ | `Redlock::LockError` | Cannot acquire distributed lock | Lock details (always whitelisted by default) |
764
+
765
+ ### FormError vs GuardError: Practical Examples
766
+
767
+ **FormError Example** - Invalid or incorrect input arguments:
768
+
769
+ ```ruby
770
+ # Attempting to create a user with invalid inputs
771
+ result = Users::CreateUserOperation.call(
772
+ email: "taken@example.com",
773
+ password: "short",
774
+ password_confirmation: "wrong"
775
+ )
776
+ # => Failure(#<Hanikamu::Operation::FormError:
777
+ # Email has been taken,
778
+ # Password is too short,
779
+ # Password confirmation does not match password>)
780
+
781
+ # Correcting the arguments allows success
782
+ result = Users::CreateUserOperation.call(
783
+ email: "unique@example.com",
784
+ password: "securePassword123!",
785
+ password_confirmation: "securePassword123!"
786
+ )
787
+ # => Success(#<struct user=#<User id: 46, email: "unique@example.com">>)
788
+ ```
789
+
790
+ **GuardError Example** - Valid arguments but invalid system state:
791
+
792
+ ```ruby
793
+ # First attempt succeeds
794
+ result = Users::CompleteUserOperation.call!(user_id: 46)
795
+ # => Success(#<struct user=#<User id: 46, completed_at: "2025-11-26">>)
796
+
797
+ # Second attempt fails due to state, even with valid arguments
798
+ result = Users::CompleteUserOperation.call!(user_id: 46)
799
+ # => Failure(#<Hanikamu::Operation::GuardError: User has already been completed>)
800
+
801
+ # The arguments are still correct, but the operation cannot proceed
802
+ # because the user's state has changed
803
+ ```
804
+
805
+ **Type Error Example** - Wrong argument type:
806
+
807
+ ```ruby
808
+ # Passing wrong type raises immediately
809
+ Users::CompleteUserOperation.call!(user_id: "not-a-number")
810
+ # => Raises Dry::Struct::Error
811
+ ```
812
+
813
+ ### Using `.call!` (Raises Exceptions)
814
+
815
+ ```ruby
816
+ begin
817
+ result = CreatePayment.call!(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
818
+ rescue Hanikamu::Operation::FormError => e
819
+ # Input validation failed
820
+ puts e.message # => "Amount cents must be greater than 0"
821
+ puts e.errors.full_messages
822
+ rescue Hanikamu::Operation::GuardError => e
823
+ # Business rule validation failed
824
+ puts e.errors.full_messages
825
+ rescue Redlock::LockError => e
826
+ # Could not acquire distributed lock
827
+ puts "Operation locked, try again later"
828
+ end
829
+ ```
830
+
831
+ ### Using `.call` (Returns Monads)
832
+
833
+ ```ruby
834
+ result = CreatePayment.call(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
835
+
836
+ case result
837
+ when Dry::Monads::Success
838
+ payment = result.success.payment
839
+ puts "Payment created: #{payment.id}"
840
+ when Dry::Monads::Failure
841
+ error = result.failure
842
+
843
+ case error
844
+ when Hanikamu::Operation::FormError
845
+ puts "Validation errors: #{error.errors.full_messages.join(', ')}"
846
+ when Hanikamu::Operation::GuardError
847
+ puts "Business rule violated: #{error.errors.full_messages.join(', ')}"
848
+ when Redlock::LockError
849
+ puts "Resource locked, try again"
850
+ else
851
+ puts "Unknown error: #{error.message}"
852
+ end
853
+ end
854
+ ```
855
+
856
+ ## Best Practices
857
+
858
+ ### Single Responsibility Principle
859
+ Each operation should handle one specific type of state change with a clear, unambiguous interface. Avoid operations that do multiple unrelated things.
860
+
861
+ ### Naming Conventions
862
+
863
+ Operations should follow this naming pattern:
864
+
865
+ **Format**: `[Namespace(s)]::[Verb][Noun]Operation`
866
+
867
+ Examples:
868
+ - `Users::CreateUserOperation`
869
+ - `Orders::CompleteCheckoutOperation`
870
+ - `Payments::ProcessRefundOperation`
871
+ - `Portfolios::Saxo::CreateTransactionsOperation`
872
+
873
+ Use imperative verb forms (Create, Update, Complete, Process, Cancel) that clearly communicate the action being performed.
874
+
875
+ ### Robust Validation Strategy
876
+
877
+ 1. **Type Safety First**: Use Dry::Types for all attributes to catch type errors early
878
+ 2. **Form Validations**: Validate argument values using ActiveModel validations
879
+ 3. **Guard Conditions**: Validate system state and preconditions before execution
880
+ 4. **Clear Error Messages**: Provide actionable error messages that guide users to corrections
881
+
882
+ ### Use the Response Helper
883
+
884
+ Always return a response struct from your operations:
885
+
886
+ ```ruby
887
+ def execute
888
+ user = User.create!(email: email, password: password)
889
+
890
+ # Good: Explicit response with clear interface
891
+ response user: user
892
+
893
+ # Avoid: Implicit return
894
+ # user
895
+ end
896
+ ```
897
+
898
+ Benefits:
899
+ - Provides clear interface for testing
900
+ - Makes return values explicit
901
+ - Allows for easy extension (add more fields to response)
902
+
903
+ ### Comprehensive Testing
904
+
905
+ Write tests for each operation covering:
906
+ - **Happy path**: Valid inputs and successful execution
907
+ - **Type validation**: Wrong argument types
908
+ - **Form validation**: Invalid argument values
909
+ - **Guard validation**: Invalid system states
910
+ - **Edge cases**: Boundary conditions and race scenarios
911
+ - **Concurrency**: Multiple simultaneous executions (if using mutexes)
912
+
913
+ ### Transaction and Lock Ordering
914
+
915
+ When combining features, use this order:
916
+
917
+ ```ruby
918
+ class MyOperation < Hanikamu::Operation
919
+ # 1. Guards (validate state first)
920
+ guard do
921
+ # validations
922
+ end
923
+
924
+ # 2. Mutex (acquire lock)
925
+ within_mutex(:mutex_lock)
926
+
927
+ # 3. Transaction (wrap database changes)
928
+ within_transaction(:base)
929
+
930
+ def execute
931
+ # implementation
932
+ end
933
+ end
934
+ ```
935
+
936
+ ## Configuration Reference
937
+
938
+ ```ruby
939
+ Hanikamu::Operation.configure do |config|
940
+ # Required
941
+ config.redis_client = RedisClient.new(url: ENV['REDIS_URL'])
942
+
943
+ # Optional Redlock settings (defaults shown)
944
+ config.mutex_expire_milliseconds = 1500 # Lock expires after 1.5 seconds
945
+ config.redlock_retry_count = 6 # Retry 6 times
946
+ config.redlock_retry_delay = 500 # Wait 500ms between retries
947
+ config.redlock_retry_jitter = 50 # Add ±50ms random jitter
948
+ config.redlock_timeout = 0.1 # Redis command timeout: 100ms
949
+
950
+ # Optional error whitelisting (Redlock::LockError always included by default)
951
+ config.whitelisted_errors = [] # Add custom errors here
952
+ end
953
+ ```
954
+
955
+ ## Testing
956
+
957
+ When testing operations with distributed locks, configure a test Redis instance:
958
+
959
+ ```ruby
960
+ # spec/spec_helper.rb or test/test_helper.rb
961
+
962
+ require 'redis-client'
963
+
964
+ RSpec.configure do |config|
965
+ config.before(:suite) do
966
+ Hanikamu::Operation.config.redis_client = RedisClient.new(
967
+ url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/1') # Use DB 1 for tests
968
+ )
969
+ end
970
+
971
+ config.after(:each) do
972
+ # Clean up Redis between tests if needed
973
+ Hanikamu::Operation.config.redis_client.call('FLUSHDB')
974
+ end
975
+ end
976
+ ```
977
+
978
+ **Testing Locked Operations**:
979
+
980
+ ```ruby
981
+ RSpec.describe ProcessSubscriptionRenewal do
982
+ it "prevents concurrent execution" do
983
+ subscription = create(:subscription)
984
+ lock_key = "subscription:#{subscription.id}:renewal"
985
+
986
+ # Simulate another process holding the lock
987
+ Hanikamu::Operation.redis_lock.lock!(lock_key, 2000) do
988
+ expect {
989
+ described_class.call!(subscription_id: subscription.id)
990
+ }.to raise_error(Redlock::LockError)
991
+ end
992
+ end
993
+ end
994
+ ```
995
+
996
+ ## Development
997
+
998
+ ```bash
999
+ # Install dependencies
1000
+ bundle install
1001
+
1002
+ # Run tests
1003
+ make rspec
1004
+
1005
+ # Run linter
1006
+ make cops
1007
+
1008
+ # Access console
1009
+ make console
1010
+
1011
+ # Access shell
1012
+ make shell
1013
+ ```
1014
+
1015
+ ## Contributing
1016
+
1017
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Hanikamu/hanikamu-operation.
1018
+
1019
+ ## License
1020
+
1021
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1022
+
1023
+ ## Credits
1024
+
1025
+ Built by [Hanikamu](https://github.com/Hanikamu) on top of:
1026
+ - [hanikamu-service](https://github.com/Hanikamu/hanikamu-service) - Base service pattern
1027
+ - [dry-rb](https://dry-rb.org/) - Type system and monads
1028
+ - [Redlock](https://github.com/leandromoreira/redlock-rb) - Distributed locking algorithm