Introduction

Hanami 2 didn’t land all at once. It arrived in three acts, and only with the third does the picture come together.

  • 2.0 (Nov 2022) rebuilt the framework: actions, the app-and-slices architecture, a real dependency-injection container.
  • 2.1 (Feb 2024) added the view layer.
  • 2.2 (Nov 2024) added the database layer and a tool for business logic.

With 2.2, Hanami is finally full-stack. What makes it interesting isn’t that it caught up to Rails, but that it got there on very different principles.

2.0: the foundation

The 2.0 rewrite set the shape of everything since.

Actions are the unit of a request: one class per endpoint, rather than a controller with many methods. The app is organised into slices (self-contained areas of the codebase) and wired together by a dependency injection container built on dry-system, with Zeitwerk autoloading. App settings are explicit and validated at boot.

The theme, from the start, was explicitness: components you can see and test in isolation, resolved through the container instead of reached for globally.

2.1: the view layer

2.1 brought views, built on dry-view.

A view is its own object: you subclass a base view rather than including a module, and it declares exposures, the values the template is allowed to see. Instead of a controller quietly handing every instance variable to a template, a view states its inputs explicitly and computes them, often with a block. It’s the same explicit-boundaries idea as actions, applied to rendering.

2.2: the database layer

This is the headline, and it’s the part the framework had been missing. It’s built on ROM (the Ruby Object Mapper), and it splits into three pieces that Active Record rolls into a single model class:

# app/relations/books.rb — low-level data sources (the tables)
class Books < MyApp::DB::Relation
end

# app/repos/book_repo.rb — your app's database interface
class BookRepo < MyApp::DB::Repo
end

# app/structs/book.rb — plain value objects, no live DB connection
class Book < MyApp::DB::Struct
end

The separation is the whole point. Relations describe the raw data sources. Repos are the interface you design, the only place that runs queries, exposing methods that mean something to your domain. Structs are what comes back: immutable value objects with no lazy-loading, no callbacks, no connection back to the database.

Compared to Active Record, where a single object is table mapping, query builder, business logic and serialisation all at once, this is a deliberate un-bundling. It’s more moving parts up front, in exchange for objects that do exactly one thing.

2.2: operations

The other half of 2.2 is operations, built on dry-operation 1.0. They’re Hanami’s answer to the service object.

An operation expresses a piece of business logic as a flow of steps. Each step returns either a success or a failure; the first failure short-circuits the rest, and the caller pattern-matches on the outcome:

case create_book.call(params)
in Success(book)
  # book was created
in Failure(errors)
  # validation or persistence failed
end

Database work wraps in a transaction do ... end block inside the operation, so a multi-step flow either commits as a whole or rolls back. It’s a composable, explicit alternative to a service object that raises or returns nil and leaves you guessing.

The database CLI

The persistence layer comes with a full command set, familiar if you’ve used Rails migrations:

hanami db create
hanami db migrate
hanami db seed
hanami db prepare   # create + migrate + seed
hanami db version

My take

Hanami 2 is not a Rails clone that lost the race. It’s a different bet: explicit boundaries everywhere, dry-rb building blocks, and ROM’s clean split between data sources, your query interface, and inert value objects.

That costs you some up-front ceremony. In return you get components that are easy to test in isolation and hard to turn into a tangled god-object. With 2.2 completing the stack, it’s finally a realistic full-stack choice — not for everyone, but genuinely appealing if that separation is what you’ve been wanting from Rails and not getting.

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht