SOLID for Rails Developers, real-life tour (with code)

posted 5 min read

You’ve heard “keep controllers skinny, models thin” but how? The SOLID principles are a compact set of habits that keep Rails apps change-friendly as they grow. Here’s a practical, with bite-size Rails examples you can use today.


What is SOLID?

Before we jump into code, let’s meet the SOLID squad five principles that save your codebase from chaos, confusion, and late-night debugging sessions.

  • S ingle Responsibility Principle (SRP): Every class should have one job. Not two. Not three. Just one.

  • O pen/Closed Principle (OCP): Your code should be open for extension, but closed for modification.

  • L iskov Substitution Principle (LSP): If your code says it’s a duck, it should quack like a duck.

  • I nterface Segregation Principle (ISP): Don’t force your classes to implement methods they don’t need.

  • D ependency Inversion Principle (DIP): Depend on abstractions, not concrete things.

Ready to SOLIDify your Rails code? Let’s go!


A running scenario

Imagine a small trading API for “Tomato” prices:

  • We fetch intraday PriceTicks.
  • We compute the best daily gain (buy low, sell high once).
  • We expose endpoints for analytics.

We’ll use this scenario to illustrate each principle.


S Single Responsibility Principle (SRP)

A class should have one reason to change.

Bad (a God Controller)

class AnalyticsController < ApplicationController
  def show
    date = Date.iso8601(params[:date])
    ticks = PriceTick.where(time: date.all_day).order(:time)
    # validation, parsing, domain logic, formatting… all here 
    min = ticks.first.value_cents
    best = 0
    ticks.drop(1).each do |t|
      best = [best, t.value_cents - min].max
      min = [min, t.value_cents].min
    end
    render json: { date: date, best_gain_cents: best * 100 }
  end
end

Good (split responsibilities)

  • Query loads just what’s needed
  • Service computes the gain
  • Controller orchestrates and renders

app/services/best_gain_calculator.rb:

class BestGainCalculator
  def self.call(ticks, quantity: 100)
    enum = ticks.each
    first = enum.next rescue (return 0)
    min   = first.value_cents
    best  = 0
    enum.each do |t|
      diff = t.value_cents - min
      best = diff if diff > best
      min  = t.value_cents if t.value_cents < min
    end
    best * quantity
  end
end

app/queries/price_ticks_on_day.rb:

class PriceTicksOnDay
  def self.call(date)
    PriceTick.where(time: date.all_day).select(:time, :value_cents).order(:time)
  end
end

app/controllers/api/v1/best_gains_controller.rb

class Api::V1::BestGainsController < ApplicationController
  def show
    ticks = PriceTicksOnDay.call(date)
    render json: { best_gain_cents: BestGainCalculator.call(ticks) }
  end
end

Win: Each class changes for exactly one reason.


O Open/Closed Principle (OCP)

Open for extension, closed for modification. Add behaviors without rewriting stable code.

Real life: new gain strategies without editing the controller

app/services/gain_strategies/base.rb:

module GainStrategies
  class Base
    def call(ticks) = raise NotImplementedError
  end
end

app/services/gain_strategies/single_trade.rb

module GainStrategies
  class SingleTrade < Base
    def call(ticks) = BestGainCalculator.call(ticks)
  end
end

app/services/gain_strategies/multi_trade.rb

module GainStrategies
  class MultiTrade < Base
    def call(ticks)
      # example: sum of all positive deltas
      prev = nil
      profit = 0
      ticks.each do |t|
        if prev
          delta = t.value_cents - prev
          profit += delta if delta.positive?
        end
        prev = t.value_cents
      end
      profit * 100 # quantity stub
    end
  end
end

app/controllers/api/v1/best_gains_controller.rb:

def show
  ticks = PriceTicksOnDay.call(date)

  strategy = params[:mode] == "multi" ? GainStrategies::MultiTrade.new : GainStrategies::SingleTrade.new
  render json: { best_gain_cents: strategy.call(ticks) }
end

Win: Adding WeekOverWeek or TrailingStop strategies doesn’t touch controller code.


L Liskov Substitution Principle (LSP)

Subtypes must be usable wherever their base type is expected.

Real life: STI pitfalls vs composition

If you have PriceTick and you try STI for FxPriceTick < PriceTick but change value_cents semantics (e.g., store pips), substituting FxPriceTick where PriceTick is expected will break calculations.

Better: keep interface and units consistent, or compose.

class PriceTick < ApplicationRecord
  # always expose value in cents
  def value_cents
    super # guaranteed integer cents
  end
end

class FxPriceValue
  def initialize(pips:, pip_value_cents:)
    @pips = pips
    @pip_value_cents = pip_value_cents
  end

  def to_cents = @pips * @pip_value_cents
end

Win: Anything expecting value_cents continues to work.


I Interface Segregation Principle (ISP)

Prefer small, focused interfaces over fat ones. Consumers shouldn’t depend on what they don’t use.

Real life: split service interfaces

Bad:

class ReportService
  def generate_csv_for_day(date); end
  def generate_pdf_for_week(date); end
  def email_pdf_to(user); end
  def email_csv_to(user); end
end

Good:

class CsvReport
  def call(date); end
end

class PdfReport
  def call(week); end
end

class ReportMailer
  def send_pdf(user, pdf); end
  def send_csv(user, csv); end
end

Win: Each client depends on only what it needs; easier to test and swap.

In Rails, ISP often means small POROs instead of piling methods into one “service class.”


D Dependency Inversion Principle (DIP)

Depend on abstractions, not concrete classes.

Real life: inject gateways (HTTP, cache, payments)

app/services/price_sync.rb:

class PriceSync
  def initialize(client: External::PricesClient.new, cache: Rails.cache)
    @client = client
    @cache  = cache
  end

  def call(date)
    @cache.fetch("prices:#{date}", expires_in: 10.minutes) do
      @client.fetch_for(date) # abstraction: responds to `fetch_for`
    end
  end
end

In tests:

fake = Class.new { def fetch_for(date) = [{ time: Time.now, value_cents: 10000 }] }.new
result = PriceSync.new(client: fake, cache: ActiveSupport::Cache::MemoryStore.new).call(Date.today)

Win: Swap implementations without changing business logic. Fewer brittle tests.


Pulling it together: a tiny SOLID checklist

  • SRP: Does this class change for exactly one reason?
  • OCP: Can I add a variation without editing existing classes?
  • LSP: Will any subtype respect the expected inputs/outputs/units?
  • ISP: Is this interface minimal for its consumer?
  • DIP: Are external details injected (e.g., clients, storage, time)?

A realistic, SOLID endpoint (end-to-end)

app/controllers/api/v1/best_gains_controller.rb

class Api::V1::BestGainsController < ApplicationController
  def show
    ticks = PriceTicksOnDay.call(date)
    strategy = strategy_for(params[:mode])
    best = strategy.call(ticks)
    return render json: { error: "no valid trades" }, status: :unprocessable_entity if best <= 0
    render json: { date: date, best_gain_cents: best }
  end

  private

  def strategy_for(mode)
    mode == "multi" ? GainStrategies::MultiTrade.new : GainStrategies::SingleTrade.new
  end
end

Everything above respects SOLID:

  • The controller just orchestrates.
  • Strategies extend behavior (OCP).
  • Calculators keep contracts consistent (LSP).
  • Tiny interfaces (ISP).
  • Dependencies injected (DIP).

Final thought

Rails gives you speed; SOLID gives you sustainable speed. Start small: extract one PORO today (calculator or query), inject one dependency, and keep your interfaces tiny. Your future self and your teammates will thank you.


Bonus: If SOLID were a superhero team...

  • SRP: Captain Focus only fights one villain at a time.
  • OCP: The Extender always ready for new powers, never rewrites their origin story.
  • LSP: Duckman if it walks like a duck and quacks like a duck, it’s Duckman!
  • ISP: The Minimalist only carries what’s needed for the mission.
  • DIP: The Abstractionist never depends on sidekicks, only on the idea of sidekicks.

Now go forth and SOLIDify your Rails app and remember, with great principles comes great maintainability!

If you read this far, tweet to the author to show them you care. Tweet a Thanks

2 Comments

2 votes
1
2 votes

More Posts

JuggleBee’s Great Leap - Rebuilding a Rails 4 App in Rails 8 (Part 1)

BrazenBraden - Aug 21

JuggleBee’s Great Leap – Data Migration, ActiveStorage, and Production Readiness (Part 2)

BrazenBraden - Aug 24

Rails releases security updates for ReDoS vulnerabilities—upgrade now to the latest versions for optimal protection!

ShahZaib - Oct 15, 2024

Getting Started with Ruby & Rails on Windows

Sunny - Aug 2

What’s Missing With AI-Generated Code? Refactoring

Steve Fenton - Sep 23
chevron_left