Rails Development

My First Model: Building a Simple Task Manager

After understanding routes, I was ready to build something real. But I quickly hit a wall: where do I store data? How do I save user information? How does Rails talk to the database?

This is where models come in. In this post, I'll walk you through creating your first Rails model by building a simple task manager app—the same project that finally made ActiveRecord click for me.

What Are Models in Rails?

Models are Ruby classes that represent data in your database. They handle:
- Database interactions (saving, reading, updating, deleting)
- Data validation (ensuring email formats are correct, required fields are present)
- Business logic (calculating totals, processing data)
- Relationships between different types of data

Think of models as the "smart" part of your data—they know how to save themselves, validate themselves, and relate to other data.

The Task Manager: Our First Real App

Let's build a simple task manager where users can:
- Create tasks with titles and descriptions
- Mark tasks as complete or incomplete
- Set due dates
- Add priority levels

This will teach us the fundamentals of Rails models without getting too complex.

Creating Our First Model

Rails provides a generator to create models:

rails generate model Task title:string description:text completed:boolean due_date:date priority:integer

This command creates several files:
- app/models/task.rb - The model class
- db/migrate/xxx_create_tasks.rb - Database migration
- test/models/task_test.rb - Test file

Let's examine what each does.

Understanding Migrations

The migration file defines how to create the database table:

# db/migrate/20231201000000_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :tasks do |t|
      t.string :title
      t.text :description
      t.boolean :completed
      t.date :due_date
      t.integer :priority

      t.timestamps
    end
  end
end

Key points:
- t.timestamps automatically adds created_at and updated_at columns
- Each data type (string, text, boolean, etc.) maps to database column types
- Migrations are like version control for your database

Run the migration to create the table:
bash
rails db:migrate

The Model Class

Here's our basic model:

# app/models/task.rb
class Task < ApplicationRecord
  # This is all Rails needs to connect to the database!
end

That's it! By inheriting from ApplicationRecord, our Task model automatically gets methods to:
- Create tasks: Task.create(title: "Learn Rails")
- Find tasks: Task.find(1) or Task.all
- Update tasks: task.update(completed: true)
- Delete tasks: task.destroy

Testing Our Model in the Console

Let's play with our model in the Rails console:

rails console
# Create a new task
task = Task.new(title: "Learn Rails Models", description: "Build a task manager")
task.save

# Or create and save in one step
task = Task.create(
  title: "Write blog post", 
  description: "Explain Rails models to beginners",
  priority: 1,
  due_date: Date.tomorrow
)

# Find tasks
Task.all                    # Returns all tasks
Task.first                  # Returns first task
Task.find(1)                # Find task with ID 1
Task.find_by(title: "Learn Rails Models")  # Find by any attribute

# Update a task
task = Task.first
task.update(completed: true)

# Delete a task
task.destroy

Adding Validations

Raw database storage isn't enough—we need to ensure data quality. Let's add validations:

# app/models/task.rb
class Task < ApplicationRecord
  validates :title, presence: true, length: { minimum: 3, maximum: 100 }
  validates :priority, inclusion: { in: 1..5, message: "must be between 1 and 5" }
  validates :due_date, presence: true

  # Custom validation
  validate :due_date_cannot_be_in_the_past

  private

  def due_date_cannot_be_in_the_past
    if due_date.present? && due_date < Date.current
      errors.add(:due_date, "can't be in the past")
    end
  end
end

Now test these validations:

# In rails console
task = Task.new(title: "")
task.valid?        # Returns false
task.errors.full_messages  # Shows validation errors

task = Task.new(title: "Test", due_date: Date.yesterday)
task.save          # Returns false because due_date is in the past
task.errors[:due_date]  # Shows specific error for due_date field

Adding Useful Methods

Models can have custom methods for business logic:

# app/models/task.rb
class Task < ApplicationRecord
  validates :title, presence: true, length: { minimum: 3, maximum: 100 }
  validates :priority, inclusion: { in: 1..5 }
  validates :due_date, presence: true

  validate :due_date_cannot_be_in_the_past

  # Scopes for common queries
  scope :completed, -> { where(completed: true) }
  scope :pending, -> { where(completed: false) }
  scope :high_priority, -> { where(priority: [1, 2]) }
  scope :due_soon, -> { where(due_date: Date.current..3.days.from_now) }

  # Instance methods
  def overdue?
    due_date < Date.current && !completed?
  end

  def priority_name
    case priority
    when 1 then "Critical"
    when 2 then "High"
    when 3 then "Medium"
    when 4 then "Low"
    when 5 then "Someday"
    else "Unknown"
    end
  end

  def complete!
    update(completed: true)
  end

  def status
    if completed?
      "Completed"
    elsif overdue?
      "Overdue"
    else
      "Pending"
    end
  end

  private

  def due_date_cannot_be_in_the_past
    if due_date.present? && due_date < Date.current
      errors.add(:due_date, "can't be in the past")
    end
  end
end

Using Scopes and Methods

Scopes provide convenient ways to query data:

# In rails console
Task.completed               # All completed tasks
Task.pending.high_priority   # High priority pending tasks
Task.due_soon               # Tasks due in the next 3 days

# Instance methods
task = Task.first
task.priority_name          # "High" instead of just "2"
task.overdue?              # true/false
task.complete!             # Marks task as completed

Database Indexes for Performance

As your app grows, you'll want to add indexes for frequently queried columns:

rails generate migration AddIndexesToTasks
# db/migrate/xxx_add_indexes_to_tasks.rb
class AddIndexesToTasks < ActiveRecord::Migration[7.0]
  def change
    add_index :tasks, :completed
    add_index :tasks, :due_date
    add_index :tasks, :priority
    add_index :tasks, [:completed, :due_date]  # Composite index
  end
end

Default Values

Set default values for certain fields:

# In the migration
class CreateTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :tasks do |t|
      t.string :title
      t.text :description
      t.boolean :completed, default: false  # Default to incomplete
      t.date :due_date
      t.integer :priority, default: 3       # Default to medium priority

      t.timestamps
    end
  end
end

Or in the model:

# app/models/task.rb
class Task < ApplicationRecord
  after_initialize :set_defaults

  private

  def set_defaults
    self.completed ||= false
    self.priority ||= 3
  end
end

Common Model Mistakes I Made

1. Forgetting Validations

# Bad - no validation
class Task < ApplicationRecord
end

# Good - validates important fields
class Task < ApplicationRecord
  validates :title, presence: true
  validates :due_date, presence: true
end

2. Putting View Logic in Models

# Bad - HTML in model
def formatted_title
  "<h2>#{title}</h2>".html_safe
end

# Good - data formatting only
def title_with_priority
  "[#{priority_name}] #{title}"
end

3. Fat Models

# Bad - model doing too much
class Task < ApplicationRecord
  def send_reminder_email
    # 20 lines of email logic
  end

  def calculate_team_productivity
    # 30 lines of calculation logic
  end
end

# Good - extract to service objects
class Task < ApplicationRecord
  def send_reminder
    TaskReminderService.new(self).send_email
  end
end

Testing Your Model

Always test your model logic:

# test/models/task_test.rb
require "test_helper"

class TaskTest < ActiveSupport::TestCase
  test "should not save task without title" do
    task = Task.new(due_date: Date.tomorrow)
    assert_not task.save
  end

  test "should not save task with past due date" do
    task = Task.new(title: "Test", due_date: Date.yesterday)
    assert_not task.save
    assert_includes task.errors[:due_date], "can't be in the past"
  end

  test "completed scope returns only completed tasks" do
    completed_task = Task.create(title: "Done", due_date: Date.tomorrow, completed: true)
    pending_task = Task.create(title: "Todo", due_date: Date.tomorrow, completed: false)

    assert_includes Task.completed, completed_task
    assert_not_includes Task.completed, pending_task
  end

  test "overdue? returns true for past due incomplete tasks" do
    task = Task.create(title: "Overdue", due_date: Date.yesterday, completed: false)
    assert task.overdue?
  end
end

Run tests with:
bash
rails test test/models/task_test.rb

What I Learned Building This

  1. Models are more than database wrappers - They contain business logic and data rules
  2. Validations save you from bad data - Always validate user input
  3. Scopes make queries readable - Task.pending.high_priority is clearer than Task.where(completed: false, priority: [1,2])
  4. The Rails console is invaluable - Test your models interactively before building views
  5. Start simple, then add complexity - Begin with basic CRUD, then add validations and methods

Next Steps

Now that you have a working model, you need ways for users to interact with it. That means building controllers and views to create, display, and manage tasks.

In the next post, we'll create controllers that use our Task model and build forms where users can create and edit tasks.

Key takeaways:
- Models represent data and business logic
- Migrations create and modify database tables
- Validations ensure data quality
- Scopes provide convenient query methods
- Custom methods add business logic
- Test your models to ensure they work correctly

Try building your own model! Start with something simple like a Book model with title, author, and publication year. The patterns are the same regardless of what data you're modeling.


Next up: "Adding User Authentication Without Gems" - We'll build login/logout functionality from scratch to understand how authentication really works.

Christopher Lim

Christopher Lim

Rails developer and Unity explorer. Family man, lifelong learner, and builder turning ideas into polished applications. Passionate about quality software development and continuous improvement.

Back to All Posts
Reading time: 8 min read

Enjoyed this rails development post?

Follow me for more insights on rails development, Rails development, and software engineering excellence.