module Result
def self.Success(value)
Success.new(value)
end
def self.Failure(error)
Failure.new(error)
end
class Success
attr_reader :value
def initialize(value)
@value = value
end
def success?
true
end
def and_then(callable = nil, &block)
(callable || block).call(value)
end
def map(&block)
Success.new(block.call(value))
end
def value_or(_default)
value
end
end
class Failure
attr_reader :error
def initialize(error)
@error = error
end
def success?
false
end
def and_then(_callable = nil)
self
end
def map
self
end
def value_or(default)
default
end
end
end
module EnrichmentSteps
ROLE_PREFIXES = %w[info admin support sales noreply].freeze
module_function
def fetch_company(ctx)
domain = ctx[:email].to_s.split("@").last
return Result.Failure(:missing_domain) if domain.nil? || domain.empty?
company = CompanyDirectory.lookup(domain)
return Result.Failure(:company_not_found) if company.nil?
Result.Success(ctx.merge(company: company, domain: domain))
end
def verify_email(ctx)
local = ctx[:email].to_s.split("@").first.to_s.downcase
if ROLE_PREFIXES.include?(local)
return Result.Failure(:role_based_email)
end
Result.Success(ctx.merge(email_verified: true))
end
def score_lead(ctx)
score = 0
score += 40 if ctx[:company] && ctx[:company][:funded]
score += 30 if ctx[:email_verified]
score += ctx[:company] ? ctx[:company][:employees].to_i / 50 : 0
Result.Success(ctx.merge(score: [score, 100].min))
end
end
class LeadEnricher
STEPS = [
EnrichmentSteps.method(:fetch_company),
EnrichmentSteps.method(:verify_email),
EnrichmentSteps.method(:score_lead)
].freeze
def initialize(steps: STEPS)
@steps = steps
end
def call(email:)
initial = Result.Success(email: email.to_s.strip.downcase)
@steps.reduce(initial) do |result, step|
result.and_then(step)
end
end
def enrich!(email:)
result = call(email: email)
result
.map { |ctx| Lead.upsert_from(ctx) }
.value_or(nil)
end
end
This snippet shows how a chain of enrichment steps can be composed with a small hand-rolled Result monad, avoiding the deeply nested conditionals that usually accumulate when each step can fail. The pattern is often called railway-oriented programming: computation runs along a "success track" until any step derails onto a "failure track", after which every subsequent step is skipped and the original failure is carried through untouched.
In Result monad, Success and Failure share a common interface so callers never need to type-check the branch. The key method is and_then, which only invokes the given block on a Success and short-circuits on a Failure. This is the monadic bind: it flattens nested results so that a step returning Result does not produce a Result[Result]. The complementary map transforms a wrapped value without the block having to re-wrap it, and value_or provides a safe unwrap with a default. Because Failure#and_then returns self, once a step fails the block is never run and the error propagates for free.
The individual steps live in EnrichmentSteps. Each is a plain callable that receives an accumulating hash and returns a Result — either Success with an augmented hash or a Failure describing what went wrong. Steps stay tiny and independent: fetch_company guards on a missing domain, verify_email rejects role-based addresses, and score_lead computes a derived field. None of them know about each other or about ordering, which makes them trivial to unit test and reorder.
LeadEnricher wires the pipeline together. Its call method threads the initial context through reduce, applying and_then for each step so the first failure halts the chain. Because and_then expects a callable, the steps are passed by method reference and invoked uniformly. The final Result is either the fully enriched lead or the exact failure from whichever step derailed.
The trade-off is a little upfront machinery versus scattered nil checks and early returns. When steps grow numerous or need to be reordered, the railway approach keeps the happy path linear and the error handling implicit, which is when a developer would reach for it rather than raising exceptions across service boundaries.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.