crefft Source 2026.10.4

Hanami Style Guide

crefft > Frameworks > Hanami

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends Ruby style guide with Hanami-specific conventions.

Target Version: Hanami 3.0.x with Ruby 4.0 (minimum Ruby 3.3)

These examples target Hanami 3.0 and were written and run against Hanami 3.0.21 with hanami-db 3.0.0, hanami-view 3.0.2 and hanami-router 3.0.0. Projects MUST NOT target Hanami 2.2 while using them: the resource/resources routing DSL used under Routing was added in Hanami 2.3.0, and the persistence, view, and settings APIs shown here are the 3.0 ones.

Quick Reference

All Ruby tooling applies. Additional considerations:

TaskToolCommand
LintStandardRB2bundle exec standardrb
TestRSpecbundle exec rspec
ConsoleHanami CLIbundle exec hanami console
ServerHanami CLIbundle exec hanami server

Why Hanami?

Hanami3 is ideal for architecture-focused web development because it:

  • Clean architecture: Enforces separation of concerns with distinct layers (actions, views, entities, repositories)
  • Explicit over implicit: No magic; dependencies are explicit and configuration is clear
  • Testability: Framework designed for testing with minimal setup and fast test suites
  • Modular monoliths: Slices architecture allows organizing code into bounded contexts within a single application
  • ROM integration: Uses Ruby Object Mapper (ROM) for flexible, powerful data persistence
  • Dependency injection: Built-in container for managing dependencies and testing
  • Modern Ruby: Built for Ruby 3.x+ with performance and developer experience in mind

Use Hanami for greenfield applications where clean architecture matters, especially domain-driven designs and modular monoliths. Choose Rails for rapid prototyping with extensive ecosystem, or Sinatra for simple APIs and microservices.

Project Structure (Slices Architecture)

Projects SHOULD organize code using Hanami’s slices architecture. Each slice represents a bounded context or feature area.

my_app/
├── app/
│   ├── actions/           # App-level actions
│   ├── views/             # App-level views
│   ├── db/                # Relation, repo and struct base classes
│   ├── relations/         # Shared relations
│   ├── repos/             # Shared repositories
│   └── structs/           # Shared structs
├── slices/
│   ├── admin/
│   │   ├── actions/       # Admin-specific actions
│   │   │   └── users/
│   │   │       ├── index.rb
│   │   │       ├── show.rb
│   │   │       └── create.rb
│   │   ├── views/         # Admin views
│   │   ├── repos/         # Admin-only data access
│   │   └── structs/       # Admin-only domain objects
│   ├── api/
│   │   ├── actions/
│   │   │   └── v1/
│   │   │       └── posts/
│   │   │           ├── index.rb
│   │   │           └── create.rb
│   │   └── serializers/
│   └── web/               # Public web interface
│       ├── actions/
│       ├── views/
│       │   └── parts/     # Presentation logic for exposed values
│       └── templates/
├── config/
│   ├── app.rb             # Application configuration
│   ├── routes.rb          # Route definitions
│   ├── db/migrate/        # Database migrations
│   ├── providers/         # Dependency injection providers
│   └── settings.rb        # Environment settings
├── lib/
│   └── my_app/
│       └── types.rb       # Custom types
└── spec/
    ├── slices/
    │   ├── admin/
    │   └── api/
    └── support/

Why slices? Slices provide:

  • Clear boundaries between features
  • Independent testing of each slice
  • Easier refactoring and code navigation
  • Natural evolution from monolith to microservices
  • Shared code only where needed

Actions and Views (Separated Concerns)

Actions MUST handle HTTP concerns only. Views MUST handle presentation logic. This separation ensures testability and maintainability.

Actions

# slices/web/actions/posts/index.rb
module Web
  module Actions
    module Posts
      class Index < Web::Action
        include Deps["repos.post_repo"]

        def handle(_request, response)
          response.render view, posts: post_repo.published
        end
      end
    end
  end
end
# slices/api/actions/v1/posts/create.rb
module API
  module Actions
    module V1
      module Posts
        class Create < API::Action
          include Deps[
            repo: "repos.post_repo",
            validator: "validators.post"
          ]

          def handle(request, response)
            result = validator.call(request.params[:post])

            if result.success?
              post = repo.create(result.to_h)
              response.status = 201
              response.format = :json
              response.body = { post: serialize(post) }.to_json
            else
              response.status = 422
              response.format = :json
              response.body = { errors: result.errors.to_h }.to_json
            end
          end

          private

          def serialize(post)
            {
              id: post.id,
              title: post.title,
              body: post.body,
              created_at: post.created_at.iso8601
            }
          end
        end
      end
    end
  end
end

Before/After Callbacks

Sessions are disabled by default, so an action that reads request.session MUST be backed by session configuration in the app class, and every collaborator a callback uses MUST be declared through Deps. Callbacks MUST NOT store per-request state in instance variables: Hanami memoizes one action instance per component key and reuses it across requests. Pass state between callbacks through response[] instead.

Why: an action that reads a disabled session, calls an undeclared method, or mutates shared instance state either raises on the first request or leaks one visitor’s identity into another’s.

# config/app.rb
require "hanami"

module MyApp
  class App < Hanami::App
    config.actions.sessions = :cookie, {
      key: "my_app.session",
      secret: settings.session_secret,
      expire_after: 60 * 60 * 24 * 7,
      secure: Hanami.env?(:production),
      httponly: true,
      same_site: :lax
    }

    # Repos live in the app container; slices only receive the standard
    # components unless the app shares them explicitly.
    config.shared_app_component_keys += ["repos.post_repo", "repos.user_repo"]
  end
end
# app/repos/user_repo.rb
module MyApp
  module Repos
    class UserRepo < MyApp::DB::Repo
      # `one` returns nil for an unknown id; `one!` would raise.
      def find(id) = users.by_pk(id).one
    end
  end
end
# app/structs/user.rb
module MyApp
  module Structs
    class User < MyApp::DB::Struct
      # The column is `admin`; ROM structs expose it without the question mark.
      def admin? = admin
    end
  end
end
# slices/admin/actions/dashboard/index.rb
module Admin
  module Actions
    module Dashboard
      class Index < Admin::Action
        include Deps["repos.user_repo"]

        before :authenticate!
        before :authorize!

        def handle(_request, response)
          response.render view
        end

        private

        # Authentication: is there a real user behind this session?
        def authenticate!(request, response)
          user_id = request.session[:user_id]
          halt 401 unless user_id

          user = user_repo.find(user_id)
          halt 401 unless user

          response[:current_user] = user
        end

        # Authorization: is that user allowed here?
        def authorize!(_request, response)
          halt 403 unless response[:current_user].admin?
        end
      end
    end
  end
end

A stale user_id — a session cookie for a deleted account — MUST produce 401 rather than a NoMethodError on nil. Cover all four cases:

# spec/requests/admin_spec.rb
RSpec.describe "Admin dashboard", :db, type: :request do
  let(:users) { Hanami.app["relations.users"] }

  def sign_in(user_id) = env("rack.session", {user_id: user_id})

  it "returns 401 without a session" do
    get "/admin"
    expect(last_response.status).to be(401)
  end

  it "returns 401 for a stale session id" do
    sign_in(12_345)
    get "/admin"
    expect(last_response.status).to be(401)
  end

  it "returns 403 for a non-admin user" do
    sign_in(users.changeset(:create, email: "a@example.com", admin: false).commit[:id])
    get "/admin"
    expect(last_response.status).to be(403)
  end

  it "renders the dashboard for an admin user" do
    sign_in(users.changeset(:create, email: "root@example.com", admin: true).commit[:id])
    get "/admin"
    expect(last_response.status).to be(200)
  end
end

Views and Templates

Views SHOULD contain presentation logic only, never business logic.

Presentation behavior attached to a domain object MUST live in a Hanami View part rather than an ad hoc presenter class. Exposures are undecorated by default in Hanami 3, so a view MUST ask for decoration explicitly with decorate or decorate: true.

Why: parts are resolved by the framework from the exposure name, are bound to the value they decorate, and can be tested in isolation. A hand-rolled presenter has to be instantiated by hand in every exposure, and — as below — is easy to place at a path Zeitwerk cannot load.

# slices/web/views/posts/index.rb
module Web
  module Views
    module Posts
      class Index < Web::View
        # The action supplies `posts:`; `decorate` exposes that input and wraps
        # each element in Web::Views::Parts::Post.
        decorate :posts

        expose :page_title do
          "All Posts"
        end
      end
    end
  end
end

Exposure block parameters are not interchangeable. A keyword parameter reads view input; a positional parameter names another exposure to depend on. Given an action calling response.render view, posts: post_repo.published:

# Don't: `posts` is read as a dependency on an exposure named :posts,
# which does not exist. Rendering raises KeyError: key not found: :posts.
expose :posts do |posts|
  posts
end

# Do: a keyword parameter reads the supplied input.
expose :posts, decorate: true do |posts:|
  posts
end
<!-- slices/web/templates/posts/index.html.erb -->
<h1><%= page_title %></h1>

<ul>
  <% posts.each do |post| %>
    <li>
      <h2><%= post.title %></h2>
      <p><%= post.excerpt %></p>
      <small>Posted <%= post.published_at_formatted %></small>
    </li>
  <% end %>
</ul>

Parts live under Views::Parts in the app or slice, and the file path MUST match the constant path: Hanami autoloads with Zeitwerk, so slices/web/views/parts/post.rb MUST define Web::Views::Parts::Post. Part methods MUST NOT depend on undeclared core-class extensions such as ActiveSupport’s String#truncate.

Why: a file whose constant path does not match its location raises Zeitwerk::NameError on eager load and NameError on first reference in development. String#truncate is not part of Ruby or of any gem this guide declares, so "body".truncate(200) raises NoMethodError on the first render.

# slices/web/views/parts/post.rb
module Web
  module Views
    module Parts
      class Post < Web::Views::Part
        EXCERPT_LENGTH = 200
        ELLIPSIS = "\u2026"

        def excerpt
          text = body.to_s
          return text if text.length <= EXCERPT_LENGTH

          "#{text[0, EXCERPT_LENGTH].rstrip}#{ELLIPSIS}"
        end

        def published_at_formatted = created_at.strftime("%B %d, %Y")
      end
    end
  end
end

String#[] and String#length count characters, not bytes, so this truncation is multibyte-safe. Test the boundaries:

# spec/slices/web/views/parts/post_spec.rb
RSpec.describe Web::Views::Parts::Post do
  def part(body)
    described_class.new(value: Struct.new(:body, :created_at).new(body, Time.now))
  end

  it "returns a short body unchanged" do
    expect(part("short").excerpt).to eq("short")
  end

  it "truncates a long body" do
    expect(part("b" * 500).excerpt).to eq("#{"b" * 200}\u2026")
  end

  it "handles empty and nil bodies" do
    expect(part("").excerpt).to eq("")
    expect(part(nil).excerpt).to eq("")
  end

  it "truncates multibyte bodies by character" do
    expect(part("\u3042" * 500).excerpt.length).to eq(201)
  end
end

Repositories and Relations (ROM-based Persistence)

Hanami uses ROM (Ruby Object Mapper)4 for data persistence. Repositories MUST encapsulate all data access. Relations SHOULD define queries and transformations.

Hanami 3 has no Hanami::Entity and no Hanami::DB::Repository. The three persistence base classes are Hanami::DB::Relation, Hanami::DB::Repo, and Hanami::DB::Struct, each subclassed once per app or slice in app/db/{relation,repo,struct}.rb by hanami new. Concrete classes MUST inherit from those local base classes and live in the generated Relations, Repos, and Structs namespaces.

Why: Hanami::Entity and Hanami::DB::Repository do not exist — referencing either raises NameError before any query runs. The namespaces are the conventional locations the generators use and the ones the container’s auto-registration and struct lookup expect (repos.post_repo, MyApp::Structs::Post).

Structs

Structs are the output of a repository: immutable projections of requested data, with no persistence logic. Attribute types come from the relation schema, so a struct class is only needed when adding behavior.

# app/structs/post.rb
module MyApp
  module Structs
    class Post < MyApp::DB::Struct
      def draft? = !published
    end
  end
end

Relations

# app/relations/posts.rb
module MyApp
  module Relations
    class Posts < MyApp::DB::Relation
      schema :posts, infer: true do
        associations do
          belongs_to :author
        end
      end

      def published = where(published: true).order { created_at.desc }

      def by_author(author_id) = where(author_id: author_id)

      def recent(limit = 10) = order { created_at.desc }.limit(limit)
    end
  end
end

Repositories

Repositories MUST provide a clean interface for data operations. Writes MUST go through a ROM changeset or command; relations expose no create, and update/delete on a relation return affected rows rather than the struct the caller needs.

Why: posts.create(attributes) raises NoMethodError. A changeset returns the committed struct, so callers can read the generated id without a second query, and map(:add_timestamps)/map(:touch) fill created_at/updated_at without the caller supplying them.

# app/repos/post_repo.rb
module MyApp
  module Repos
    class PostRepo < MyApp::DB::Repo
      def all = posts.to_a
      def published = posts.published.to_a
      def get!(id) = posts.by_pk(id).one!
      def recent(limit: 10) = posts.recent(limit).to_a
      def by_author(author_id) = posts.by_author(author_id).to_a

      def create(attributes)
        posts.changeset(:create, attributes).map(:add_timestamps).commit
      end

      def update(id, attributes)
        posts.by_pk(id).changeset(:update, attributes).map(:touch).commit
      end

      def delete(id) = posts.by_pk(id).changeset(:delete).commit

      # Associated data, loaded through the relation's associations
      def with_author(id) = posts.by_pk(id).combine(:author).one!
    end
  end
end

Dependency Injection Container

Hanami’s dependency injection container SHOULD be used for managing dependencies. This improves testability and makes dependencies explicit.

Providers

Providers SHOULD define how dependencies are created and configured.

A provider MUST resolve everything it needs from target into local variables before entering a third-party DSL that is instance-evaluated.

Why: Mail.defaults runs its block through Configuration.instance.instance_eval, so self inside the block is a Mail::Configuration. target is a method on the provider source, not on that object, and calling it there raises NameError: undefined local variable or method 'target'. Local variables, by contrast, are captured by the block’s closure and remain visible.

# config/providers/mailer.rb
Hanami.app.register_provider :mailer do
  prepare do
    require "mail"
  end

  start do
    settings = target["settings"]

    smtp_options = {
      address: settings.smtp_host,
      port: settings.smtp_port,
      user_name: settings.smtp_username,
      password: settings.smtp_password,
      authentication: :plain,
      enable_starttls_auto: true
    }

    # Never open an SMTP connection from the test suite.
    adapter, adapter_options =
      Hanami.env?(:test) ? [:test, {}] : [:smtp, smtp_options]

    Mail.defaults do
      delivery_method adapter, adapter_options
    end

    register "mailer", Mail
  end
end

Every setting the provider reads — smtp_host, smtp_port, smtp_username, smtp_password — MUST be declared in config/settings.rb; see Configuration and Settings.

Using Dependencies

Request parameters are untrusted input. An action that writes to the database MUST declare a params schema covering only the writable fields, MUST check request.params.valid? before persisting, and MUST pass the validated result — never the raw request hash — to the repository.

Why: repo.create(request.params[:post]) forwards every key the client sent. Posting post[id]=4321&post[author_id]=1&post[published]=true against that action writes all three straight into the row, letting a visitor choose primary keys, reassign authorship, and publish without review. A schema discards anything it does not name.

# slices/web/actions/posts/create.rb
module Web
  module Actions
    module Posts
      class Create < Web::Action
        include Deps[
          repo: "repos.post_repo",
          logger: "logger"
        ]

        params do
          required(:post).hash do
            required(:title).filled(:string, min_size?: 5)
            required(:body).filled(:string)
            optional(:published).filled(:bool)
          end
        end

        def handle(request, response)
          unless request.params.valid?
            response.status = 422
            response.render view, errors: request.params.errors
            return
          end

          post = repo.create(request.params[:post])
          logger.info "Post created: #{post.id}"
          response.redirect_to routes.path(:post, id: post.id)
        end
      end
    end
  end
end

Prove the allowlist rather than assuming it:

# spec/requests/posts_spec.rb
RSpec.describe "Posts", :db, type: :request do
  let(:post_repo) { Hanami.app["repos.post_repo"] }

  it "discards params outside the allowlist" do
    post "/posts", post: {title: "Valid title", body: "Body", id: 999, author_id: 42}

    expect(last_response.status).to be(302)
    created = post_repo.all.last
    expect(created.id).not_to eq(999)
    expect(created.author_id).to be_nil
  end

  it "rejects invalid input without persisting" do
    post "/posts", post: {title: "no", body: ""}

    expect(last_response.status).to be(422)
    expect(post_repo.all).to be_empty
  end
end

Testing with Dependency Injection

# spec/slices/web/actions/posts/create_spec.rb
RSpec.describe Web::Actions::Posts::Create do
  let(:repo) { instance_double("MyApp::Repos::PostRepo", create: double("post", id: 1)) }
  let(:logger) { instance_double("Logger", info: nil) }
  let(:action) { described_class.new(repo: repo, logger: logger) }

  it "creates a post and logs" do
    action.call(post: {title: "Test title", body: "Body"})

    expect(repo).to have_received(:create)
    expect(logger).to have_received(:info).with("Post created: 1")
  end
end

See Testing Actions for the full set of cases.

Configuration and Settings

Projects MUST use environment-specific settings. Settings SHOULD be type-safe using Dry::Types.

Settings sourced from the environment MUST use the Types::Params::* coercing constructors, not the strict Types::* ones. Security-sensitive settings SHOULD carry a constraint.

Why: environment variables always arrive as strings. PORT=2300 with constructor: Types::Integer raises Dry::Types::ConstraintError: "2300" violates constraints, and ENABLE_API=false with Types::Bool fails the same way — the app refuses to boot the moment anyone sets the variable. Types::Params::Integer and Types::Params::Bool coerce "2300" to 2300 and "false" to false. Constraining session_secret to Hanami’s documented minimum of 32 characters stops a short, guessable cookie-signing key reaching production.

# config/settings.rb
module MyApp
  class Settings < Hanami::Settings
    setting :database_url, constructor: Types::String
    setting :port, default: 2300, constructor: Types::Params::Integer
    setting :host, default: "0.0.0.0", constructor: Types::String
    setting :enable_api, default: true, constructor: Types::Params::Bool
    setting :session_secret, constructor: Types::String.constrained(min_size: 32)
    setting :smtp_host, constructor: Types::String
    setting :smtp_port, default: 587, constructor: Types::Params::Integer
    setting :smtp_username, constructor: Types::String.optional
    setting :smtp_password, constructor: Types::String.optional
  end
end

Secrets MUST NOT be committed. Hanami loads .env.{environment}.local before .env.{environment} and .env, so keep shared, non-secret defaults in the tracked files and real secrets in the ignored .local ones:

# .env (tracked, loaded in development and test: no secrets)
DATABASE_URL=sqlite://db/my_app.sqlite
PORT=2300
ENABLE_API=false
SMTP_HOST=localhost
SMTP_PORT=1025
# .env.test (tracked: throwaway value, never used to sign a real session)
SESSION_SECRET=0000000000000000000000000000000000000000000000000000000000000000
# .env.development.local (git-ignored; development only)
SESSION_SECRET=replace-with-64-hex-chars-from-securerandom-hex-32
SMTP_USERNAME=mailer@example.com
SMTP_PASSWORD=replace-me

.env.local is not loaded in the test environment, which is why the test secret lives in .env.test. Production MUST supply SESSION_SECRET and the SMTP credentials from the deployment environment or a secret manager, never from a dotenv file.

# spec/guide/settings_spec.rb
RSpec.describe MyApp::Settings do
  it "coerces string environment values" do
    settings = described_class.new(
      database_url: "sqlite://db/x.sqlite",
      port: "2300",
      enable_api: "false",
      session_secret: "0" * 64,
      smtp_host: "localhost"
    )

    expect(settings.port).to eq(2300)
    expect(settings.enable_api).to be(false)
  end

  it "rejects a short session secret" do
    expect {
      described_class.new(
        database_url: "sqlite://db/x.sqlite",
        session_secret: "short",
        smtp_host: "localhost"
      )
    }.to raise_error(Hanami::Settings::InvalidSettingsError, /session_secret/)
  end
end

Routing

Routes MUST be defined in config/routes.rb. Routes SHOULD be organized by slice.

A route target names an action within the container it is resolved against; the resolver supplies the actions. prefix itself. App-level routes therefore target "home.index", and routes inside slice :web target "posts.index". scope changes only path and route-name prefixes, so a versioned resource MUST name its action namespace with to:.

Why: the resolver concatenates actions. with the given target. Booting root to: "web.actions.home.index" and requesting / raises Hanami::Routes::MissingActionError: Could not find action with key "actions.web.actions.home.index" in MyApp::App; to: "actions.posts.index" inside a slice looks for actions.actions.posts.index. Likewise scope "/v1" { resources :posts } generates to: "posts.create", which never reaches API::Actions::V1::Posts::Create — only resources :posts, to: "v1.posts" produces v1.posts.create.

# config/routes.rb
module MyApp
  class Routes < Hanami::Routes
    root to: "home.index"

    slice :web, at: "/" do
      get "/posts", to: "posts.index"
      get "/posts/:id", to: "posts.show", as: :post
      post "/posts", to: "posts.create"
    end

    slice :admin, at: "/admin" do
      get "/", to: "dashboard.index"
      resources :users, only: [:index, :show, :create]
    end

    slice :api, at: "/api" do
      scope "/v1" do
        resources :posts, to: "v1.posts", only: [:index, :show, :create, :update, :destroy]
      end
    end

    get "/health", to: ->(*) { [200, {}, ["OK"]] }
  end
end

bundle exec hanami routes prints the resolved target and helper name for every route, and SHOULD be checked after editing this file:

GET     /                    home.index          as :root
GET     /posts               posts.index
GET     /posts/:id           posts.show          as :post
POST    /posts               posts.create
GET     /admin               dashboard.index
GET     /admin/users         users.index         as :admin_users
GET     /admin/users/:id     users.show          as :admin_user
POST    /admin/users         users.create        as :admin_users
GET     /api/v1/posts        v1.posts.index      as :api_v1_posts
GET     /api/v1/posts/:id    v1.posts.show       as :api_v1_post
POST    /api/v1/posts        v1.posts.create     as :api_v1_posts
PATCH   /api/v1/posts/:id    v1.posts.update     as :api_v1_post
DELETE  /api/v1/posts/:id    v1.posts.destroy    as :api_v1_post
GET     /health              (proc)

Route helpers MUST use the names in that as column — routes.path(:post, id: post.id) for the show route above, not an invented :posts_show.

The resources and resource DSL was added in Hanami 2.3.0; apps still on 2.2 have to write each route out longhand.

Validation

Projects MUST use Hanami validations (based on dry-validation) for input validation.

# lib/my_app/validators/post.rb
module MyApp
  module Validators
    class Post < Hanami::Validator
      params do
        required(:title).filled(:string)
        required(:body).filled(:string)
        optional(:published).filled(:bool)
      end

      rule(:title) do
        key.failure("must be at least 5 characters") if value.length < 5
      end
    end
  end
end

Testing Patterns

Hanami applications MUST be tested at multiple levels: unit, integration, and system tests.

Testing Actions

Unit action specs MUST inject exactly the dependencies the action declares through Deps, and MUST assert the real status and side effects.

# spec/slices/web/actions/posts/create_spec.rb
RSpec.describe Web::Actions::Posts::Create do
  let(:created_post) { double("post", id: 1) }
  let(:repo) { instance_double("MyApp::Repos::PostRepo", create: created_post) }
  let(:logger) { instance_double("Logger", info: nil) }
  let(:action) { described_class.new(repo: repo, logger: logger) }

  it "persists only allowlisted fields and redirects" do
    response = action.call(post: {title: "Test title", body: "Body", id: 999})

    expect(response.status).to be(302)
    expect(response.headers["Location"]).to eq("/posts/1")
    expect(repo).to have_received(:create).with(title: "Test title", body: "Body")
    expect(logger).to have_received(:info).with("Post created: 1")
  end

  it "responds 422 for invalid params without persisting" do
    response = action.call(post: {title: "no", body: ""})

    expect(response.status).to be(422)
    expect(repo).not_to have_received(:create)
  end
end

Testing Repositories

Repositories are container components, so resolve them from the app rather than calling new: MyApp::DB::Repo needs the ROM container its relations live in.

# spec/repos/post_repo_spec.rb
RSpec.describe MyApp::Repos::PostRepo, :db do
  subject(:repo) { Hanami.app["repos.post_repo"] }

  describe "#published" do
    it "returns only published posts" do
      published = repo.create(title: "Published", body: "Body", published: true)
      repo.create(title: "Draft", body: "Body", published: false)

      posts = repo.published

      expect(posts.size).to eq(1)
      expect(posts.first.id).to eq(published.id)
    end
  end
end

Integration Tests

# spec/requests/posts_index_spec.rb
RSpec.describe "Posts", :db, type: :request do
  describe "GET /posts" do
    it "lists published posts" do
      Hanami.app["repos.post_repo"].create(
        title: "Test Post",
        body: "Body",
        published: true
      )

      get "/posts"

      expect(last_response).to be_ok
      expect(last_response.body).to include("Test Post")
    end
  end
end

See Also

References

Footnotes

  1. Hanami CHANGELOG - Release history; resource/resources routing arrived in v2.3.0 (2025-11-12), and 3.0.2 is the release these examples were run against ↩

  2. StandardRB - Ruby style guide, linter, and formatter ↩

  3. Hanami - Modern Ruby web framework ↩

  4. ROM (Ruby Object Mapper) - Data mapping and persistence toolkit ↩