ORM Adapter #
ORM (Object-Relational Mapper) adalah lapisan abstraksi yang memetakan baris di database menjadi objek Ruby — kamu bekerja dengan objek dan method Ruby, bukan SQL mentah. Ruby punya ekosistem ORM yang kaya: ActiveRecord yang terintegrasi penuh dengan Rails dan sangat produktif, Sequel yang lebih eksplisit dan fleksibel, ROM (Ruby Object Mapper) yang mengambil pendekatan fungsional dengan pemisahan yang ketat antara persistence dan domain model, hingga Mongoid untuk MongoDB dan Ohm untuk Redis. Memahami trade-off setiap ORM — antara kemudahan penggunaan, kontrol SQL, testability, dan arsitektur — adalah keterampilan yang membedakan developer Ruby yang baik.
Perbandingan ORM Ruby #
flowchart TD
A[Kebutuhan Persistence] --> B{Jenis Database?}
B --> C["Relasional\nPostgreSQL/MySQL/SQLite"]
B --> D["MongoDB"]
B --> E["Redis"]
C --> F{Prioritas?}
F --> G["Produktivitas\n& Rails Integration\n→ ActiveRecord"]
F --> H["Kontrol SQL\n& Fleksibilitas\n→ Sequel"]
F --> I["Arsitektur Bersih\n& Domain-Driven\n→ ROM / Hanami::DB"]
D --> J["Mongoid\natau mongo driver"]
E --> K["Ohm\natau redis-objects"]ORM Paradigma Cocok untuk
──────────────────────────────────────────────────────────
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 database
Ohm Hash Model Redis-backed simple objects
ActiveRecord — ORM Default Rails #
ActiveRecord mengimplementasikan pola Active Record: model adalah objek yang tahu cara menyimpan dan membaca dirinya sendiri dari database. Ini sangat produktif tapi mencampur domain logic dengan persistence concern.
# Gemfile
# gem 'activerecord', '~> 7.1'
# gem 'pg' # atau mysql2, sqlite3
require 'active_record'
ActiveRecord::Base.establish_connection(
adapter: "postgresql",
host: "localhost",
database: "toko_db",
username: "appuser",
password: ENV["DB_PASSWORD"]
)
# Model ActiveRecord
class Produk < ActiveRecord::Base
belongs_to :kategori
has_many :item_pesanan
has_many :pesanan, through: :item_pesanan
validates :nama, presence: true, length: { minimum: 2 }
validates :harga, numericality: { greater_than: 0 }
scope :aktif, -> { where(aktif: true) }
scope :terbaru, -> { order(created_at: :desc) }
before_save :normalisasi_nama
private
def normalisasi_nama
self.nama = nama.strip if nama
end
end
# Query — sangat ekspresif
Produk.aktif.terbaru.limit(10)
Produk.where("harga BETWEEN ? AND ?", 100_000, 5_000_000)
Produk.includes(:kategori).where(kategori: { nama: "Elektronik" })
# CRUD
produk = Produk.create!(nama: "Laptop", harga: 15_000_000)
produk.update!(harga: 14_500_000)
produk.destroy
Kekuatan dan Kelemahan ActiveRecord #
Kekuatan ActiveRecord:
✓ Sangat produktif — sedikit kode untuk banyak fungsionalitas
✓ Terintegrasi sempurna dengan Rails (mailer, cache, job)
✓ Ekosistem besar — Devise, PaperTrail, Ransack, dll.
✓ Migrations yang elegan dan mudah
✓ Callback lifecycle yang kaya
Kelemahan ActiveRecord:
✗ Model "fat" — domain logic dan persistence logic bercampur
✗ Coupling ke database — sulit mock/test tanpa database
✗ N+1 query jika tidak hati-hati
✗ Callback yang kompleks bisa menyebabkan bug yang sulit dilacak
✗ Kurang cocok untuk arsitektur yang memisahkan domain dari infrastruktur
Sequel — Query Builder yang Eksplisit #
Sequel memberikan kontrol penuh atas SQL yang dihasilkan sambil tetap menyediakan DSL Ruby yang ekspresif. Ia lebih eksplisit dari ActiveRecord — kamu tahu persis SQL apa yang dieksekusi.
# Gemfile
# gem 'sequel', '~> 5.75'
# gem 'pg'
require 'sequel'
DB = Sequel.connect(
adapter: "postgres",
host: "localhost",
database: "toko_db",
user: "appuser",
password: ENV["DB_PASSWORD"],
max_connections: 10
)
# Dataset — lazy query builder
produk = DB[:produk]
# Query dasar
produk.where(aktif: true).order(Sequel.desc(:created_at)).limit(10).all
produk.where { harga > 1_000_000 }.all
produk.where(kategori_id: [1, 2, 3]).all
# JOIN — Sequel menghasilkan SQL yang sangat tepat
DB[:produk]
.join(:kategori, id: :kategori_id)
.select(
Sequel[:produk][:id],
Sequel[:produk][:nama],
Sequel[:produk][:harga],
Sequel[:kategori][:nama].as(:kategori_nama)
)
.where(Sequel[:produk][:aktif] => true)
.all
# Agregasi
produk.where(aktif: true).count
produk.group(:kategori_id).select(:kategori_id, Sequel.function(:count).as(:total)).all
# INSERT dengan RETURNING
id_baru = DB[:produk].returning(:id).insert(
nama: "Monitor 4K",
harga: 5_500_000,
aktif: true,
created_at: Time.now
)
# UPDATE
DB[:produk].where(id: id_baru).update(harga: 5_200_000)
# DELETE
DB[:produk].where(id: id_baru).delete
# Raw SQL ketika dibutuhkan
DB.fetch("SELECT * FROM produk WHERE nama ILIKE ?", "%laptop%").all
Model Sequel dengan Sequel::Model #
require 'sequel'
class Produk < Sequel::Model
# Asosiasi
many_to_one :kategori
one_to_many :item_pesanan
many_to_many :pesanan, join_table: :item_pesanan
# Validasi (berbeda sintaks dari ActiveRecord)
plugin :validation_helpers
def validate
super
validates_presence [:nama, :harga]
validates_min_length 2, :nama
validates_numeric :harga, greater_than: 0
validates_unique :sku, allow_nil: true
end
# Plugin — fitur opsional yang di-opt-in
plugin :timestamps, update_on_create: true
plugin :soft_deletes # soft delete bawaan
plugin :pagination # untuk pagination
dataset_module do
def aktif
where(aktif: true)
end
def terbaru
order(Sequel.desc(:created_at))
end
def dalam_harga(min, maks)
where(harga: min..maks)
end
end
end
# Penggunaan
Produk.aktif.terbaru.paginate(1, 20).all
Produk.aktif.dalam_harga(500_000, 5_000_000).all
# Hook (seperti callback di ActiveRecord)
class Produk < Sequel::Model
def before_save
self.nama = nama&.strip
super
end
def after_create
Rails.logger.info "Produk baru: #{nama}"
super
end
end
ROM — Ruby Object Mapper #
ROM mengimplementasikan pola Data Mapper yang memisahkan domain object dari persistence logic secara ketat. Lebih verbose dari ActiveRecord tapi menghasilkan kode yang lebih bersih dan mudah ditest.
# 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'
# Konfigurasi ROM
config = ROM::Configuration.new(:sql, 'postgresql://localhost/toko_db')
# Relation — mendefinisikan struktur data dan query
config.relation(:produk) do
schema(infer: true) do
associations do
belongs_to :kategori
has_many :item_pesanan
end
end
def aktif
where(aktif: true)
end
def terbaru
order(self.class.schema[:created_at].desc)
end
def dengan_kategori
join(:kategori)
end
end
# Repository — interface untuk mengakses data
class ProdukRepository < ROM::Repository[:produk]
commands :create, update: :by_pk, delete: :by_pk
def semua_aktif
produk.aktif.terbaru.to_a
end
def temukan(id)
produk.by_pk(id).one!
end
def cari(kata_kunci)
produk.where { nama.ilike("%#{kata_kunci}%") }.to_a
end
def dengan_kategori(id)
produk.dengan_kategori.by_pk(id).one
end
end
# Container — dependency injection container
container = ROM.container(config)
# Penggunaan
repo = ProdukRepository.new(container)
semua = repo.semua_aktif
produk = repo.temukan(1)
# Create
repo.produk.command(:create).call(
nama: "Laptop Gaming",
harga: 22_000_000,
stok: 5,
aktif: true,
created_at: Time.now
)
Struct sebagai Domain Object di ROM #
# ROM mengembalikan struct (bukan ActiveRecord object)
# Ini memungkinkan test tanpa database
require 'dry-struct'
require 'dry-types'
module Types
include Dry.Types()
end
# Domain entity — murni Ruby object, tidak tahu tentang database
class Produk < Dry::Struct
attribute :id, Types::Integer
attribute :nama, Types::String
attribute :harga, Types::Decimal
attribute :stok, Types::Integer
attribute :aktif, Types::Bool
attribute :created_at, Types::Time
def tersedia?
aktif && stok > 0
end
def harga_format
"Rp #{harga.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse}"
end
end
# Sekarang domain logic bisa ditest tanpa database sama sekali:
produk = Produk.new(id: 1, nama: "Test", harga: 10_000, stok: 5, aktif: true, created_at: Time.now)
puts produk.tersedia? # => true (tidak butuh database!)
Hanami::DB — ORM untuk Hanami Framework #
Hanami menggunakan ROM di balik layar dengan integrasi yang lebih seamless:
# Gemfile (dalam konteks Hanami)
# gem 'hanami', '~> 2.1'
# gem 'hanami-db', '~> 2.1'
# gem 'rom-sql'
# slices/main/persistence/relations/produk.rb
module Main
module Persistence
module Relations
class Produk < Hanami::DB::Relation
schema(:produk, infer: true) do
associations do
belongs_to :kategori
end
end
def aktif
where(aktif: true)
end
end
end
end
end
# slices/main/repositories/produk_repository.rb
module Main
module Repositories
class ProdukRepository < Hanami::DB::Repository
def semua_aktif
produk.aktif.to_a
end
def temukan(id)
produk.by_pk(id).one
end
end
end
end
Mongoid — ODM untuk MongoDB #
Mongoid menyediakan pengalaman mirip ActiveRecord untuk MongoDB — dengan embedded documents dan flexible schema:
# Gemfile
# gem 'mongoid', '~> 8.1'
require 'mongoid'
Mongoid.load!("config/mongoid.yml")
class Produk
include Mongoid::Document
include Mongoid::Timestamps
field :nama, type: String
field :harga, type: BigDecimal
field :stok, type: Integer, default: 0
field :aktif, type: Boolean, default: true
field :tag, type: Array, default: []
embeds_one :spesifikasi
embeds_many :gambar
belongs_to :kategori, optional: true
index({ nama: "text" })
index({ aktif: 1, created_at: -1 })
validates :nama, presence: true
validates :harga, numericality: { greater_than: 0 }
scope :aktif, -> { where(aktif: true) }
scope :terbaru, -> { order(created_at: :desc) }
scope :dengan_tag, ->(t) { where(tag: t) }
def tersedia?
aktif && stok > 0
end
end
class Spesifikasi
include Mongoid::Document
embedded_in :produk
field :prosesor, type: String
field :ram, type: String
field :storage, type: String
end
# Penggunaan
produk = Produk.create!(
nama: "Laptop Gaming Pro",
harga: 22_000_000,
tag: ["laptop", "gaming"]
)
produk.build_spesifikasi(prosesor: "Intel i9", ram: "32GB")
produk.save!
Produk.aktif.terbaru.limit(10)
Produk.dengan_tag("gaming")
Produk.where(:harga.gt => 10_000_000)
Ohm — Model Sederhana di atas Redis #
Ohm menyediakan model Redis yang sangat ringan — cocok untuk data yang butuh akses super cepat:
# Gemfile
# gem 'ohm', '~> 3.2'
require 'ohm'
Ohm.connect(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))
class Sesi < Ohm::Model
attribute :user_id
attribute :token
attribute :expires_at
attribute :ip_address
index :user_id
index :token
def self.temukan_by_token(token)
find(token: token).first
end
def kadaluarsa?
Time.parse(expires_at) < Time.now
end
end
# Ohm menyimpan ke Redis dengan key otomatis
sesi = Sesi.create(
user_id: "1",
token: SecureRandom.hex(32),
expires_at: (Time.now + 3600).iso8601,
ip_address: "192.168.1.1"
)
puts sesi.id # => Redis key
# Cari
ditemukan = Sesi.temukan_by_token(sesi.token)
puts ditemukan.user_id # => "1"
Pola Repository Pattern #
Repository Pattern memisahkan domain logic dari persistence logic — domain object tidak tahu cara menyimpan diri sendiri:
# Interface repository (abstrak)
module ProdukRepositoryInterface
def temukan(id) = raise NotImplementedError
def semua(filter: {}) = raise NotImplementedError
def simpan(produk) = raise NotImplementedError
def hapus(id) = raise NotImplementedError
end
# Implementasi dengan ActiveRecord
class ActiveRecordProdukRepository
include ProdukRepositoryInterface
def temukan(id)
record = ProdukRecord.find(id)
ke_domain(record)
rescue ActiveRecord::RecordNotFound
nil
end
def semua(filter: {})
query = ProdukRecord.all
query = query.where(aktif: filter[:aktif]) if filter.key?(:aktif)
query = query.where("harga <= ?", filter[:maks_harga]) if filter[:maks_harga]
query.map { |r| ke_domain(r) }
end
def simpan(produk)
record = produk.id ? ProdukRecord.find(produk.id) : ProdukRecord.new
record.assign_attributes(
nama: produk.nama,
harga: produk.harga,
stok: produk.stok,
aktif: produk.aktif,
kategori_id: produk.kategori_id
)
record.save!
ke_domain(record)
end
def hapus(id)
ProdukRecord.find(id).destroy
true
rescue ActiveRecord::RecordNotFound
false
end
private
# Konversi dari ActiveRecord record ke domain object
def ke_domain(record)
ProdukDomain.new(
id: record.id,
nama: record.nama,
harga: record.harga,
stok: record.stok,
aktif: record.aktif,
kategori_id: record.kategori_id,
created_at: record.created_at
)
end
end
# Domain object murni — tidak tahu tentang database
class ProdukDomain
attr_reader :id, :nama, :harga, :stok, :aktif, :kategori_id, :created_at
def initialize(id: nil, nama:, harga:, stok: 0, aktif: true, kategori_id:, created_at: nil)
@id = id
@nama = nama
@harga = harga
@stok = stok
@aktif = aktif
@kategori_id = kategori_id
@created_at = created_at
end
def tersedia?
@aktif && @stok > 0
end
def terapkan_diskon(persen)
raise ArgumentError, "Diskon harus 0-100%" unless (0..100).include?(persen)
@harga = @harga * (1 - persen / 100.0)
self
end
end
# Implementasi alternatif — in-memory untuk testing
class InMemoryProdukRepository
include ProdukRepositoryInterface
def initialize
@store = {}
@next_id = 1
end
def temukan(id)
@store[id]
end
def semua(filter: {})
result = @store.values
result = result.select { |p| p.aktif == filter[:aktif] } if filter.key?(:aktif)
result
end
def simpan(produk)
if produk.id.nil?
produk = ProdukDomain.new(**produk.to_h.merge(id: @next_id))
@next_id += 1
end
@store[produk.id] = produk
produk
end
def hapus(id)
!@store.delete(id).nil?
end
end
# Penggunaan — bergantung pada interface, bukan implementasi
class ProdukService
def initialize(repository)
@repo = repository
end
def tampilkan_tersedia
@repo.semua(filter: { aktif: true }).select(&:tersedia?)
end
def buat(nama:, harga:, kategori_id:)
produk = ProdukDomain.new(nama: nama, harga: harga, kategori_id: kategori_id)
@repo.simpan(produk)
end
end
# Di production
service = ProdukService.new(ActiveRecordProdukRepository.new)
# Di test — tidak butuh database sama sekali!
service = ProdukService.new(InMemoryProdukRepository.new)
Query Object — Enkapsulasi Query Kompleks #
Query Object memindahkan query yang kompleks keluar dari model:
# app/queries/produk_tersedia_query.rb
class ProdukTersediaQuery
def initialize(relation = Produk.all)
@relation = relation
end
def call(kategori_id: nil, min_harga: nil, maks_harga: nil,
kata_kunci: nil, urut: :harga_asc, halaman: 1, per_halaman: 20)
result = @relation.aktif.where("stok > 0")
result = result.where(kategori_id: kategori_id) if kategori_id
result = result.where("harga >= ?", min_harga) if min_harga
result = result.where("harga <= ?", maks_harga) if maks_harga
result = result.where("nama ILIKE ?", "%#{kata_kunci}%") if kata_kunci
result = case urut.to_sym
when :harga_asc then result.order(:harga)
when :harga_desc then result.order(harga: :desc)
when :terbaru then result.order(created_at: :desc)
when :terlaris then result.joins(:item_pesanan).group(:id).order("COUNT(*) DESC")
else result.order(:nama)
end
result.page(halaman).per(per_halaman)
end
end
# Penggunaan di controller
def index
@produk = ProdukTersediaQuery.new.call(
kategori_id: params[:kategori_id],
kata_kunci: params[:q],
urut: params[:urut] || :terbaru,
halaman: params[:page] || 1
)
end
# Mudah ditest
RSpec.describe ProdukTersediaQuery do
it "mengembalikan hanya produk aktif dengan stok" do
create(:produk, aktif: true, stok: 5)
create(:produk, aktif: false, stok: 5)
create(:produk, aktif: true, stok: 0)
hasil = ProdukTersediaQuery.new.call
expect(hasil.length).to eq(1)
end
end
Memilih ORM yang Tepat #
flowchart TD
A[Pilih ORM] --> B{Framework?}
B --> C["Rails"]
B --> D["Sinatra / Hanami / Rack"]
B --> E["Tanpa Framework"]
C --> F["ActiveRecord\nDefault, ekosistem terlengkap"]
D --> G{Prioritas?}
G --> H["Produktivitas → Sequel + Sinatra"]
G --> I["Arsitektur → ROM/Hanami::DB"]
E --> J{Database?}
J --> K["Relasional → Sequel\natau ActiveRecord standalone"]
J --> L["MongoDB → Mongoid"]
J --> M["Redis → Ohm\natau redis-objects"]
J --> N["Multiple DB → ROM"]Panduan singkat:
ActiveRecord → Rails, prototyping cepat, tim yang butuh konvensi
Sequel → Kontrol SQL penuh, non-Rails, query kompleks
ROM → DDD, clean architecture, multiple databases
Mongoid → MongoDB + Rails-like experience
Ohm → Model sederhana di Redis, sangat cepat
DataMapper → (legacy, tidak aktif dikembangkan)
Kapan Repository Pattern lebih baik dari ActiveRecord langsung:
✓ Domain model yang kompleks dan kaya logic
✓ Perlu test domain logic tanpa database (sangat cepat)
✓ Mungkin ganti database di masa depan
✓ Tim yang familiar dengan DDD dan clean architecture
✗ CRUD sederhana — ActiveRecord lebih dari cukup
✗ Tim kecil dengan deadline ketat
Ringkasan #
- ActiveRecord untuk Rails dan produktivitas — konvensi yang ketat, ekosistem terlengkap, dan integrasi sempurna dengan Rails menjadikannya pilihan default yang sangat kuat untuk mayoritas aplikasi web.
- Sequel untuk kontrol SQL penuh — lebih verbose dari ActiveRecord tapi setiap query yang dihasilkan bisa diprediksi; sangat baik untuk aplikasi dengan query yang sangat kompleks atau optimasi performa yang ketat.
- ROM untuk arsitektur yang bersih — memisahkan domain logic dari persistence secara ketat; domain object bisa ditest tanpa database sama sekali, menghasilkan test suite yang jauh lebih cepat.
- Mongoid untuk MongoDB + Rails experience — jika database adalah MongoDB, Mongoid memberikan familiar Rails-like API dengan embedded documents dan flexible schema.
- Repository Pattern untuk testability —
InMemoryRepositorymemungkinkan test domain logic dengan kecepatan penuh tanpa menyentuh database; sangat berharga untuk proyek jangka panjang.- Query Object untuk query kompleks — jangan biarkan filter, sorting, dan pagination bercampur di model atau controller; enkapsulasi ke kelas Query Object yang bisa ditest secara terpisah.
N+1adalah masalah di semua ORM — selalu analisis query yang dihasilkan;includes/eager_load(ActiveRecord) ataueager(Sequel/ROM) adalah solusi; gunakan Bullet gem untuk deteksi otomatis.- Plugin Sequel untuk fitur opsional —
plugin :timestamps,plugin :soft_deletes,plugin :paginationharus di-opt-in secara eksplisit; berbeda dari ActiveRecord yang menyertakan semua secara default.- Hindari fat model di ActiveRecord — pindahkan business logic ke Service Object, Query Object, atau Form Object; model sebaiknya hanya punya validasi, scope, asosiasi, dan method yang benar-benar terkait dengan entity.
- ORM bukan pengganti pemahaman SQL — apapun ORM yang kamu pilih, pahami SQL yang dihasilkan;
to_sql(ActiveRecord),sql(Sequel), atau log query adalah alat yang harus selalu kamu periksa saat optimasi.