hanami
SKILL.md
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 ... endblock inside the action - Always check
request.params.valid?before processing - Use
haltfor early returns (401, 403, 404) - Handle exceptions with
handle_exceptionclass method - Web actions render views; API actions set
response.bodywith JSON
Views & Templates
- Views expose data to templates via
exposedeclarations - 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: truein relation schema to auto-detect columns - Repositories define commands (
:create,update: :by_pk,delete: :by_pk) - Wrap multi-step writes in
transactionblocks - Use
combinefor 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
beforehook orauthenticate!method - Set CSP headers via
config.actions.content_security_policy
Testing
- Use RSpec with
rack-testfor request specs - Use
database_cleaner-sequelwith 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_botfor 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 (rom-sql (pg |
Persistence (ROM + PostgreSQL) |
dry-types, dry-monads, dry-validation |
Type system, results, validation |
bcrypt (jwt ( |
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
Weekly Installs
5
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
Mar 1, 2026
Security Audits
Installed on
opencode5
gemini-cli5
codebuddy5
github-copilot5
codex5
kimi-cli5