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 InMemoryRepository allows 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) or eager (Sequel/ROM) are the solutions; use the Bullet gem for automatic detection.
  • Sequel plugins for optional featuresplugin :timestamps, plugin :soft_deletes, plugin :pagination must 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.

← Previous: Sinatra   Next: Selenium →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact