Back to Scaling Ruby Applications guides

Ruby on Rails vs Hanami

Stanley Ulili
Updated on October 10, 2025

Ruby developers building web applications typically reach for Rails by default. But Hanami offers a different take on full-stack Ruby development, prioritizing explicit code and modular architecture over Rails' convention-heavy approach. Both frameworks aim to help you build complete web applications, yet they diverge sharply in how they structure code and manage dependencies.

This comparison explores how Rails and Hanami handle common development tasks, revealing where each framework shines and helping you decide which matches your project needs.

What is Ruby on Rails?

Screenshot of Ruby on Rails Github page

Ruby on Rails has defined full-stack Ruby web development since 2004. Rails bundles everything needed to build database-backed web applications: an ORM (ActiveRecord), routing, view rendering, background jobs, caching, and testing tools.

The framework follows "convention over configuration," reducing boilerplate through naming patterns and implicit connections. When you create an ArticlesController, Rails automatically looks for Article models and renders views from app/views/articles/. This magic accelerates development but requires understanding Rails' many conventions.

Rails remains the dominant Ruby web framework, powering GitHub, Shopify, Basecamp, and thousands of other applications. The ecosystem includes gems for virtually any web development need.

What is Hanami?

Screenshot of Hanami

Hanami emerged in 2014 as a rethinking of full-stack Ruby frameworks. Hanami provides the same components as Rails (routing, models, views, and more) but organizes them differently, favoring explicit dependencies and isolated components over global state and conventions.

The framework applies object-oriented design principles strictly. Instead of ActiveRecord's approach where models inherit database behavior, Hanami separates persistence from business logic through repository objects. Rather than controller classes with many actions, Hanami uses single-action classes. These architectural choices reduce coupling and improve testability.

Hanami 2.0, released in 2022, rebuilt the framework around dry-rb libraries, bringing modern Ruby patterns like dependency injection and functional programming concepts to full-stack development.

Quick Comparison

Feature Ruby on Rails Hanami
Approach Convention over configuration Explicit over implicit
Architecture Monolithic with modules Modular with slices
ORM ActiveRecord (Active Record pattern) ROM (Repository pattern)
Controllers Multi-action controller classes Single-action classes
Views Template-centric with helpers View objects with templates
Routing Resourceful with conventions Explicit route definitions
Dependencies Global state and autoloading Dependency injection container
Testing Integration-focused by default Unit testing encouraged
Memory Usage ~60-100MB base ~40-60MB base
Maturity 20+ years, stable 10 years, evolving

Project initialization

I wanted to build a library management system, so I created new projects in both frameworks. Rails installation and setup:

 
gem install rails
rails new library
cd library

This generated 87 files before I wrote any code. A database configuration, web server setup, and complete directory structure appeared instantly. The sheer amount of generated scaffolding felt overwhelming. I hadn't written anything yet but already had views, controllers, and configuration spread across dozens of directories.

Hanami follows a similar installation pattern:

 
gem install hanami
hanami new library
cd library

This created 43 files. Less scaffolding meant less to understand upfront, but also less hand-holding. Where Rails assumed I'd use PostgreSQL and gave me database.yml configured for it, Hanami made me think about these choices.

Starting both servers revealed another difference:

 
# Rails
rails server

# Hanami
hanami server

Rails felt noticeably slower to boot and used more memory sitting idle. Hanami started faster with a lighter footprint. For development, those extra seconds waiting for Rails to restart after code changes accumulated into real time lost.

Routing decisions

Adding book management routes showed how differently these frameworks think. In Rails, I wrote resources :books in routes.rb and got seven routes instantly. Rails assumed I wanted index, show, new, create, edit, update, and destroy actions because that's what CRUD applications need.

config/routes.rb
Rails.application.routes.draw do
  resources :books
  resources :authors do
    resources :books  # Nested routes work automatically
  end
end

Running rails routes showed Rails generated URLs, HTTP verbs, and controller mappings for me. This felt productive until I needed custom routes. Then I had to remember Rails' routing DSL: the difference between member and collection routes, how concerns work, when to use scope versus namespace.

Hanami made me write each route explicitly:

Hanami: config/routes.rb
module Library
  class Routes < Hanami::Routes
    get "/books", to: "books.index"
    get "/books/:id", to: "books.show"
    post "/books", to: "books.create"
    get "/books/new", to: "books.new"
    # ... and so on
  end
end

This verbosity annoyed me initially. Why write five lines when Rails needs one? But debugging routing issues later, I appreciated seeing exactly which URLs mapped where. No guessing about what resources :books actually generated or why a nested route wasn't working as expected.

Controller structure

Creating a books controller revealed fundamentally different architectures. Rails gave me a controller class with seven empty methods waiting for me to fill them:

app/controllers/books_controller.rb
class BooksController < ApplicationController
  def index
    @books = Book.all
  end

  def show
    @book = Book.find(params[:id])
  end

  def create
    @book = Book.new(book_params)
    if @book.save
      redirect_to @book
    else
      render :new
    end
  end

  private

  def book_params
    params.require(:book).permit(:title, :author, :isbn)
  end
end

One class handling seven different HTTP requests felt natural from years of Rails experience. But when I wanted to test just the create action, I had to instantiate the entire controller class with all its callbacks, filters, and inherited behavior.

Hanami split each action into its own class:

app/actions/books/index.rb
module Library
  module Actions
    module Books
      class Index < Library::Action
        def handle(request, response)
          books = book_repo.all
          response.render view, books: books
        end
      end
    end
  end
end
app/actions/books/create.rb
module Library
  module Actions
    module Books
      class Create < Library::Action
        params do
          required(:book).hash do
            required(:title).filled(:string)
            required(:author).filled(:string)
            required(:isbn).filled(:string)
          end
        end

        def handle(request, response)
          if request.params.valid?
            book = book_repo.create(request.params[:book])
            response.redirect_to routes.path(:book, id: book.id)
          else
            response.render view
          end
        end
      end
    end
  end
end

More files, more classes, more boilerplate. But each action became completely isolated. Testing the create action meant instantiating just that one class. No worrying about before_action callbacks from the parent controller or shared state between actions. The explicitness felt verbose until I needed to refactor. Then moving one action to a different domain slice took seconds because nothing else depended on it.

Data persistence patterns

Saving books to the database exposed the biggest divide between these frameworks. Rails' ActiveRecord made the Book class responsible for both business logic and database operations:

app/models/book.rb
class Book < ApplicationRecord
  belongs_to :author
  has_many :reviews

  validates :title, presence: true
  validates :isbn, uniqueness: true, format: { with: /\A\d{13}\z/ }

  def available?
    !checked_out_at.present?
  end

  def check_out!(user)
    update!(checked_out_at: Time.current, checked_out_by: user)
  end

  scope :available, -> { where(checked_out_at: nil) }
end

# Using it
book = Book.find(params[:id])
book.check_out!(current_user)
book.save

Everything lived in one place. The Book object knew how to validate itself, save itself, and query for other books. This convenience meant I could write Book.where(available: true).order(:title) and get exactly what I needed without ceremony.

The downside hit when testing. Every Book test required a database connection because the object couldn't exist without ActiveRecord. Testing business logic like available? required setting up database transactions and fixtures even though no data actually needed persisting.

Hanami separated these concerns completely. Entities held business logic, repositories handled persistence:

lib/library/entities/book.rb
module Library
  module Entities
    class Book
      attr_reader :id, :title, :author, :isbn, :checked_out_at

      def initialize(attributes)
        @id = attributes[:id]
        @title = attributes[:title]
        @author = attributes[:author]
        @isbn = attributes[:isbn]
        @checked_out_at = attributes[:checked_out_at]
      end

      def available?
        checked_out_at.nil?
      end

      def checked_out?
        !available?
      end
    end
  end
end
lib/library/repositories/book_repo.rb
module Library
  module Repositories
    class BookRepo < Hanami::Repository
      def all_available
        books.where(checked_out_at: nil).to_a
      end

      def check_out(book_id, user_id)
        books.by_pk(book_id).update(
          checked_out_at: Time.now,
          user_id: user_id
        )
      end

      def find_by_isbn(isbn)
        books.where(isbn: isbn).one
      end
    end
  end
end

# Using it
book = book_repo.find(params[:id])
if book.available?
  book_repo.check_out(book.id, current_user.id)
end

More code, more classes, more indirection. But testing Book#available? now required zero database setup. Just instantiate a Book with test data. The repository tests hit the database, the entity tests didn't. This separation seemed academic until I had 200 tests running and Hanami's entity tests completed in 0.3 seconds while Rails' model tests took 8 seconds loading ActiveRecord.

View rendering strategies

Displaying book lists showed another architectural split. Rails mixed Ruby logic and HTML templates, using instance variables to pass data from controllers:

app/controllers/books_controller.rb
def index
  @books = Book.includes(:author).order(:title)
end
app/views/books/index.html.erb
<h1>Library Books</h1>
<ul>
  <% @books.each do |book| %>
    <li>
      <%= link_to book.title, book_path(book) %> by <%= book.author.name %>
      <% if book.available? %>
        <span class="badge">Available</span>
      <% end %>
    </li>
  <% end %>
</ul>

This worked fine for simple views. But as presentation logic grew (formatting dates, handling nil values, determining CSS classes), my templates filled with conditionals and helper method calls. Where should the logic for "format author name as Last, First" live? In the template? A helper module? The model?

Hanami introduced view objects between actions and templates:

app/views/books/index.rb
module Library
  module Views
    module Books
      class Index < Library::View
        expose :books do |books:|
          books.map { |book| BookPresenter.new(book) }
        end
      end
    end
  end
end
 
[label app/presenters/book_presenter.rb
module Library
  class BookPresenter
    def initialize(book)
      @book = book
    end

    def title
      @book.title
    end

    def author_name
      "#{@book.author.last_name}, #{@book.author.first_name}"
    end

    def availability_badge
      @book.available? ? "available" : "checked-out"
    end
  end
end
app/templates/books/index.html.erb
<h1>Library Books</h1>
<ul>
  <% books.each do |book| %>
    <li>
      <%= book.title %> by <%= book.author_name %>
      <span class="badge badge-<%= book.availability_badge %>">
        <%= book.availability_badge.titleize %>
      </span>
    </li>
  <% end %>
</ul>

Extra presenter classes felt like overkill initially. But when product requirements changed and authors needed different display formats across five different views, updating one presenter method beat hunting through five templates for scattered formatting logic.

Form handling complexity

Building the book creation form pushed me into each framework's validation strategy. Rails integrated validations into models, making forms feel like they "just worked":

app/models/book.rb
class Book < ApplicationRecord
  validates :title, presence: true, length: { minimum: 3 }
  validates :isbn, presence: true, format: { with: /\A\d{13}\z/ }, uniqueness: true
  validates :publication_year, numericality: { 
    greater_than: 1450, 
    less_than_or_equal_to: Date.current.year 
  }
end
app/views/books/new.html.erb
<%= form_with model: @book do |f| %>
  <% if @book.errors.any? %>
    <div class="errors">
      <ul>
        <% @book.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= f.label :title %>
  <%= f.text_field :title %>

  <%= f.label :isbn %>
  <%= f.text_field :isbn %>

  <%= f.submit %>
<% end %>

Everything connected automatically. Invalid data triggered model validations, errors appeared in the form, field values persisted across failed submissions. Rails' form helpers knew to check @book.errors and highlight invalid fields.

The catch appeared when I needed different validation rules for different contexts. Creating a book required an ISBN, but importing legacy books from old records didn't have ISBNs. Rails' solution involved conditional validations with if: and unless:, which quickly became tangled when multiple contexts shared some but not all rules.

Hanami validated at the action level, making each endpoint's requirements explicit:

app/actions/books/create.rb
module Library
  module Actions
    module Books
      class Create < Library::Action
        params do
          required(:book).hash do
            required(:title).filled(:string, min_size?: 3)
            required(:isbn).filled(:string, format?: /\A\d{13}\z/)
            required(:publication_year).filled(:integer, gteq?: 1450, lteq?: Date.today.year)
          end
        end

        def handle(request, response)
          if request.params.valid?
            book = book_repo.create(request.params[:book])
            response.redirect_to routes.path(:book, id: book.id)
          else
            response.status = 422
            response.render view, errors: request.params.errors
          end
        end
      end
    end
  end
end

Now my legacy book import action defined different rules without affecting the create action:

app/actions/books/import.rb
module Library
  module Actions
    module Books
      class Import < Library::Action
        params do
          required(:book).hash do
            required(:title).filled(:string)
            optional(:isbn).maybe(:string)  # ISBN optional for imports
            required(:publication_year).filled(:integer)
          end
        end
        # ... rest of action
      end
    end
  end
end

Each action's validations lived right there in the action file. No hunting through model callbacks or conditional validation logic. The price was duplication. If both actions needed the same title validation, I wrote it twice. Hanami's answer was extracting shared validation schemas, but that meant more objects to manage.

Testing strategies

Writing tests revealed how architectural decisions affect testing approaches. Rails' integrated testing felt straightforward for the first few tests:

test/models/book_test.rb
class BookTest < ActiveSupport::TestCase
  test "book is available when not checked out" do
    book = books(:available_book)
    assert book.available?
  end

  test "book is not available when checked out" do
    book = books(:checked_out_book)
    assert_not book.available?
  end
end
test/controllers/books_controller_test.rb
class BooksControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get books_url
    assert_response :success
    assert_select "h1", "Library Books"
  end

  test "should create book" do
    assert_difference("Book.count") do
      post books_url, params: { 
        book: { 
          title: "Test Book", 
          isbn: "9781234567890",
          publication_year: 2024
        } 
      }
    end
    assert_redirected_to book_url(Book.last)
  end
end

These tests required loading Rails' entire testing infrastructure: database transactions, fixture loading, request/response cycle simulation. Running 50 model tests took 12 seconds because each one went through ActiveRecord even when testing pure business logic.

Hanami's separated architecture let me test entities without touching the database:

spec/unit/entities/book_spec.rb
RSpec.describe Library::Entities::Book do
  describe "#available?" do
    it "returns true when checked_out_at is nil" do
      book = described_class.new(
        id: 1,
        title: "Test Book",
        checked_out_at: nil
      )

      expect(book.available?).to be true
    end

    it "returns false when checked_out_at has a value" do
      book = described_class.new(
        id: 1,
        title: "Test Book",
        checked_out_at: Time.now
      )

      expect(book.available?).to be false
    end
  end
end

These entity tests ran in 0.4 seconds total. No database, no framework loading. Repository tests hit the database when actually needed:

spec/integration/repositories/book_repo_spec.rb
RSpec.describe Library::Repositories::BookRepo, :db do
  let(:repo) { described_class.new }

  describe "#all_available" do
    it "returns only books without checked_out_at" do
      available = repo.create(title: "Available", checked_out_at: nil)
      checked_out = repo.create(title: "Checked Out", checked_out_at: Time.now)

      result = repo.all_available

      expect(result).to include(available)
      expect(result).not_to include(checked_out)
    end
  end
end

I ran entity tests constantly while developing because they completed instantly. Repository and action tests ran before commits since they needed database setup. Rails collapsed these distinctions. Everything touched the database whether it needed to or not.

Dependency management

Adding background jobs for overdue book notifications showed completely different approaches to managing dependencies. Rails used global state. I could call Book.find(id) from anywhere because ActiveRecord was always available:

app/jobs/overdue_notification_job.rb
class OverdueNotificationJob < ApplicationJob
  def perform(book_id)
    book = Book.find(book_id)
    user = book.checked_out_by

    if book.checked_out_at < 14.days.ago
      BookMailer.overdue_notice(user, book).deliver_now
    end
  end
end

This convenience meant I could access any model, mailer, or service from any job. But it also meant tracking dependencies required reading the entire method body. What does this job actually need? Book? User? BookMailer? All three? The code didn't say explicitly.

Hanami injected dependencies through the container:

lib/library/operations/send_overdue_notice.rb
module Library
  module Operations
    class SendOverdueNotice
      include Deps[
        book_repo: "repositories.book_repo",
        mailer: "mailers.book_mailer"
      ]

      def call(book_id)
        book = book_repo.find(book_id)
        return unless book.overdue?

        mailer.overdue_notice(book).deliver
      end
    end
  end
end

The include Deps line declared exactly what this operation needed. Testing became simpler because I could inject mocks:

spec/unit/operations/send_overdue_notice_spec.rb
RSpec.describe Library::Operations::SendOverdueNotice do
  subject(:operation) {
    described_class.new(
      book_repo: book_repo,
      mailer: mailer
    )
  }

  let(:book_repo) { instance_double("BookRepo") }
  let(:mailer) { instance_double("BookMailer") }

  it "sends email for overdue books" do
    book = double(overdue?: true)
    allow(book_repo).to receive(:find).with(1).and_return(book)
    allow(mailer).to receive(:overdue_notice).and_return(double(deliver: true))

    operation.call(1)

    expect(mailer).to have_received(:overdue_notice).with(book)
  end
end

No database, no real mailer, just unit testing the logic. Rails could achieve similar isolation with careful mocking, but Hanami's architecture pushed me toward it naturally.

Final thoughts

This article looks at the different values behind Rails and Hanami in Ruby web development.

Rails is a great choice when speed matters most. Its maturity, large ecosystem, and strong conventions let teams deliver products quickly. The trade-off is that as your application grows, you’ll need discipline to keep things well-organized.

Hanami is better suited when long-term clarity and maintainability matter more than getting started fast. Its structure encourages clean design patterns and better testing, though it often means writing more setup code in the beginning.

Got an article suggestion? Let us know
Next article
ActiveRecord vs Sequel
Learn how ActiveRecord simplifies database work in Rails, while Sequel offers SQL transparency and control for apps needing flexibility and speed.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.