ORM Adapter #
An ORM (Object-Relational Mapper) is an abstraction layer that maps database rows into Ruby objects — you work with Ruby objects and methods, not raw SQL. Ruby has a rich ORM ecosystem: ActiveRecord, fully integrated with Rails and very productive; Sequel, which is more explicit and flexible; ROM (Ruby Object Mapper), which takes a functional approach with a strict separation between persistence and domain models; plus Mongoid for MongoDB and Ohm for Redis. Understanding each ORM’s trade-offs — between ease of use, SQL control, testability, and architecture — is a skill that separates good Ruby developers from the rest.
Ruby ORM Comparison #
flowchart TD
A[Persistence Needs] --> B{Database Type?}
B --> C["Relational\nPostgreSQL/MySQL/SQLite"]
B --> D["MongoDB"]
B --> E["Redis"]
C --> F{Priority?}
F --> G["Productivity\n& Rails Integration\n→ ActiveRecord"]
F --> H["SQL Control\n& Flexibility\n→ Sequel"]
F --> I["Clean Architecture\n& Domain-Driven\n→ ROM / Hanami::DB"]
D --> J["Mongoid\nor the mongo driver"]
E --> K["Ohm\nor redis-objects"]ORM Paradigm Best for
──────────────────────────────────────────────────────────
ActiveRecord Active Record Rails apps, rapid development
Sequel Query Builder Complex SQL, non-Rails apps
ROM Data Mapper DDD, clean architecture, testability
Mongoid ODM (MongoDB) Document databases
Ohm Hash Model Simple Redis-backed objects
ActiveRecord — Rails’ Default ORM #
ActiveRecord implements the Active Record pattern: a model is an object that knows how to save and read itself from the database. This is very productive but mixes domain logic with persistence concerns.
# Gemfile
# gem 'activerecord', '~> 7.1'
# gem 'pg' # or mysql2, sqlite3
require 'active_record'
ActiveRecord::Base.establish_connection(
adapter: "postgresql",
host: "localhost",
database: "store_db",
username: "appuser",
password: ENV["DB_PASSWORD"]
)
# ActiveRecord model
class Product < ActiveRecord::Base
belongs_to :category
has_many :order_items
has_many :orders, through: :order_items
validates :name, presence: true, length: { minimum: 2 }
validates :price, numericality: { greater_than: 0 }
scope :active, -> { where(active: true) }
scope :newest, -> { order(created_at: :desc) }
before_save :normalize_name
private
def normalize_name
self.name = name.strip if name
end
end
# Queries — very expressive
Product.active.newest.limit(10)
Product.where("price BETWEEN ? AND ?", 100_000, 5_000_000)
Product.includes(:category).where(categories: { name: "Electronics" })
# CRUD
product = Product.create!(name: "Laptop", price: 15_000_000)
product.update!(price: 14_500_000)
product.destroy
ActiveRecord’s Strengths and Weaknesses #
ActiveRecord strengths:
✓ Very productive — little code for lots of functionality
✓ Perfectly integrated with Rails (mailers, cache, jobs)
✓ Large ecosystem — Devise, PaperTrail, Ransack, etc.
✓ Elegant, easy migrations
✓ Rich lifecycle callbacks
ActiveRecord weaknesses:
✗ "Fat" models — domain logic and persistence logic are mixed
✗ Coupled to the database — hard to mock/test without a database
✗ N+1 queries if you're not careful
✗ Complex callbacks can cause hard-to-trace bugs
✗ Less suitable for architectures separating domain from infrastructure
Sequel — An Explicit Query Builder #
Sequel gives you full control over the generated SQL while still providing an expressive Ruby DSL. It’s more explicit than ActiveRecord — you know exactly what SQL is executed.
# Gemfile
# gem 'sequel', '~> 5.75'
# gem 'pg'
require 'sequel'
DB = Sequel.connect(
adapter: "postgres",
host: "localhost",
database: "store_db",
user: "appuser",
password: ENV["DB_PASSWORD"],
max_connections: 10
)
# Dataset — a lazy query builder
products = DB[:products]
# Basic queries
products.where(active: true).order(Sequel.desc(:created_at)).limit(10).all
products.where { price > 1_000_000 }.all
products.where(category_id: [1, 2, 3]).all
# JOINs — Sequel produces very precise SQL
DB[:products]
.join(:categories, id: :category_id)
.select(
Sequel[:products][:id],
Sequel[:products][:name],
Sequel[:products][:price],
Sequel[:categories][:name].as(:category_name)
)
.where(Sequel[:products][:active] => true)
.all
# Aggregations
products.where(active: true).count
products.group(:category_id).select(:category_id, Sequel.function(:count).as(:total)).all
# INSERT with RETURNING
new_id = DB[:products].returning(:id).insert(
name: "4K Monitor",
price: 5_500_000,
active: true,
created_at: Time.now
)
# UPDATE
DB[:products].where(id: new_id).update(price: 5_200_000)
# DELETE
DB[:products].where(id: new_id).delete
# Raw SQL when needed
DB.fetch("SELECT * FROM products WHERE name ILIKE ?", "%laptop%").all
Sequel Models with Sequel::Model #
require 'sequel'
class Product < Sequel::Model
# Associations
many_to_one :category
one_to_many :order_items
many_to_many :orders, join_table: :order_items
# Validations (different syntax from ActiveRecord)
plugin :validation_helpers
def validate
super
validates_presence [:name, :price]
validates_min_length 2, :name
validates_numeric :price, greater_than: 0
validates_unique :sku, allow_nil: true
end
# Plugins — optional features that are opted in
plugin :timestamps, update_on_create: true
plugin :soft_deletes # built-in soft delete
plugin :pagination # for pagination
dataset_module do
def active
where(active: true)
end
def newest
order(Sequel.desc(:created_at))
end
def in_price_range(min, max)
where(price: min..max)
end
end
end
# Usage
Product.active.newest.paginate(1, 20).all
Product.active.in_price_range(500_000, 5_000_000).all
# Hooks (like ActiveRecord callbacks)
class Product < Sequel::Model
def before_save
self.name = name&.strip
super
end
def after_create
Rails.logger.info "New product: #{name}"
super
end
end
ROM — Ruby Object Mapper #
ROM implements the Data Mapper pattern, strictly separating domain objects from persistence logic. More verbose than ActiveRecord but produces cleaner, more testable code.
# Gemfile
# gem 'rom', '~> 5.3'
# gem 'rom-sql', '~> 3.6'
# gem 'rom-repository','~> 2.3'
# gem 'pg'
require 'rom'
require 'rom-sql'
require 'rom-repository'
# ROM configuration
config = ROM::Configuration.new(:sql, 'postgresql://localhost/store_db')
# Relation — defines the data structure and queries
config.relation(:products) do
schema(infer: true) do
associations do
belongs_to :category
has_many :order_items
end
end
def active
where(active: true)
end
def newest
order(self.class.schema[:created_at].desc)
end
def with_category
join(:categories)
end
end
# Repository — the interface for accessing data
class ProductRepository < ROM::Repository[:products]
commands :create, update: :by_pk, delete: :by_pk
def all_active
products.active.newest.to_a
end
def find(id)
products.by_pk(id).one!
end
def search(keyword)
products.where { name.ilike("%#{keyword}%") }.to_a
end
def with_category(id)
products.with_category.by_pk(id).one
end
end
# Container — dependency injection container
container = ROM.container(config)
# Usage
repo = ProductRepository.new(container)
all = repo.all_active
product = repo.find(1)
# Create
repo.products.command(:create).call(
name: "Gaming Laptop",
price: 22_000_000,
stock: 5,
active: true,
created_at: Time.now
)
Structs as Domain Objects in ROM #
# ROM returns structs (not ActiveRecord objects)
# This enables testing without a database
require 'dry-struct'
require 'dry-types'
module Types
include Dry.Types()
end
# Domain entity — a pure Ruby object, unaware of the database
class Product < Dry::Struct
attribute :id, Types::Integer
attribute :name, Types::String
attribute :price, Types::Decimal
attribute :stock, Types::Integer
attribute :active, Types::Bool
attribute :created_at, Types::Time
def available?
active && stock > 0
end
def formatted_price
"Rp #{price.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
end
# Now domain logic can be tested with no database at all:
product = Product.new(id: 1, name: "Test", price: 10_000, stock: 5, active: true, created_at: Time.now)
puts product.available? # => true (no database needed!)
Hanami::DB — ORM for the Hanami Framework #
Hanami uses ROM behind the scenes with a more seamless integration:
# Gemfile (within a Hanami context)
# gem 'hanami', '~> 2.1'
# gem 'hanami-db', '~> 2.1'
# gem 'rom-sql'
# slices/main/persistence/relations/products.rb
module Main
module Persistence
module Relations
class Products < Hanami::DB::Relation
schema(:products, infer: true) do
associations do
belongs_to :categories
end
end
def active
where(active: true)
end
end
end
end
end
# slices/main/repositories/product_repository.rb
module Main
module Repositories
class ProductRepository < Hanami::DB::Repository
def all_active
products.active.to_a
end
def find(id)
products.by_pk(id).one
end
end
end
end
Mongoid — ODM for MongoDB #
Mongoid provides an ActiveRecord-like experience for MongoDB — with embedded documents and a flexible schema:
# Gemfile
# gem 'mongoid', '~> 8.1'
require 'mongoid'
Mongoid.load!("config/mongoid.yml")
class Product
include Mongoid::Document
include Mongoid::Timestamps
field :name, type: String
field :price, type: BigDecimal
field :stock, type: Integer, default: 0
field :active, type: Boolean, default: true
field :tags, type: Array, default: []
embeds_one :specs
embeds_many :images
belongs_to :category, optional: true
index({ name: "text" })
index({ active: 1, created_at: -1 })
validates :name, presence: true
validates :price, numericality: { greater_than: 0 }
scope :active, -> { where(active: true) }
scope :newest, -> { order(created_at: :desc) }
scope :with_tag, ->(t) { where(tags: t) }
def available?
active && stock > 0
end
end
class Specs
include Mongoid::Document
embedded_in :product
field :processor, type: String
field :ram, type: String
field :storage, type: String
end
# Usage
product = Product.create!(
name: "Pro Gaming Laptop",
price: 22_000_000,
tags: ["laptop", "gaming"]
)
product.build_specs(processor: "Intel i9", ram: "32GB")
product.save!
Product.active.newest.limit(10)
Product.with_tag("gaming")
Product.where(:price.gt => 10_000_000)
Ohm — A Simple Model on Top of Redis #
Ohm provides a very lightweight Redis model — suitable for data needing super-fast access:
# Gemfile
# gem 'ohm', '~> 3.2'
require 'ohm'
Ohm.connect(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))
class Session < Ohm::Model
attribute :user_id
attribute :token
attribute :expires_at
attribute :ip_address
index :user_id
index :token
def self.find_by_token(token)
find(token: token).first
end
def expired?
Time.parse(expires_at) < Time.now
end
end
# Ohm stores to Redis with an automatic key
session = Session.create(
user_id: "1",
token: SecureRandom.hex(32),
expires_at: (Time.now + 3600).iso8601,
ip_address: "192.168.1.1"
)
puts session.id # => the Redis key
# Find
found = Session.find_by_token(session.token)
puts found.user_id # => "1"
The Repository Pattern #
The Repository Pattern separates domain logic from persistence logic — domain objects don’t know how to save themselves:
# Repository interface (abstract)
module ProductRepositoryInterface
def find(id) = raise NotImplementedError
def all(filter: {}) = raise NotImplementedError
def save(product) = raise NotImplementedError
def delete(id) = raise NotImplementedError
end
# ActiveRecord implementation
class ActiveRecordProductRepository
include ProductRepositoryInterface
def find(id)
record = ProductRecord.find(id)
to_domain(record)
rescue ActiveRecord::RecordNotFound
nil
end
def all(filter: {})
query = ProductRecord.all
query = query.where(active: filter[:active]) if filter.key?(:active)
query = query.where("price <= ?", filter[:max_price]) if filter[:max_price]
query.map { |r| to_domain(r) }
end
def save(product)
record = product.id ? ProductRecord.find(product.id) : ProductRecord.new
record.assign_attributes(
name: product.name,
price: product.price,
stock: product.stock,
active: product.active,
category_id: product.category_id
)
record.save!
to_domain(record)
end
def delete(id)
ProductRecord.find(id).destroy
true
rescue ActiveRecord::RecordNotFound
false
end
private
# Convert from an ActiveRecord record to a domain object
def to_domain(record)
ProductDomain.new(
id: record.id,
name: record.name,
price: record.price,
stock: record.stock,
active: record.active,
category_id: record.category_id,
created_at: record.created_at
)
end
end
# Pure domain object — unaware of the database
class ProductDomain
attr_reader :id, :name, :price, :stock, :active, :category_id, :created_at
def initialize(id: nil, name:, price:, stock: 0, active: true, category_id:, created_at: nil)
@id = id
@name = name
@price = price
@stock = stock
@active = active
@category_id = category_id
@created_at = created_at
end
def available?
@active && @stock > 0
end
def apply_discount(percent)
raise ArgumentError, "Discount must be 0-100%" unless (0..100).include?(percent)
@price = @price * (1 - percent / 100.0)
self
end
end
# Alternative implementation — in-memory for testing
class InMemoryProductRepository
include ProductRepositoryInterface
def initialize
@store = {}
@next_id = 1
end
def find(id)
@store[id]
end
def all(filter: {})
result = @store.values
result = result.select { |p| p.active == filter[:active] } if filter.key?(:active)
result
end
def save(product)
if product.id.nil?
product = ProductDomain.new(**product.to_h.merge(id: @next_id))
@next_id += 1
end
@store[product.id] = product
product
end
def delete(id)
!@store.delete(id).nil?
end
end
# Usage — depends on the interface, not the implementation
class ProductService
def initialize(repository)
@repo = repository
end
def show_available
@repo.all(filter: { active: true }).select(&:available?)
end
def create(name:, price:, category_id:)
product = ProductDomain.new(name: name, price: price, category_id: category_id)
@repo.save(product)
end
end
# In production
service = ProductService.new(ActiveRecordProductRepository.new)
# In tests — no database needed at all!
service = ProductService.new(InMemoryProductRepository.new)
Query Objects — Encapsulating Complex Queries #
Query Objects move complex queries out of the model:
# app/queries/available_products_query.rb
class AvailableProductsQuery
def initialize(relation = Product.all)
@relation = relation
end
def call(category_id: nil, min_price: nil, max_price: nil,
keyword: nil, sort: :price_asc, page: 1, per_page: 20)
result = @relation.active.where("stock > 0")
result = result.where(category_id: category_id) if category_id
result = result.where("price >= ?", min_price) if min_price
result = result.where("price <= ?", max_price) if max_price
result = result.where("name ILIKE ?", "%#{keyword}%") if keyword
result = case sort.to_sym
when :price_asc then result.order(:price)
when :price_desc then result.order(price: :desc)
when :newest then result.order(created_at: :desc)
when :bestselling then result.joins(:order_items).group(:id).order("COUNT(*) DESC")
else result.order(:name)
end
result.page(page).per(per_page)
end
end
# Usage in a controller
def index
@products = AvailableProductsQuery.new.call(
category_id: params[:category_id],
keyword: params[:q],
sort: params[:sort] || :newest,
page: params[:page] || 1
)
end
# Easy to test
RSpec.describe AvailableProductsQuery do
it "returns only active products with stock" do
create(:product, active: true, stock: 5)
create(:product, active: false, stock: 5)
create(:product, active: true, stock: 0)
result = AvailableProductsQuery.new.call
expect(result.length).to eq(1)
end
end
Choosing the Right ORM #
flowchart TD
A[Choose an ORM] --> B{Framework?}
B --> C["Rails"]
B --> D["Sinatra / Hanami / Rack"]
B --> E["No Framework"]
C --> F["ActiveRecord\nDefault, largest ecosystem"]
D --> G{Priority?}
G --> H["Productivity → Sequel + Sinatra"]
G --> I["Architecture → ROM/Hanami::DB"]
E --> J{Database?}
J --> K["Relational → Sequel\nor standalone ActiveRecord"]
J --> L["MongoDB → Mongoid"]
J --> M["Redis → Ohm\nor redis-objects"]
J --> N["Multiple DBs → ROM"]A quick guide:
ActiveRecord → Rails, rapid prototyping, teams needing conventions
Sequel → Full SQL control, non-Rails, complex queries
ROM → DDD, clean architecture, multiple databases
Mongoid → MongoDB + a Rails-like experience
Ohm → Simple models on Redis, very fast
DataMapper → (legacy, no longer actively developed)
When the Repository Pattern beats direct ActiveRecord:
✓ Complex domain models rich with logic
✓ Need to test domain logic without a database (very fast)
✓ May switch databases in the future
✓ Teams familiar with DDD and clean architecture
✗ Simple CRUD — ActiveRecord is more than enough
✗ Small teams with tight deadlines
Summary #
- ActiveRecord for Rails and productivity — strict conventions, the largest ecosystem, and perfect Rails integration make it a very strong default choice for most web applications.
- Sequel for full SQL control — more verbose than ActiveRecord but every generated query is predictable; excellent for applications with very complex queries or tight performance optimization.
- ROM for clean architecture — strictly separates domain logic from persistence; domain objects can be tested with no database at all, producing a much faster test suite.
- Mongoid for MongoDB + a Rails experience — if the database is MongoDB, Mongoid provides a familiar Rails-like API with embedded documents and a flexible schema.
- The Repository Pattern for testability — an
InMemoryRepositoryallows testing domain logic at full speed without touching the database; very valuable for long-term projects.- Query Objects for complex queries — don’t let filters, sorting, and pagination mix into models or controllers; encapsulate them into separately testable Query Object classes.
- N+1 is a problem in every ORM — always analyze the generated queries;
includes/eager_load(ActiveRecord) oreager(Sequel/ROM) are the solutions; use the Bullet gem for automatic detection.- Sequel plugins for optional features —
plugin :timestamps,plugin :soft_deletes,plugin :paginationmust be explicitly opted in; unlike ActiveRecord, which includes everything by default.- Avoid fat models in ActiveRecord — move business logic to Service Objects, Query Objects, or Form Objects; models should only have validations, scopes, associations, and methods truly related to the entity.
- An ORM is not a replacement for SQL knowledge — whatever ORM you choose, understand the SQL it generates;
to_sql(ActiveRecord),sql(Sequel), or query logs are tools you should always check when optimizing.