Ruby on Rails vs Hanami
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?
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?
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.
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:
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:
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:
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
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:
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:
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
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:
def index
@books = Book.includes(:author).order(:title)
end
<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:
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
<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":
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
<%= 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:
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:
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:
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
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:
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:
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:
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:
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:
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.