Functional Programming Patterns in Ruby

Ruby is object-oriented at its core. But it also ships with powerful functional programming tools built right in. By mixing both paradigms, you can write code that is shorter, easier to test, and far less prone to unexpected bugs caused by shared mutable state.

This guide covers practical functional patterns you can start using in your Ruby projects today.

What Makes Code Functional

Functional programming centers on building programs from small, predictable functions. Instead of telling the computer how to do something step by step (imperative style), you describe what transformations to apply to your data.

Three core ideas drive the approach:

  1. Pure functions - Same input always gives same output. No hidden dependencies.
  2. Immutability - Data does not change after creation. You produce new data instead.
  3. First-class functions - Functions are values. Store them in variables, pass them as arguments, return them from other functions.

These ideas are not all-or-nothing. You can apply them selectively in Ruby wherever they make your code clearer. Combined with Ruby's metaprogramming capabilities, they become even more powerful.

Lambdas and Procs: Functions as Values

Ruby treats blocks, procs, and lambdas as first-class citizens. This means you can store a piece of behavior in a variable and reuse it.

double = ->(x) { x * 2 }
triple = ->(x) { x * 3 }

[1, 2, 3].map(&double)  # => [2, 4, 6]
[1, 2, 3].map(&triple)  # => [3, 6, 9]

The & operator converts a lambda into a block that map can use. This is useful when you want to apply the same transformation in multiple places without duplicating the logic.

There is a key difference between procs and lambdas worth knowing. Lambdas check argument count and return control to the calling method. Procs do not check arity, and return inside a proc exits the enclosing method entirely.

safe = ->(x, y) { x + y }
safe.call(1)       # ArgumentError: wrong number of arguments (given 1, expected 2)

loose = Proc.new { |x, y| (x || 0) + (y || 0) }
loose.call(1)      # => 1  (y becomes nil, then 0)

For functional patterns, prefer lambdas. Their strict behavior catches mistakes early.

Map, Select, Reduce: The Core Trio

These three methods handle the vast majority of collection transformations. They return new collections and leave the original untouched.

numbers = [1, 2, 3, 4, 5]

squares = numbers.map { |n| n ** 2 }
# => [1, 4, 9, 16, 25]

evens = numbers.select { |n| n.even? }
# => [2, 4]

sum = numbers.reduce(0) { |acc, n| acc + n }
# => 15

The original numbers array stays unchanged through all of this.

reduce is the most flexible of the three. You can build any data structure with it. Here is a practical example that groups words by their first letter:

words = %w[ruby rails rack rspec sinatra sequel]

grouped = words.reduce(Hash.new { |h, k| h[k] = [] }) do |acc, word|
  acc[word[0]] << word
  acc
end
# => {"r"=>["ruby", "rails", "rack", "rspec"], "s"=>["sinatra", "sequel"]}

Ruby also provides group_by for this exact case, but understanding how to do it with reduce teaches you how the method really works.

Method Chaining: Building Pipelines

Chain transformations together. Each step returns a new collection, so you can read the pipeline top to bottom.

users = [
  { name: 'Alice', age: 25, active: true },
  { name: 'Bob', age: 30, active: false },
  { name: 'Carol', age: 28, active: true }
]

active_names = users
  .select { |u| u[:active] }
  .map { |u| u[:name] }
  .sort
# => ['Alice', 'Carol']

For large collections, use lazy to avoid creating intermediate arrays:

(1..Float::INFINITY)
  .lazy
  .select { |n| n.odd? }
  .map { |n| n ** 2 }
  .first(5)
# => [1, 9, 25, 49, 81]

Without lazy, Ruby would try to process an infinite range and hang. Lazy enumerators process one element at a time through the full chain, only computing what is needed.

Pure Functions in Practice

A pure function has no side effects. It does not modify its arguments, read global state, or write to a database. Given the same inputs, it always returns the same output.

# Impure - modifies the input array
def add_item_bad(items, item)
  items << item
end

# Pure - returns new array
def add_item_good(items, item)
  items + [item]
end

original = [1, 2, 3]
add_item_bad(original, 4)
# original is now [1, 2, 3, 4] - mutated!

original = [1, 2, 3]
result = add_item_good(original, 4)
# original is still [1, 2, 3]
# result is [1, 2, 3, 4]

Pure functions are trivial to test. No setup, no mocking, no teardown. Just assert input produces expected output.

def calculate_discount(price, percentage)
  price * (percentage / 100.0)
end

# Testing this is one line:
assert_equal 15.0, calculate_discount(100, 15)

Higher-Order Functions

A higher-order function either takes a function as an argument or returns one. Ruby methods that accept blocks are all higher-order functions. You can build your own.

def apply_twice(fn, value)
  fn.call(fn.call(value))
end

increment = ->(x) { x + 1 }
apply_twice(increment, 5)  # => 7

upcase_first = ->(s) { s[0].upcase + s[1..] }
apply_twice(upcase_first, "hello")  # => "Hello" (idempotent after first call)

Returning functions is powerful for creating specialized behavior:

def multiplier(factor)
  ->(x) { x * factor }
end

double = multiplier(2)
triple = multiplier(3)

double.call(10)  # => 20
triple.call(10)  # => 30

[1, 2, 3].map(&double)  # => [2, 4, 6]

This pattern is called currying in a loose sense. Ruby also supports it directly:

multiply = ->(a, b) { a * b }
double = multiply.curry.(2)

double.call(5)   # => 10
double.call(21)  # => 42

Function Composition

Combine small functions into bigger ones. Ruby 2.6 added the >> and << composition operators for procs and lambdas.

add_one = ->(x) { x + 1 }
double  = ->(x) { x * 2 }
square  = ->(x) { x ** 2 }

# >> composes left to right
pipeline = add_one >> double >> square
pipeline.call(3)  # => 64  (3+1=4, 4*2=8, 8**2=64)

# << composes right to left (mathematical notation)
pipeline2 = square << double << add_one
pipeline2.call(3)  # => 64

You can also use then (aliased as yield_self) for one-off pipelines on a value:

result = 3.then(&add_one).then(&double).then(&square)
# => 64

The >> operator is better when you want to store the composed function and reuse it. then is better for inline, one-time transformations.

Immutability Patterns

Ruby does not enforce immutability, but freeze prevents modifications at runtime.

config = {
  api_url: 'https://api.example.com',
  timeout: 30
}.freeze

config[:timeout] = 60  # Raises FrozenError

Watch out: freeze is shallow. Nested objects remain mutable unless you freeze them too.

data = { tags: ['ruby', 'rails'] }.freeze
data[:tags] << 'sinatra'  # This works! The array inside is not frozen.

# Deep freeze manually:
data = { tags: ['ruby', 'rails'].freeze }.freeze
data[:tags] << 'sinatra'  # Now raises FrozenError

For value objects that should never change, combine freeze with Struct:

Point = Struct.new(:x, :y) do
  def initialize(*)
    super
    freeze
  end

  def translate(dx, dy)
    Point.new(x + dx, y + dy)
  end
end

p = Point.new(1, 2)
p.x = 5          # Raises FrozenError
moved = p.translate(3, 4)  # => #<Point x=4, y=6>

The translate method returns a new Point instead of modifying the existing one. This is the core immutability pattern: always produce new data.

Functional Patterns in Rails

These ideas work well inside Rails applications, especially in service objects and data processing.

Scoping with chains instead of mutation:

class OrderReport
  def self.generate(start_date:, end_date:)
    Order
      .where(created_at: start_date..end_date)
      .where(status: :completed)
      .includes(:line_items)
      .map { |order| format_order(order) }
  end

  def self.format_order(order)
    {
      id: order.id,
      total: order.line_items.sum(&:price),
      date: order.created_at.to_date
    }
  end
end

Transforming params with pure functions:

def normalize_params(params)
  params
    .permit(:name, :email, :phone)
    .to_h
    .transform_keys(&:to_sym)
    .transform_values(&:strip)
end

Replacing conditionals with a function map:

FORMATTERS = {
  'csv'  => ->(data) { data.map { |row| row.values.join(',') }.join("\n") },
  'json' => ->(data) { data.to_json },
  'yaml' => ->(data) { data.to_yaml }
}.freeze

def export(data, format)
  formatter = FORMATTERS.fetch(format) { raise "Unknown format: #{format}" }
  formatter.call(data)
end

This replaces a chain of if/elsif with a hash lookup. Adding a new format means adding one line, not restructuring control flow. This pattern works especially well alongside other Ruby design patterns like Factory and Decorator.

When to Use Functional Patterns

Good fit:

  • Data transformations and pipelines
  • Processing collections (map/filter/reduce)
  • Pure calculations (pricing, scoring, formatting)
  • Configuration objects and value types
  • Anywhere you want easy-to-test logic

Stick with OOP when:

  • Managing stateful resources (database connections, file handles)
  • Modeling entities with identity and lifecycle (users, orders)
  • Working with I/O (HTTP calls, file writes, logging)

The best Ruby code mixes both styles. Push your business logic toward pure functions. Keep side effects at the edges of your application. Your tests will thank you. If you're ready to go further, explore advanced functional programming techniques in Ruby including monads and immutable data structures.