Ruby Functional Programming: Beyond Object-Oriented

Ruby developers love objects. But some problems are just easier to solve with functions.

I've been mixing functional patterns into Ruby apps for years now. Not because it's trendy, but because it works. Functional code is often cleaner, easier to test, and handles edge cases better than OOP.

Let me show you the techniques I actually use in production.

Why Bother with Functional Ruby?

Ruby isn't Haskell. You don't have to go full functional. That's the point - pick what works.

Here's a real example. Processing orders:

# The OOP way
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)
    order.merge(discounted_amount: order[:amount] * 0.9)
  end

  def calculate_tax(order)
    order.merge(tax: order[:discounted_amount] * 0.08)
  end
end

# The functional way
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

The functional version has no state. No instance variables getting mutated. Just data flowing through transformations.

Easier to test, easier to debug, easier to understand.

Closures: Functions That Remember

Closures are functions that capture their environment. Ruby's blocks and lambdas do this naturally.

Configuration with Closures

Instead of passing config objects around:

class APIClient
  def initialize(api_key, base_url, timeout)
    @api_key = api_key
    @base_url = base_url
    @timeout = timeout
  end

  def request(endpoint, params)
    # Use @api_key, @base_url, @timeout
  end
end

# Better: closure captures config
def create_api_client(api_key:, base_url:, timeout: 30)
  ->(endpoint, params = {}) do
    # api_key, base_url, and timeout are captured here
    uri = URI.join(base_url, endpoint)
    http = Net::HTTP.new(uri.host, uri.port)
    http.read_timeout = timeout

    request = Net::HTTP::Post.new(uri)
    request['Authorization'] = "Bearer #{api_key}"
    request.set_form_data(params)

    http.request(request)
  end
end

# Usage
api_client = create_api_client(
  api_key: ENV['API_KEY'],
  base_url: 'https://api.example.com',
  timeout: 60
)

# Config is baked in
response = api_client.call('/users', { name: 'Alice' })

No class needed. The lambda remembers its config.

Function Factories

Closures let you build functions from functions:

def create_pricing_rule(base_price)
  {
    standard: ->(quantity) { base_price * quantity },
    bulk: ->(quantity) {
      discount = quantity > 10 ? 0.1 : 0
      base_price * quantity * (1 - discount)
    },
    vip: ->(quantity) { base_price * quantity * 0.8 }
  }
end

widget_pricing = create_pricing_rule(100)

widget_pricing[:standard].call(5)  # => 500
widget_pricing[:bulk].call(15)     # => 1350 (10% discount)
widget_pricing[:vip].call(5)       # => 400 (20% discount)

Each pricing function remembers the base price. No objects, no state.

Memoization with Closures

def memoize(&block)
  cache = {}
  ->(arg) do
    cache[arg] ||= block.call(arg)
  end
end

# Expensive fibonacci
fib = ->(n) { n <= 1 ? n : fib.call(n - 1) + fib.call(n - 2) }

# Memoized version
fast_fib = memoize { |n| n <= 1 ? n : fast_fib.call(n - 1) + fast_fib.call(n - 2) }

# Much faster
fast_fib.call(30)  # Cached results

The cache lives in the closure's environment. Private to that function.

Functional Data Pipelines

Chaining transformations instead of mutating state.

Log Analysis

# Parse, filter, transform log lines
def analyze_logs(log_file)
  File.readlines(log_file)
    .lazy
    .map(&:strip)
    .reject(&:empty?)
    .map { |line| parse_log_line(line) }
    .select { |entry| entry[:level] == 'ERROR' }
    .group_by { |entry| entry[:service] }
    .transform_values(&:count)
end

def parse_log_line(line)
  parts = line.split('|')
  {
    timestamp: parts[0],
    level: parts[1],
    service: parts[2],
    message: parts[3]
  }
end

errors_by_service = analyze_logs('app.log')
# => {"auth"=>5, "payment"=>12, "api"=>8}

Each step is a pure function. No side effects. Easy to test each part separately.

Sales Analytics

def analyze_sales(orders)
  orders
    .reject { |o| o[:status] == 'cancelled' }
    .map { |o| o.merge(revenue: o[:quantity] * o[:price]) }
    .group_by { |o| o[:category] }
    .transform_values { |orders|
      {
        total_revenue: orders.sum { |o| o[:revenue] },
        order_count: orders.size,
        avg_order: orders.sum { |o| o[:revenue] } / orders.size.to_f
      }
    }
end

sales = [
  { category: 'electronics', quantity: 2, price: 500, status: 'completed' },
  { category: 'books', quantity: 5, price: 20, status: 'completed' },
  { category: 'electronics', quantity: 1, price: 200, status: 'cancelled' }
]

analyze_sales(sales)
# => {
#   "electronics"=>{:total_revenue=>1000, :order_count=>1, :avg_order=>1000.0},
#   "books"=>{:total_revenue=>100, :order_count=>1, :avg_order=>100.0}
# }

Data in, data out. No objects getting mutated along the way.

Monads (Don't Run Away)

Monads sound scary. They're just containers that let you chain operations safely.

Result Monad

Handle success and failure without exceptions:

class Result
  def self.success(value)
    Success.new(value)
  end

  def self.failure(error)
    Failure.new(error)
  end
end

class Success < Result
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def map(&block)
    begin
      Success.new(block.call(@value))
    rescue => e
      Failure.new(e.message)
    end
  end

  def flat_map(&block)
    block.call(@value)
  end

  def or_else(_)
    self
  end

  def success?
    true
  end
end

class Failure < Result
  attr_reader :error

  def initialize(error)
    @error = error
  end

  def map(&_block)
    self
  end

  def flat_map(&_block)
    self
  end

  def or_else(default)
    default
  end

  def success?
    false
  end
end

# Usage
def fetch_user(id)
  user = User.find_by(id: id)
  user ? Result.success(user) : Result.failure("User not found")
end

def validate_user(user)
  user.active? ? Result.success(user) : Result.failure("User inactive")
end

def send_email(user)
  # Send email logic
  Result.success("Email sent to #{user.email}")
end

# Chain operations
result = fetch_user(123)
  .flat_map { |user| validate_user(user) }
  .flat_map { |user| send_email(user) }

if result.success?
  puts result.value
else
  puts "Error: #{result.error}"
end

No exceptions flying around. Errors are values you can handle explicitly.

Maybe Monad

Handle nil without constant nil checks:

class Maybe
  def self.some(value)
    value.nil? ? None.new : Some.new(value)
  end

  def self.none
    None.new
  end
end

class Some < Maybe
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def map(&block)
    Maybe.some(block.call(@value))
  end

  def flat_map(&block)
    block.call(@value)
  end

  def or_else(_)
    @value
  end

  def present?
    true
  end
end

class None < Maybe
  def map(&_block)
    self
  end

  def flat_map(&_block)
    self
  end

  def or_else(default)
    default
  end

  def present?
    false
  end
end

# Usage
def find_config(key)
  config = CONFIG[key]
  Maybe.some(config)
end

result = find_config('api_key')
  .map(&:upcase)
  .map { |key| "Bearer #{key}" }
  .or_else("No API key configured")

No more value&.method1&.method2. The Maybe handles nil propagation.

Immutable Data Structures

Stop mutating. Start transforming.

class ImmutableRecord
  def initialize(**attrs)
    attrs.each do |key, value|
      instance_variable_set("@#{key}", value.freeze)
      self.class.define_method(key) { instance_variable_get("@#{key}") }
    end
    freeze
  end

  def with(**changes)
    attrs = instance_variables.each_with_object({}) do |var, hash|
      key = var.to_s.delete('@').to_sym
      hash[key] = instance_variable_get(var)
    end
    self.class.new(**attrs.merge(changes))
  end
end

class User < ImmutableRecord
end

user = User.new(name: "Alice", email: "alice@example.com", role: "user")
admin = user.with(role: "admin")

user.role   # => "user" (unchanged)
admin.role  # => "admin" (new object)

Original stays intact. Changes create new objects. Easier to reason about.

State Management

class StateManager
  def initialize(initial_state)
    @state = initial_state
    @listeners = []
  end

  def get_state
    @state
  end

  def update_state(&reducer)
    new_state = reducer.call(@state)
    return if new_state == @state

    @state = new_state
    @listeners.each { |listener| listener.call(@state) }
  end

  def subscribe(&listener)
    @listeners << listener
  end
end

# Usage
state = StateManager.new({ count: 0, users: [] })

state.subscribe { |s| puts "Count: #{s[:count]}" }

state.update_state { |s| s.merge(count: s[:count] + 1) }
# => Count: 1

state.update_state { |s| s.merge(count: s[:count] + 1) }
# => Count: 2

Redux-style state management in Ruby. All changes go through reducers.

Performance: Functional vs OOP

Let's test it:

require 'benchmark'

# OOP approach
class NumberProcessor
  def initialize(numbers)
    @numbers = numbers
  end

  def process
    @numbers.select { |n| n.even? }
            .map { |n| n * 2 }
            .reduce(:+)
  end
end

# Functional approach
def process_numbers(numbers)
  numbers
    .select { |n| n.even? }
    .map { |n| n * 2 }
    .reduce(:+)
end

numbers = (1..100_000).to_a

Benchmark.bm do |x|
  x.report("OOP:") do
    1000.times { NumberProcessor.new(numbers).process }
  end

  x.report("Functional:") do
    1000.times { process_numbers(numbers) }
  end
end

# Results on my machine:
#       user     system      total        real
# OOP:  12.450000   0.000000  12.450000 ( 12.454321)
# Functional:  11.980000   0.000000  11.980000 ( 11.984567)

Functional is slightly faster. No object allocation overhead.

When to Use Functional Patterns

Use functional when:

  • Processing collections of data
  • Building data transformation pipelines
  • You need referential transparency (same input = same output)
  • Testing is a priority
  • Handling errors without exceptions

Stick with OOP when:

  • Modeling real-world objects with behavior
  • You need polymorphism
  • State changes are the core of your domain
  • The team isn't comfortable with functional patterns

Mix them:

  • OOP for domain models
  • Functional for data processing
  • Both in the same codebase

Testing Functional Code

Functional code is easy to test. No setup, no mocks:

RSpec.describe "Order processing" do
  it "applies discounts and tax" do
    orders = [
      { amount: 100, customer_id: 1 },
      { amount: 200, customer_id: 2 }
    ]

    result = OrderProcessing.process_orders(orders)

    expect(result[:processed].size).to eq(2)
    expect(result[:processed].first[:discounted_amount]).to eq(90)
    expect(result[:processed].first[:tax]).to eq(7.2)
  end

  it "filters invalid orders" do
    orders = [
      { amount: 0, customer_id: 1 },  # Invalid
      { amount: 100, customer_id: 2 }
    ]

    result = OrderProcessing.process_orders(orders)

    expect(result[:failed].size).to eq(1)
    expect(result[:processed].size).to eq(1)
  end
end

No mocks. No stubs. Just data in, assertions out.

The Truth About Functional Ruby

Ruby isn't a functional language. But it supports functional patterns well enough.

You don't need to go full Haskell. Use functional patterns where they help:

  • Data pipelines
  • Error handling
  • Stateless transformations
  • Testing

Keep OOP for:

  • Domain models
  • Complex state machines
  • Anything with lifecycle hooks

Mix them. That's the Ruby way.

The goal isn't purity. It's better code.