Hanami Guide
Applies to: Hanami 2.x, Ruby 3.1+, Web Applications, APIs, Domain-Driven Design, Clean Architecture
Core Principles
-
Clean Architecture: Strict separation between delivery (actions/views) and domain (operations/repos)
-
Slices as Bounded Contexts: Each slice is an isolated module with its own dependencies
-
Dependency Injection: Auto-injection via include Deps[...] -- no globals, no singletons
-
dry-rb Ecosystem: Leverage dry-types, dry-monads, dry-validation for type safety and result handling
-
ROM Persistence: Relations for queries, repositories for data access, entities for domain objects
-
Convention Over Configuration: Predictable file layout, auto-registration of components
Guardrails
Architecture
-
Use slices for bounded contexts (e.g., slices/api/ , slices/admin/ )
-
Keep actions thin -- delegate to operations for business logic
-
Operations return Dry::Monads::Result (Success/Failure), never raise for domain errors
-
Repositories wrap ROM relations -- never call relations directly from actions
-
Entities are value objects (ROM::Struct) -- keep behavior minimal
-
Use providers for external service registration (config/providers/ )
Actions
-
One action per route handler (no fat controllers)
-
Validate params with params do ... end block inside the action
-
Always check request.params.valid? before processing
-
Use halt for early returns (401, 403, 404)
-
Handle exceptions with handle_exception class method
-
Web actions render views; API actions set response.body with JSON
Views & Templates
-
Views expose data to templates via expose declarations
-
Keep logic in view classes, not in ERB templates
-
Use layouts for shared page structure (config.layout = "app" )
-
Templates use ERB by default; keep them presentation-only
Persistence (ROM)
-
Relations define schema, associations, and reusable query scopes
-
Use infer: true in relation schema to auto-detect columns
-
Repositories define commands (:create , update: :by_pk , delete: :by_pk )
-
Wrap multi-step writes in transaction blocks
-
Use combine for eager loading associations (avoids N+1)
-
Paginate with .limit().offset() -- never load unbounded datasets
Security
-
Validate all inputs at the action params layer
-
Use parameterized queries via ROM (never string interpolation)
-
Hash passwords with bcrypt; verify with constant-time comparison
-
Store secrets in settings (config/settings.rb ), loaded from environment
-
API auth: verify JWT tokens in a base action before hook or authenticate! method
-
Set CSP headers via config.actions.content_security_policy
Testing
-
Use RSpec with rack-test for request specs
-
Use database_cleaner-sequel with transaction strategy
-
Test operations in isolation (unit tests) -- they are pure business logic
-
Test actions as request specs (integration) -- verify HTTP status and body
-
Use factory_bot for test data setup
-
Coverage target: >80% for operations and repositories
Project Structure
myapp/ ├── app/ # Main application slice │ ├── action.rb # Base action class │ ├── view.rb # Base view class │ ├── actions/ # Route handlers (one class per endpoint) │ │ └── users/ │ │ ├── index.rb │ │ ├── show.rb │ │ └── create.rb │ ├── views/ # View classes (expose data to templates) │ │ └── users/ │ │ ├── index.rb │ │ └── show.rb │ └── templates/ # ERB templates │ ├── layouts/ │ │ └── app.html.erb │ └── users/ │ ├── index.html.erb │ └── show.html.erb ├── slices/ # Additional slices (bounded contexts) │ └── api/ │ ├── action.rb # API base action (format :json) │ └── actions/ │ └── v1/ │ └── users/ ├── config/ │ ├── app.rb # Application configuration │ ├── routes.rb # Route definitions │ ├── settings.rb # Settings schema (env vars) │ └── providers/ # Service providers ├── db/ │ ├── migrate/ # ROM migrations │ └── seeds.rb ├── lib/ │ └── myapp/ │ ├── entities/ # ROM structs (value objects) │ ├── repositories/ # Data access (ROM repositories) │ ├── operations/ # Business logic (dry-monads) │ ├── services/ # Infrastructure services │ └── types.rb # Custom dry-types ├── spec/ │ ├── spec_helper.rb │ ├── support/ │ ├── actions/ │ ├── operations/ │ └── repositories/ ├── Gemfile └── config.ru
Layer Responsibilities
Layer Knows About Never References
Actions Operations, views, params Repositories, ROM directly
Views Exposed data, template helpers Actions, operations
Operations Repositories, services, monads Actions, views, request/response
Repositories ROM relations, entities Operations, actions
Services External APIs, libraries Actions, views
Quick Reference Commands
Create new app
gem install hanami hanami new myapp && cd myapp
Development
bundle exec hanami server # Start dev server bundle exec hanami console # Interactive console
Generate components
bundle exec hanami generate slice api bundle exec hanami generate action web.users.index bundle exec hanami generate relation users
Database
bundle exec hanami db create bundle exec hanami db migrate bundle exec hanami db seed
Testing
bundle exec rspec bundle exec rspec --format documentation
Configuration
Application (config/app.rb)
config/app.rb
require "hanami"
module MyApp class App < Hanami::App config.actions.default_response_format = :html config.actions.content_security_policy[:default_src] = "'self'"
config.sessions = :cookie, {
key: "_myapp_session",
secret: settings.session_secret,
expire_after: 60 * 60 * 24 * 7
}
end end
Settings (config/settings.rb)
module MyApp class Settings < Hanami::Settings setting :database_url, constructor: Types::String setting :session_secret, constructor: Types::String setting :redis_url, constructor: Types::String.optional setting :log_level, default: "info", constructor: Types::String.enum("debug", "info", "warn", "error") end end
Routes (config/routes.rb)
module MyApp class Routes < Hanami::Routes root to: "home.index"
scope "users" do
get "/", to: "users.index"
get "/new", to: "users.new"
post "/", to: "users.create"
get "/:id", to: "users.show"
patch "/:id", to: "users.update"
delete "/:id", to: "users.destroy"
end
# Mount a slice at a path prefix
slice :api, at: "/api" do
scope "v1" do
get "/users", to: "v1.users.index"
post "/users", to: "v1.users.create"
get "/users/:id", to: "v1.users.show"
end
end
end end
Actions
Web Action
app/actions/users/create.rb
module MyApp module Actions module Users class Create < MyApp::Action include Deps["operations.users.create"]
params do
required(:user).hash do
required(:email).filled(:string)
required(:name).filled(:string)
required(:password).filled(:string, min_size?: 8)
end
end
def handle(request, response)
unless request.params.valid?
response.render(view, errors: request.params.errors)
return
end
result = create.call(request.params[:user])
if result.success?
response.flash[:success] = "User created"
response.redirect_to routes.path(:users_show, id: result.value!.id)
else
response.render(view, errors: result.failure)
end
end
end
end
end end
API Base Action (Slice)
slices/api/action.rb -- JSON-only base with JWT auth and error handlers
module API class Action < Hanami::Action format :json
handle_exception ROM::TupleCountMismatchError => :handle_not_found
handle_exception StandardError => :handle_error
private
def authenticate!
token = request.get_header("HTTP_AUTHORIZATION")&.sub("Bearer ", "")
halt 401, { error: "Missing token" }.to_json unless token
payload = JWT.decode(token, ENV["JWT_SECRET"], true, algorithm: "HS256").first
@current_user = user_repo.find(payload["user_id"])
halt 401, { error: "Invalid token" }.to_json unless @current_user
rescue JWT::DecodeError
halt 401, { error: "Invalid token" }.to_json
end
def handle_not_found(_req, res, _ex) = (res.status = 404; res.body = { error: "Not found" }.to_json)
def handle_error(_req, res, ex) = (Hanami.logger.error(ex); res.status = 500; res.body = { error: "Internal server error" }.to_json)
end end
API Endpoint
slices/api/actions/v1/users/index.rb
module API module Actions module V1 module Users class Index < API::Action include Deps["repositories.user_repo"]
params do
optional(:page).filled(:integer, gt?: 0)
optional(:per_page).filled(:integer, gt?: 0, lteq?: 100)
end
def handle(request, response)
page = request.params[:page] || 1
per_page = request.params[:per_page] || 20
users = user_repo.all_paginated(page: page, per_page: per_page)
response.body = {
users: users.map { |u| { id: u.id, email: u.email, name: u.name } },
meta: { page: page, per_page: per_page, total: user_repo.count }
}.to_json
end
end
end
end
end end
Persistence (ROM)
Relation
lib/myapp/persistence/relations/users.rb
module MyApp module Persistence module Relations class Users < ROM::Relation[:sql] schema(:users, infer: true) do associations do has_many :posts end end
def by_id(id) = where(id: id)
def by_email(e) = where(email: e.downcase)
def active = where(active: true)
def with_posts = combine(:posts)
end
end
end end
Repository
lib/myapp/repositories/user_repo.rb
module MyApp module Repositories class UserRepo < ROM::Repository[:users] include Deps[container: "persistence.rom"]
commands :create, update: :by_pk, delete: :by_pk
def find(id) = users.by_id(id).one
def find_by_email(email) = users.by_email(email).one
def all_active = users.active.to_a
def count = users.count
def all_paginated(page:, per_page:)
users.active
.order { created_at.desc }
.limit(per_page)
.offset((page - 1) * per_page)
.to_a
end
end
end end
Migration
db/migrate/20240115000001_create_users.rb
ROM::SQL.migration do change do create_table :users do primary_key :id column :email, String, null: false, unique: true column :name, String, null: false column :password_digest, String, null: false column :role, String, default: "user" column :active, TrueClass, default: true column :created_at, DateTime, null: false column :updated_at, DateTime, null: false end
add_index :users, :email, unique: true
end end
Operations (Business Logic)
lib/myapp/operations/users/create.rb
require "dry/monads"
module MyApp module Operations module Users class Create include Dry::Monads[:result] include Deps["repositories.user_repo", "services.password_hasher"]
def call(params)
return Failure(email: ["already taken"]) if user_repo.find_by_email(params[:email])
user = user_repo.create(
email: params[:email].downcase.strip,
name: params[:name].strip,
password_digest: password_hasher.hash(params[:password]),
created_at: Time.now,
updated_at: Time.now
)
Success(user)
rescue => e
Hanami.logger.error(e)
Failure(base: ["An unexpected error occurred"])
end
end
end
end end
Views & Providers
app/view.rb -- Base view: set layout, expose shared data
module MyApp class View < Hanami::View config.paths = [File.join(dir, "templates")] config.layout = "app" expose :current_user expose :flash end end
app/views/users/show.rb -- Expose user, add helper methods for templates
module MyApp module Views module Users class Show < MyApp::View expose :user private def user_posts(user) = user.posts.select(&:published?) end end end end
config/providers/services.rb -- Register services in the DI container
Hanami.app.register_provider :services do start do register "services.password_hasher", MyApp::Services::PasswordHasher.new register "services.jwt_encoder", MyApp::Services::JWTEncoder.new end end
Dependencies
Gem Purpose
hanami (~> 2.1), -router , -controller , -view
Framework core
puma (~> 6.0) Application server
rom (> 5.3), rom-sql (> 3.6), pg
Persistence (ROM + PostgreSQL)
dry-types , dry-monads , dry-validation
Type system, results, validation
bcrypt (> 3.1), jwt (> 2.7) Auth (password hashing, tokens)
rspec , rack-test , database_cleaner-sequel , factory_bot
Testing
Advanced Topics
For detailed patterns, validation contracts, interactors, testing strategies, assets, and deployment, see:
- references/patterns.md -- ROM advanced queries, dry-validation contracts, interactor pipelines, testing patterns, asset management, deployment
External References
-
Hanami Guides
-
Hanami API Docs
-
Hanami GitHub
-
ROM Documentation
-
Dry-rb Libraries