The Railway Plan
The Railway Plan: dry-monads taught Ruby that failure handling is COMPOSITION, not rescue blocks - a pipeline of steps where success rides the happy track and the first failure switches every later step onto the bypass, carrying WHY. TaskResult/TaskFailure are already Result values in street clothes; this gives them bind, so a plan's outcome composes like a railway instead of nesting like an if-tree.
Data & Pipelines
Round 16
Piotr Solnica
exit 0
bundle exec ruby examples/railway_plan.rb
a real captured run
THE RAILWAY PLAN (bind, not rescue)
a full cart:
-> invoice #41: 1550 cents
an empty cart:
-> diverted at: LlmInvalidRequestError - cart is empty
(price and invoice never ran - no nil checks asked, none needed)
what the railway buys, precisely: the checkout reads as three
binds top to bottom, and that reading IS the control flow - no
rescue pyramid, no `return unless`, no nil creeping past step
two. the diverted train still carries a first-class TaskFailure
(type, message, timestamp, retryable verdict), because the
framework already models failure as data; the monad is just
fourteen lines acknowledging it. Success/Failure with bind is
all you need for the pattern - do-notation is nicer, but the
discipline (failures COMPOSE, they don't interrupt) is the part
that survives translation into any codebase.
source
# frozen_string_literal: true # The Railway Plan: dry-monads taught Ruby that failure handling is # COMPOSITION, not rescue blocks - a pipeline of steps where success # rides the happy track and the first failure switches every later # step onto the bypass, carrying WHY. TaskResult/TaskFailure are # already Result values in street clothes; this gives them bind, so # a plan's outcome composes like a railway instead of nesting like # an if-tree. # # bundle exec ruby examples/railway_plan.rb # # Runs offline; one train arrives, one is politely diverted. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # The whole monad: Success carries a value through bind; Failure # short-circuits, carrying the failure to the end of the line module Railway Success = Struct.new(class="y">:value) do def bind = yield(value) def success? = true end Failure = Struct.new(class="y">:failure) do def bind = self # the bypass track: later steps never run def success? = false end # Lift a plan's task outcome onto the railway def self.from(result, task) task_result = result.task_result(task.id) task_result.successful? ? Success.new(task_result.output) : Failure.new(task_result.failure) end end def run_task(description, payload, &work) orchestrator = Agentic:class="y">:PlanOrchestrator.new(retry_policy: {max_retries: 0, retryable_errors: []}) task = Agentic:class="y">:Task.new(description: description, agent_spec: {class="s">"name" => description, class="s">"instructions" => class="s">"w"}, payload: payload) orchestrator.add_task(task, agent: ->(t) { work.call(t.payload) }) Railway.from(orchestrator.execute_plan, task) end # Three steps of a checkout, each a real journaled-able plan task, # composed with bind - read it top to bottom, that IS the control flow def checkout(order) run_task(class="s">"validate", order) { |o| raise Agentic:class="y">:Errors:class="y">:LlmInvalidRequestError, class="s">"cart is empty" if o[class="y">:items].empty? o }.bind { |o| run_task(class="s">"price", o) { |ord| ord.merge(total_cents: ord[class="y">:items].sum { |i| i[class="y">:cents] }) } }.bind { |o| run_task(class="s">"invoice", o) { |ord| class="s">"invoice ##{ord[class="y">:id]}: #{ord[class="y">:total_cents]} cents" } } end puts class="s">"THE RAILWAY PLAN (bind, not rescue)" puts happy = checkout({id: 41, items: [{cents: 1200}, {cents: 350}]}) puts class="s">" a full cart:" puts class="s">" -> #{happy.success? ? happy.value : happy.failure.message}" puts sad = checkout({id: 42, items: []}) puts class="s">" an empty cart:" puts class="s">" -> diverted at: #{sad.failure.type.split("::class="s">").last} - #{sad.failure.message}" puts class="s">" (price and invoice never ran - no nil checks asked, none needed)" puts puts class="s">" what the railway buys, precisely: the checkout reads as three" puts class="s">" binds top to bottom, and that reading IS the control flow - no" puts class="s">" rescue pyramid, no `return unless`, no nil creeping past step" puts class="s">" two. the diverted train still carries a first-class TaskFailure" puts class="s">" (type, message, timestamp, retryable verdict), because the" puts class="s">" framework already models failure as data; the monad is just" puts class="s">" fourteen lines acknowledging it. Success/Failure with bind is" puts class="s">" all you need for the pattern - do-notation is nicer, but the" puts class="s">" discipline (failures COMPOSE, they don't interrupt) is the part" puts class="s">" that survives translation into any codebase."