Hanikamu::Operation
A Ruby gem that extends 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.
Table of Contents
- Why Hanikamu::Operation?
- Quick Start
- Installation
- Setup
- Usage
- Basic Operation
- Distributed Locking
- Database Transactions
- Form Validations
- Guard Conditions
- Block Requirements
- Complete Example
- Error Handling
- Best Practices
- Configuration Reference
- Testing
- Development
- Contributing
- License
- Credits
Why Hanikamu::Operation?
hanikamu-operation builds upon the service object pattern established by hanikamu-service, adding critical infrastructure concerns that complex business operations require:
Core Principles from hanikamu-service
- Single Responsibility: Each operation encapsulates one business transaction
- Type Safety: Input validation via dry-struct type checking
-
Monadic Error Handling:
.callreturnsSuccessorFailuremonads;.call!raises exceptions - Clean Architecture: Business logic isolated from models and controllers
-
Predictable Interface: All operations follow the same
.call/.call!pattern
Extended Operation Capabilities
Building on this foundation, hanikamu-operation adds:
- Distributed Locking: Prevent race conditions across multiple processes/servers using Redis locks (Redlock algorithm)
- Database Transactions: Wrap operations in ActiveRecord transactions with automatic rollback
- Form Validations: ActiveModel validations on the operation itself
- Guard Conditions: Pre-execution business rule validation (e.g., permissions, state checks)
- Block Requirements: Enforce callback patterns for operations that need them
When to Use
Use Hanikamu::Operation (instead of plain Hanikamu::Service) when your business logic requires:
- Concurrency Control: Multiple users/processes might execute the same operation simultaneously
- Transactional Integrity: Multiple database changes must succeed/fail atomically
- Complex Validation: Both input validation AND business rule validation
- State Guards: Pre-conditions that determine if the operation can proceed
- Critical Sections: Code that must not be interrupted or run concurrently
Quick Start
1. Install the gem
# Gemfile
gem 'hanikamu-operation', '~> 0.1.1'bundle install2. Configure Redis (required for distributed locking)
# config/initializers/hanikamu_operation.rb
require 'redis-client'
Hanikamu::Operation.configure do |config|
config.redis_client = RedisClient.new(url: ENV.fetch('REDIS_URL'))
end3. Create an operation
class Payments::ChargeOperation < Hanikamu::Operation
attribute :user_id, Types::Integer
validates :user_id, presence: true
within_mutex(:mutex_lock)
within_transaction(:base)
def execute
user = User.find(user_id)
user.charge!
response user: user
end
private
def mutex_lock
"user:#{user_id}:charge"
end
end4. Call the operation
# Raises exceptions on failure
Payments::ChargeOperation.call!(user_id: current_user.id)
# Returns Success/Failure monad
result = Payments::ChargeOperation.call(user_id: current_user.id)
if result.success?
user = result.success.user
else
errors = result.failure
endInstallation
Add to your application's Gemfile:
gem 'hanikamu-operation', '~> 0.1.1'Then execute:
bundle installSetup
Rails Application Setup Guide
Follow these steps to integrate Hanikamu::Operation into a Rails application:
Step 1: Add the gem to your Gemfile
# Gemfile
gem 'hanikamu-operation', '~> 0.1.1'
gem 'redis-client', '~> 0.22' # Required for distributed lockingbundle installStep 2: Define your Types module
# app/types.rb
module Types
include Dry.Types()
endStep 3: Create the initializer
# config/initializers/hanikamu_operation.rb
require 'redis-client'
Hanikamu::Operation.configure do |config|
config.redis_client = RedisClient.new(
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
reconnect_attempts: 3,
timeout: 1.0
)
endStep 4: Add Redis to your development environment
For Docker Compose:
# docker-compose.yml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
web:
# ... your Rails app config
environment:
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
volumes:
redis_data:For local development without Docker:
# macOS with Homebrew
brew install redis
brew services start redis
# Your .env file
REDIS_URL=redis://localhost:6379/0Step 5: Create your first operation
# Create operations directory
mkdir -p app/operations/users# app/operations/users/create_user_operation.rb
module Users
class CreateUserOperation < Hanikamu::Operation
attribute :email, Types::String
attribute :password, Types::String
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }
within_transaction(:base)
def execute
user = User.create!(
email: email,
password: password
)
response user: user
end
end
endStep 6: Use in your controller
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
result = Users::CreateUserOperation.call(user_params)
if result.success?
user = result.success.user
render json: { user: user }, status: :created
else
error = result.failure
case error
when Hanikamu::Operation::FormError
render json: { errors: error.errors.full_messages }, status: :unprocessable_entity
else
render json: { error: error.message }, status: :internal_server_error
end
end
end
private
def user_params
params.require(:user).permit(:email, :password)
end
endStep 7: Configure for production
Set your Redis URL in production (Heroku, AWS, etc.):
# Heroku
heroku addons:create heroku-redis:mini
# REDIS_URL is automatically set
# Or set manually
heroku config:set REDIS_URL=redis://your-redis-host:6379/0Detailed Configuration Options
If you need more control, create a detailed initializer:
# config/initializers/hanikamu_operation.rb
require 'redis-client'
Hanikamu::Operation.configure do |config|
# Required: Redis client for distributed locking
config.redis_client = RedisClient.new(
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
reconnect_attempts: 3,
timeout: 1.0
)
# Optional: Customize Redlock settings (these are the defaults)
config.mutex_expire_milliseconds = 1500 # Lock TTL
config.redlock_retry_count = 6 # Number of retry attempts
config.redlock_retry_delay = 500 # Milliseconds between retries
config.redlock_retry_jitter = 50 # Random jitter to prevent thundering herd
config.redlock_timeout = 0.1 # Redis command timeout
# Optional: Add errors to whitelist (Redlock::LockError is always included by default)
config.whitelisted_errors = [CustomBusinessError]
endRedis Setup by Environment
For Development (Docker Compose):
# docker-compose.yml
services:
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
networks:
- app_network
app:
# ... your app config
environment:
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
networks:
- app_network
volumes:
redis_data:
networks:
app_network:For Production:
Use a managed Redis service (AWS ElastiCache, Heroku Redis, Redis Labs, etc.) and set the REDIS_URL environment variable.
Usage
Hanikamu::Operation provides five key features that you can combine as needed:
| Feature | Declaration | Purpose |
|---|---|---|
| Input Attributes | attribute :name, Type |
Define typed input parameters |
| Form Validations | validates :field, ... |
Validate input values (format, presence, etc.) |
| Guard Conditions | guard do ... end |
Validate business rules and state before execution |
| Distributed Locking | within_mutex(:method) |
Prevent concurrent execution of the same resource |
| Database Transactions | within_transaction(:base) |
Wrap execution in an atomic database transaction |
| Block Requirement | block true |
Require a block to be passed to the operation |
Basic Operation
module Types
include Dry.Types()
end
class CreatePayment < Hanikamu::Operation
attribute :user_id, Types::Integer
attribute :amount_cents, Types::Integer
attribute :payment_method_id, Types::String
validates :amount_cents, numericality: { greater_than: 0 }
def execute
payment = Payment.create!(
user_id: user_id,
amount_cents: amount_cents,
payment_method_id: payment_method_id,
status: 'completed'
)
response payment: payment
end
end
# Usage
result = CreatePayment.call!(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
# => #<struct payment=#<Payment...>>
# Or with monadic interface
result = CreatePayment.call(user_id: 123, amount_cents: 5000, payment_method_id: 'pm_123')
if result.success?
payment = result.success.payment
else
error = result.failure
endDistributed Locking with within_mutex
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.
class ProcessSubscriptionRenewal < Hanikamu::Operation
attribute :subscription_id, Types::Integer
# The :mutex_lock method will be called to generate the lock identifier
within_mutex(:mutex_lock, expire_milliseconds: 3000)
def execute
subscription = Subscription.find(subscription_id)
subscription.renew!
subscription.charge_payment!
response subscription: subscription
end
private
# This method returns the Redis lock key
# Must be unique per resource you want to lock
def mutex_lock
"subscription:#{subscription_id}:renewal"
end
end
# If another process holds the lock, this raises Redlock::LockError
ProcessSubscriptionRenewal.call!(subscription_id: 456)How it works:
-
within_mutex(:method_name)tells the operation which method to call for the lock key - Before
executeruns, the operation calls your method (e.g.,mutex_lock) to get a unique string - It attempts to acquire a distributed lock using that key
- If successful,
executeruns and the lock is released afterward - If the lock can't be acquired, raises
Redlock::LockError - Locks automatically expire after
expire_milliseconds(default: 1500ms) to prevent deadlocks
Key points:
- The method name (
:mutex_lock) can be anything you want - The method must return a string that uniquely identifies the resource being locked
- Use different lock keys for different types of operations on the same resource
- Common pattern:
"resource_type:#{id}:operation_name"
Common patterns:
# Lock by resource ID
within_mutex(:mutex_lock)
def mutex_lock
"stream:#{stream_id}:processing"
end
# Lock by multiple attributes
within_mutex(:mutex_lock)
def mutex_lock
"user:#{user_id}:account:#{account_id}:transfer"
endDatabase Transactions with within_transaction
Ensure multiple database changes succeed or fail together atomically. If any database operation raises an exception, all changes are rolled back.
class TransferFunds < Hanikamu::Operation
attribute :from_account_id, Types::Integer
attribute :to_account_id, Types::Integer
attribute :amount_cents, Types::Integer
validates :amount_cents, numericality: { greater_than: 0 }
# Wrap the execute method in a database transaction
within_transaction(:base)
def execute
from_account = Account.lock.find(from_account_id)
to_account = Account.lock.find(to_account_id)
from_account.withdraw!(amount_cents)
to_account.deposit!(amount_cents)
response(
from_account: from_account,
to_account: to_account
)
end
end
# Both withdraw and deposit happen atomically
# If either fails, both are rolled back
TransferFunds.call!(from_account_id: 1, to_account_id: 2, amount_cents: 10000)Transaction Options:
-
within_transaction(:base)- UseActiveRecord::Base.transaction(most common) -
within_transaction(User)- Use a specific model's transaction (useful for multiple databases)
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.
Form Validations
Validate input values using familiar ActiveModel validations. These run after type checking but before guards and execution.
class RegisterUser < Hanikamu::Operation
attribute :email, Types::String
attribute :password, Types::String
attribute :age, Types::Integer
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }
validates :age, numericality: { greater_than_or_equal_to: 18 }
def execute
user = User.create!(email: email, password: password, age: age)
response user: user
end
end
# Invalid inputs raise FormError
RegisterUser.call!(email: 'invalid', password: '123', age: 15)
# => Hanikamu::Operation::FormError: Email is invalid, Password is too short, Age must be >= 18
# With monadic interface
result = RegisterUser.call(email: 'invalid', password: '123', age: 15)
result.failure.errors.full_messages
# => ["Email is invalid", "Password is too short (minimum is 8 characters)", "Age must be greater than or equal to 18"]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.
Guard Conditions
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.
class PublishArticle < Hanikamu::Operation
attribute :article_id, Types::Integer
attribute :user_id, Types::Integer
# Define guard conditions using a block
guard do
# Access operation attributes directly using delegates
delegates :article_id, :user_id
validate :user_must_be_author
validate :article_must_be_draft
def article
@article ||= Article.find(article_id)
end
def user
@user ||= User.find(user_id)
end
def user_must_be_author
errors.add(:user, "must be the article author") unless article.user_id == user.id
end
def article_must_be_draft
errors.add(:article, "must be in draft status") unless article.draft?
end
end
def execute
article = Article.find(article_id)
article.update!(status: 'published', published_at: Time.current)
response article: article
end
end
# Raises GuardError if guards fail
PublishArticle.call!(article_id: 999, user_id: 1)
# => Hanikamu::Operation::GuardError: User must be the article author, Article must be in draft statusForm Validations vs Guards:
| Form Validations | Guards | |
|---|---|---|
| Purpose | Validate input values | Validate system state and business rules |
| Example | Email format, password length | User permissions, resource status |
| Error | FormError |
GuardError |
| When | After type check, before guards | After validations, before execution |
| Can succeed later? | Yes, by correcting inputs | Maybe, if system state changes |
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).
Block Requirements
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.
class BatchProcessRecords < Hanikamu::Operation
attribute :record_ids, Types::Array.of(Types::Integer)
block true # Callers must provide a block
def execute(&block)
record_ids.each do |id|
record = Record.find(id)
yield record # Pass each record to the caller
end
response processed_count: record_ids.size
end
end
# Valid usage with a block
BatchProcessRecords.call!(record_ids: [1, 2, 3]) do |record|
puts "Processing #{record.id}"
end
# Calling without a block raises an error
BatchProcessRecords.call!(record_ids: [1, 2, 3])
# => Hanikamu::Operation::MissingBlockError: This service requires a block to be calledWhen to use: Batch processors, iterators, or any operation where the caller needs to handle each item individually.
Complete Example: Combining All Features
class CheckoutOrder < Hanikamu::Operation
attribute :order_id, Types::Integer
attribute :user_id, Types::Integer
attribute :payment_method_id, Types::String
# Form validation
validates :payment_method_id, presence: true
# Guard conditions using a block
guard do
# Shortcut helper to delegate to the operation instance
delegates :order_id, :user_id
validate :user_owns_order
validate :order_not_checked_out
validate :sufficient_inventory
def order
@order ||= Order.find(order_id)
end
def user
@user ||= User.find(user_id)
end
def user_owns_order
errors.add(:order, "does not belong to user") unless order.user_id == user.id
end
def order_not_checked_out
errors.add(:order, "already checked out") if order.checked_out?
end
def sufficient_inventory
order.line_items.each do |item|
if item.product.stock < item.quantity
errors.add(:base, "Insufficient stock for #{item.product.name}")
end
end
end
end
# Distributed lock to prevent double-checkout
within_mutex(:mutex_lock, expire_milliseconds: 5000)
# Database transaction for atomicity
within_transaction(:base)
def execute
order = Order.find(order_id)
# Decrease inventory
order.line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
# Process payment
payment = Payment.create!(
order: order,
user_id: user_id,
amount_cents: order.total_cents,
payment_method_id: payment_method_id
)
# Mark order as checked out
order.update!(
status: 'completed',
checked_out_at: Time.current
)
response order: order, payment: payment
end
private
def mutex_lock
"order:#{order_id}:checkout"
end
end
# Usage
result = CheckoutOrder.call(
order_id: 789,
user_id: 123,
payment_method_id: 'pm_abc'
)
if result.success?
order = result.success.order
payment = result.success.payment
# Send confirmation email, etc.
else
# Handle FormError, GuardError, or other failures
errors = result.failure
endError Handling
Understanding Validation Layers
Operations validate at three distinct levels, each serving a specific purpose:
1. Type Validation (Dry::Struct::Error)
- Validates that input arguments are of the correct type
- Raised automatically by dry-struct before the operation executes
- Example: Passing a string when an integer is expected
2. Form Validation (Hanikamu::Operation::FormError)
- Validates input argument values and basic business rules
- Raised when the provided values don't meet criteria
- Key principle: Correcting the arguments may allow the operation to succeed
- Examples: Missing required fields, invalid format, duplicate values, out-of-range numbers
3. Guard Validation (Hanikamu::Operation::GuardError)
- Validates system state and pre-conditions
- Raised when arguments are valid but the system state prevents execution
- Key principle: The operation cannot proceed due to current state, regardless of argument changes
- Examples: Resource already processed, insufficient permissions, preconditions not met
Error Types Reference
| Error Class | When Raised | Contains |
|---|---|---|
Dry::Struct::Error |
Type validation fails (wrong argument types) | Type error details |
Hanikamu::Operation::FormError |
Input validation fails (ActiveModel validations) |
errors - ActiveModel::Errors object |
Hanikamu::Operation::GuardError |
Guard validation fails (business rules/state) |
errors - ActiveModel::Errors object |
Hanikamu::Operation::MissingBlockError |
Block required but not provided | Standard error message |
Hanikamu::Operation::ConfigurationError |
Redis client not configured | Configuration instructions |
Redlock::LockError |
Cannot acquire distributed lock | Lock details (always whitelisted by default) |
FormError vs GuardError: Practical Examples
FormError Example - Invalid or incorrect input arguments:
# Attempting to create a user with invalid inputs
result = Users::CreateUserOperation.call(
email: "taken@example.com",
password: "short",
password_confirmation: "wrong"
)
# => Failure(#<Hanikamu::Operation::FormError:
# Email has been taken,
# Password is too short,
# Password confirmation does not match password>)
# Correcting the arguments allows success
result = Users::CreateUserOperation.call(
email: "unique@example.com",
password: "securePassword123!",
password_confirmation: "securePassword123!"
)
# => Success(#<struct user=#<User id: 46, email: "unique@example.com">>)GuardError Example - Valid arguments but invalid system state:
# First attempt succeeds
result = Users::CompleteUserOperation.call!(user_id: 46)
# => Success(#<struct user=#<User id: 46, completed_at: "2025-11-26">>)
# Second attempt fails due to state, even with valid arguments
result = Users::CompleteUserOperation.call!(user_id: 46)
# => Failure(#<Hanikamu::Operation::GuardError: User has already been completed>)
# The arguments are still correct, but the operation cannot proceed
# because the user's state has changedType Error Example - Wrong argument type:
# Passing wrong type raises immediately
Users::CompleteUserOperation.call!(user_id: "not-a-number")
# => Raises Dry::Struct::ErrorUsing .call! (Raises Exceptions)
begin
result = CreatePayment.call!(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
rescue Hanikamu::Operation::FormError => e
# Input validation failed
puts e.message # => "Amount cents must be greater than 0"
puts e.errors.full_messages
rescue Hanikamu::Operation::GuardError => e
# Business rule validation failed
puts e.errors.full_messages
rescue Redlock::LockError => e
# Could not acquire distributed lock
puts "Operation locked, try again later"
endUsing .call (Returns Monads)
result = CreatePayment.call(user_id: 1, amount_cents: -100, payment_method_id: 'pm_123')
case result
when Dry::Monads::Success
payment = result.success.payment
puts "Payment created: #{payment.id}"
when Dry::Monads::Failure
error = result.failure
case error
when Hanikamu::Operation::FormError
puts "Validation errors: #{error.errors.full_messages.join(', ')}"
when Hanikamu::Operation::GuardError
puts "Business rule violated: #{error.errors.full_messages.join(', ')}"
when Redlock::LockError
puts "Resource locked, try again"
else
puts "Unknown error: #{error.message}"
end
endBest Practices
Single Responsibility Principle
Each operation should handle one specific type of state change with a clear, unambiguous interface. Avoid operations that do multiple unrelated things.
Naming Conventions
Operations should follow this naming pattern:
Format: [Namespace(s)]::[Verb][Noun]Operation
Examples:
Users::CreateUserOperationOrders::CompleteCheckoutOperationPayments::ProcessRefundOperationPortfolios::Saxo::CreateTransactionsOperation
Use imperative verb forms (Create, Update, Complete, Process, Cancel) that clearly communicate the action being performed.
Robust Validation Strategy
- Type Safety First: Use Dry::Types for all attributes to catch type errors early
- Form Validations: Validate argument values using ActiveModel validations
- Guard Conditions: Validate system state and preconditions before execution
- Clear Error Messages: Provide actionable error messages that guide users to corrections
Use the Response Helper
Always return a response struct from your operations:
def execute
user = User.create!(email: email, password: password)
# Good: Explicit response with clear interface
response user: user
# Avoid: Implicit return
# user
endBenefits:
- Provides clear interface for testing
- Makes return values explicit
- Allows for easy extension (add more fields to response)
Comprehensive Testing
Write tests for each operation covering:
- Happy path: Valid inputs and successful execution
- Type validation: Wrong argument types
- Form validation: Invalid argument values
- Guard validation: Invalid system states
- Edge cases: Boundary conditions and race scenarios
- Concurrency: Multiple simultaneous executions (if using mutexes)
Transaction and Lock Ordering
When combining features, use this order:
class MyOperation < Hanikamu::Operation
# 1. Guards (validate state first)
guard do
# validations
end
# 2. Mutex (acquire lock)
within_mutex(:mutex_lock)
# 3. Transaction (wrap database changes)
within_transaction(:base)
def execute
# implementation
end
endConfiguration Reference
Hanikamu::Operation.configure do |config|
# Required
config.redis_client = RedisClient.new(url: ENV['REDIS_URL'])
# Optional Redlock settings (defaults shown)
config.mutex_expire_milliseconds = 1500 # Lock expires after 1.5 seconds
config.redlock_retry_count = 6 # Retry 6 times
config.redlock_retry_delay = 500 # Wait 500ms between retries
config.redlock_retry_jitter = 50 # Add ±50ms random jitter
config.redlock_timeout = 0.1 # Redis command timeout: 100ms
# Optional error whitelisting (Redlock::LockError always included by default)
config.whitelisted_errors = [] # Add custom errors here
endTesting
When testing operations with distributed locks, configure a test Redis instance:
# spec/spec_helper.rb or test/test_helper.rb
require 'redis-client'
RSpec.configure do |config|
config.before(:suite) do
Hanikamu::Operation.config.redis_client = RedisClient.new(
url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/1') # Use DB 1 for tests
)
end
config.after(:each) do
# Clean up Redis between tests if needed
Hanikamu::Operation.config.redis_client.call('FLUSHDB')
end
endTesting Locked Operations:
RSpec.describe ProcessSubscriptionRenewal do
it "prevents concurrent execution" do
subscription = create(:subscription)
lock_key = "subscription:#{subscription.id}:renewal"
# Simulate another process holding the lock
Hanikamu::Operation.redis_lock.lock!(lock_key, 2000) do
expect {
described_class.call!(subscription_id: subscription.id)
}.to raise_error(Redlock::LockError)
end
end
endDevelopment
# Install dependencies
bundle install
# Run tests
make rspec
# Run linter
make cops
# Access console
make console
# Access shell
make shellContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/Hanikamu/hanikamu-operation.
License
The gem is available as open source under the terms of the MIT License.
Credits
Built by Hanikamu on top of:
- hanikamu-service - Base service pattern
- dry-rb - Type system and monads
- Redlock - Distributed locking algorithm