Software Architectย Guide:๐Ÿ’ก Understanding Design Patterns & Anti-Patterns in Ruby

In the world of software development, design patterns and anti-patterns play a critical role in writing maintainable, clean, and scalable code. As a Ruby developer, mastering these concepts will help you design robust applications and avoid common pitfalls.


What are Design Patterns?

Design patterns are time-tested, reusable solutions to common problems in software design. They aren’t code snippets but conceptual templates you can adapt based on your needs. Think of them as architectural blueprints.

๐Ÿงฑ Common Ruby Design Patterns

1. Singleton Pattern

the_logger = Logger.new
class Logger
  @@instance = Logger.new

  def self.instance
    @@instance
  end

  private_class_method :new
end

Use when only one instance of a class should exist (e.g., logger, configuration manager).

๐Ÿง  What is the Singleton Pattern?

The Singleton Pattern ensures that only one instance of a class exists during the lifetime of an application. This is useful for managing shared resources like:

  • a logger (so logs are not duplicated or misdirected),
  • a configuration manager (so all parts of the app read/write the same config),
  • or a database connection pool.

๐Ÿ” Why private_class_method :new?

This line prevents other parts of the code from calling Logger.new, like this:

Logger.new  # โŒ Will raise a NoMethodError

So, you’re restricting object creation from outside the class.

Q) Then how do you get an object of Logger?

By using a class method like Logger.instance that returns the only instance of the class.

โœ… Full Singleton Example in Ruby

class Logger
  # Create and store the single instance
  @@instance = Logger.new

  # Provide a public way to access that instance
  def self.instance
    @@instance
  end

  # Prevent external instantiation
  private_class_method :new

  # Example method
  def log(message)
    puts "[LOG] #{message}"
  end
end

# Usage
logger1 = Logger.instance
logger2 = Logger.instance

logger1.log("Singleton works!")

puts logger1.object_id == logger2.object_id  # true

๐Ÿงพ Explanation:

  • @@instance = Logger.new: Creates the only instance when the class is loaded.
  • Logger.instance: The only way to access that object.
  • private_class_method :new: Prevents creation of new objects using Logger.new.
  • logger1 == logger2: โœ… True, because they point to the same object.

๐Ÿ’ฌ Think of a Real-World Example

Imagine a central control tower at an airport:

  • There should only be one control tower instance managing flights.
  • If each plane connected to a new tower, it would be chaos!

The Singleton pattern in Ruby ensures there’s just one control tower (object) shared globally.


2. Observer Pattern

class Order
  include Observable

  def place_order
    changed
    notify_observers(self)
  end
end

class EmailNotifier
  def update(order)
    puts "Email sent for order #{order.id}"
  end
end

order = Order.new
order.add_observer(EmailNotifier.new)
order.place_order

Use when one change in an object should trigger actions in other objects.

๐Ÿ” Observer Pattern in Ruby โ€“ Explained in Detail

The Observer Pattern is a behavioral design pattern that lets one object (the subject) notify other objects (the observers) when its state changes.

Ruby has built-in support for this through the Observable module in the standard library (require 'observer').

How it Works

  1. The Subject (e.g., Order) includes the Observable module.
  2. The subject calls:
    • changed โ†’ marks the object as changed.
    • notify_observers(data) โ†’ notifies all subscribed observers.
  3. Observers (e.g., EmailNotifier) implement an update method.
  4. Observers are registered using add_observer(observer_object).

๐Ÿงช Complete Working Example

require 'observer'

class Order
  include Observable
  attr_reader :id

  def initialize(id)
    @id = id
  end

  def place_order
    puts "Placing order #{@id}..."
    changed                      # Mark this object as changed
    notify_observers(self)       # Notify all observers
  end
end

class EmailNotifier
  def update(order)
    puts "๐Ÿ“ง Email sent for Order ##{order.id}"
  end
end

class SMSNotifier
  def update(order)
    puts "๐Ÿ“ฑ SMS sent for Order ##{order.id}"
  end
end

# Create subject and observers
order = Order.new(101)
order.add_observer(EmailNotifier.new)
order.add_observer(SMSNotifier.new)

order.place_order

# Output:
# Placing order 101...
# ๐Ÿ“ง Email sent for Order #101
# ๐Ÿ“ฑ SMS sent for Order #101

๐Ÿง  When to Use the Observer Pattern

  • You have one object whose changes should automatically update other dependent objects.
  • Examples:
    • UI updates in response to data changes
    • Logging, email, or analytics triggers after a user action
    • Notification systems in event-driven apps

Updated Observer Pattern

require 'observer'

class Order
  include Observable
  attr_reader :id

  def initialize(id)
    @id = id
  end

  def place_order
    changed
    notify_observers(self)
  end
end

class EmailNotifier
  def update(order)
    puts "๐Ÿ“ง Email sent for Order ##{order.id}"
  end
end

order = Order.new(42)
order.add_observer(EmailNotifier.new)
order.place_order

What’s Happening?

  • Order includes the Observable module to gain observer capabilities.
  • add_observer registers an observer object.
  • When place_order is called:
    • changed marks the state as changed.
    • notify_observers(self) triggers the observer’s update method.
  • EmailNotifier reacts to the change โ€” in this case, it simulates sending an email.

๐Ÿงฉ Use this pattern when one change should trigger multiple actions โ€” like sending notifications, logging, or syncing data across objects.

Check: https://docs.ruby-lang.org/en/2.2.0/Observable.html

3. Decorator Pattern

class SimpleCoffee
  def cost
    2
  end
end

class MilkDecorator
  def initialize(coffee)
    @coffee = coffee
  end

  def cost
    @coffee.cost + 0.5
  end
end

coffee = MilkDecorator.new(SimpleCoffee.new)
puts coffee.cost  # => 2.5

Add responsibilities to objects dynamically without modifying their code.


๐Ÿ”„ 4. Strategy Pattern

The Strategy Pattern allows choosing an algorithm’s behaviour at runtime. This pattern is useful when you have multiple interchangeable ways to perform a task.

โœ… Example: Text Formatter Strategies

Let’s say you have a system that outputs text in different formats โ€” plain, HTML, or Markdown.

โŒ Before Using Strategy Pattern โ€” Hardcoded Conditional Formatting

class TextFormatter
  def initialize(format)
    @format = format
  end

  def format(text)
    case @format
    when :plain
      text
    when :html
      "<p>#{text}</p>"
    when :markdown
      "**#{text}**"
    else
      raise "Unknown format: #{@format}"
    end
  end
end

# Usage
formatter1 = TextFormatter.new(:html)
puts formatter1.format("Hello, World")       # => <p>Hello, World</p>

formatter2 = TextFormatter.new(:markdown)
puts formatter2.format("Hello, World")       # => **Hello, World**

โš ๏ธ Problems With This Approach

  • All formatting logic is stuffed into one method.
  • Adding a new format (like XML) means modifying this method (violates Open/Closed Principle).
  • Not easy to test or extend individual formatting behaviors.
  • Harder to maintain and violates SRP (Single Responsibility Principle).

This sets the stage perfectly to apply the Strategy Pattern, where each format becomes its own class with a clear responsibility.

Instead of writing if/else logic everywhere, use strategy objects:

# Strategy Interface
class TextFormatter
  def self.for(format)
    case format
    when :plain
      PlainFormatter.new
    when :html
      HtmlFormatter.new
    when :markdown
      MarkdownFormatter.new
    else
      raise "Unknown format"
    end
  end
end

# Concrete Strategies
class PlainFormatter
  def format(text)
    text
  end
end

class HtmlFormatter
  def format(text)
    "<p>#{text}</p>"
  end
end

class MarkdownFormatter
  def format(text)
    "**#{text}**"
  end
end

# Usage
def render_text(text, format)
  formatter = TextFormatter.for(format)
  formatter.format(text)
end

puts render_text("Hello, world", :html)     # => <p>Hello, world</p>
puts render_text("Hello, world", :markdown) # => **Hello, world**

๐Ÿ“Œ Why It’s Better

  • Adds new formats easily without changing existing code.
  • Keeps formatting logic isolated in dedicated classes.
  • Follows the Open/Closed Principle.

๐Ÿ’ณ Strategy Pattern in Rails: Payment Gateway Integration

Here’s a Rails-specific Strategy Pattern example. This example uses a service to handle different payment gateways (e.g., Stripe, PayPal, Razorpay), which are chosen dynamically based on configuration or user input.

Problem:

You want to process payments, but the actual logic differs depending on which gateway (Stripe, PayPal, Razorpay) is being used. Avoid if/else or case all over your controller or service.

โœ… Solution Using Strategy Pattern

# app/services/payment_processor.rb
class PaymentProcessor
  def self.for(gateway)
    case gateway.to_sym
    when :stripe
      StripeGateway.new
    when :paypal
      PaypalGateway.new
    when :razorpay
      RazorpayGateway.new
    else
      raise "Unsupported payment gateway"
    end
  end
end

# app/services/stripe_gateway.rb
class StripeGateway
  def charge(amount, user)
    # Stripe API integration here
    puts "Charging #{user.name} โ‚น#{amount} via Stripe"
  end
end

# app/services/paypal_gateway.rb
class PaypalGateway
  def charge(amount, user)
    # PayPal API integration here
    puts "Charging #{user.name} โ‚น#{amount} via PayPal"
  end
end

# app/services/razorpay_gateway.rb
class RazorpayGateway
  def charge(amount, user)
    # Razorpay API integration here
    puts "Charging #{user.name} โ‚น#{amount} via Razorpay"
  end
end

# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
  def create
    user = User.find(params[:user_id])
    gateway = params[:gateway] # e.g., 'stripe', 'paypal', etc.
    amount = params[:amount].to_i

    processor = PaymentProcessor.for(gateway)
    processor.charge(amount, user)

    render json: { message: \"Payment processed via #{gateway.capitalize}\" }
  end
end

โœ… Benefits in Rails Context

  • Keeps your controller slim and readable.
  • Each gateway integration is encapsulated in its own class.
  • Easy to test, extend, and maintain (open/closed principle).
  • Avoids future code smell like “Shotgun Surgery”.

โš ๏ธ What are Anti-Patterns?

Anti-patterns are poor programming practices or ineffective solutions that seem helpful at first but cause long-term issues like unmaintainable code or hidden bugs.

Common Ruby Anti-Patterns

๐Ÿงจ 1. God Object

A class that knows too much or does too much.

class UserManager
  def create_user
    # logic
  end

  def send_email
    # unrelated responsibility
  end

  def generate_report
    # another unrelated responsibility
  end
end

Fix: Follow the Single Responsibility Principle. Split into smaller, focused classes.


๐Ÿงจ Shotgun Surgery

Tiny changes require touching many different places in code.

# Example of Shotgun Surgery - Business rule spread across many files

# In order.rb
class Order
  def eligible_for_discount?
    user.vip? && total > 1000
  end
end

# In invoice.rb
class Invoice
  def apply_discount(order)
    if order.user.vip? && order.total > 1000
      # apply discount logic
    end
  end
end

# In email_service.rb
class EmailService
  def send_discount_email(order)
    if order.user.vip? && order.total > 1000
      # send congratulatory email
    end
  end
end

Here, the logic user.vip? && total > 1000 is repeated in multiple places. If the discount eligibility rules change (e.g., change the threshold to 2000 or add a new condition), you’ll have to update every occurrence.

โœ… Fix: Centralize the Logic

class DiscountPolicy
  def self.eligible?(order)
    order.user.vip? && order.total > 1000
  end
end

Now all files can use DiscountPolicy.eligible?(order), ensuring consistency and easier maintenance.


3. Spaghetti Code

Unstructured and difficult-to-follow code.

def calculate_discount(user, items)
  if user.vip?
    # deeply nested logic
    total = items.sum { |i| i.price }
    total * 0.2
  else
    if user.new_customer?
      total = items.sum { |i| i.price }
      total * 0.1
    else
      total = items.sum { |i| i.price }
      total * 0.05
    end
  end
end

This code is hard to read, hard to extend, and violates SRP (Single Responsibility Principle).

Fix: Break into smaller methods, use polymorphism or strategy patterns.

โœ… Refactored with Strategy Pattern

# Strategy Interface
class DiscountStrategy
  def self.for(user)
    if user.vip?
      VipDiscount.new
    elsif user.new_customer?
      NewCustomerDiscount.new
    else
      RegularDiscount.new
    end
  end
end

# Concrete Strategies
class VipDiscount
  def apply(items)
    total = items.sum(&:price)
    total * 0.2
  end
end

class NewCustomerDiscount
  def apply(items)
    total = items.sum(&:price)
    total * 0.1
  end
end

class RegularDiscount
  def apply(items)
    total = items.sum(&:price)
    total * 0.05
  end
end

# Usage
def calculate_discount(user, items)
  strategy = DiscountStrategy.for(user)
  strategy.apply(items)
end

๐ŸŽฏ Benefits

  • No nested conditionals
  • Easy to add new discount types (open/closed principle)
  • Each class has one responsibility
  • Testable and readable

๐Ÿ” How to Detect Anti-Patterns in Ruby Code

  1. Code Smells:
    • Long methods
    • Large classes
    • Repetition (DRY violations)
    • Deep nesting
  2. Code Review Checklist:
    • Is each class doing only one thing?
    • Can this method be broken down?
    • Are we repeating business logic?
    • Is the code testable?
  3. Use Static Analysis Tools:
    • Rubocop for style and complexity checks
    • Reek for code smells
    • Flog for measuring code complexity

๐Ÿ›  How to Refactor Anti-Patterns

  • Use Service Objects: Extract complex logic into standalone classes.
  • Apply Design Patterns: Choose the right pattern that matches your need (Strategy, Adapter, etc).
  • Keep Methods Small: Limit to a single task; ideally under 10 lines.
  • Write Tests First: Test-driven development (TDD) helps spot untestable designs.

๐ŸŽฏ Conclusion

Understanding design patterns and identifying anti-patterns is crucial for writing better Ruby code. While patterns guide you toward elegant solutions, anti-patterns warn you about common mistakes. With good design principles, automated tools, and thoughtful reviews, your Ruby codebase can remain healthy, scalable and developer-friendly.


Happy Designing Ruby! โœจ