hanami

Applies to: Hanami 2.x, Ruby 3.1+, Web Applications, APIs, Domain-Driven Design, Clean Architecture

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "hanami" with this command: npx skills add ar4mirez/samuel/ar4mirez-samuel-hanami

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")&#x26;.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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

actix-web

No summary provided by upstream source.

Repository SourceNeeds Review
General

frontend-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

blazor

No summary provided by upstream source.

Repository SourceNeeds Review
General

fastapi

No summary provided by upstream source.

Repository SourceNeeds Review