"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
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
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
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
Step 2: Rewrite One Controller
class UsersController < ApplicationController
def show
user = UserRepo.new(ROM.env).find(params[:id])
render json: user
end
end
Step 3: Migrate Queries
# Before (ActiveRecord)
User.includes(:posts).where(active: true)
# After (ROM.rb)
UserRepo.new.active_users_with_posts
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:
- Start with read-heavy models (e.g., reports)
- Keep ActiveRecord for simple CRUD
- Gradually shift logic to ROM
Tried ROM.rb or other ORM alternatives? Share your take below.
Top comments (0)