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!