Jhumur Chatterjee

Fullstack Developer

Organizing Rails Models in Folders with Custom Table Names

As Rails applications grow in complexity, organizing models into logical folders becomes essential for maintainability and code organization. This comprehensive guide explores how to structure models in folders while configuring table names to reflect the folder hierarchy, creating a clean and scalable architecture.

Why Organize Models in Folders?

Benefits of Folder Organization

  1. Logical Grouping: Related models stay together
  2. Namespace Separation: Prevents naming conflicts
  3. Improved Navigation: Easier to find specific models
  4. Domain-Driven Design: Reflects business domains
  5. Team Collaboration: Clear ownership boundaries

Common Use Cases

  • E-commerce: products/, orders/, payments/
  • CRM: customers/, sales/, support/
  • Content Management: articles/, media/, comments/
  • HR System: employees/, payroll/, performance/

Basic Folder Structure

Let's start with a typical Rails application structure:

app/
├── models/
   ├── user.rb
   ├── product.rb
   ├── order.rb
   └── payment.rb

After organizing into folders:

app/
├── models/
   ├── user.rb
   ├── catalog/
      ├── product.rb
      ├── category.rb
      └── variant.rb
   ├── sales/
      ├── order.rb
      ├── line_item.rb
      └── invoice.rb
   └── billing/
       ├── payment.rb
       ├── subscription.rb
       └── credit_card.rb

Setting Up Namespaced Models

Step 1: Create Folder Structure

# Create the directory structure
mkdir -p app/models/catalog
mkdir -p app/models/sales
mkdir -p app/models/billing
mkdir -p app/models/admin

Step 2: Generate Namespaced Models

# Generate models with namespace
rails generate model Catalog::Product name:string price:decimal
rails generate model Sales::Order total:decimal status:string
rails generate model Billing::Payment amount:decimal method:string

Step 3: Configure Table Names

By default, Rails will create table names like catalog_products, sales_orders, etc. Here's how to customize this behavior:

Option A: Explicit Table Name Configuration

# app/models/catalog/product.rb
class Catalog::Product < ApplicationRecord
  self.table_name = 'catalog_products'

  validates :name, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
end

Option B: Automatic Table Name Generation

# app/models/catalog/product.rb
class Catalog::Product < ApplicationRecord
  # Rails automatically generates 'catalog_products' table name

  validates :name, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
end

Option C: Custom Table Name Pattern

# app/models/catalog/product.rb
class Catalog::Product < ApplicationRecord
  self.table_name = "#{name.deconstantize.underscore}_#{name.demodulize.underscore.pluralize}"

  validates :name, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
end

Complete Example: E-commerce Application

Let's build a complete e-commerce application with organized models:

Database Migrations

# db/migrate/20240101000001_create_catalog_products.rb
class CreateCatalogProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :catalog_products do |t|
      t.string :name, null: false
      t.text :description
      t.decimal :price, precision: 8, scale: 2, null: false
      t.string :sku, null: false
      t.integer :stock_quantity, default: 0
      t.boolean :active, default: true
      t.references :catalog_category, null: false, foreign_key: true
      t.timestamps
    end

    add_index :catalog_products, :sku, unique: true
    add_index :catalog_products, :active
    add_index :catalog_products, :catalog_category_id
  end
end
# db/migrate/20240101000002_create_catalog_categories.rb
class CreateCatalogCategories < ActiveRecord::Migration[7.0]
  def change
    create_table :catalog_categories do |t|
      t.string :name, null: false
      t.string :slug, null: false
      t.text :description
      t.references :parent, null: true, foreign_key: { to_table: :catalog_categories }
      t.boolean :active, default: true
      t.timestamps
    end

    add_index :catalog_categories, :slug, unique: true
    add_index :catalog_categories, :parent_id
  end
end
# db/migrate/20240101000003_create_sales_orders.rb
class CreateSalesOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :sales_orders do |t|
      t.string :order_number, null: false
      t.references :user, null: false, foreign_key: true
      t.decimal :subtotal, precision: 8, scale: 2, null: false
      t.decimal :tax_amount, precision: 8, scale: 2, default: 0
      t.decimal :total, precision: 8, scale: 2, null: false
      t.string :status, null: false, default: 'pending'
      t.datetime :shipped_at
      t.timestamps
    end

    add_index :sales_orders, :order_number, unique: true
    add_index :sales_orders, :user_id
    add_index :sales_orders, :status
  end
end

Model Definitions

Catalog Models

# app/models/catalog/category.rb
class Catalog::Category < ApplicationRecord
  self.table_name = 'catalog_categories'

  has_many :products, class_name: 'Catalog::Product', foreign_key: 'catalog_category_id'
  has_many :children, class_name: 'Catalog::Category', foreign_key: 'parent_id'
  belongs_to :parent, class_name: 'Catalog::Category', optional: true

  validates :name, presence: true
  validates :slug, presence: true, uniqueness: true

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

  def self.find_by_slug(slug)
    find_by(slug: slug)
  end

  def full_name
    parent ? "#{parent.full_name} > #{name}" : name
  end
end
# app/models/catalog/product.rb
class Catalog::Product < ApplicationRecord
  self.table_name = 'catalog_products'

  belongs_to :category, class_name: 'Catalog::Category', foreign_key: 'catalog_category_id'
  has_many :line_items, class_name: 'Sales::LineItem', foreign_key: 'catalog_product_id'
  has_many :orders, through: :line_items, source: :order

  validates :name, presence: true
  validates :price, presence: true, numericality: { greater_than: 0 }
  validates :sku, presence: true, uniqueness: true
  validates :stock_quantity, numericality: { greater_than_or_equal_to: 0 }

  scope :active, -> { where(active: true) }
  scope :in_stock, -> { where('stock_quantity > 0') }
  scope :by_category, ->(category) { where(catalog_category_id: category.id) }

  def in_stock?
    stock_quantity > 0
  end

  def formatted_price
    "$#{price.to_f}"
  end

  def reduce_stock!(quantity)
    update!(stock_quantity: stock_quantity - quantity)
  end
end

Sales Models

# app/models/sales/order.rb
class Sales::Order < ApplicationRecord
  self.table_name = 'sales_orders'

  belongs_to :user
  has_many :line_items, class_name: 'Sales::LineItem', foreign_key: 'sales_order_id', dependent: :destroy
  has_many :products, through: :line_items, source: :product
  has_many :payments, class_name: 'Billing::Payment', foreign_key: 'sales_order_id'

  validates :order_number, presence: true, uniqueness: true
  validates :total, presence: true, numericality: { greater_than: 0 }
  validates :status, inclusion: { in: %w[pending processing shipped delivered cancelled] }

  scope :pending, -> { where(status: 'pending') }
  scope :shipped, -> { where(status: 'shipped') }
  scope :recent, -> { order(created_at: :desc) }

  before_create :generate_order_number
  after_create :reduce_product_stock

  def can_cancel?
    status.in?(%w[pending processing])
  end

  def total_items
    line_items.sum(:quantity)
  end

  def formatted_total
    "$#{total.to_f}"
  end

  private

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

  def reduce_product_stock
    line_items.each do |line_item|
      line_item.product.reduce_stock!(line_item.quantity)
    end
  end
end
# app/models/sales/line_item.rb
class Sales::LineItem < ApplicationRecord
  self.table_name = 'sales_line_items'

  belongs_to :order, class_name: 'Sales::Order', foreign_key: 'sales_order_id'
  belongs_to :product, class_name: 'Catalog::Product', foreign_key: 'catalog_product_id'

  validates :quantity, presence: true, numericality: { greater_than: 0 }
  validates :unit_price, presence: true, numericality: { greater_than: 0 }

  before_save :calculate_total

  def total_price
    quantity * unit_price
  end

  private

  def calculate_total
    self.total = total_price
  end
end

Billing Models

# app/models/billing/payment.rb
class Billing::Payment < ApplicationRecord
  self.table_name = 'billing_payments'

  belongs_to :order, class_name: 'Sales::Order', foreign_key: 'sales_order_id'
  belongs_to :payment_method, class_name: 'Billing::PaymentMethod', foreign_key: 'billing_payment_method_id'

  validates :amount, presence: true, numericality: { greater_than: 0 }
  validates :status, inclusion: { in: %w[pending processing completed failed] }

  scope :completed, -> { where(status: 'completed') }
  scope :failed, -> { where(status: 'failed') }

  def formatted_amount
    "$#{amount.to_f}"
  end

  def success?
    status == 'completed'
  end
end
# app/models/billing/payment_method.rb
class Billing::PaymentMethod < ApplicationRecord
  self.table_name = 'billing_payment_methods'

  belongs_to :user
  has_many :payments, class_name: 'Billing::Payment', foreign_key: 'billing_payment_method_id'

  validates :method_type, inclusion: { in: %w[credit_card paypal bank_transfer] }
  validates :is_default, uniqueness: { scope: :user_id }, if: :is_default?

  scope :active, -> { where(active: true) }
  scope :default, -> { where(is_default: true) }

  def display_name
    case method_type
    when 'credit_card'
      "Credit Card ending in #{last_four}"
    when 'paypal'
      "PayPal (#{email})"
    when 'bank_transfer'
      "Bank Transfer"
    end
  end
end

Advanced Configuration Patterns

Custom Table Name Helpers

# app/models/concerns/custom_table_naming.rb
module CustomTableNaming
  extend ActiveSupport::Concern

  class_methods do
    def set_namespaced_table_name
      namespace = name.deconstantize.underscore
      model_name = name.demodulize.underscore
      self.table_name = "#{namespace}_#{model_name.pluralize}"
    end

    def set_prefixed_table_name(prefix)
      model_name = name.demodulize.underscore
      self.table_name = "#{prefix}_#{model_name.pluralize}"
    end
  end
end

Usage:

# app/models/catalog/product.rb
class Catalog::Product < ApplicationRecord
  include CustomTableNaming

  set_namespaced_table_name  # Results in 'catalog_products'

  # ... rest of model
end

Base Classes for Namespaces

# app/models/catalog/base.rb
class Catalog::Base < ApplicationRecord
  self.abstract_class = true

  # Common behavior for all catalog models
  scope :active, -> { where(active: true) }

  def self.table_name_prefix
    'catalog_'
  end
end
# app/models/catalog/product.rb
class Catalog::Product < Catalog::Base
  # Inherits table_name_prefix, so table becomes 'catalog_products'

  validates :name, presence: true
  # ... rest of model
end

Handling Associations Across Namespaces

Explicit Class Names

# app/models/user.rb
class User < ApplicationRecord
  has_many :orders, class_name: 'Sales::Order'
  has_many :payment_methods, class_name: 'Billing::PaymentMethod'

  def recent_orders
    orders.recent.limit(5)
  end
end

Foreign Key Conventions

# app/models/sales/order.rb
class Sales::Order < ApplicationRecord
  # Rails expects 'user_id' for belongs_to :user
  belongs_to :user

  # Explicit foreign key for namespaced associations
  has_many :line_items, class_name: 'Sales::LineItem', foreign_key: 'sales_order_id'
end

Database Indexing Strategies

Composite Indexes for Namespaced Tables

# db/migrate/create_composite_indexes.rb
class CreateCompositeIndexes < ActiveRecord::Migration[7.0]
  def change
    # Catalog indexes
    add_index :catalog_products, [:catalog_category_id, :active]
    add_index :catalog_products, [:active, :stock_quantity]

    # Sales indexes
    add_index :sales_orders, [:user_id, :status]
    add_index :sales_orders, [:status, :created_at]

    # Billing indexes
    add_index :billing_payments, [:sales_order_id, :status]
    add_index :billing_payment_methods, [:user_id, :is_default]
  end
end

Testing Namespaced Models

RSpec Configuration

# spec/rails_helper.rb
RSpec.configure do |config|
  # Configure factory paths for namespaced models
  config.include FactoryBot::Syntax::Methods
end

Factory Definitions

# spec/factories/catalog/products.rb
FactoryBot.define do
  factory :catalog_product, class: 'Catalog::Product' do
    name { "Sample Product" }
    price { 29.99 }
    sku { "SKU-#{SecureRandom.hex(4)}" }
    stock_quantity { 10 }
    active { true }
    association :category, factory: :catalog_category
  end
end

# spec/factories/catalog/categories.rb
FactoryBot.define do
  factory :catalog_category, class: 'Catalog::Category' do
    name { "Sample Category" }
    slug { name.parameterize }
    active { true }
  end
end

Model Tests

# spec/models/catalog/product_spec.rb
RSpec.describe Catalog::Product, type: :model do
  describe 'table name' do
    it 'uses the correct table name' do
      expect(described_class.table_name).to eq('catalog_products')
    end
  end

  describe 'associations' do
    it 'belongs to category' do
      expect(described_class.reflect_on_association(:category).class_name).to eq('Catalog::Category')
    end
  end

  describe 'validations' do
    subject { build(:catalog_product) }

    it { should validate_presence_of(:name) }
    it { should validate_presence_of(:price) }
    it { should validate_uniqueness_of(:sku) }
  end
end

Controller Organization

Namespaced Controllers

# app/controllers/catalog/products_controller.rb
class Catalog::ProductsController < ApplicationController
  before_action :set_product, only: [:show, :edit, :update, :destroy]

  def index
    @products = Catalog::Product.active.includes(:category)
  end

  def show
    @related_products = @product.category.products.active.limit(4)
  end

  private

  def set_product
    @product = Catalog::Product.find(params[:id])
  end

  def product_params
    params.require(:catalog_product).permit(:name, :price, :description, :sku, :stock_quantity, :catalog_category_id)
  end
end

Routes Configuration

# config/routes.rb
Rails.application.routes.draw do
  namespace :catalog do
    resources :categories do
      resources :products
    end
    resources :products
  end

  namespace :sales do
    resources :orders do
      resources :line_items
    end
  end

  namespace :billing do
    resources :payments
    resources :payment_methods
  end
end

Performance Considerations

Eager Loading

# app/models/sales/order.rb
class Sales::Order < ApplicationRecord
  scope :with_details, -> { includes(:line_items, :user, line_items: :product) }

  def self.detailed_orders
    with_details.limit(10)
  end
end

Query Optimization

# Efficient queries across namespaces
def recent_order_summary
  Sales::Order.joins(:line_items, :user)
               .joins('JOIN catalog_products ON sales_line_items.catalog_product_id = catalog_products.id')
               .select('sales_orders.*, users.name as user_name, COUNT(sales_line_items.id) as item_count')
               .group('sales_orders.id, users.name')
               .order(created_at: :desc)
               .limit(10)
end

Best Practices

1. Consistent Naming Conventions

# Good: Consistent namespace and table naming
class Catalog::Product < ApplicationRecord
  self.table_name = 'catalog_products'
end

class Sales::Order < ApplicationRecord
  self.table_name = 'sales_orders'
end

2. Clear Association Definitions

# Always specify class_name for cross-namespace associations
class Sales::Order < ApplicationRecord
  has_many :line_items, class_name: 'Sales::LineItem', foreign_key: 'sales_order_id'
  has_many :products, through: :line_items, source: :product, class_name: 'Catalog::Product'
end

3. Proper Index Management

# Add indexes for foreign keys in namespaced tables
add_index :sales_orders, :user_id
add_index :sales_line_items, :sales_order_id
add_index :sales_line_items, :catalog_product_id

4. Documentation

# Document namespace purpose and relationships
# app/models/catalog/README.md
# Catalog Namespace
#
# Contains all product catalog related models:
# - Product: Individual sellable items
# - Category: Product categorization
# - Variant: Product variations (size, color, etc.)
#
# Table naming convention: catalog_[model_name]

Common Pitfalls and Solutions

1. Association Confusion

# Problem: Ambiguous associations
class Sales::Order < ApplicationRecord
  has_many :line_items  # Rails doesn't know which LineItem
end

# Solution: Explicit class names
class Sales::Order < ApplicationRecord
  has_many :line_items, class_name: 'Sales::LineItem', foreign_key: 'sales_order_id'
end

2. Migration Naming

# Problem: Unclear migration names
class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :catalog_products do |t|
      # ...
    end
  end
end

# Solution: Clear migration names
class CreateCatalogProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :catalog_products do |t|
      # ...
    end
  end
end

3. Factory Naming

# Problem: Factory name conflicts
factory :product, class: 'Catalog::Product' do
  # ...
end

# Solution: Namespaced factory names
factory :catalog_product, class: 'Catalog::Product' do
  # ...
end

Conclusion

Organizing Rails models in folders with properly configured table names provides significant benefits for large applications:

  1. Improved Organization: Logical grouping of related models
  2. Better Maintainability: Easier to navigate and understand code structure
  3. Domain Separation: Clear boundaries between different business areas
  4. Scalability: Supports growth without becoming unwieldy
  5. Team Productivity: Multiple developers can work on different domains

The key is to establish consistent patterns early and maintain them throughout the application's lifecycle. With proper setup, namespaced models with folder-based table names create a robust foundation for complex Rails applications.

Remember to always:

  • Use explicit class names in associations
  • Maintain consistent naming conventions
  • Add proper indexes for foreign keys
  • Document your namespace organization
  • Test thoroughly, especially cross-namespace relationships

This approach scales well from small applications to large enterprise systems, providing the structure needed for long-term maintenance and development.