Understanding Dependency Inversion Principle (DIP) in Ruby

Oct 22, 2017

Hello everyone! Let's dive into the last principle of SOLID: the Dependency Inversion Principle. This principle, defined by Robert C. Martin, is fundamental to creating maintainable and flexible software.

The Definition

The Dependency Inversion Principle consists of two key parts:

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

  2. Abstractions should not depend on details. Details should depend on abstractions.

Let's break this down with some practical examples.

A Common Anti-Pattern

Here's an example of code that violates DIP. Let's say we're building a notification system:

class EmailSender
  def send_email(to, subject, body)
    # Implementation for sending email
    puts "Sending email to #{to}: #{subject}"
  end
end

class UserNotifier
  def initialize
    @email_sender = EmailSender.new
  end

  def notify_user(user, message)
    @email_sender.send_email(
      user.email,
      'Notification',
      message
    )
  end
end

# Usage
notifier = UserNotifier.new
user = OpenStruct.new(email: 'user@example.com')
notifier.notify_user(user, 'Hello!')

The problem here is that UserNotifier (high-level module) directly depends on EmailSender (low-level module). This creates several issues:

  1. We can't easily switch to a different notification method
  2. Testing becomes harder because we can't easily mock the email sender
  3. The code is tightly coupled

Applying DIP

Let's refactor this code to follow the Dependency Inversion Principle:

# First, define an abstraction (interface)
class NotificationService
  def send_notification(to, message)
    raise NotImplementedError, "#{self.class} must implement #send_notification"
  end
end

# Create concrete implementations
class EmailNotificationService < NotificationService
  def send_notification(to, message)
    # Implementation for sending email
    puts "Sending email to #{to}: #{message}"
  end
end

class SMSNotificationService < NotificationService
  def send_notification(to, message)
    # Implementation for sending SMS
    puts "Sending SMS to #{to}: #{message}"
  end
end

class SlackNotificationService < NotificationService
  def send_notification(to, message)
    # Implementation for sending Slack message
    puts "Sending Slack message to #{to}: #{message}"
  end
end

# High-level module depends on abstraction
class UserNotifier
  def initialize(notification_service)
    @notification_service = notification_service
  end

  def notify_user(user, message)
    @notification_service.send_notification(
      user.contact_info,
      message
    )
  end
end

# Usage
email_notifier = UserNotifier.new(EmailNotificationService.new)
sms_notifier = UserNotifier.new(SMSNotificationService.new)
slack_notifier = UserNotifier.new(SlackNotificationService.new)

user = OpenStruct.new(contact_info: 'user@example.com')
email_notifier.notify_user(user, 'Hello via email!')
user = OpenStruct.new(contact_info: '+1234567890')
sms_notifier.notify_user(user, 'Hello via SMS!')
user = OpenStruct.new(contact_info: '@username')
slack_notifier.notify_user(user, 'Hello via Slack!')

Let's See Another Example

Here's another example involving payment processing:

# Abstract payment processor
class PaymentProcessor
  def process_payment(amount)
    raise NotImplementedError, "#{self.class} must implement #process_payment"
  end
end

# Concrete implementations
class StripePaymentProcessor < PaymentProcessor
  def process_payment(amount)
    puts "Processing $#{amount} payment via Stripe"
    # Stripe-specific implementation
  end
end

class PayPalPaymentProcessor < PaymentProcessor
  def process_payment(amount)
    puts "Processing $#{amount} payment via PayPal"
    # PayPal-specific implementation
  end
end

# High-level module
class OrderProcessor
  def initialize(payment_processor)
    @payment_processor = payment_processor
  end

  def process_order(order)
    # Other order processing logic...
    @payment_processor.process_payment(order.total_amount)
  end
end

# Usage
class Order
  attr_reader :total_amount
  
  def initialize(amount)
    @total_amount = amount
  end
end

# We can easily switch between payment processors
stripe_processor = OrderProcessor.new(StripePaymentProcessor.new)
paypal_processor = OrderProcessor.new(PayPalPaymentProcessor.new)

order = Order.new(99.99)
stripe_processor.process_order(order)
paypal_processor.process_order(order)

Benefits of Following DIP

  1. Flexibility: We can easily swap implementations without changing the high-level code
  2. Testability: We can easily mock dependencies for testing
  3. Maintainability: Changes to low-level modules don't affect high-level modules
  4. Reusability: Components are more loosely coupled and thus more reusable

Testing Example

Here's how easy it becomes to test our code with DIP:

require 'minitest/autorun'

class TestNotifier < Minitest::Test
  class MockNotificationService < NotificationService
    attr_reader :last_notification
    
    def send_notification(to, message)
      @last_notification = {to: to, message: message}
    end
  end
  
  def test_notify_user
    mock_service = MockNotificationService.new
    notifier = UserNotifier.new(mock_service)
    user = OpenStruct.new(contact_info: 'test@example.com')
    
    notifier.notify_user(user, 'Test message')
    
    assert_equal 'test@example.com', mock_service.last_notification[:to]
    assert_equal 'Test message', mock_service.last_notification[:message]
  end
end

Real-World Implementation Using Dependency Injection Container

For larger applications, you might want to use a dependency injection container:

class Container
  def initialize
    @registrations = {}
  end

  def register(key, klass)
    @registrations[key] = klass
  end

  def resolve(key)
    @registrations[key].new
  end
end

# Setup
container = Container.new
container.register(:notification_service, EmailNotificationService)

# Usage in production
notifier = UserNotifier.new(container.resolve(:notification_service))

# Switch to SMS for different environment
container.register(:notification_service, SMSNotificationService)

Common Pitfalls to Avoid

  1. Over-abstraction: Don't create abstractions until you need them
  2. Leaky abstractions: Make sure your abstractions don't expose implementation details
  3. Rigid interfaces: Create interfaces that are flexible enough to accommodate future requirements

Conclusion

The Dependency Inversion Principle is crucial for creating maintainable and flexible software. By depending on abstractions rather than concrete implementations, we create code that is:

  • Easier to test
  • More flexible to change
  • More maintainable in the long run
  • Better suited for team development

Remember, while Ruby doesn't have formal interfaces, we can still apply DIP effectively using duck typing and abstract classes. The key is to depend on abstractions and inject dependencies rather than creating them directly within our classes.

Thanks for reading! Feel free to ask any questions if something isn't clear.

Mirzalazuardi Hermawan