HatiOperation
HatiOperation is a lightweight Ruby toolkit that helps you compose domain logic into clear, reusable operations. Built on top of hati-command, it serves as an aggregator that orchestrates multiple services and commands into cohesive business operations.
✨ Key Features
- Step-based execution – write each unit of work as a small service object and compose them with
step
- Implicit result propagation – methods return
Success(...)
orFailure(...)
and are automatically unpacked - Fail-fast transactions – stop the chain as soon as a step fails
- Dependency injection (DI) – override steps at call-time for ultimate flexibility
- Macro DSL – declaratively configure validation, error mapping, transactions and more
- Service aggregation – orchestrate multiple services into cohesive business operations
🏗️ Architecture
HatiOperation builds on top of hati-command and serves as an aggregator pattern implementation:
┌─────────────────────────────────────────────────────────────┐
│ HatiOperation │
│ (Aggregator Layer) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Service A │ │ Service B │ │ Service C │ │
│ │ (Command) │ │ (Command) │ │ (Command) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ hati-command │
│ (Foundation Layer) │
└─────────────────────────────────────────────────────────────┘
📋 Table of Contents
- Installation
- Quick Start
- Step DSL
- Dependency Injection
- Alternative DSL Styles
- Testing
- Contributing
- License
🚀 Installation
Add HatiOperation to your Gemfile and bundle:
# Gemfile
gem 'hati_operation'
bundle install
Alternatively:
gem install hati_operation
🎯 Quick Start
The example below shows how HatiOperation can be leveraged inside a Rails API controller to aggregate multiple services:
# app/controllers/api/v1/withdrawal_controller.rb
class Api::V1::WithdrawalController < ApplicationController
def create
result = Withdrawal::Operation::Create.call(params: params.to_unsafe_h)
run_and_render(result)
end
private
def run_and_render(result)
if result.success?
render json: TransferSerializer.new.serialize(result.value), status: :created
else
error = ApiError.new(result.value)
render json: error.to_json, status: error.status
end
end
end
🔧 Defining the Operation
# app/operations/withdrawal/operation/create.rb
class Withdrawal::Operation::Create < HatiOperation::Base
# Wrap everything in DB transaction
ar_transaction :funds_transfer_transaction!
def call(params:)
params = step MyApiContract.call(params), err: ApiErr.call(422)
transfer = step funds_transfer_transaction(params[:account_id])
EventBroadcast.new.stream(transfer.to_event)
transfer.meta
end
def funds_transfer_transaction(acc_id)
acc = Account.find_by(find_by: acc_id).presence : Failure!(err: ApiErr.call(404))
withdrawal = step WithdrawalService.call(acc), err: ApiErr.call(409)
transfer = step ProcessTransferService.call(withdrawal), err: ApiErr.call(503)
Success(transfer)
end
end
🎛️ Base Operation Configuration
class ApiOperation < HatiOperation::Base
operation do
unexpected_err ApiErr.call(500)
end
end
🛠️ Step DSL
The DSL gives you fine-grained control over every stage of the operation:
Core DSL Methods
step
– register a dependency serviceparams
– validate/transform incoming parameterson_success
– handle successful operation resultson_failure
– map and handle failure results
Extended Configuration
📖 See: hati-command for all configuration options
ar_transaction
– execute inside database transactionfail_fast
– configure fail-fast behaviorfailure
– set default failure handlingunexpected_err
– configure generic error behavior
🔄 Dependency Injection
At runtime you can swap out any step for testing, feature-flags, or different environments:
result = Withdrawal::Operation::Create.call(params) do
step broadcast: DummyBroadcastService
step transfer: StubbedPaymentProcessor
end
🎨 Alternative DSL Styles
Declarative Style
Prefer more declarative code? Use the class-level DSL:
class Withdrawal::Operation::Create < ApiOperation
params CreateContract, err: ApiErr.call(422)
ar_transaction :funds_transfer_transaction!
step withdrawal: WithdrawalService, err: ApiErr.call(409)
step transfer: ProcessTransferService, err: ApiErr.call(503)
step broadcast: Broadcast
on_success SerializerService.call(Transfer, status: 201)
on_failure ApiErrorSerializer
# requires :params keyword to access overwritten params
# same as params = step CreateContract.call(params), err: ApiErr.call(422)
def call(params:)
transfer = step funds_transfer_transaction!(params[:account_id])
broadcast.new.stream(transfer.to_event)
transfer.
end
def funds_transfer_transaction!(acc_id)
acc = step(err: ApiErr.call(404)) { User.find(id) }
withdrawal = step withdrawal.call(acc)
transfer = step transfer.call(withdrawal)
Success(transfer)
end
end
class Api::V2::WithdrawalController < ApiController
def create
run_and_render Withdrawal::Operation::Create
end
private
def run_and_render(operation, &block)
render JsonResult.prepare operation.call(params.to_unsafe_h).value
end
end
🏗️ Full-Stack DI Example
class Api::V2::WithdrawalController < ApplicationController
def create
run_and_render Withdrawal::Operation::Create.call(params.to_unsafe_h) do
step broadcast: API::V2::BroadcastService
step transfer: API::V2::PaymentProcessorService
step serializer: ExtendedTransferSerializer
end
end
end
🧪 Testing
Run the test-suite with:
bundle exec rspec
HatiOperation is fully covered by RSpec. See spec/
for reference examples including stubbed services and DI.
🤝 Contributing
Bug reports and pull requests are welcome on GitHub. Please:
- Fork the project and create your branch from
main
- Run
bundle exec rspec
to ensure tests pass - Submit a pull request with a clear description of your changes
📄 License
HatiOperation is released under the MIT License.