Refactoring Rigid Code Structures in Ruby

Improving Maintainability and Extensibility

Posted by Theo Cha on November 26, 2024

“Writing maintainable code is a journey, not a destination.”

When working with Ruby applications, you may encounter rigid code structures that make your application harder to maintain and extend. In this post, I’ll walk you through common scenarios where refactoring can improve your code’s readability, testability, and extensibility. We’ll discuss:

  • Why rigid code structures are problematic
  • How to refactor them using object-oriented principles
  • Specific examples with detailed explanations

1. Repeated Conditionals or Hardcoded Values

Problem Code:

1
2
3
4
5
6
7
8
9
def price(product)
  if product == "apple"
    1.0
  elsif product == "banana"
    0.5
  else
    2.0
  end
end

Why it’s a problem:

  • Hardcoded values (“apple”, “banana”) make the method brittle.
  • Adding a new product requires modifying the method, violating the Open/Closed Principle (OCP).
  • Any typo in hardcoded strings could break the functionality.

Solution Using Polymorphism:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Product
  def price
    raise NotImplementedError
  end
end

class Apple < Product
  def price
    1.0
  end
end

class Banana < Product
  def price
    0.5
  end
end

class DefaultProduct < Product
  def price
    2.0
  end
end

# Usage
def product_price(product)
  product.price
end

apple = Apple.new
puts product_price(apple) # Output: 1.0

Why this is a good solution:

  • Extensibility: Adding a new product requires creating a new class, leaving existing code untouched.
  • Encapsulation: Each product’s pricing logic is isolated, making it easier to maintain and test.
  • OCP Adherence: Existing code is closed for modification but open for extension.

Solution Using a Lookup Table:

1
2
3
4
5
6
7
8
9
10
PRODUCT_PRICES = {
  "apple" => 1.0,
  "banana" => 0.5
}

def price(product)
  PRODUCT_PRICES.fetch(product, 2.0)
end

puts price("apple") # Output: 1.0

Why this is a good solution:

  • Simplicity: Easy to read and update the mapping.
  • Flexibility: Adding new products is as simple as updating the hash.
  • Consistency: Centralized product information reduces typos and errors.

2. Nested Conditionals

Problem Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def shipping_cost(location, weight)
  if location == "domestic"
    if weight < 5
      5.0
    else
      10.0
    end
  elsif location == "international"
    if weight < 5
      20.0
    else
      50.0
    end
  else
    "Unknown location"
  end
end

Why it’s a problem:

  • Deep nesting: Makes the code harder to read and maintain.
  • High cognitive load: Developers must mentally parse multiple layers of conditionals to understand the logic.
  • Duplication: Similar logic (weight comparisons) is repeated across locations.

Solution Using Strategy Pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class DomesticShipping
  def cost(weight)
    weight < 5 ? 5.0 : 10.0
  end
end

class InternationalShipping
  def cost(weight)
    weight < 5 ? 20.0 : 50.0
  end
end

class UnknownShipping
  def cost(_weight)
    "Unknown location"
  end
end

def shipping_cost(strategy, weight)
  strategy.cost(weight)
end

domestic = DomesticShipping.new
puts shipping_cost(domestic, 3) # Output: 5.0

Why this is a good solution:

  • Readability: Eliminates deep nesting, making the logic straightforward.
  • Reusability: Individual shipping strategies can be reused across different parts of the application.
  • Extensibility: Adding new strategies like FreeShipping requires no changes to existing code.

3. Violation of the Single Responsibility Principle (SRP)

Problem Code:

1
2
3
4
5
6
7
8
9
10
def process_order(order)
  if order.status == "new"
    "Processing order..."
    log_order(order)
  elsif order.status == "shipped"
    "Order already shipped."
  else
    "Invalid order status."
  end
end

Why it’s a problem:

  • Mixed responsibilities: The method handles both decision-making (order.status) and side effects (log_order).
  • Hard to test: Testing this method requires mocking both conditions and the side effect.
  • Violates SRP: Makes the method less modular.

Solution Using Separate Responsibility Classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class NewOrderProcessor
  def process(order)
    log_order(order)
    "Processing order..."
  end

  private

  def log_order(order)
    puts "Order logged: #{order.id}"
  end
end

class ShippedOrderProcessor
  def process(_order)
    "Order already shipped."
  end
end

class InvalidOrderProcessor
  def process(_order)
    "Invalid order status."
  end
end

def process_order(order)
  processor = case order.status
              when "new" then NewOrderProcessor.new
              when "shipped" then ShippedOrderProcessor.new
              else InvalidOrderProcessor.new
              end
  processor.process(order)
end

Why this is a good solution:

  • Separation of concerns: Each class handles one specific responsibility.
  • Testability: Each processor can be independently tested without side effects.
  • Flexibility: Adding new statuses, like “cancelled,” only requires creating a new processor.

References