SOLID Principles in Ruby: A Practical Guide with Real-World Examples

SOLID is a collection of five object-oriented design principles that help developers build maintainable, flexible and testable applications.

As Ruby on Rails developers, we frequently work with classes, modules, services, models and dependency injection. Understanding SOLID helps us decide where responsibilities belong and how different components should communicate.

Let’s understand each principle with practical Ruby examples.

What Does SOLID Stand For?

PrincipleMeaningMain idea
SSingle Responsibility PrincipleOne responsibility per class
OOpen/Closed PrincipleOpen for extension, closed for modification
LLiskov Substitution PrincipleSubtypes should be interchangeable
IInterface Segregation PrinciplePrefer small, focused interfaces
DDependency Inversion PrincipleDepend on abstractions, not concrete implementations

Let’s look at three practical examples covering all five principles.

1. Single Responsibility Principle (SRP)

A class should have one reason to change.

This does not mean a class should contain only one method. It means that the class should have one clearly defined responsibility.

❌ Bad example

Imagine an Order class responsible for:

  • Calculating order totals.
  • Saving orders to the database.
  • Sending confirmation emails.
class Order
  def initialize(items)
    @items = items
  end

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

  def save
    # Database persistence
  end

  def send_confirmation_email
    # Email delivery
  end
end

What’s wrong here?

Suppose we change the email provider. We need to modify the Order class.

Suppose we change the persistence mechanism. Again, we modify Order.

The class has multiple reasons to change.

✅ Better example

Separate responsibilities into dedicated classes.

class Order
  def initialize(items)
    @items = items
  end

  def total
    @items.sum(&:price)
  end
end

class OrderRepository
  def save(order)
    order.save!
  end
end

class ReceiptMailer
  def deliver(order)
    OrderMailer.confirmation(order).deliver_later
  end
end

class CheckoutService
  def initialize(repository:, receipt_mailer:)
    @repository = repository
    @receipt_mailer = receipt_mailer
  end

  def call(order)
    @repository.save(order)
    @receipt_mailer.deliver(order)
  end
end

Now each class has a focused responsibility.

Rails application example:

  • Models handle domain behaviour and persistence.
  • Service objects coordinate business operations.
  • Query objects encapsulate complex read queries.
  • Mailers handle email composition and delivery.

Avoid putting every business operation inside a single model or controller.

2. Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

This means we should be able to introduce new behaviour without continuously modifying existing, stable code.

❌ Bad example

Consider an application that calculates discounts.

class OrderCalculator
  def calculate(total, discount_type)
    case discount_type
    when :percentage
      total * 0.10
    when :fixed
      100
    when :seasonal
      total * 0.20
    end
  end
end

Every time we introduce a new discount type, we must modify the existing class.

This becomes difficult to maintain as the number of conditions increases.

✅ Better example using Ruby classes

Create separate discount strategies.

module Discounts
  class Percentage
    def initialize(rate:)
      @rate = rate
    end

    def call(total)
      total * @rate
    end
  end

  class FixedAmount
    def initialize(amount:)
      @amount = amount
    end

    def call(_total)
      @amount
    end
  end
end

class OrderCalculator
  def initialize(discount:)
    @discount = discount
  end

  def calculate(total)
    total - @discount.call(total)
  end
end

Usage:

discount = Discounts::Percentage.new(rate: 0.10)

calculator = OrderCalculator.new(
  discount: discount
)

calculator.calculate(1000)
# => 900.0

Later, we can introduce a new discount:

module Discounts
  class Seasonal
    def call(total)
      total * 0.20
    end
  end
end

We don’t need to modify OrderCalculator.

This approach uses the Strategy pattern.

Important: Ruby supports this naturally through duck typing. Different objects can implement the same expected method without requiring inheritance.

3. Liskov Substitution, Interface Segregation and Dependency Inversion

These three principles are closely related when designing integrations such as payment gateways, notification providers, or storage services.

A. Liskov Substitution Principle (LSP)

Objects of a parent type should be replaceable with objects of its subtype without breaking application behaviour.

Imagine a payment system supporting different payment providers.

Every payment gateway must follow the same contract:

module Chargeable
  def charge(amount:, currency:)
    raise NotImplementedError
  end
end

Implementations:

class StripeGateway
  include Chargeable

  def charge(amount:, currency:)
    # Call Stripe API
    # Return a consistent payment result
  end
end

class WalletGateway
  include Chargeable

  def charge(amount:, currency:)
    # Call wallet provider
    # Return the same payment result contract
  end
end

Both gateways should respect the same expected behaviour.

For example, if CheckoutService expects a payment result with success?, every implementation must provide that behaviour consistently.

Otherwise, replacing StripeGateway with WalletGateway might break CheckoutService.

That’s an LSP violation.

B. Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they don’t use.

Suppose we create one large payment interface:

module PaymentGateway
  def charge; end
  def refund; end
  def cancel; end
  def recurring_payment; end
end

But some payment providers support charging only. Others support refunds and recurring payments.

Forcing every provider to implement all these methods creates unnecessary dependencies.

Instead, separate capabilities:

module Chargeable
  def charge(amount:, currency:)
    raise NotImplementedError
  end
end

module Refundable
  def refund(transaction_id:, amount:)
    raise NotImplementedError
  end
end

Now a gateway can implement only the capabilities it supports.

class GiftCardGateway
  include Chargeable

  def charge(amount:, currency:)
    # Gift card payment implementation
  end
end

A provider supporting refunds can include both modules.

Ruby modules provide a useful way of expressing these contracts, although Ruby does not enforce interfaces at compile time. Tests should verify that implementations meet their contracts.

C. Dependency Inversion Principle (DIP)

High-level modules should not depend directly on low-level modules. Both should depend on abstractions.

Consider a checkout operation.

❌ Bad example

class CheckoutService
  def initialize
    @gateway = StripeGateway.new
  end

  def call(amount)
    @gateway.charge(
      amount: amount,
      currency: "USD"
    )
  end
end

CheckoutService is tightly coupled to StripeGateway.

Switching to another provider requires changing the service.

✅ Better example

Inject the dependency.

class CheckoutService
  def initialize(payment_gateway:)
    @payment_gateway = payment_gateway
  end

  def call(amount)
    @payment_gateway.charge(
      amount: amount,
      currency: "USD"
    )
  end
end

Usage:

checkout = CheckoutService.new(
  payment_gateway: StripeGateway.new
)

checkout.call(500)

For testing:

fake_gateway = FakePaymentGateway.new

checkout = CheckoutService.new(
  payment_gateway: fake_gateway
)

Now CheckoutService doesn’t know whether the implementation uses Stripe, a wallet, or a test double.

This makes the code easier to test, replace and maintain.

SOLID in a Ruby on Rails Application

Here’s how these principles translate into everyday Rails development.

PrincipleRails example
SRPKeep controllers thin and isolate responsibilities into services or query objects
OCPUse strategies or polymorphism instead of continuously adding conditional logic
LSPEnsure alternative implementations respect the same behavioural contract
ISPSeparate provider capabilities into small interfaces/modules
DIPInject dependencies instead of directly creating concrete service objects

Remember that SOLID does not mean creating a class for every method or introducing unnecessary abstractions.

Apply these principles when they solve a real maintainability or testing problem.

Common Interview Questions

1. What are SOLID principles?

SOLID is a set of five object-oriented design principles that improve maintainability, flexibility and testability. They are SRP, OCP, LSP, ISP and DIP.

2. What is the difference between SRP and OCP?

SRP focuses on giving a class one responsibility. OCP focuses on allowing new behaviour without modifying existing stable code.

3. How does Ruby support interfaces?

Ruby doesn’t have traditional compile-time interfaces like Java. We commonly use duck typing, modules and conventions to define expected behaviour.

4. What is dependency injection?

Dependency injection is a technique where an object receives its dependencies from outside rather than creating them internally. It reduces coupling and makes testing easier.

5. Are SOLID principles mandatory in every class?

No. They are design guidelines, not strict rules. Over-engineering small classes can introduce unnecessary complexity.

🏁 Final Takeaway

When designing Ruby applications, ask yourself:

  • Does this class have multiple reasons to change?
  • Can I add new behaviour without modifying existing code?
  • Can I replace one implementation with another safely?
  • Am I forcing objects to implement methods they don’t need?
  • Am I depending on concrete implementations unnecessarily?

These questions help identify SOLID violations during code reviews and system design discussions.