Rails Development

Rails Development Best Practices That Actually Matter in Production

The Rails practices I enforce on every project — not because some style guide says so, but because they prevent the most common and expensive production problems.

J

Justin Hamilton

Founder & Principal Engineer

rails ruby best practices web development code quality

There’s no shortage of “Rails best practices” content out there. Most of it is written by people who’ve read a lot about Rails without shipping enough of it. Here’s what I actually enforce on projects — practices that have prevented production incidents, saved debugging hours, and made code maintainable over years instead of months.

Keep Your Controllers Thin

Controllers should do one thing: receive a request, pass it to the right business logic, and return a response. That’s it.

When controllers start containing domain logic — validation rules, complex conditionals, data transformation — they become impossible to test cleanly and painful to reason about. More importantly, that logic is now locked to the HTTP layer. You can’t reuse it in background jobs, rake tasks, or APIs without duplication.

The fix is service objects. A plain Ruby class that takes inputs and produces a result. It’s testable in isolation, reusable anywhere, and keeps your controller readable.

class OrdersController < ApplicationController
  def create
    result = CreateOrder.call(order_params, current_user)
    if result.success?
      render json: result.order, status: :created
    else
      render json: { errors: result.errors }, status: :unprocessable_entity
    end
  end
end

CreateOrder contains the logic. The controller just coordinates.

Strong Parameters, Always

Every Rails developer knows params.require(:user).permit(:name, :email). But on large projects I see this drift — developers adding new attributes without thinking about whether they should be user-controlled, or using params.permit! to allow everything “temporarily.”

Permitting all parameters is a mass assignment vulnerability. There is no situation where it’s the right call in production code. If you find yourself writing permit!, stop and think about what you’re actually allowing.

Write Migrations That Are Safe to Run in Production

Database migrations are the operation that scares me most in production. Unlike application code, you can’t just roll back a migration if something goes wrong — especially once data has been affected.

Rules I follow:

Never remove a column in the same migration that stops using it. Deploy code first that ignores the column, then remove the column in a separate migration. This is “down-first” migration strategy, and it prevents errors when your app is partially deployed.

Adding NOT NULL columns requires a default. If you add a NOT NULL constraint without a default and there’s existing data, your migration fails. Always provide a default or migrate existing data first.

Large table migrations need careful planning. Adding an index on a large table in production can lock the table. Use algorithm: :concurrently in PostgreSQL to avoid this. Be aware of the implications.

Don’t assume migrations are instantaneous. Test them on a copy of production data to understand the real duration before running them.

Indexes Are Not Optional

Every foreign key column needs an index. Every column you sort by. Every column you filter on. If you’re querying by something, it needs an index or your application will crawl under load.

The add-migration for a new association should almost always include an index on the foreign key column:

add_reference :orders, :customer, null: false, foreign_key: true, index: true

Use the Bullet gem in development to catch N+1 queries and missing indexes automatically.

Environment-Specific Configuration Belongs in Credentials

Hardcoded API keys and configuration values in code are a security problem and a deployment problem. Use Rails credentials (config/credentials.yml.enc) for secrets, environment variables for environment-specific values, and never commit .env files with real values.

The pattern that works:

# config/credentials.yml.enc
stripe:
  secret_key: sk_live_...
  webhook_secret: whsec_...

# In code
Stripe.api_key = Rails.application.credentials.stripe[:secret_key]

Background Jobs Need Idempotency

Active Job with Sidekiq is excellent. But jobs fail. They get retried. Sidekiq can run a job multiple times if something goes wrong at the wrong moment.

Every background job needs to be safe to run multiple times with the same input — that’s idempotency. Build it in from the start.

class ProcessPaymentJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    return if order.payment_processed? # Already done — skip
    # ... process payment
    order.update!(payment_processed: true)
  end
end

N+1 Queries Are Not a “Someday” Problem

The Bullet gem will tell you about N+1 queries in development. Don’t ignore them. What’s a 10ms query in development on 5 records is a 2-second query in production on 500.

Use includes(), eager_load(), or preload() depending on your needs:

# Bad: fires a query for each order's customer
orders = Order.all
orders.each { |o| puts o.customer.name }

# Good: eager loads customers in one query
orders = Order.includes(:customer).all
orders.each { |o| puts o.customer.name }

Test at the Right Level

Unit tests for business logic (service objects, models). Request specs for API endpoints (tests the full stack). System tests for critical user flows (login, checkout, whatever would lose money if broken).

Don’t write tests just to hit a coverage percentage. Write tests for the logic that matters — the code where a bug costs money or breaks trust.

My minimum on any project: test every service object, test every API endpoint’s happy path and at least one failure path, and test the critical user flows end-to-end.

Logging That’s Actually Useful

Rails’ default logging is verbose but not always useful. In production, you want structured logging that’s queryable.

Use the lograge gem to get structured, one-line-per-request logs. Add custom fields for the user ID, request ID, and any other context that would help you debug production issues.

When something goes wrong at 2am (and it will), you’ll be grateful you can filter logs by user ID and see exactly what they did.

Handle Errors, Don’t Ignore Them

begin
  payment.charge
rescue Stripe::StripeError => e
  # Log it, notify the team, tell the user something helpful
  Rails.logger.error("Stripe error: #{e.message}")
  Sentry.capture_exception(e)
  render json: { error: "Payment failed" }, status: :payment_required
end

Don’t swallow errors silently. Don’t let them crash unhandled. Handle them, log them, and tell the user something actionable.


These aren’t exotic practices — they’re the fundamentals that separate code you can maintain for years from code that becomes a liability. If your Rails application has drifted away from these, that’s fixable. Let’s talk about what a recovery plan looks like.

Let's Build Something Together

Hamilton Development Company builds custom software for businesses ready to stop fitting themselves into someone else's box. $500/mo retainer or $125/hr — no surprises.

Schedule a Consultation