17 mins read

Understanding Rails Models: Structure, Logic & Best Practices

A simple guide to Rails models covering creation, migrations, and model logic. Perfect for developers upgrading their Rails apps.
Blog Image

Raisa Kanagaraj

Technical Content Writer

Blog Image

Introduction

Imagine building a recipe app. You’ve got users who can save recipes, ingredients with quantities, and a list of steps to follow. But how do you store all this information and make sense of it when someone uses your app? In a Ruby on Rails application, this structure and logic live inside something called a model.

The Rails model is more than just a container for data. It's the core part that connects your app to the database, holds the rules, and knows how to talk to other parts of your system. Whether you're new to Rails or brushing up before a Rails upgrade, understanding how models work is key to writing clean, reliable code.

What is a Rails Model?

A Rails model is a Ruby class that represents a table in your database. Think of it as a bridge between your application and the data stored in your database. Each model corresponds to a specific table, and each instance of that model represents a row in that table. For example, if you’re building a blog application, you might have a model called Post that maps to a posts table in your database.

At its core, a Rails model inherits from ApplicationRecord, which in turn inherits from ActiveRecord::Base. This inheritance chain provides your model with an extensive set of methods for creating, reading, updating, and deleting records, commonly known as CRUD operations.

class User < ApplicationRecord 
end 

This simple three-line model definition gives you access to powerful functionality. You can create new users, find existing ones, update their information, and delete them from the database—all without writing a single line of SQL:

# Create a new user
user = User.create(name: "John Doe", email: "john@example.com") 
# Find a user by ID
user = User.find(1) 
# Update user information
user.update(name: "Jane Doe") 
# Delete a user
user.destroy 

ActiveRecord, Models, and Migrations

To truly understand Rails models, you need to understand the three pillars that support them: ActiveRecord, the model layer itself, and migrations. These three components work together to create a seamless development experience that has made Rails famous among web developers worldwide.

ActiveRecord: The Foundation

ActiveRecord is Rails' implementation of the Active Record pattern, a design pattern that was first described by Martin Fowler in his book "Patterns of Enterprise Application Architecture". The pattern describes "an object that wraps a row in a database table, encapsulates the database access, and adds domain logic to that data".

What makes ActiveRecord special is how it combines data and behavior in a single object. Your user model doesn't just hold user data, it also knows how to save itself to the database, validate its attributes, and interact with related models. This approach eliminates the need for separate Data Access Objects (DAOs) or repository patterns that you might find in other frameworks.

ActiveRecord provides several key mechanisms that make Rails models so powerful:

  • Object mapping: Database tables become Ruby classes, and database records become Ruby objects with attributes that correspond to table columns
  • CRUD operations: Built-in methods for creating, reading, updating, and deleting records without writing SQL
  • Associations: Easy ways to define relationships between different models
  • Validations: Rules to ensure data integrity before records are saved to the database
  • Callbacks: Hooks that allow you to execute code at specific points in an object's lifecycle

Models: Where Business Logic Lives

In the Model-View-Controller (MVC) architecture, models represent the "M". They're responsible for managing data and business logic. This is where the "Fat Model, Skinny Controller" principle comes into play. Rather than stuffing business logic into controllers, Rails encourages you to keep that logic in models where it can be easily tested, reused, and maintained.

A well-designed Rails model serves multiple roles:

  • Data representation: It represents the structure and attributes of your data entities
  • Business logic container: It houses the rules and behaviors that define how your application works
  • Data validation: It ensures that only valid data is saved to the database
  • Relationship management: It defines how different data entities relate to each other

Consider this example of a more sophisticated model:

ruby 
class Order < ApplicationRecord  
  belongs_to :user 
  has_many :order_items 
  has_many :products, through: :order_items 
  validates :total_amount, presence: true, numericality: { greater_than: 0 } 
  validates :status, inclusion: { in: %w[pending processing shipped delivered] } 
  before_save :calculate_total 
  after_create :send_confirmation_email 
  def shipped?  
    status == 'shipped' 
 end 

  private 

  def calculate_total 
    self.total_amount = order_items.sum(&:price) 
 end 

  def send_confirmation_email  
    OrderMailer.confirmation_email(self).deliver_now 
  end 
end 

This Order model demonstrates how Rails models encapsulate complex behavior while maintaining clean, readable code.

Migrations: Database Schema Evolution

Rails migrations are Ruby scripts that allow you to modify your database schema over time in a consistent and organized manner. They're the bridge between your Rails models and the underlying database structure, ensuring that your database stays in sync with your application code.

When you create Rails models, you typically generate them along with their corresponding migrations:

bash 
rails generate model User name:string email:string age:integer 

This command creates both a model file and a migration file:

ruby 
# db/migrate/20240101120000_create_users.rb 
class CreateUsers < ActiveRecord::Migration[7.0] 
  def change 
   create_table :users do |t| 
     t.string :name 
     t.string :email 
     t.integer :age 
     t.timestamps  
    end 
  end 
end 

Migrations provide several critical benefits:

  • Version control for database schema: Each migration is timestamped and can be applied or rolled back independently
  • Environment consistency: The same migrations run in development, staging, and production, ensuring database consistency across environments
  • Team collaboration: Multiple developers can work on the same application without database conflicts
  • Database agnostic: Migrations use Rails' DSL rather than raw SQL, making your application portable across different database systems

To run migrations and update your database schema, you use the Rails migration command:

bash 
rails db:migrate 

This command executes any pending migrations and updates your db/schema.rb file to reflect the current database structure.

The relationship between models and migrations is symbiotic. Migrations create and modify the database tables that your models represent, while models provide the Ruby interface for interacting with that data. When you're planning a Rails upgrade, tools like RailsUp can help you identify potential compatibility issues with your models and migrations, ensuring a smooth transition to newer Rails versions.

The Logic a Rails Model Holds

Rails models are the intellectual powerhouses of your application. They're where raw data transforms into meaningful business entities, where complex rules are enforced, and where the core functionality of your application lives. Understanding what logic belongs in a Rails model and what doesn't is crucial for building maintainable, scalable applications.

Data Integrity and Validation Logic

The first and most fundamental responsibility of a Rails model is ensuring data integrity. Before any data reaches your database, your model should validate that it meets your application's requirements. This isn't just about preventing crashes—it's about maintaining the reliability and trustworthiness of your entire system.

Rails provides a comprehensive validation system that lets you enforce rules declaratively:

ruby 
class User < ApplicationRecord 
  validates :email, presence: true, uniqueness: true, format: { with: 
  /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i } 
  validates :password, length: { minimum: 8 }, confirmation: true 
  validates :age, numericality: { greater_than: 0, less_than: 120 } 
  validates :role, inclusion: { in: %w[user admin moderator] } 
end 

These validations ensure that every user in your system has a valid email address, a secure password, a reasonable age, and a recognized role. When validation fails, Rails prevents the record from being saved and provides detailed error messages that you can display to users.

Business Rules and Domain Logic

Beyond basic data validation, Rails models are the natural home for business rules, the logic that defines how your application behaves. This might include calculating derived values, enforcing business constraints, or implementing domain-specific workflows.

Consider an e-commerce application with sophisticated business logic:

ruby 
class Product < ApplicationRecord   
  validates :price, presence: true, numericality: { greater_than: 0 }   
  validates :inventory_count, numericality: { greater_than_or_equal_to: 0 }    

  def available_for_purchase?     
    active? && inventory_count > 0 && !discontinued?   
  end 

  def apply_discount(percentage)    
    return false unless percentage.between?(0, 100)      

    discounted_price = price * (1 - percentage / 100.0)    
    update(price: discounted_price, last_discounted_at: Time.current)  
  end 

  def low_stock?     
    inventory_count < minimum_stock_threshold 
  end 

  def days_until_expiry     
    return nil unless expiry_date    
    (expiry_date - Date.current).to_i   
  end 

This Product model encapsulates complex business logic while keeping the interface clean and intuitive. Controllers can simply call product.availableforpurchase? without needing to understand the underlying complexity.

State Management and Lifecycle Hooks

Rails models excel at managing object state through their lifecycle. Callbacks provide hooks that let you execute code at specific points—before validation, after saving, around updates, and many others:

ruby 
class Order < ApplicationRecord 
  enum status: { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 }   
  before_validation :set_order_number, on: :create 
  before_save :calculate_totals 
  after_create :send_confirmation_email 
  after_update :notify_status_change, if: :saved_change_to_status? 
  before_destroy :check_if_cancellable 

  private 

  def set_order_number     
    self.order_number = generate_unique_order_number  
  end 

  def calculate_totals     
    self.subtotal = order_items.sum(&:total_price)     
    self.tax_amount = subtotal * tax_rate     
    self.total_amount = subtotal + tax_amount + shipping_cost   
  end 

  def send_confirmation_email   
    OrderMailer.confirmation(self).deliver_later   
  end 

  def notify_status_change   
    if shipped?       
      OrderMailer.shipping_notification(self).deliver_later     
    elsif delivered?       
      OrderMailer.delivery_confirmation(self).deliver_later     
    end   
  end 

  def check_if_cancellable   
    unless pending? || processing?      
      errors.add(:base, "Cannot cancel order that has already shipped")       
      throw :abort    
    end   
  end 
end 

These callbacks ensure that your models maintain consistency and trigger appropriate side effects throughout their lifecycle. The order automatically gets a unique number when created, recalculates totals when modified, sends emails at appropriate times, and prevents inappropriate state transitions.

Query Logic and Scopes

Rails models also serve as the interface for retrieving data from the database. While basic finding operations are provided by ActiveRecord, complex query logic belongs in the model through scopes and class methods:

ruby 

class User < ApplicationRecord   
  scope :active, -> { where(active: true) }  
  scope :premium, -> { where(subscription_type: 'premium') }   
  scope :recent, -> { where('created_at > ?', 1.month.ago) }   
  scope :by_location, ->(city) { where(city: city) } 

  def self.top_contributors(limit = 10)     
    joins(:posts)       
      .group('users.id')       
      .order('COUNT(posts.id) DESC')      
      .limit(limit)   
  end 

  def self.search(query)     
    where('name ILIKE ? OR email ILIKE ?', "%#{query}%", "%#{query}%")   
  end 
end 

These scopes and class methods provide a clean, chainable interface for complex queries while keeping the query logic encapsulated within the model:

ruby 
# Clean, readable queries 
premium_users = User.active.premium.recent 
top_users_in_sf = User.by_location('San Francisco').top_contributors(5) 
search_results = User.active.search('john')

Calculated Attributes and Virtual Properties

Models can also provide calculated attributes that derive their values from other attributes or related data:

ruby 

class User < ApplicationRecord   
  has_many :orders   
  has_many :posts 

  def full_name     
    "#{first_name} #{last_name}".strip   
  end 

  def total_spent     
    orders.sum(:total_amount)   
  end 

  def lifetime_value     
    return 0 if orders.empty? 

    total_spent / orders.count * estimated_order_frequency   
  end 

  def engagement_score   
    recent_posts = posts.where('created_at > ?', 3.months.ago).count     
    recent_comments = comments.where('created_at > ?', 3.months.ago).count 

    (recent_posts * 2 + recent_comments) / 3.0   
  end 
end  

These calculated attributes provide clean interfaces to complex calculations while ensuring the logic remains testable and maintainable.

Rails Models Responsibilities

Rails models carry significant responsibilities within the MVC architecture, serving as the guardians of data integrity, the implementers of business logic, and the managers of complex relationships. Understanding these responsibilities helps you build applications that are not only functional but also maintainable and scalable over time.

Data Representation and Persistence

The primary responsibility of a Rails model is to represent your application's data and manage its persistence to the database. This goes beyond simple storage—models must ensure that data is stored correctly, retrieved efficiently, and maintained consistently across your application's lifecycle.

Rails models automatically map to database tables following convention over configuration principles. A User model corresponds to a users table, with each instance representing a single row and each attribute mapping to a column. This mapping is handled transparently by ActiveRecord's ORM layer:

ruby 
class User < ApplicationRecord   
  # Automatically maps to 'users' table   
  # Inherits CRUD operations from ActiveRecord::Base 
end 

# Create and persist a new user 
user = User.create(   
  name: "Alice Johnson",   
  email: "alice@example.com",   
  role: "admin" 

) 

# Retrieve users from database 
active_users = User.where(active: true) 
admin_user = User.find_by(role: "admin") 

Models handle the complexity of database interactions, abstracting away SQL queries and connection management. This allows developers to work with intuitive Ruby objects rather than wrestling with database-specific syntax.

Business Logic Implementation

Rails models are the natural home for business logic, the rules and behaviors that define what your application does. This is where you implement the core functionality that makes your application unique and valuable.

The "Fat Model, Skinny Controller" principle encourages placing business logic in models rather than controllers. This approach provides several benefits:

  • Reusability: Business logic in models can be accessed from multiple controllers, background jobs, or console operations
  • Testability: Model logic is easier to unit test than controller logic
  • Maintainability: Business rules are centralized in one location, making updates more straightforward

Consider a subscription-based application where business logic determines user access:

ruby 
class User < ApplicationRecord   
  enum subscription_status: { trial: 0, active: 1, expired: 2, cancelled: 3 } 

  def can_access_premium_features?     
    active? && (subscription_active? || within_grace_period?) 

  end 

  def subscription_active?     
    active_subscription? && subscription_expires_at > Time.current 

  end 

  def within_grace_period?     
    return false unless subscription_expires_at 

    grace_period_end = subscription_expires_at + 7.days     
    Time.current <= grace_period_end 

  end 

  def upgrade_to_premium!     
    transaction do       
      update!(        
        subscription_status: :active,         
        subscription_expires_at: 1.year.from_now,         
        upgraded_at: Time.current 

      ) 

      notify_upgrade_success       
      track_conversion_event     
    end   
  end 

  private 

  def notify_upgrade_success     
    UserMailer.subscription_upgraded(self).deliver_later 

  end 

  def track_conversion_event 

    Analytics.track( 
      user_id: id, 
      event: 'subscription_upgraded', 
      properties: { plan: 'premium' } 
    ) 
  end 
end 

This model encapsulates complex subscription logic while providing a clean interface for controllers and other parts of the application.

Data Validation and Integrity

Ensuring data integrity is a critical responsibility of Rails models. Models should validate data before it reaches the database, preventing invalid states and maintaining consistency across your application.

Rails provides a comprehensive validation framework that operates at the model level:

ruby 

class Product < ApplicationRecord 
  validates :name, presence: true, length: { maximum: 255 } 
  validates :price, presence: true, numericality: { greater_than: 0 } 
  validates :sku, presence: true, uniqueness: { case_sensitive: false } 
  validates :category, inclusion: { in: %w[electronics clothing books home] } 
  validates :description, length: { maximum: 1000 } 

  validate :price_within_reasonable_range 
  validate :inventory_count_not_negative 

  private 

  def price_within_reasonable_range 
    return unless price.present? 

    if price > 10_000 
      errors.add(:price, 'seems unreasonably high') 
    elsif price < 0.01 
      errors.add(:price, 'must be at least $0.01') 
    end 
  end 

  def inventory_count_not_negative 
    if inventory_count && inventory_count < 0 
      errors.add(:inventory_count, 'cannot be negative') 
    end 
  end 
end 

Model-level validations provide several advantages over other validation approaches:

  • Database agnostic: Work with any database supported by Rails
  • Consistent: Apply the same rules regardless of how data is created or updated
  • User-friendly: Provide detailed error messages for form handling
  • Secure: Cannot be bypassed by malicious users

Association Management

Rails models excel at managing relationships between different data entities. Through associations, models define how data relates to other data, automatically handling the complexity of foreign keys and join operations.

The six main types of Rails associations each serve different relationship patterns:

ruby 

class User < ApplicationRecord 
  has_one :profile, dependent: :destroy 
  has_many :posts, dependent: :destroy 
  has_many :comments, dependent: :destroy 
  has_many :likes, dependent: :destroy 
  has_many :liked_posts, through: :likes, source: :post 
  has_and_belongs_to_many :roles 
end 

class Post < ApplicationRecord 
  belongs_to :user 
  has_many :comments, dependent: :destroy 
  has_many :likes, dependent: :destroy 
  has_many :likers, through: :likes, source: :user 
  has_many :tags, through: :post_tags 
  has_many :post_tags, dependent: :destroy 
end 

class Comment < ApplicationRecord 
  belongs_to :user 
  belongs_to :post 
  has_many :replies, class_name: 'Comment', foreign_key: 'parent_id' 
  belongs_to :parent, class_name: 'Comment', optional: true 
end 

These associations enable intuitive navigation between related data:

ruby 

# Find all posts by a specific user 
user = User.find(1) 
user_posts = user.posts 

# Find all comments on a specific post 
post = Post.find(1) 
post_comments = post.comments.includes(:user) 

# Find all posts liked by a user 
liked_posts = user.liked_posts 

# Complex queries using associations 
popular_posts = Post.joins(:likes) 
                   .group('posts.id') 
                   .having('COUNT(likes.id) > ?', 10) 
                   .includes(:user) 

Lifecycle Management Through Callbacks

Rails models manage object lifecycles through an extensive callback system. Callbacks allow models to execute code at specific points during an object's existence, ensuring consistent behavior and triggering necessary side effects.

ruby 

class Order < ApplicationRecord 
  enum status: { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 } 

  # Validation callbacks 
  before_validation :normalize_data 
  after_validation :log_validation_errors 

  # Save callbacks 
  before_save :calculate_totals 
  after_save :update_inventory 

  # Create callbacks 
  before_create :generate_order_number 
  after_create :send_confirmation 

  # Update callbacks 
  before_update :check_status_transition 
  after_update :notify_status_change, if: :saved_change_to_status? 

  # Destroy callbacks 
  before_destroy :ensure_cancellable 
  after_destroy :refund_payment 

  # Transaction callbacks 
  after_commit :sync_with_external_systems, on: [:create, :update] 
  after_rollback :log_transaction_failure 

  private 

  def normalize_data 
    self.email = email&.downcase&.strip 
    self.phone = phone&.gsub(/\D/, '') 
  end 

  def generate_order_number 
    self.order_number = "ORDER-#{Time.current.strftime('%Y%m%d')}-#{SecureRandom.hex(4).upcase}" 
  end 

  def calculate_totals 
    self.subtotal = order_items.sum(&:total_price) 
    self.tax_amount = subtotal * applicable_tax_rate 
    self.total_amount = subtotal + tax_amount + shipping_cost 
  end 

  def send_confirmation 
    OrderMailer.confirmation(self).deliver_later 
  end 

  def notify_status_change 
    case status 
    when 'shipped' 
      OrderMailer.shipping_notification(self).deliver_later 
      SmsService.send_tracking_info(self) 
    when 'delivered' 
      OrderMailer.delivery_confirmation(self).deliver_later 
      schedule_feedback_request 
    end 
  end 
end 

This comprehensive callback system ensures that orders maintain consistency and trigger appropriate actions throughout their lifecycle.

Query Interface and Data Access

Models provide the primary interface for retrieving data from the database. Beyond basic CRUD operations, models should encapsulate complex query logic through scopes and class methods:

ruby 

class User < ApplicationRecord 
  # Scopes for common queries 
  scope :active, -> { where(active: true) } 
  scope :premium, -> { where(subscription_type: 'premium') } 
  scope :recent, ->(days = 30) { where('created_at > ?', days.days.ago) } 
  scope :by_role, ->(role) { where(role: role) } 
  scope :with_activity, -> { joins(:posts).distinct } 

  # Class methods for complex queries 
  def self.top_contributors(limit = 10) 
    joins(:posts) 
      .group('users.id') 
      .order('COUNT(posts.id) DESC') 
      .limit(limit) 
      .includes(:profile) 
  end 

  def self.search(query) 
    return none if query.blank? 

    where( 
      'name ILIKE :query OR email ILIKE :query OR bio ILIKE :query', 
      query: "%#{query}%" 
    ) 
  end 

  def self.activity_report(start_date, end_date) 
    select( 
      'users.*', 
      'COUNT(posts.id) as post_count', 
      'COUNT(comments.id) as comment_count' 
    ) 
    .left_joins(:posts, :comments) 
    .where(posts: { created_at: start_date..end_date }) 
    .or(where(comments: { created_at: start_date..end_date })) 
    .group('users.id') 
    .order('post_count + comment_count DESC') 
  end 
end 

These scopes and class methods provide a clean, chainable interface for data access while keeping query logic organized within the model.

When planning Rails upgrades, it's important to verify that your model logic remains compatible with newer Rails versions. Tools like RailsUp can help identify potential compatibility issues and ensure your models continue to function properly after upgrading.

Rails Models Best Practices

Keeping models clean and focused makes them easier to work with and less error-prone. Here are a few tips:

  • Skinny models, even skinnier controllers - While models can handle logic, avoid stuffing too much into one. If it’s getting too long, extract the logic into service objects or concerns.
  • Use validations wisely - Don’t validate everything—only what’s essential for your app’s data to make sense.
  • Stick to meaningful associations - Overusing hasmany or belongsto can lead to overly complex models. Think through the relationships first.
  • Avoid fat callbacks - If a before_save callback is doing too much, it may be a sign that logic belongs elsewhere.
  • Use scopes for reusable queries - Instead of writing raw where conditions in multiple places, define scopes that keep queries tidy.
  • Version with care during Rails upgrades - If you're upgrading your Rails version, especially from older versions to Rails 7 or beyond, make sure your models follow current syntax and ActiveRecord patterns.

During Rails upgrades, it's also important to keep an eye on your gems. Some gems that work with your models (like actsaslist or paperclip) may not be compatible with the newer version of Rails.

RailsUp can help you here. It’s a free gems compatibility checker that scans your Gemfile and tells you which gems won’t work with your target Rails version. This can save hours of debugging and ensure your models don’t break during the upgrade.

Conclusion

Rails models are the heart of any Ruby on Rails application. They connect your app to the database, manage data, enforce rules, and handle the logic that makes your app unique. By understanding how models work with ActiveRecord and migrations, you can create Rails models that are powerful yet simple to use. Following best practices and using tools like RailsUp during upgrades ensures your models remain robust as your application grows.

Whether you’re building a small blog or a complex e-commerce platform, mastering Rails models will give you the foundation you need to succeed. So, dive into your next Rails project with confidence, knowing that your models are ready to handle whatever challenges come your way.

Written by Raisa Kanagaraj

Your one-stop shop for expert RoR services

Join 250+ companies achieving top-notch RoR development without increasing your workforce.

Book a 15-min chat