Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 003 / 100

Ruby basics for Rails developers: lessons from production

The language essentials.

Expona Admin10 min read

Ruby basics for Rails developers: lessons from production

If you're building Rails applications, you've probably noticed that Ruby feels different from most languages. The syntax is cleaner, the philosophy rewards readability, and there's often multiple ways to solve the same problem. Understanding Ruby basics for Rails developers isn't just about syntax—it's about writing code that your team maintains without friction, that scales without surprising you, and that production monitors without keeping you awake at night.

Everything is an object

In Ruby, everything—and we mean everything—is an object. Strings, numbers, arrays, even methods themselves. This isn't a theoretical nicety; it's a practical reality that changes how you write Rails code.

Consider what happens when you work with a simple integer in your Rails controller:

ruby
class ProductsController < ApplicationController
  def calculate_discount
    price = 99
    discounted = price.times { |i| puts i }  # Works! Integer has .times
    tax_multiplier = 1.15
    final_price = (price * tax_multiplier).round(2)
  end
end

That .times method on an integer, the .round on a float—these are possible because numbers are objects with their own methods. In production systems handling payment calculations, this object-oriented approach often means you're calling well-tested, built-in methods instead of writing custom utility functions. You're not manually iterating a counter; you call 5.times and Ruby handles it. You're not parsing a timestamp manually; you call Time.current.to_i and trust the implementation.

Even nil is an object with behavior:

ruby
user = User.find_by(email: "notfound@example.com")  # Returns nil
user&.name  # Safe navigation operator works because nil is an object
user&.name&.upcase  # Chains safely, returns nil if user is nil

This is crucial in Rails apps. We use the safe navigation operator &. constantly because nil responds to it gracefully. Your code doesn't crash when a database query returns no results—it just moves forward. Compare this to languages where you'd write explicit null checks at every step. In a ride-hailing application processing thousands of concurrent requests, this safety matters. A driver lookup might return nil during peak hours when the cache misses, and you need the code to degrade gracefully rather than raise an exception.

Strings are objects with methods too:

ruby
email = "  USER@EXAMPLE.COM  "
cleaned = email.strip.downcase  # String methods chained together
slug = "Ruby Basics".parameterize  # Rails extension on String
uuid = SecureRandom.uuid  # Returns a String object with all string methods

In real-world Rails apps, this means you can trust that "hello".reverse works, or that you can call .include? on an array without checking its type first. The object model is consistent.

Blocks with do...end and { }

Blocks are among the most powerful features in Ruby, and they're everywhere in Rails. A block is a chunk of code you pass to a method. It's not a lambda or a proc (though those exist too)—it's inline, it's readable, and it's how Rails idioms work.

You've written this a thousand times:

ruby
@posts = Post.where(published: true).map { |post| post.title }

That { |post| post.title } is a block. The curly brace syntax is common for single-line operations. For multi-line blocks, we use do...end:

ruby
Post.where(published: true).each do |post|
  puts "Processing: #{post.title}"
  post.update(last_checked_at: Time.current)
  NotificationService.new.notify(post.author)
end

In production, we see blocks used for iterating through database results, transforming collections, and handling callbacks. A background job in Sidekiq might look like:

ruby
class OrderProcessorJob
  include Sidekiq::Job
  
  def perform(order_ids)
    Order.where(id: order_ids).find_each do |order|
      order.items.each do |item|
        InventoryService.reserve(item)
      end
      order.update(status: :processing)
    end
  end
end

Notice we use find_each, which accepts a block and handles pagination automatically. This pattern prevents loading thousands of records into memory at once—a real concern when processing millions of orders daily. Without understanding blocks, you might write this incorrectly as Order.where(id: order_ids).each, which loads everything into memory and crashes in production when processing large datasets.

The key distinction: curly braces have higher precedence and work better for single operations, while do...end is more readable for complex logic and should be your default for anything over a few lines. This matters in debugging. A colleague reading your code should instantly recognize whether a block is a transformation or a side effect.

Blocks also power Rails conventions like validations and callbacks:

ruby
class Product < ApplicationRecord
  validates :price, presence: true
  validate do
    if price.present? && price < 0
      errors.add(:price, "cannot be negative")
    end
  end
  
  before_save do
    self.slug = name.parameterize if name.present?
  end
end

Here, blocks define behavior that runs at specific lifecycle points. Rails passes context into these blocks (like the model instance itself), and your code responds. In a trading system where prices update constantly, this pattern lets you validate complex invariants without cluttering your model with separate methods.

A common mistake is misunderstanding block scope. Variables defined outside a block are accessible inside it, but variables created inside the block don't leak out:

ruby
results = []
[1, 2, 3].each do |num|
  squared = num * num  # squared is local to this block
  results << squared
end

puts squared  # Error! squared is not defined here

In production code, this has trapped many developers when using blocks with ActiveRecord. The data exists only within the block's scope.

Symbols like :name

Symbols are lightweight, immutable strings. You create them with a colon: :name, :user_id, :active. They're interned in memory, meaning :status always refers to the same object, regardless of where it appears in your code.

This makes them perfect for hash keys, which is why Rails leans on them:

ruby
def greet(name, greeting: "Hello")
  "#{greeting}, #{name}!"
end

# Usage
greet("Alice", greeting: "Hi")  # Returns "Hi, Alice!"

Here, greeting: is a named parameter using a symbol internally. Rails ActiveRecord queries use symbols too:

ruby
User.where(status: :active).order(created_at: :desc)
User.pluck(:id, :email)  # Column names as symbols

In production e-commerce systems, we often build query builders and filters like this:

ruby
class OrderFilter
  ALLOWED_STATUSES = [:pending, :processing, :shipped, :delivered].freeze
  
  def initialize(params)
    @status = params[:status]&.to_sym
    @customer_id = params[:customer_id]
    @date_range = params[:date_range]
  end
  
  def apply
    query = Order.all
    query = query.where(status: @status) if @status.in?(ALLOWED_STATUSES)
    query = query.where(customer_id: @customer_id) if @customer_id.present?
    query
  end
end

Notice we convert string params to symbols after validation. Symbols are more memory-efficient than strings for this use case because they're cached. If you're processing thousands of requests per second with millions of status lookups, this adds up. We've seen applications waste gigabytes of memory by using string keys instead of symbols in high-throughput scenarios.

However, there's a gotcha: never convert untrusted user input directly to symbols. Symbols are never garbage collected, so if an attacker sends infinite unique symbols, you'll leak memory. Always validate or convert explicitly like we show above.

Implicit returns

Ruby methods return the value of the last expression automatically. You don't need an explicit return statement (though you can use one for early exits).

This leads to clean, readable code:

ruby
def calculate_total(items)
  items.sum(&:price)
end

def active_users
  User.where(status: :active)
end

def generate_report
  data = Report.last_7_days
  formatted = data.map { |row| format_row(row) }
  formatted
end

The last line of each method is what gets returned. This is powerful because it encourages shorter methods—when the return value is the focus, you naturally write functions that do one thing well. In a SaaS application with strict performance requirements, this discipline means fewer methods doing multiple things and easier optimization.

In Rails controllers, implicit returns simplify rendering logic:

ruby
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    @line_items = @invoice.line_items.eager_load(:product)
  end
end

The controller method doesn't explicitly return anything, but Rails knows to render the view because that's the implicit return. Similarly, in ActiveRecord callbacks:

ruby
class Order < ApplicationRecord
  before_save :calculate_totals
  
  private
  
  def calculate_totals
    self.subtotal = items.sum(&:price)
    self.tax = subtotal * TAX_RATE
    self.total = subtotal + tax
  end
end

If the last line were false or nil, it would halt the save, but returning the calculation values allows the callback to proceed. Understanding implicit returns helps you debug why a method isn't behaving as expected. We've debugged production issues where a callback silently failed to save because the developer inadvertently made the last line return nil.

Early returns are still idiomatic when you need to exit immediately:

ruby
def process_payment(order)
  return false unless order.valid?
  return false if order.total.zero?
  
  # Continue with payment logic
  PaymentGateway.charge(order)
end

This pattern is clear: fail fast, with explicit return statements signaling that method execution stops here. Your teammates will understand that subsequent code doesn't run if these conditions fail.

Truthiness and falsy values

In Ruby, only false and nil are falsy. Everything else is truthy, including 0 and empty strings. This differs from Python or JavaScript:

ruby
if 0
  puts "This prints! 0 is truthy in Ruby"
end

if ""
  puts "This prints! Empty string is truthy in Ruby"
end

if []
  puts "This prints! Empty array is truthy in Ruby"
end

In production Rails code, this catches people constantly. When checking if something exists, be explicit:

ruby
# Risky - fails if count is 0
if order.items.count
  # Process items
end

# Better
if order.items.any?
  # Process items
end

# Also fine
if order.items.present?  # Rails method
  # Process items
end

The .present? Rails method returns true if an object is not blank. It's your safest bet in Rails applications. Similarly, .blank? returns true for nil, false, empty strings, empty arrays, and empty hashes. These methods prevent subtle bugs in payment processing or inventory systems where falsy behavior matters.

Common mistakes

We've seen teams stumble on these patterns in production:

  • Treating symbols as strings: params[:user_id] and params["user_id"] are different. Rails typically converts form params to strings, so be careful with your key lookups. Use params.permit(:user_id) to handle this safely. A real bug we debugged: querying User.where(id: @params[:user_id]) where @params came from JSON, returning no results because the key was a string.
  • Mixing block styles inconsistently: A codebase with random switches between { } and do...end is harder to review. Pick a standard: one-liners use braces, multi-line use do...end.
  • Forgetting implicit returns cause issues: If your method has complex logic with multiple branches, add explicit return statements for clarity. Future readers will thank you. We've seen data corruption in trading systems where an implicit nil return halted a critical save.
  • Assuming everything being an object means you can call any method: Not every object responds to every method. Always check the documentation or test in a console before assuming a method exists on an object type. Call .methods on an object to see what's available.
  • Converting untrusted input to symbols: Never do params[:action].to_sym without validation. Use a whitelist approach as shown in the OrderFilter example.
  • Misunderstanding block scope: Variables created inside blocks don't leak outside. This is by design, but it surprises developers from languages with function scope instead of block scope.

Key takeaways

  • Every value in Ruby is an object with methods—this means you can chain operations and call built-in functionality instead of writing utility functions. Safe navigation with nil prevents crashes in production.
  • Blocks are fundamental to Rails idioms like .map, .each, and find_each. Use curly braces for one-liners and do...end for multi-line logic. They're not just syntax sugar; they're how Rails queries scale efficiently.
  • Symbols are lightweight, interned strings perfect for hash keys and parameters. They're more memory-efficient than strings in high-frequency scenarios. Protect against symbol DoS by validating user input.
  • Implicit returns make Ruby methods cleaner and encourage single-responsibility functions. Use explicit return for early exits and complex logic. Understand that false and nil are the only falsy values.
  • Understanding these basics prevents bugs in production: safe navigation with nil objects, efficient filtering with symbols, database queries that don't load entire tables into memory, and predictable method behavior with implicit returns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles