DEV Community

Alex Aslam
Alex Aslam

Posted on

Rails with ROM.rb: Life After ActiveRecord

"ActiveRecord is like training wheels—great for learning, but eventually, you need to ride free."

If you’ve ever:

  • Battled N+1 queries that refuse to die
  • Been surprised by silent callback side effects
  • Wished your models weren’t glued to the database

…it’s time to meet ROM.rb (Ruby Object Mapper).

Here’s how to use it in Rails—without losing the magic.


1. Why Leave ActiveRecord?

The Pain Points

  • Performance: ActiveRecord abstractions hide inefficient SQL.
  • Testability: Models are entangled with persistence.
  • Maintainability: Callbacks create unpredictable side effects.

Where ROM.rb Shines

Explicit data layer (no magic)
Blazing-fast SQL (no N+1 surprises)
Decoupled business logic


2. Core Concepts

1. Relations (Your New Models)

# Define a relation (like a slimmed-down ActiveRecord model)
module Relations
  class Users < ROM::Relation[:sql]
    schema(:users, infer: true) do
      associations do
        has_many :posts
      end
    end

    # Custom queries
    def by_name(name)
      where(name: name)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

2. Repositories (Your Query Layer)

# Handles data loading
class UserRepo < ROM::Repository[:users]
  def find_by_name(name)
    users.by_name(name).one!
  end

  def with_posts(user_id)
    users.combine(:posts).by_pk(user_id).one!
  end
end
Enter fullscreen mode Exit fullscreen mode

3. Changesets (Your Write Layer)

# Validate + persist data
class CreateUser < ROM::Changeset::Create
  map do |tuple|
    { **tuple, created_at: Time.now }
  end

  validate do
    required(:name).filled(:string)
  end
end

# Usage
user_repo.changeset(CreateUser, name: "Jane").commit
Enter fullscreen mode Exit fullscreen mode

3. Gradual Adoption in Rails

Step 1: Replace One Model

# app/models/user.rb → Leave this as a PORO (Plain Old Ruby Object)
class User
  attr_accessor :id, :name, :email
end

# config/initializers/rom.rb
ROM::Rails::Railtie.configure do |config|
  config.gateways[:default] = [:sql, ENV.fetch("DATABASE_URL")]
end
Enter fullscreen mode Exit fullscreen mode

Step 2: Rewrite One Controller

class UsersController < ApplicationController
  def show
    user = UserRepo.new(ROM.env).find(params[:id])
    render json: user
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 3: Migrate Queries

# Before (ActiveRecord)
User.includes(:posts).where(active: true)

# After (ROM.rb)
UserRepo.new.active_users_with_posts
Enter fullscreen mode Exit fullscreen mode

4. Performance Wins

Benchmark: Loading 1000 Users with Posts

ActiveRecord ROM.rb
Time 1200ms 350ms
Queries 2 (N+1) 1 (JOIN)

Why It’s Faster

  • No lazy loading: ROM forces explicit queries.
  • No callbacks: Pure data operations.

5. When to Stick with ActiveRecord

🚫 Prototyping (ROM adds upfront complexity)
🚫 Apps with simple CRUD (no need to overengineer)
🚫 Teams resistant to change (learning curve exists)


"But ActiveRecord Is Everywhere!"

It is—but you don’t have to go cold turkey:

  1. Start with read-heavy models (e.g., reports)
  2. Keep ActiveRecord for simple CRUD
  3. Gradually shift logic to ROM

Tried ROM.rb or other ORM alternatives? Share your take below.

Top comments (0)