MongoDB #
MongoDB is a document database that stores data as BSON (Binary JSON) documents — unlike relational databases, which store data in rigid rows and columns. Each document can have a different structure, supports nested objects natively, and doesn’t require a pre-defined schema. This makes it ideal for data whose structure evolves, content that varies greatly per record, or when iteration speed matters more than strict relational consistency. In Ruby, there are two main ways to work with MongoDB: the official mongo driver for flexible low-level access, and Mongoid as an ODM (Object Document Mapper) that provides an ActiveRecord-like experience in Rails.
MongoDB vs Relational Databases — When to Choose Which #
MongoDB is suitable for:
✓ Data with varying structure per record (product catalogs with different attributes)
✓ Nested documents frequently read together (articles + comments + tags)
✓ High write throughput — logs, events, analytics
✓ Schemas that change often — fast-evolving MVP startups
✓ Geospatial data with $near, $geoWithin queries
✓ Horizontal scaling with built-in sharding
Relational databases (PostgreSQL/MySQL) are better for:
✓ Highly relational data — lots of JOINs between tables
✓ Strict ACID transactions — banking, e-commerce
✓ Complex reports with GROUP BY, window functions
✓ Database-enforced referential integrity
✓ Teams already familiar with SQL
Installation and Setup #
# Install the mongo gem (official MongoDB driver)
gem install mongo
# Or Mongoid (ODM for Rails)
gem install mongoid
# Gemfile
gem 'mongo', '~> 2.19' # official driver
gem 'mongoid', '~> 8.1' # ODM for Rails (optional)
gem 'bson', '~> 4.15' # BSON types (usually already a dependency)
Connecting with the mongo Driver #
require 'mongo'
# Connect to local MongoDB
client = Mongo::Client.new(
["localhost:27017"],
database: "store_db"
)
# Connect with authentication
client = Mongo::Client.new(
["localhost:27017"],
database: "store_db",
user: "appuser",
password: ENV["MONGO_PASSWORD"],
auth_source: "admin" # database where the user was created
)
# Connect via URI (more concise)
client = Mongo::Client.new(ENV["MONGODB_URI"])
# MONGODB_URI=mongodb://appuser:***@localhost:27017/store_db
# Connect to MongoDB Atlas (cloud)
client = Mongo::Client.new(
"mongodb+srv://appuser:***@cluster0.xxxxx.mongodb.net/store_db",
server_selection_timeout: 5,
connect_timeout: 10
)
# Connection pool options
client = Mongo::Client.new(
["localhost:27017"],
database: "store_db",
max_pool_size: 10,
min_pool_size: 2,
wait_queue_timeout: 5,
socket_timeout: 5,
server_selection_timeout: 5
)
# Check the connection
puts client.database.name
puts client.cluster.servers.first.description.server_type
# Close the connection
client.close
Accessing Collections #
# Access a collection (like a table in SQL)
products_col = client[:products] # shorthand
users_col = client.use("store_db")[:users]
# Or with an explicit database
db = client.database
products_col = db[:products]
CRUD — Create, Read, Update, Delete #
Create — Inserting Documents #
products_col = client[:products]
# Insert a single document
result = products_col.insert_one({
name: "Pro Gaming Laptop",
price: 22_000_000,
stock: 10,
category: "Electronics",
tags: ["laptop", "gaming", "high-performance"],
specs: {
processor: "Intel Core i9",
ram: "32GB DDR5",
storage: "1TB NVMe SSD",
gpu: "NVIDIA RTX 4070"
},
active: true,
created_at: Time.now
})
puts result.inserted_id # => BSON::ObjectId('...')
puts result.n # => 1
# Insert many documents at once
product_list = [
{ name: "Wireless Mouse", price: 350_000, stock: 50, category: "Accessories" },
{ name: "Mech Keyboard", price: 750_000, stock: 30, category: "Accessories" },
{ name: "4K 27\" Monitor", price: 5_500_000, stock: 8, category: "Monitors" }
]
result = products_col.insert_many(product_list)
puts result.inserted_count # => 3
puts result.inserted_ids.inspect # array of ObjectIds
# BSON::ObjectId — MongoDB's unique ID
new_id = BSON::ObjectId.new
puts new_id.to_s # => "64f1a2b3c4d5e6f7a8b9c0d1"
puts new_id.generation_time # => the time the ID was created
Read — Querying Documents #
products_col = client[:products]
# find — returns a cursor (lazy, not yet executed)
all = products_col.find # all documents
# Iterate the cursor
all.each do |doc|
puts "#{doc['name']}: Rp #{doc['price']}"
end
# first — the first document
one = products_col.find({ name: "Pro Gaming Laptop" }).first
puts one.inspect
# Filter queries
products_col.find({ category: "Electronics" }).each { |d| puts d['name'] }
# Query operators
# $gt, $lt, $gte, $lte, $ne — comparisons
products_col.find({ price: { "$gt" => 1_000_000 } }).to_a
products_col.find({ price: { "$gte" => 500_000, "$lte" => 5_000_000 } }).to_a
# $in, $nin — in / not in an array
products_col.find({ category: { "$in" => ["Electronics", "Monitors"] } }).to_a
products_col.find({ tags: "gaming" }).to_a # match inside an array
# $regex — text search with regex
products_col.find({ name: { "$regex" => "laptop", "$options" => "i" } }).to_a
# $exists — check whether a field exists
products_col.find({ "specs.gpu" => { "$exists" => true } }).to_a
# $and, $or — logic
products_col.find({
"$and" => [
{ price: { "$lt" => 10_000_000 } },
{ stock: { "$gt" => 0 } },
{ active: true }
]
}).to_a
products_col.find({
"$or" => [
{ category: "Electronics" },
{ category: "Monitors" }
]
}).to_a
# Nested document queries (dot notation)
products_col.find({ "specs.ram" => "32GB DDR5" }).to_a
# Projection — choose which fields to fetch
products_col.find({ active: true }).projection({ name: 1, price: 1, _id: 0 }).to_a
# only fetch name and price, skip _id
# Sort, limit, skip
products_col.find.sort({ price: -1 }).limit(10).skip(20).to_a
# -1 = descending, 1 = ascending
Update — Updating Documents #
products_col = client[:products]
# update_one — update a single document
products_col.update_one(
{ name: "Pro Gaming Laptop" }, # filter
{ "$set" => { price: 21_000_000, updated_at: Time.now } } # update
)
# update_many — update everything that matches
products_col.update_many(
{ category: "Accessories" },
{ "$set" => { discount_pct: 10, updated_at: Time.now } }
)
# Update operators
# $set — set field values
# $unset — remove fields
# $inc — add/subtract numeric values
# $push — add an element to an array
# $pull — remove an element from an array
# $addToSet — add to an array only if it doesn't exist
# Reduce stock when a purchase happens
products_col.update_one(
{ _id: product_id, stock: { "$gte" => quantity } },
{
"$inc" => { stock: -quantity },
"$set" => { updated_at: Time.now }
}
)
# Add a new tag to an array
products_col.update_one(
{ _id: product_id },
{ "$addToSet" => { tags: "promo-ramadan" } }
)
# Remove a specific tag
products_col.update_one(
{ _id: product_id },
{ "$pull" => { tags: "promo-ramadan" } }
)
# Remove a field
products_col.update_many(
{ discount_pct: { "$exists" => true } },
{ "$unset" => { discount_pct: "" } }
)
# Upsert — insert if not present, update if present
products_col.update_one(
{ sku: "SKU-LAPTOP-001" },
{ "$set" => { name: "Laptop X1", price: 15_000_000 },
"$setOnInsert" => { created_at: Time.now } },
upsert: true
)
# find_one_and_update — update and return the document
old_doc = products_col.find_one_and_update(
{ _id: product_id },
{ "$inc" => { stock: -1 } },
return_document: :after # return the document AFTER the update
)
puts old_doc["stock"]
Delete — Removing Documents #
# delete_one — remove the first matching document
products_col.delete_one({ sku: "SKU-OLD-001" })
# delete_many — remove everything that matches
products_col.delete_many({ active: false, stock: 0 })
# Soft delete — safer for important data
products_col.update_one(
{ _id: product_id },
{ "$set" => { deleted_at: Time.now, active: false } }
)
The Aggregation Pipeline #
The Aggregation Pipeline is MongoDB’s powerful way to transform and analyze data — the equivalent of GROUP BY, JOIN, and window functions in SQL:
# Pipeline — an array of stages
pipeline = [
# Stage 1: Filter
{ "$match" => { active: true } },
# Stage 2: Lookup (like a LEFT JOIN)
{
"$lookup" => {
from: "categories", # collection being joined
localField: "category_id", # field in this collection
foreignField: "_id", # field in the joined collection
as: "category_info" # name of the join result field (array)
}
},
# Stage 3: Unwind the lookup result array
{ "$unwind" => { path: "$category_info", preserveNullAndEmptyArrays: true } },
# Stage 4: Project and add new fields
{
"$addFields" => {
category_name: "$category_info.name",
discounted_price: { "$multiply" => ["$price", 0.9] }
}
},
# Stage 5: Group — calculate totals and averages per category
{
"$group" => {
_id: "$category_name",
product_count: { "$sum" => 1 },
total_stock: { "$sum" => "$stock" },
avg_price: { "$avg" => "$price" },
min_price: { "$min" => "$price" },
max_price: { "$max" => "$price" }
}
},
# Stage 6: Sort
{ "$sort" => { product_count: -1 } },
# Stage 7: Limit
{ "$limit" => 10 }
]
result = products_col.aggregate(pipeline).to_a
result.each do |r|
puts "#{r['_id']}: #{r['product_count']} products, avg Rp #{r['avg_price'].round}"
end
# Aggregation for a monthly sales report
sales_pipeline = [
{ "$match" => { status: "completed" } },
{
"$group" => {
_id: {
year: { "$year" => "$created_at" },
month: { "$month" => "$created_at" }
},
total_transactions: { "$sum" => 1 },
total_value: { "$sum" => "$total" },
avg_value: { "$avg" => "$total" }
}
},
{
"$project" => {
_id: 0,
period: { "$concat" => [
{ "$toString" => "$_id.year" }, "-",
{ "$toString" => "$_id.month" }
]},
total_transactions: 1,
total_value: 1,
avg_value: { "$round" => ["$avg_value", 0] }
}
},
{ "$sort" => { "period" => 1 } }
]
orders_col.aggregate(sales_pipeline).each do |r|
puts "#{r['period']}: #{r['total_transactions']} transactions, Rp #{r['total_value']}"
end
Indexing #
# Create an index on a single field
products_col.indexes.create_one({ name: 1 }) # ascending
products_col.indexes.create_one({ price: -1 }) # descending
products_col.indexes.create_one({ sku: 1 }, unique: true) # unique
# Compound index — for queries that frequently use multiple fields
products_col.indexes.create_one({ category: 1, price: 1 }) # category first, then price
products_col.indexes.create_one({ active: 1, created_at: -1 }) # filter + sort
# Text index — full-text search
products_col.indexes.create_one(
{ name: "text", description: "text" },
name: "text_search_idx",
weights: { name: 10, description: 5 } # name is more relevant than description
)
# Geospatial index
stores_col.indexes.create_one({ location: "2dsphere" })
# Partial index — only index documents meeting a condition
products_col.indexes.create_one(
{ price: 1 },
partial_filter_expression: { active: { "$eq" => true } }
)
# TTL index — documents automatically deleted after a certain time
sessions_col.indexes.create_one(
{ expires_at: 1 },
expire_after: 0 # MongoDB deletes when expires_at has passed
)
# View all indexes
products_col.indexes.each { |idx| puts idx.inspect }
# Full-text search using the text index
products_col.find({ "$text" => { "$search" => "laptop gaming" } })
.projection({ score: { "$meta" => "textScore" } })
.sort({ score: { "$meta" => "textScore" } })
.to_a
Mongoid — ODM for Rails #
Mongoid is an ODM (Object Document Mapper) that provides an ActiveRecord-like experience for MongoDB:
# Initialize Mongoid in Rails
rails generate mongoid:config
# config/mongoid.yml
development:
clients:
default:
database: store_development
hosts:
- localhost:27017
options:
server_selection_timeout: 5
production:
clients:
default:
uri: <%= ENV["MONGODB_URI"] %>
options:
server_selection_timeout: 5
max_pool_size: 10
# Mongoid model
class Product
include Mongoid::Document
include Mongoid::Timestamps # created_at, updated_at automatically
# Fields
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: []
field :description, type: String
field :category_id, type: BSON::ObjectId
# Embedded documents (stored in the same document)
embeds_one :specs
embeds_many :images
# Reference relations (store IDs, separate documents)
belongs_to :category, optional: true
has_many :reviews, dependent: :destroy
# Validations
validates :name, presence: true, length: { minimum: 2, maximum: 200 }
validates :price, numericality: { greater_than: 0 }
# Indexes
index({ sku: 1 }, { unique: true, sparse: true })
index({ name: "text", description: "text" })
index({ category_id: 1, price: 1 })
index({ active: 1, created_at: -1 })
# Callbacks
before_save :normalize_name
after_create :log_new_product
# Scopes
scope :active, -> { where(active: true) }
scope :newest, -> { order(created_at: :desc) }
scope :cheap, -> { where(:price.lt => 1_000_000) }
scope :with_tag, ->(t) { where(tags: t) }
def available?
active && stock > 0
end
private
def normalize_name
self.name = name.strip if name
end
def log_new_product
Rails.logger.info "New product: #{name} (#{id})"
end
end
# Embedded document
class Specs
include Mongoid::Document
embedded_in :product
field :processor, type: String
field :ram, type: String
field :storage, type: String
field :gpu, type: String
end
class Image
include Mongoid::Document
embedded_in :product
field :url, type: String
field :alt, type: String
field :order, type: Integer, default: 0
end
Querying with Mongoid #
# Basic queries — very similar to ActiveRecord
Product.all
Product.active
Product.active.newest.limit(10)
Product.with_tag("gaming")
Product.where(category_id: category.id)
# Mongoid query operators
Product.where(:price.gt => 1_000_000)
Product.where(:price.between => (500_000..5_000_000))
Product.where(:name.in => ["Laptop", "Monitor"])
Product.where(:tags.all => ["gaming", "laptop"]) # tags contain ALL
# Nested/embedded queries
Product.where("specs.ram" => "32GB DDR5")
Product.where("images.0.url".exists => true)
# Full-text search
Product.text_search("cheap gaming laptop")
# Aggregation via Mongoid
Product.collection.aggregate([
{ "$match" => { active: true } },
{ "$group" => { _id: "$category_id", total: { "$sum" => 1 } } }
]).to_a
# Create, update, delete
product = Product.create!(
name: "Pro Laptop",
price: 18_000_000,
tags: ["laptop", "premium"]
)
product.update!(price: 17_500_000)
Product.where(stock: 0).update_all("$set" => { active: false })
product.destroy
# Embedded documents
product.build_specs(processor: "i9", ram: "32GB")
product.save!
product.images.create!(url: "/images/product.jpg", order: 1)
Multi-Document Transactions #
MongoDB has supported ACID transactions since version 4.0 for replica sets:
# A simple transaction
client.start_session do |session|
session.start_transaction(
read_concern: { level: :snapshot },
write_concern: { w: :majority }
)
begin
# Reduce stock
products_col.update_one(
{ _id: product_id, stock: { "$gte" => 1 } },
{ "$inc" => { stock: -1 } },
session: session
)
# Create an order
orders_col.insert_one(
{
product_id: product_id,
user_id: user_id,
quantity: 1,
total: price,
status: "pending",
created_at: Time.now
},
session: session
)
session.commit_transaction
puts "Transaction succeeded"
rescue => e
session.abort_transaction
puts "Transaction aborted: #{e.message}"
raise e
end
end
Change Streams — Real-time Monitoring #
Change Streams let applications listen to data changes in real time:
# Watch changes on the products collection
Thread.new do
products_col.watch([
{ "$match" => { "operationType" => { "$in" => ["insert", "update", "delete"] } } }
]) do |stream|
stream.each do |event|
puts "Operation: #{event['operationType']}"
puts "Document ID: #{event['documentKey']['_id']}"
puts "New data: #{event['fullDocument']&.slice('name', 'price')}"
puts "---"
# Example usage: invalidate cache when data changes
Cache.invalidate("product:#{event['documentKey']['_id']}")
# Broadcast to WebSocket
ActionCable.server.broadcast("product_#{event['documentKey']['_id']}", {
action: event['operationType'],
data: event['fullDocument']
})
end
end
end
GridFS — Storing Large Files #
GridFS is a MongoDB specification for storing files larger than the 16MB document limit:
require 'mongo'
client = Mongo::Client.new(["localhost:27017"], database: "store_db")
grid_fs = client.database.fs
# Upload a file
File.open("product_image.jpg", "rb") do |file|
file_id = grid_fs.upload_from_stream(
"product_image.jpg",
file,
metadata: {
product_id: product_id.to_s,
content_type: "image/jpeg",
size: File.size("product_image.jpg")
}
)
puts "File stored with ID: #{file_id}"
end
# Download a file
File.open("output.jpg", "wb") do |file|
grid_fs.download_to_stream(file_id, file)
end
# Stream to a response (for web servers)
# (e.g. in a Sinatra or Rails controller)
stream = StringIO.new
grid_fs.download_to_stream(file_id, stream)
stream.rewind
stream.read # file content as a String
# Delete a file
grid_fs.delete(file_id)
# Find files by name
grid_fs.find(filename: "product_image.jpg").each do |info|
puts "#{info.filename}: #{info.length} bytes, #{info.upload_date}"
end
Document Patterns — Embedding vs Referencing #
The most important design decision in MongoDB is whether to store data in a single document (embedding) or separately with references:
# PATTERN 1: Embedding — store in one document
# Use when: data is always read together, has no independent lifecycle
{
_id: BSON::ObjectId("..."),
title: "Ruby Tutorial",
author: {
name: "Rina",
email: "[email protected]"
},
tags: ["ruby", "programming"],
comments: [
{ body: "Great!", date: Time.now },
{ body: "Thanks", date: Time.now }
]
}
# One query for article + author + tags + comments — efficient!
# PATTERN 2: Referencing — store IDs, separate documents
# Use when: data has an independent lifecycle, is frequently accessed alone,
# or is too large (> a few KB)
{
_id: BSON::ObjectId("..."),
title: "Ruby Tutorial",
author_id: BSON::ObjectId("author123"), # reference
category_id: BSON::ObjectId("category456") # reference
}
# Patterns that often cause problems:
# ANTI-PATTERN: storing an ever-growing list of IDs in a document
{
_id: "author123",
name: "Rina",
article_ids: [id1, id2, id3, ... id999999] # can exceed 16MB!
}
# CORRECT: store the reference on the "many" side
{
_id: "article001",
title: "Ruby Tutorial",
author_id: "author123" # reference on the article side
}
Summary #
- MongoDB for flexible documents, relational for strictly structured data — choose based on data access patterns, not hype; if you need lots of JOINs and complex transactions, PostgreSQL is more appropriate.
findreturns a cursor, not an array — call.to_aonly when you need the whole result in memory; iterating the cursor is more efficient for large datasets.- The
$set,$inc,$push,$pulloperators — don’t replace an entire document for partial updates; use update operators for efficiency and safe concurrent access.- The Aggregation Pipeline for data analysis — more powerful than a plain find;
$matchat the start of the pipeline uses indexes,$lookupfor joins between collections.- Compound indexes follow query order —
{ category: 1, price: 1 }is efficient forwhere(category: ...).order(price: ...)but not forwhere(price: ...)alone.- Embedding for data always read together — article + comments in one document = one query; but watch out for documents that grow without limit.
- Referencing for data with independent lifecycles — users, categories, and products as separate collections referenced by ID.
- Text indexes for simple full-text search —
{ name: "text", description: "text" }with weights for relevance; for more advanced search, consider Elasticsearch.- MongoDB transactions require a Replica Set — multi-document transactions only work if MongoDB runs as a replica set (not standalone); in production, this is already the standard.
- Change Streams for real-time — listen to data changes without polling; useful for cache invalidation, WebSocket broadcasts, or cross-system synchronization.