rubyguides

ROM Persistence in Hanami: Relations, Repositories, Commands

In the previous tutorial, we explored Hanami’s action and view layer. Now we move into ROM persistence, which is Hanami’s default data persistence library. ROM gives you a clean, functional way to work with databases without pushing all of your logic into a single oversized model object.

Before you start

If you need to persist data in a Hanami app, ROM gives you a useful middle ground between raw SQL and a heavyweight ORM. The library is built around small, composable pieces, so you can define relations, repositories, and commands that each do one clear job.

That structure is especially helpful in larger applications where database work needs to stay predictable. Instead of scattering SQL fragments through actions and services, you can keep the persistence rules in one place and reuse them across the app.

TL;DR

  • Use relations to map database tables to Ruby objects.
  • Use repositories to centralize query and write logic.
  • Use entities when you want domain objects around your records.
  • Use migrations to evolve the schema in a controlled way.
  • Use transactions when multiple writes must succeed or fail together.

What is ROM?

ROM is a Ruby library that maps Ruby objects to database records. Unlike ActiveRecord, ROM takes a more minimal approach; it’s not an ORM in the traditional sense. Instead, ROM focuses on providing building blocks that you compose to fit your needs.

Hanami 2 integrates ROM through the hanami-model gem, giving you:

  • Relations - Database table mappings
  • Repositories - Data access objects that wrap relations
  • Commands - Database operations (create, update, delete)

When people first meet ROM, they often expect a model layer that behaves like ActiveRecord. ROM works differently on purpose. It gives you smaller primitives, which means you decide how much behavior belongs in a relation, a repository, or an entity instead of inheriting a large amount of default behavior.

Setting up your database

First, configure your database connection. In a Hanami app, this typically goes in config/boot.rb or your environment configuration:

# config/boot.rb
Hanami.configure do
  environment :development do
    database do
      adapter :sqlite, Pathname.new("db/development.sqlite")
      migrations_path Pathname.new("db/migrations")
    end
  end
end

For PostgreSQL or MySQL, change the adapter. The connection configuration is the foundation that every other persistence component depends on, so getting it right early saves time later. Hanami reads this configuration at boot time and makes the database handle available to relations through the ROM container:

database do
  adapter :postgres, host: "localhost", database: "myapp_development"
end

Once the connection is in place, the rest of the persistence layer becomes much easier to reason about. You can define relations against the same database, then build repositories and entities on top of that shared foundation. This layered approach means you can swap the database adapter without touching any of your repository or entity code.

Defining Relations

A relation represents a database table. Create a relation for your entities:

# app/relations/books.rb
module App
  module Relations
    class Books < ROM::Relation[:sql]
      schema do
        attribute :id, Types::Integer
        attribute :title, Types::String
        attribute :author, Types::String
        attribute :published_at, Types::timestamp
        attribute :created_at, Types::timestamp
        attribute :updated_at, Types::timestamp
      end
    end
  end
end

The schema block defines how database columns map to Ruby types. Hanami uses Dry::Types for type safety, which is useful when you want a predictable boundary between the database and the rest of your code.

Creating repositories

Repositories provide an interface between your application and relations. They encapsulate data access logic:

# app/repositories/books.rb
module App
  module Repositories
    class BookRepository < ROM::Repository[:books]
      commands :create, :update, :delete

      def all
        books.order(:created_at).to_a
      end

      def find(id)
        books.by_id(id).one
      end

      def find_by_title(title)
        books.where(title: title).one
      end

      def latest(limit = 10)
        books.order(published_at: :desc).limit(limit).to_a
      end

      def create_with_author(title, author_name)
        transaction do
          author = authors.create(name: author_name)
          books.create(title: title, author_id: author[:id])
        end
      end
    end
  end
end

The commands :create, :update, :delete line automatically generates CRUD methods. That keeps the write API consistent and makes the repository the place where persistence behavior lives, which is much easier to test than duplicated SQL scattered across actions.

Working with entities

Entities are domain objects that wrap relation data:

# app/entities/book.rb
module App
  module Entities
    class Book < Hanami::Entity
      attributes do
        attribute :id, Types::Integer
        attribute :title, Types::String
        attribute :author, Types::String
        attribute :published_at, Types:::timestamp
      end
    end
  end
end

Repositories automatically map relation tuples to entities. That means the code that reads from the repository gets a small object with intent-revealing attributes instead of a raw hash or tuple.

# In an action or service
book_repo = App::Repositories::BookRepository.new

# Create returns an entity
book = book_repo.create(title: "The Pragmatic Programmer", author: "David Thomas")

book.id          # => 1
book.title       # => "The Pragmatic Programmer"
book.class       # => App::Entities::Book

Database migrations

Create migrations to manage your schema:

bundle exec hanami generate migration create_books

Edit the generated migration:

# db/migrations/20260307120000_create_books.rb
ROM::SQL.migration do
  change do
    create_table :books do
      primary_key :id
      column :title, String, null: false
      column :author, String, null: false
      column :published_at, :timestamp
      column :created_at, :timestamp, null: false
      column :updated_at, :timestamp, null: false
    end
  end
end

Run migrations:

bundle exec hanami db migrate

Migrations are the safest way to evolve the schema because they describe each step explicitly. That matters in real projects where the code, the schema, and the deployed database may not always move at exactly the same time.

Complex queries

ROM provides a powerful query DSL:

class BookRepository < ROM::Repository[:books]
  def search(query)
    books.where { title.like("%#{query}%") | author.like("%#{query}%") }
  end

  def published_between(start_date, end_date)
    books.where {
      (published_at >= start_date) & (published_at <= end_date)
    }.order(:published_at)
  end

  def with_recent_updates(limit = 5)
    books.order(updated_at: :desc).limit(limit)
  end
end

Associations

Define relationships between relations:

class Books < ROM::Relation[:sql]
  schema do
    attribute :id, Types::Integer
    attribute :title, Types::String
    attribute :author_id, Types::Integer

    associations do
      belongs_to :author
    end
  end
end

class Authors < ROM::Relation[:sql]
  schema do
    attribute :id, Types::Integer
    attribute :name, Types::String

    associations do
      has_many :books
    end
  end
end

Associations keep relationship logic close to the data layer, which helps when you need to load connected records without writing ad hoc joins in every action or service. The repository can expose the assembled data in one place, and the rest of the app just consumes the result. This pattern keeps your actions thin and your queries centralized. ROM’s association DSL supports belongs_to, has_many, and has_many :through, covering the common relationship types you will encounter in most database schemas. Defining associations in the relation layer also lets ROM optimize queries by eager-loading related records when you call combine from a repository method.

Then combine data through associations:

class BookRepository < ROM::Repository[:books]
  def with_authors
    books.combine(:author).to_a
  end
end

# Returns books with author data nested
book = book_repo.with_authors.first
book.author.name  # => "David Thomas"

Associations keep relationship logic close to the data layer, which helps when you need to load connected records without writing ad hoc joins in every action or service. The repository can expose the assembled data in one place, and the rest of the app just consumes the result.

Transactions

Use transactions for atomic operations:

class OrderRepository < ROM::Repository[:orders]
  def create_order_with_items(order_data, items_data)
    transaction do
      order = orders.create(order_data)
      items_data.each do |item|
        order_items.create(order_id: order[:id], **item)
      end
      order
    end
  end
end

Transactions are the right choice whenever a partial write would leave your system in a bad state. A good example is creating a parent row and several child rows at once, because the whole operation should succeed together or fail together. ROM’s transaction block automatically commits when the block completes without errors and rolls back if any exception is raised, which is the same behavior you would expect from ActiveRecord or any other database library. The block-based API also makes it natural to wrap multiple repository calls together without managing connection state manually. Nested transaction blocks are supported as well; ROM uses savepoints when the database adapter supports them.

Frequently asked questions

When should I use a repository instead of a relation directly?

Use a repository when the query logic belongs to the domain or is reused in more than one place. Relations are fine for simple traversal, but repositories are a better home for named application behavior.

Do I need entities for every table?

No. Use entities when the record should behave like a domain object with a clear purpose. For small or purely structural data, a relation tuple may be enough.

How do I keep writes safe?

Wrap related writes in a transaction and let ROM commit them together. That is the simplest way to keep the database consistent when one step depends on another.

When to use ROM

ROM works well when you want fine-grained control over database operations, a minimal and composable data layer, explicit queries over magic methods, and type-safe database interactions. It is a natural fit for Hanami applications that value clear separation of concerns and testable persistence logic.

Consider alternatives if you need heavy Rails integration where ActiveRecord conventions save time, very rapid prototyping where ORM defaults matter more than control, or complex schema management that benefits from a more opinionated tool.

Summary

You have learned how to set up ROM persistence in Hanami 2, from configuring the database connection to writing repositories that encapsulate query logic. Each piece of the persistence layer has a clear role, and the separation between relations, repositories, and entities makes it easier to test and maintain each part independently. Key takeaways:

  1. Relations map database tables to queryable objects
  2. Repositories encapsulate data access and provide an entity interface
  3. Migrations manage your schema evolution
  4. Commands generate CRUD methods automatically
  5. Transactions ensure atomicity for complex operations

In the next tutorial, we’ll explore Hanami Slices; Hanami’s approach to organizing code in larger applications.

That workflow is usually enough to keep persistence code understandable as the application grows, because each step stays in the right layer.

Next steps

Once you are comfortable with ROM persistence, the next step is usually to connect it back to your Hanami actions and views so the request cycle can create, read, and display data cleanly. When an action calls a repository method and the repository wraps the work in a transaction, any failure automatically rolls back, keeping the database consistent without extra error-handling code in the controller layer.

See Also