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
endThe 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 resultsThe 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}"
endNo 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: 2Redux-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
endNo 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.