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?
| Principle | Meaning | Main idea |
|---|---|---|
| S | Single Responsibility Principle | One responsibility per class |
| O | Open/Closed Principle | Open for extension, closed for modification |
| L | Liskov Substitution Principle | Subtypes should be interchangeable |
| I | Interface Segregation Principle | Prefer small, focused interfaces |
| D | Dependency Inversion Principle | Depend 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.
| Principle | Rails example |
|---|---|
| SRP | Keep controllers thin and isolate responsibilities into services or query objects |
| OCP | Use strategies or polymorphism instead of continuously adding conditional logic |
| LSP | Ensure alternative implementations respect the same behavioural contract |
| ISP | Separate provider capabilities into small interfaces/modules |
| DIP | Inject 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.