Mongoid

Mongoid is "the officially supported object-document mapper (ODM) for MongoDB in Ruby" developed as a comprehensive ODM framework that provides seamless integration between Ruby applications and MongoDB. With API parity to ActiveRecord wherever possible, it ensures developers working with Rails applications can use familiar methods and mechanics. Mongoid supports flexible data modeling native to Ruby applications, offering powerful features like embedded documents, dynamic attributes, complex queries, and comprehensive validation. As the standard MongoDB solution for Ruby development, it provides production-ready performance and maintainability for modern web applications.

ODMRubyMongoDBNoSQLDocument DatabaseRails

GitHub Overview

mongodb/mongoid

The Official Ruby Object Mapper for MongoDB

Stars3,915
Watchers110
Forks1,383
Created:September 16, 2009
Language:Ruby
License:MIT License

Topics

mongodbmongodb-driverormorm-frameworkrubyruby-on-rails

Star History

mongodb/mongoid Star History
Data as of: 7/19/2025, 10:35 AM

Library

Mongoid

Overview

Mongoid is "the officially supported object-document mapper (ODM) for MongoDB in Ruby" developed as a comprehensive ODM framework that provides seamless integration between Ruby applications and MongoDB. With API parity to ActiveRecord wherever possible, it ensures developers working with Rails applications can use familiar methods and mechanics. Mongoid supports flexible data modeling native to Ruby applications, offering powerful features like embedded documents, dynamic attributes, complex queries, and comprehensive validation. As the standard MongoDB solution for Ruby development, it provides production-ready performance and maintainability for modern web applications.

Details

Mongoid 2025 edition fully supports Ruby 3.3 and Rails 8, providing a mature ODM experience that maximizes modern Ruby features including pattern matching, fiber schedulers, and type annotations. The beautiful DSL allows intuitive definition of document models with embedded relationships, inheritance hierarchies, and custom field types. Deep Rails integration includes automatic configuration, ActiveJob compatibility, and seamless controller patterns. MongoDB's document nature enables flexible schema evolution, powerful aggregation pipelines, and efficient geospatial queries, making it ideal for modern applications requiring rapid development and horizontal scaling.

Key Features

  • Document-oriented Design: Natural mapping between Ruby objects and MongoDB documents
  • Embedded Documents: Hierarchical data structures with nested relationships
  • Dynamic Attributes: Flexible schema with runtime field addition
  • Rails Integration: Seamless ActiveRecord-like API for Rails developers
  • Advanced Querying: MongoDB aggregation pipelines and complex criteria
  • Geospatial Support: Location-based queries and indexing

Pros and Cons

Pros

  • ActiveRecord-like API provides familiar development experience for Rails developers
  • Perfect integration with Rails ecosystem and convention-over-configuration
  • Flexible schema allows rapid prototyping and evolution
  • Excellent performance for read-heavy workloads and document-oriented data
  • Built-in support for embedded documents and complex nested structures
  • Comprehensive documentation and active community support

Cons

  • Learning curve for developers accustomed to relational database concepts
  • MongoDB dependency limits deployment options compared to SQL databases
  • Complex queries may require understanding of MongoDB aggregation framework
  • Memory usage can be high with large embedded documents
  • Transactions support is limited compared to traditional RDBMS
  • Index management requires careful planning for optimal performance

Reference Pages

Code Examples

Setup

# Gemfile
gem 'mongoid', '~> 9.0'
gem 'mongo', '~> 2.21'

# Install gems
bundle install
# config/mongoid.yml
development:
  clients:
    default:
      database: blog_development
      hosts:
        - localhost:27017
      options:
        read:
          mode: :primary
        max_pool_size: 1

test:
  clients:
    default:
      database: blog_test
      hosts:
        - localhost:27017
      options:
        read:
          mode: :primary
        max_pool_size: 1

production:
  clients:
    default:
      uri: <%= ENV['MONGODB_URI'] %>
      options:
        max_pool_size: 20
        retry_writes: true
        w: :majority
# config/application.rb (Rails)
require 'mongoid'
Mongoid.load!(Rails.root.join('config', 'mongoid.yml'), Rails.env)

# Standalone usage
require 'mongoid'
Mongoid.configure do |config|
  config.connect_to("blog_development")
end

Basic Model Definition

# app/models/user.rb
class User
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Attributes::Dynamic  # For dynamic fields

  # Basic fields
  field :name, type: String
  field :email, type: String
  field :age, type: Integer, default: 18
  field :is_active, type: Boolean, default: true
  field :tags, type: Array, default: []
  field :metadata, type: Hash, default: {}
  field :birth_date, type: Date
  field :last_login_at, type: DateTime

  # Validations
  validates :name, presence: true, length: { minimum: 2, maximum: 50 }
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :age, numericality: { greater_than: 0, less_than: 150 }

  # Indexes
  index({ email: 1 }, { unique: true })
  index({ name: 1, age: 1 })
  index({ is_active: 1, last_login_at: -1 })
  index({ tags: 1 })

  # Callbacks
  before_save :normalize_email
  after_create :send_welcome_email

  # Scopes
  scope :active, -> { where(is_active: true) }
  scope :adults, -> { where(:age.gte => 18) }
  scope :recent_login, -> { where(:last_login_at.gte => 1.week.ago) }

  # Instance methods
  def full_name
    name.titleize
  end

  def adult?
    age >= 18
  end

  def activate!
    update!(is_active: true, last_login_at: Time.current)
  end

  def deactivate!
    update!(is_active: false)
  end

  private

  def normalize_email
    self.email = email.downcase.strip if email.present?
  end

  def send_welcome_email
    # Welcome email logic
  end
end

# app/models/address.rb - Embedded document
class Address
  include Mongoid::Document

  field :street, type: String
  field :city, type: String
  field :state, type: String
  field :postal_code, type: String
  field :country, type: String, default: 'USA'
  field :coordinates, type: Array  # [longitude, latitude]

  validates :street, :city, :postal_code, presence: true
  validates :postal_code, format: { with: /\A\d{5}(-\d{4})?\z/ }

  # Geospatial index
  index({ coordinates: '2dsphere' })

  embedded_in :user

  def full_address
    "#{street}, #{city}, #{state} #{postal_code}, #{country}"
  end
end

# Add embedded relationship to User
class User
  embeds_one :address
  accepts_nested_attributes_for :address
end

Relationships

# app/models/post.rb
class Post
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Slug

  field :title, type: String
  field :content, type: String
  field :excerpt, type: String
  field :is_published, type: Boolean, default: false
  field :published_at, type: DateTime
  field :view_count, type: Integer, default: 0
  field :tags, type: Array, default: []
  field :featured_image_url, type: String

  # Slug generation
  slug :title

  # Relationships
  belongs_to :user
  belongs_to :category
  has_many :comments, dependent: :destroy
  has_and_belongs_to_many :topics

  # Validations
  validates :title, presence: true, length: { maximum: 200 }
  validates :content, presence: true, length: { minimum: 10 }
  validates :user, presence: true

  # Indexes
  index({ user_id: 1, published_at: -1 })
  index({ is_published: 1, published_at: -1 })
  index({ tags: 1 })
  index({ _slugs: 1 }, { unique: true })

  # Scopes
  scope :published, -> { where(is_published: true) }
  scope :recent, -> { order(published_at: :desc) }
  scope :by_tag, ->(tag) { where(tags: tag) }
  scope :popular, -> { order(view_count: :desc) }

  # Callbacks
  before_save :generate_excerpt
  before_save :set_published_at

  def publish!
    update!(is_published: true, published_at: Time.current)
  end

  def unpublish!
    update!(is_published: false)
  end

  def increment_view_count!
    inc(view_count: 1)
  end

  def related_posts(limit = 5)
    Post.published
        .where(:_id.ne => id)
        .any_of(
          { tags: { '$in' => tags } },
          { category_id: category_id }
        )
        .limit(limit)
  end

  private

  def generate_excerpt
    if content.present? && excerpt.blank?
      self.excerpt = content.truncate(200)
    end
  end

  def set_published_at
    if is_published? && published_at.blank?
      self.published_at = Time.current
    end
  end
end

# app/models/category.rb
class Category
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Tree

  field :name, type: String
  field :description, type: String
  field :color, type: String, default: '#007bff'
  field :is_active, type: Boolean, default: true

  has_many :posts
  has_many :subcategories, class_name: 'Category', inverse_of: :parent
  belongs_to :parent, class_name: 'Category', optional: true

  validates :name, presence: true, uniqueness: true
  validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }

  index({ name: 1 }, { unique: true })
  index({ parent_id: 1 })

  scope :active, -> { where(is_active: true) }
  scope :root_categories, -> { where(parent_id: nil) }

  def posts_count
    posts.count
  end

  def total_posts_count
    posts.count + subcategories.sum(&:total_posts_count)
  end
end

# app/models/comment.rb
class Comment
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Tree

  field :content, type: String
  field :author_name, type: String
  field :author_email, type: String
  field :is_approved, type: Boolean, default: false
  field :is_spam, type: Boolean, default: false
  field :user_agent, type: String
  field :ip_address, type: String

  belongs_to :post
  belongs_to :user, optional: true  # For registered users
  has_many :replies, class_name: 'Comment', inverse_of: :parent
  belongs_to :parent, class_name: 'Comment', optional: true

  validates :content, presence: true, length: { minimum: 5, maximum: 1000 }
  validates :author_name, presence: true, unless: :user_id?
  validates :author_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, unless: :user_id?

  index({ post_id: 1, created_at: -1 })
  index({ is_approved: 1, created_at: -1 })
  index({ parent_id: 1 })

  scope :approved, -> { where(is_approved: true) }
  scope :pending, -> { where(is_approved: false) }
  scope :top_level, -> { where(parent_id: nil) }

  def approve!
    update!(is_approved: true)
  end

  def mark_as_spam!
    update!(is_spam: true, is_approved: false)
  end

  def author_display_name
    user&.name || author_name
  end
end

# app/models/topic.rb
class Topic
  include Mongoid::Document
  include Mongoid::Timestamps

  field :name, type: String
  field :description, type: String
  field :posts_count, type: Integer, default: 0

  has_and_belongs_to_many :posts

  validates :name, presence: true, uniqueness: true

  index({ name: 1 }, { unique: true })

  def increment_posts_count!
    inc(posts_count: 1)
  end

  def decrement_posts_count!
    inc(posts_count: -1) if posts_count > 0
  end
end

Querying and Aggregation

# app/services/user_service.rb
class UserService
  # Basic CRUD operations
  def self.create_user(params)
    User.create!(params)
  end

  def self.find_user(id)
    User.find(id)
  rescue Mongoid::Errors::DocumentNotFound
    nil
  end

  def self.update_user(id, params)
    user = User.find(id)
    user.update!(params)
    user
  end

  def self.delete_user(id)
    user = User.find(id)
    user.destroy
  end

  # Advanced queries
  def self.search_users(query, filters = {})
    criteria = User.all

    # Text search
    if query.present?
      criteria = criteria.any_of(
        { name: /#{Regexp.escape(query)}/i },
        { email: /#{Regexp.escape(query)}/i }
      )
    end

    # Age filter
    if filters[:min_age].present?
      criteria = criteria.where(:age.gte => filters[:min_age])
    end

    if filters[:max_age].present?
      criteria = criteria.where(:age.lte => filters[:max_age])
    end

    # Status filter
    if filters[:is_active].present?
      criteria = criteria.where(is_active: filters[:is_active])
    end

    # Tag filter
    if filters[:tags].present?
      criteria = criteria.where(tags: { '$in' => Array(filters[:tags]) })
    end

    # Date range filter
    if filters[:created_after].present?
      criteria = criteria.where(:created_at.gte => filters[:created_after])
    end

    criteria.order_by(filters[:sort_by]&.to_sym || :created_at => :desc)
           .limit(filters[:limit] || 20)
           .offset(filters[:offset] || 0)
  end

  def self.users_by_location(latitude, longitude, radius_km = 10)
    User.where(
      'address.coordinates' => {
        '$near' => {
          '$geometry' => {
            type: 'Point',
            coordinates: [longitude, latitude]
          },
          '$maxDistance' => radius_km * 1000  # Convert to meters
        }
      }
    )
  end

  def self.user_statistics
    pipeline = [
      {
        '$group' => {
          '_id' => nil,
          'total_users' => { '$sum' => 1 },
          'active_users' => { 
            '$sum' => { '$cond' => [{ '$eq' => ['$is_active', true] }, 1, 0] }
          },
          'average_age' => { '$avg' => '$age' },
          'age_distribution' => {
            '$push' => {
              '$switch' => {
                'branches' => [
                  { 'case' => { '$lt' => ['$age', 18] }, 'then' => 'under_18' },
                  { 'case' => { '$lt' => ['$age', 30] }, 'then' => '18_29' },
                  { 'case' => { '$lt' => ['$age', 50] }, 'then' => '30_49' },
                  { 'case' => { '$gte' => ['$age', 50] }, 'then' => '50_plus' }
                ],
                'default' => 'unknown'
              }
            }
          }
        }
      }
    ]

    User.collection.aggregate(pipeline).first
  end
end

# app/services/blog_service.rb
class BlogService
  def self.published_posts_with_stats
    Post.collection.aggregate([
      { '$match' => { 'is_published' => true } },
      {
        '$lookup' => {
          'from' => 'users',
          'localField' => 'user_id',
          'foreignField' => '_id',
          'as' => 'author'
        }
      },
      {
        '$lookup' => {
          'from' => 'comments',
          'localField' => '_id',
          'foreignField' => 'post_id',
          'as' => 'comments'
        }
      },
      {
        '$project' => {
          'title' => 1,
          'published_at' => 1,
          'view_count' => 1,
          'author_name' => { '$arrayElemAt' => ['$author.name', 0] },
          'comments_count' => { '$size' => '$comments' },
          'approved_comments_count' => {
            '$size' => {
              '$filter' => {
                'input' => '$comments',
                'cond' => { '$eq' => ['$$this.is_approved', true] }
              }
            }
          }
        }
      },
      { '$sort' => { 'published_at' => -1 } },
      { '$limit' => 10 }
    ])
  end

  def self.popular_tags(limit = 10)
    Post.collection.aggregate([
      { '$match' => { 'is_published' => true } },
      { '$unwind' => '$tags' },
      { '$group' => { '_id' => '$tags', 'count' => { '$sum' => 1 } } },
      { '$sort' => { 'count' => -1 } },
      { '$limit' => limit }
    ])
  end

  def self.monthly_post_stats(year = Date.current.year)
    Post.collection.aggregate([
      {
        '$match' => {
          'is_published' => true,
          'published_at' => {
            '$gte' => Time.new(year, 1, 1),
            '$lt' => Time.new(year + 1, 1, 1)
          }
        }
      },
      {
        '$group' => {
          '_id' => { '$month' => '$published_at' },
          'count' => { '$sum' => 1 },
          'total_views' => { '$sum' => '$view_count' }
        }
      },
      { '$sort' => { '_id' => 1 } }
    ])
  end
end

Embedded Documents and Complex Operations

# app/models/profile.rb
class Profile
  include Mongoid::Document

  field :bio, type: String
  field :website, type: String
  field :social_links, type: Hash, default: {}
  field :skills, type: Array, default: []
  field :experience_years, type: Integer
  field :location, type: String
  field :avatar_url, type: String
  field :preferences, type: Hash, default: {}

  embeds_many :work_experiences
  embeds_many :educations
  embedded_in :user

  validates :bio, length: { maximum: 500 }
  validates :website, format: { with: URI::DEFAULT_PARSER.make_regexp }, allow_blank: true
end

# app/models/work_experience.rb
class WorkExperience
  include Mongoid::Document

  field :company, type: String
  field :position, type: String
  field :description, type: String
  field :start_date, type: Date
  field :end_date, type: Date
  field :is_current, type: Boolean, default: false
  field :skills_used, type: Array, default: []

  embedded_in :profile

  validates :company, :position, :start_date, presence: true
  validate :end_date_after_start_date

  scope :current, -> { where(is_current: true) }
  scope :past, -> { where(is_current: false) }

  def duration_in_months
    end_date_for_calculation = is_current? ? Date.current : end_date
    return 0 unless end_date_for_calculation

    ((end_date_for_calculation.year * 12 + end_date_for_calculation.month) - 
     (start_date.year * 12 + start_date.month))
  end

  private

  def end_date_after_start_date
    if end_date.present? && end_date < start_date
      errors.add(:end_date, 'must be after start date')
    end
  end
end

# app/models/education.rb
class Education
  include Mongoid::Document

  field :institution, type: String
  field :degree, type: String
  field :field_of_study, type: String
  field :start_year, type: Integer
  field :end_year, type: Integer
  field :gpa, type: Float
  field :description, type: String

  embedded_in :profile

  validates :institution, :degree, :start_year, presence: true
  validates :gpa, numericality: { greater_than: 0, less_than_or_equal_to: 4.0 }, allow_nil: true
  validate :end_year_after_start_year

  private

  def end_year_after_start_year
    if end_year.present? && end_year < start_year
      errors.add(:end_year, 'must be after start year')
    end
  end
end

# Update User model to include profile
class User
  embeds_one :profile
  accepts_nested_attributes_for :profile

  def complete_profile?
    profile.present? && 
    profile.bio.present? && 
    profile.work_experiences.any? &&
    profile.educations.any?
  end
end

# Service for complex operations
class UserProfileService
  def self.create_complete_profile(user_params, profile_params)
    User.transaction do
      user = User.create!(user_params)
      
      profile = user.build_profile(profile_params.except(:work_experiences, :educations))
      
      # Add work experiences
      profile_params[:work_experiences]&.each do |exp_params|
        profile.work_experiences.build(exp_params)
      end
      
      # Add educations
      profile_params[:educations]&.each do |edu_params|
        profile.educations.build(edu_params)
      end
      
      user.save!
      user
    end
  end

  def self.update_profile_with_experience(user_id, experience_params)
    user = User.find(user_id)
    user.profile ||= user.build_profile
    
    experience = user.profile.work_experiences.build(experience_params)
    user.save!
    
    # Update total experience years
    total_months = user.profile.work_experiences.sum(&:duration_in_months)
    user.profile.update!(experience_years: (total_months / 12.0).round(1))
    
    user
  end

  def self.find_users_by_skills(skills, location = nil)
    criteria = User.where('profile.skills' => { '$in' => Array(skills) })
    criteria = criteria.where('profile.location' => /#{Regexp.escape(location)}/i) if location.present?
    
    criteria.includes(:profile)
           .order_by('profile.experience_years' => :desc)
  end
end

Error Handling and Validation

# app/services/robust_user_service.rb
class RobustUserService
  class UserCreationError < StandardError; end
  class UserNotFoundError < StandardError; end
  class ValidationError < StandardError; end

  def self.create_user_safely(params)
    begin
      user = User.new(params)
      
      if user.valid?
        user.save!
        { success: true, user: user, message: 'User created successfully' }
      else
        { success: false, errors: user.errors.full_messages }
      end
    rescue Mongo::Error::OperationFailure => e
      if e.message.include?('duplicate key error')
        { success: false, errors: ['Email already exists'] }
      else
        Rails.logger.error "MongoDB error: #{e.message}"
        { success: false, errors: ['Database error occurred'] }
      end
    rescue => e
      Rails.logger.error "Unexpected error: #{e.message}"
      { success: false, errors: ['System error occurred'] }
    end
  end

  def self.update_user_safely(id, params)
    begin
      user = User.find(id)
      
      if user.update(params)
        { success: true, user: user, message: 'User updated successfully' }
      else
        { success: false, errors: user.errors.full_messages }
      end
    rescue Mongoid::Errors::DocumentNotFound
      { success: false, errors: ['User not found'] }
    rescue Mongoid::Errors::Validations => e
      { success: false, errors: e.document.errors.full_messages }
    rescue => e
      Rails.logger.error "Error updating user: #{e.message}"
      { success: false, errors: ['Update failed'] }
    end
  end

  def self.bulk_update_users(user_ids, updates)
    results = { success: [], failed: [], total: user_ids.count }
    
    user_ids.each do |user_id|
      result = update_user_safely(user_id, updates)
      
      if result[:success]
        results[:success] << { id: user_id, user: result[:user] }
      else
        results[:failed] << { id: user_id, errors: result[:errors] }
      end
    end
    
    results
  end

  def self.data_integrity_check
    issues = []
    
    # Check for users without email
    users_without_email = User.where(email: nil).count
    if users_without_email > 0
      issues << "#{users_without_email} users found without email addresses"
    end
    
    # Check for invalid age values
    invalid_ages = User.where(:age.lt => 0).or(:age.gt => 150).count
    if invalid_ages > 0
      issues << "#{invalid_ages} users found with invalid age values"
    end
    
    # Check for orphaned posts
    orphaned_posts = Post.where(:user_id.nin => User.pluck(:id)).count
    if orphaned_posts > 0
      issues << "#{orphaned_posts} posts found without valid user references"
    end
    
    # Check for broken embedded documents
    broken_profiles = User.where('profile.bio' => nil).where(:profile.exists => true).count
    if broken_profiles > 0
      issues << "#{broken_profiles} users found with incomplete profile data"
    end
    
    {
      healthy: issues.empty?,
      issues: issues,
      checked_at: Time.current
    }
  end

  def self.cleanup_orphaned_data
    cleanup_results = {}
    
    # Remove posts without valid users
    orphaned_posts_count = Post.where(:user_id.nin => User.pluck(:id)).count
    Post.where(:user_id.nin => User.pluck(:id)).destroy_all
    cleanup_results[:orphaned_posts_removed] = orphaned_posts_count
    
    # Remove comments without valid posts
    orphaned_comments_count = Comment.where(:post_id.nin => Post.pluck(:id)).count
    Comment.where(:post_id.nin => Post.pluck(:id)).destroy_all
    cleanup_results[:orphaned_comments_removed] = orphaned_comments_count
    
    # Clean up invalid embedded documents
    users_with_broken_profiles = User.where('profile.bio' => nil).where(:profile.exists => true)
    users_with_broken_profiles.each { |user| user.unset(:profile) }
    cleanup_results[:broken_profiles_cleaned] = users_with_broken_profiles.count
    
    cleanup_results[:cleaned_at] = Time.current
    cleanup_results
  end
end

# Usage examples
def demonstrate_error_handling
  # Safe user creation
  result = RobustUserService.create_user_safely({
    name: 'Test User',
    email: '[email protected]',
    age: 25
  })
  
  if result[:success]
    puts "User created: #{result[:user].name}"
  else
    puts "Creation failed: #{result[:errors].join(', ')}"
  end
  
  # Bulk operations
  user_ids = User.limit(5).pluck(:id)
  bulk_result = RobustUserService.bulk_update_users(user_ids, { is_active: false })
  puts "Bulk update: #{bulk_result[:success].count} successful, #{bulk_result[:failed].count} failed"
  
  # Data integrity check
  integrity_result = RobustUserService.data_integrity_check
  if integrity_result[:healthy]
    puts "Data integrity check passed"
  else
    puts "Issues found: #{integrity_result[:issues].join(', ')}"
  end
  
  # Cleanup
  cleanup_result = RobustUserService.cleanup_orphaned_data
  puts "Cleanup completed: #{cleanup_result}"
end