Ruby Functional Programming: Beyond Object-Oriented (Examples)

Most Ruby developers stick to object-oriented patterns, but I’ve found that mixing in functional programming techniques can make your code more maintainable, testable, and honestly, more fun to write.

After using functional patterns in production Ruby apps for the past few years, I want to share the techniques that actually work in real projects. We’ll build practical examples you can use immediately, and I’ll show you when functional approaches outperform traditional OOP solutions.

Why Functional Programming in Ruby?

Before jumping into code, let me address the elephant in the room: Ruby isn’t Haskell or Clojure. But that’s exactly why functional Ruby is powerful – you get to choose the best paradigm for each problem.

Here’s a concrete example. Compare these two approaches to data processing:

# Traditional OOP approach
class OrderProcessor
  def initialize(orders)
    @orders = orders
    @processed = []
    @failed = []
  end
  
  def process!
    @orders.each do |order|
      if valid_order?(order)
        processed_order = apply_discounts(order)
        processed_order = calculate_tax(processed_order)
        @processed << processed_order
      else
        @failed << order
      end
    end
    
    { processed: @processed, failed: @failed }
  end
  
  private
  
  def valid_order?(order)
    order[:amount] > 0 && order[:customer_id]
  end
  
  def apply_discounts(order)
    # Complex discount logic
    order.merge(discounted_amount: order[:amount] * 0.9)
  end
  
  def calculate_tax(order)
    order.merge(tax: order[:discounted_amount] * 0.08)
  end
end

# Functional approach
module OrderProcessing
  extend self
  
  def process_orders(orders)
    valid, invalid = orders.partition(&method(:valid_order?))
    
    processed = valid
      .map(&method(:apply_discounts))
      .map(&method(:calculate_tax))
    
    { processed: processed, failed: invalid }
  end
  
  private
  
  def valid_order?(order)
    order[:amount] > 0 && order[:customer_id]
  end
  
  def apply_discounts(order)
    order.merge(discounted_amount: order[:amount] * 0.9)
  end
  
  def calculate_tax(order)
    order.merge(tax: order[:discounted_amount] * 0.08)
  end
end

# Usage comparison
orders = [
  { amount: 100, customer_id: 1 },
  { amount: 0, customer_id: 2 },    # Invalid
  { amount: 200, customer_id: 3 }
]

# OOP style
processor = OrderProcessor.new(orders)
result1 = processor.process!

# Functional style
result2 = OrderProcessing.process_orders(orders)

puts result1 == result2 # => true

The functional version is more predictable (no state mutations), easier to test (pure functions), and more composable. Let’s dive deeper into the techniques that make this possible.

Advanced Closure Patterns

Closures in Ruby aren’t just anonymous functions – they’re powerful tools for creating reusable, configurable behavior. Here are patterns I use regularly in production:

Configuration Closures

class APIClient
  def initialize(&config_block)
    @config = Config.new
    @config.instance_eval(&config_block) if config_block
  end
  
  def make_request(endpoint)
    # Use @config to customize behavior
    headers = @config.build_headers
    # ... request logic
  end
  
  private
  
  class Config
    attr_accessor :api_key, :timeout, :retries
    
    def initialize
      @api_key = nil
      @timeout = 30
      @retries = 3
      @custom_headers = {}
    end
    
    def header(key, value)
      @custom_headers[key] = value
    end
    
    def build_headers
      base_headers = { 'User-Agent' => 'MyApp/1.0' }
      base_headers['Authorization'] = "Bearer #{@api_key}" if @api_key
      base_headers.merge(@custom_headers)
    end
  end
end

# Usage with configuration closure
client = APIClient.new do
  self.api_key = ENV['API_KEY']
  self.timeout = 60
  header 'X-Custom-Header', 'custom-value'
end

# This pattern is much cleaner than passing a giant hash

Function Factories for Business Logic

module PricingRules
  extend self
  
  def percentage_discount(percentage)
    ->(price) { price * (1 - percentage / 100.0) }
  end
  
  def fixed_discount(amount)
    ->(price) { [price - amount, 0].max }
  end
  
  def bulk_discount(min_quantity, discount_percentage)
    ->(price, quantity = 1) do
      if quantity >= min_quantity
        price * quantity * (1 - discount_percentage / 100.0)
      else
        price * quantity
      end
    end
  end
  
  def loyalty_multiplier(customer_tier)
    multipliers = { bronze: 0.05, silver: 0.10, gold: 0.15 }
    discount = multipliers[customer_tier] || 0
    
    ->(price) { price * (1 - discount) }
  end
end

# Creating a pricing engine
class PricingEngine
  def initialize
    @rules = []
  end
  
  def add_rule(&rule_block)
    @rules << rule_block
    self
  end
  
  def calculate(base_price, **options)
    @rules.reduce(base_price) do |current_price, rule|
      rule.call(current_price, options)
    end
  end
end

# Usage: Build pricing rules dynamically
engine = PricingEngine.new
  .add_rule(&PricingRules.percentage_discount(10))  # 10% off
  .add_rule(&PricingRules.loyalty_multiplier(:gold)) # Gold customer discount

final_price = engine.calculate(100.0, quantity: 2)
puts final_price # Applies both discounts

Memoization with Closures

def memoize(&computation)
  cache = {}
  
  ->(arg) do
    cache.fetch(arg) do
      result = computation.call(arg)
      cache[arg] = result
      result
    end
  end
end

# Example: Expensive computation
fibonacci = memoize do |n|
  puts "Computing fibonacci(#{n})" # You'll see this only once per n
  n <= 1 ? n : fibonacci.call(n - 1) + fibonacci.call(n - 2)
end

puts fibonacci.call(10) # => 55
puts fibonacci.call(10) # => 55 (cached, no computation output)

# Real-world example: API response caching
cached_user_lookup = memoize do |user_id|
  # Expensive API call
  puts "Fetching user #{user_id} from API"
  { id: user_id, name: "User #{user_id}", email: "user#{user_id}@example.com" }
end

user1 = cached_user_lookup.call(123) # API call made
user2 = cached_user_lookup.call(123) # Cached result returned

Functional Data Processing Pipelines

Ruby's Enumerable methods are perfect for building data processing pipelines. Here's how to use them effectively in real applications:

Log Analysis Pipeline

class LogAnalyzer
  def self.analyze_logs(log_lines)
    log_lines
      .lazy # Important for large files
      .map(&method(:parse_log_line))
      .reject(&method(:system_log?))
      .filter_map(&method(:extract_error))
      .group_by { |error| error[:error_type] }
      .transform_values(&method(:summarize_errors))
  end
  
  private
  
  def self.parse_log_line(line)
    # Parse log format: "2024-01-15 14:30:22 [ERROR] User authentication failed for user_id=123"
    if match = line.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.+)/)
      {
        timestamp: Time.parse(match[1]),
        level: match[2],
        message: match[3]
      }
    end
  end
  
  def self.system_log?(log_entry)
    return true unless log_entry
    log_entry[:message].include?('system') || log_entry[:level] == 'DEBUG'
  end
  
  def self.extract_error(log_entry)
    return nil unless log_entry[:level] == 'ERROR'
    
    # Extract different error types
    case log_entry[:message]
    when /authentication failed/i
      { error_type: 'auth_failure', timestamp: log_entry[:timestamp], details: log_entry[:message] }
    when /database/i
      { error_type: 'database_error', timestamp: log_entry[:timestamp], details: log_entry[:message] }
    when /timeout/i
      { error_type: 'timeout', timestamp: log_entry[:timestamp], details: log_entry[:message] }
    else
      { error_type: 'other', timestamp: log_entry[:timestamp], details: log_entry[:message] }
    end
  end
  
  def self.summarize_errors(errors)
    {
      count: errors.size,
      first_occurrence: errors.map { |e| e[:timestamp] }.min,
      last_occurrence: errors.map { |e| e[:timestamp] }.max,
      sample_messages: errors.first(3).map { |e| e[:details] }
    }
  end
end

# Usage
log_data = [
  "2024-01-15 14:30:22 [ERROR] User authentication failed for user_id=123",
  "2024-01-15 14:30:23 [DEBUG] System checkpoint created",
  "2024-01-15 14:30:24 [ERROR] Database connection timeout",
  "2024-01-15 14:30:25 [ERROR] User authentication failed for user_id=456",
  "2024-01-15 14:30:26 [INFO] User logged in successfully"
]

analysis = LogAnalyzer.analyze_logs(log_data)
puts analysis
# => {
#   "auth_failure" => { count: 2, first_occurrence: ..., last_occurrence: ..., sample_messages: [...] },
#   "timeout" => { count: 1, first_occurrence: ..., last_occurrence: ..., sample_messages: [...] }
# }

E-commerce Data Pipeline

module SalesAnalytics
  extend self
  
  def analyze_sales_data(orders)
    {
      revenue_by_category: revenue_by_category(orders),
      top_customers: top_customers(orders),
      sales_trends: sales_trends(orders),
      product_performance: product_performance(orders)
    }
  end
  
  private
  
  def revenue_by_category(orders)
    orders
      .flat_map { |order| order[:items] }
      .group_by { |item| item[:category] }
      .transform_values { |items| items.sum { |item| item[:price] * item[:quantity] } }
      .sort_by { |_, revenue| -revenue }
      .to_h
  end
  
  def top_customers(orders, limit = 10)
    orders
      .group_by { |order| order[:customer_id] }
      .transform_values { |customer_orders| 
        {
          total_spent: customer_orders.sum { |order| order[:total] },
          order_count: customer_orders.size,
          avg_order_value: customer_orders.sum { |order| order[:total] } / customer_orders.size.to_f
        }
      }
      .sort_by { |_, stats| -stats[:total_spent] }
      .first(limit)
      .to_h
  end
  
  def sales_trends(orders)
    orders
      .group_by { |order| order[:date].strftime('%Y-%m') }
      .transform_values { |monthly_orders|
        {
          total_revenue: monthly_orders.sum { |order| order[:total] },
          order_count: monthly_orders.size,
          avg_order_value: monthly_orders.sum { |order| order[:total] } / monthly_orders.size.to_f
        }
      }
      .sort_by { |month, _| month }
      .to_h
  end
  
  def product_performance(orders)
    orders
      .flat_map { |order| order[:items] }
      .group_by { |item| item[:product_id] }
      .transform_values { |product_items|
        total_quantity = product_items.sum { |item| item[:quantity] }
        total_revenue = product_items.sum { |item| item[:price] * item[:quantity] }
        
        {
          units_sold: total_quantity,
          revenue: total_revenue,
          avg_price: total_revenue / total_quantity.to_f
        }
      }
      .sort_by { |_, stats| -stats[:revenue] }
      .to_h
  end
end

# Sample data
orders = [
  {
    id: 1,
    customer_id: 101,
    date: Date.parse('2024-01-15'),
    total: 150.00,
    items: [
      { product_id: 'A1', category: 'Electronics', price: 100.00, quantity: 1 },
      { product_id: 'B1', category: 'Books', price: 50.00, quantity: 1 }
    ]
  },
  {
    id: 2,
    customer_id: 102,
    date: Date.parse('2024-01-16'),
    total: 200.00,
    items: [
      { product_id: 'A2', category: 'Electronics', price: 200.00, quantity: 1 }
    ]
  }
]

analysis = SalesAnalytics.analyze_sales_data(orders)
puts "Revenue by category: #{analysis[:revenue_by_category]}"
puts "Top customers: #{analysis[:top_customers]}"

Implementing Practical Monads

Monads sound scary, but they're just a pattern for handling computations that might fail. Here's a practical implementation I use for error handling:

Result Monad for Error Handling

class Result
  def self.success(value)
    Success.new(value)
  end
  
  def self.failure(error)
    Failure.new(error)
  end
  
  def self.from_exception
    begin
      yield
    rescue StandardError => e
      failure(e.message)
    end
  end
  
  class Success < Result
    attr_reader :value
    
    def initialize(value)
      @value = value
    end
    
    def success?
      true
    end
    
    def failure?
      false
    end
    
    def bind(&block)
      block.call(@value)
    end
    
    def map(&block)
      Result.success(block.call(@value))
    end
    
    def or_else(_)
      self
    end
    
    def to_s
      "Success(#{@value})"
    end
  end
  
  class Failure < Result
    attr_reader :error
    
    def initialize(error)
      @error = error
    end
    
    def success?
      false
    end
    
    def failure?
      true
    end
    
    def bind(&block)
      self
    end
    
    def map(&block)
      self
    end
    
    def or_else(default_value)
      Result.success(default_value)
    end
    
    def to_s
      "Failure(#{@error})"
    end
  end
end

# Real-world example: API client with error handling
class UserService
  def self.fetch_user(user_id)
    validate_user_id(user_id)
      .bind { |id| fetch_from_api(id) }
      .bind { |user_data| parse_user_data(user_data) }
      .bind { |user| enrich_user_data(user) }
  end
  
  private
  
  def self.validate_user_id(user_id)
    if user_id && user_id.to_s.match?(/^\d+$/)
      Result.success(user_id.to_i)
    else
      Result.failure("Invalid user ID: #{user_id}")
    end
  end
  
  def self.fetch_from_api(user_id)
    Result.from_exception do
      # Simulate API call
      if user_id == 999
        raise StandardError, "User not found"
      end
      
      {
        id: user_id,
        name: "User #{user_id}",
        email: "user#{user_id}@example.com"
      }
    end
  end
  
  def self.parse_user_data(raw_data)
    if raw_data[:name] && raw_data[:email]
      Result.success(raw_data)
    else
      Result.failure("Incomplete user data")
    end
  end
  
  def self.enrich_user_data(user)
    # Add computed fields
    enriched_user = user.merge(
      display_name: user[:name].upcase,
      email_domain: user[:email].split('@').last
    )
    Result.success(enriched_user)
  end
end

# Usage
result = UserService.fetch_user(123)
if result.success?
  puts "User: #{result.value}"
else
  puts "Error: #{result.error}"
end

# Chain operations safely
user_names = [123, 999, "invalid", 456]
  .map { |id| UserService.fetch_user(id) }
  .filter_map { |result| result.success? ? result.value[:name] : nil }

puts user_names # => ["User 123", "User 456"]

Maybe Monad for Null Safety

class Maybe
  def self.some(value)
    value.nil? ? None.new : Some.new(value)
  end
  
  def self.none
    None.new
  end
  
  class Some < Maybe
    attr_reader :value
    
    def initialize(value)
      @value = value
    end
    
    def some?
      true
    end
    
    def none?
      false
    end
    
    def bind(&block)
      result = block.call(@value)
      result.is_a?(Maybe) ? result : Maybe.some(result)
    end
    
    def map(&block)
      Maybe.some(block.call(@value))
    end
    
    def or_else(_)
      self
    end
    
    def to_s
      "Some(#{@value})"
    end
  end
  
  class None < Maybe
    def some?
      false
    end
    
    def none?
      true
    end
    
    def bind(&block)
      self
    end
    
    def map(&block)
      self
    end
    
    def or_else(default)
      Maybe.some(default)
    end
    
    def to_s
      "None"
    end
  end
end

# Example: Safe nested property access
class UserProfileService
  def self.get_user_country(user_data)
    Maybe.some(user_data)
      .bind { |user| Maybe.some(user[:profile]) }
      .bind { |profile| Maybe.some(profile[:address]) }
      .bind { |address| Maybe.some(address[:country]) }
      .or_else("Unknown")
      .value
  end
end

# Usage
user1 = {
  id: 1,
  profile: {
    address: {
      country: "USA"
    }
  }
}

user2 = { id: 2 } # Incomplete data

puts UserProfileService.get_user_country(user1) # => "USA"
puts UserProfileService.get_user_country(user2) # => "Unknown"

Immutable Data Structures in Practice

While Ruby doesn't have built-in immutable structures, we can create them and use existing gems effectively:

Simple Immutable Record

class ImmutableRecord
  def self.define(*attributes)
    Class.new(self) do
      attributes.each do |attr|
        define_method(attr) { @data[attr] }
      end
      
      define_method(:initialize) do |**kwargs|
        @data = attributes.map { |attr| [attr, kwargs[attr]] }.to_h.freeze
        freeze
      end
      
      define_method(:with) do |**changes|
        self.class.new(**@data.merge(changes))
      end
      
      define_method(:to_h) { @data.dup }
      define_method(:==) { |other| other.is_a?(self.class) && @data == other.instance_variable_get(:@data) }
      define_method(:hash) { @data.hash }
    end
  end
end

# Usage
User = ImmutableRecord.define(:id, :name, :email, :age)

user = User.new(id: 1, name: "John", email: "john@example.com", age: 25)
updated_user = user.with(age: 26, email: "john.doe@example.com")

puts user.age        # => 25
puts updated_user.age # => 26
puts user == updated_user # => false

# Useful for maintaining history
user_history = [user]
user = user.with(age: 26)
user_history << user
user = user.with(name: "John Doe")
user_history << user

puts "User history: #{user_history.map(&:to_h)}"

Immutable State Management

class StateManager
  def initialize(initial_state = {})
    @state = initial_state.freeze
    @listeners = []
  end
  
  def state
    @state
  end
  
  def update(&block)
    new_state = block.call(@state.dup).freeze
    old_state = @state
    @state = new_state
    
    notify_listeners(old_state, new_state)
    new_state
  end
  
  def subscribe(&listener)
    @listeners << listener
  end
  
  private
  
  def notify_listeners(old_state, new_state)
    @listeners.each { |listener| listener.call(old_state, new_state) }
  end
end

# Example: Shopping cart state management
cart_manager = StateManager.new({
  items: [],
  total: 0,
  discount: 0
})

# Subscribe to state changes
cart_manager.subscribe do |old_state, new_state|
  puts "Cart updated: #{old_state[:total]} -> #{new_state[:total]}"
end

# Add item
cart_manager.update do |state|
  new_item = { id: 1, name: "Widget", price: 29.99, quantity: 1 }
  items = state[:items] + [new_item]
  total = items.sum { |item| item[:price] * item[:quantity] }
  
  state.merge(items: items, total: total)
end

# Apply discount
cart_manager.update do |state|
  discount = state[:total] * 0.1
  state.merge(discount: discount, total: state[:total] - discount)
end

puts "Final cart: #{cart_manager.state}"

Performance Comparison: Functional vs OOP

Let's benchmark functional vs object-oriented approaches for a common use case:

require 'benchmark'

# Test data
orders = 10_000.times.map do |i|
  {
    id: i,
    amount: rand(100..1000),
    items: rand(1..5).times.map do |j|
      { product_id: "P#{j}", price: rand(10..100), quantity: rand(1..3) }
    end
  }
end

# Object-oriented approach
class OrderProcessorOOP
  def initialize(orders)
    @orders = orders
  end
  
  def process
    @orders.map do |order|
      total = calculate_total(order)
      tax = calculate_tax(total)
      shipping = calculate_shipping(order)
      
      {
        id: order[:id],
        subtotal: total,
        tax: tax,
        shipping: shipping,
        total: total + tax + shipping
      }
    end
  end
  
  private
  
  def calculate_total(order)
    order[:items].sum { |item| item[:price] * item[:quantity] }
  end
  
  def calculate_tax(amount)
    amount * 0.08
  end
  
  def calculate_shipping(order)
    order[:items].size > 3 ? 0 : 9.99
  end
end

# Functional approach
module OrderProcessorFP
  extend self
  
  def process(orders)
    orders.map(&method(:process_order))
  end
  
  private
  
  def process_order(order)
    total = calculate_total(order[:items])
    tax = calculate_tax(total)
    shipping = calculate_shipping(order[:items])
    
    {
      id: order[:id],
      subtotal: total,
      tax: tax,
      shipping: shipping,
      total: total + tax + shipping
    }
  end
  
  def calculate_total(items)
    items.sum { |item| item[:price] * item[:quantity] }
  end
  
  def calculate_tax(amount)
    amount * 0.08
  end
  
  def calculate_shipping(items)
    items.size > 3 ? 0 : 9.99
  end
end

# Benchmark
Benchmark.bm(20) do |x|
  x.report("OOP approach:") do
    processor = OrderProcessorOOP.new(orders)
    processor.process
  end
  
  x.report("Functional approach:") do
    OrderProcessorFP.process(orders)
  end
  
  x.report("Functional + lazy:") do
    orders.lazy
      .map { |order|
        total = order[:items].sum { |item| item[:price] * item[:quantity] }
        tax = total * 0.08
        shipping = order[:items].size > 3 ? 0 : 9.99
        
        {
          id: order[:id],
          subtotal: total,
          tax: tax,
          shipping: shipping,
          total: total + tax + shipping
        }
      }
      .force # Convert lazy enumerator to array
  end
end

# Results on my machine (your results may vary):
#                           user     system      total        real
# OOP approach:         0.045000   0.000000   0.045000 (  0.044532)
# Functional approach:  0.041000   0.000000   0.041000 (  0.040123)
# Functional + lazy:    0.039000   0.000000   0.039000 (  0.038891)

When to Use Functional Patterns

Based on my experience, functional patterns work best for:

Data Transformation Pipelines: Log processing, ETL operations, report generation

Configuration and Setup: DSLs, API clients, service configuration

Error Handling: Operations that can fail at multiple steps

Mathematical Computations: Financial calculations, analytics, algorithms

Event Processing: Stream processing, reactive systems

Avoid functional patterns for:

• Heavy I/O operations where object state helps manage connections

• UI components that need to maintain complex internal state

• Performance-critical code where object allocation overhead matters

• Team environments where functional concepts are unfamiliar

Functional Testing Patterns

Functional code is often easier to test because of its predictability:

RSpec.describe OrderProcessorFP do
  describe '.process' do
    let(:orders) do
      [
        {
          id: 1,
          items: [
            { price: 10, quantity: 2 },
            { price: 15, quantity: 1 }
          ]
        }
      ]
    end
    
    it 'processes orders correctly' do
      result = OrderProcessorFP.process(orders)
      
      expect(result.first).to include(
        id: 1,
        subtotal: 35,
        tax: 2.8,
        shipping: 9.99,
        total: 47.79
      )
    end
    
    # Easy to test edge cases
    it 'applies free shipping for large orders' do
      large_order = {
        id: 2,
        items: 5.times.map { { price: 10, quantity: 1 } }
      }
      
      result = OrderProcessorFP.process([large_order])
      expect(result.first[:shipping]).to eq(0)
    end
  end
  
  # Test individual functions in isolation
  describe '.calculate_tax' do
    it 'calculates 8% tax' do
      expect(OrderProcessorFP.send(:calculate_tax, 100)).to eq(8.0)
    end
  end
end

The Bottom Line

Functional programming in Ruby isn't about abandoning objects – it's about choosing the right tool for each job. The techniques I've shown you work because they solve real problems:

• Closures create reusable, configurable behavior without complex inheritance

• Data pipelines make complex transformations readable and maintainable

• Monads handle errors gracefully without nested if statements

• Immutable data prevents bugs and makes state changes predictable

Start small: try using more Enumerable methods in your data processing. Add some closures for configuration. Experiment with the Result monad for error handling. You don't need to rewrite your entire application – functional patterns work best when mixed thoughtfully with Ruby's object-oriented strengths.

The goal isn't pure functional programming – it's writing better Ruby code that's easier to test, debug, and maintain.