FunctionalLightService

Gem Version CI Tests Codecov License Download Count

Table of Content

Requirements

This gem requires ruby >= 3.1 (tested up to ruby 4.0)

Installation

Add this line to your application's Gemfile:

    gem 'functional-light-service'

And then execute:

    $ bundle

Or install it yourself as:

    $ gem install functional-light-service

Why FunctionalLightService?

While studying functional programming in Ruby, I discovered the fantastic gem Deterministic, which made it much easier to write Ruby code in a functional style.
By leveraging its in_sequence method, I can chain a series of actions:

  • If every step completes without raising an exception, the call returns a Success() monad.
  • If any step fails, the remaining actions are skipped and a Failure() monad is returned.

I writing this code:

class Foo
  include Deterministic::Prelude

  def call(input)
    result = in_sequence do
      get(:sanitized_input) { sanitize(input) }
      and_then              { validate(sanitized_input) }
      and_then              { connect_db }
      get(:user)            { get_user(sanitized_input) }
      and_yield             { print_response(user) }
    end
    logger.warn(result.value) if result.failure?
  rescue StandardError => e
    logger.fatal(e.message)
  end

  def sanitize(input)
    sanitized_input = {}
    sanitized_input[:name] = input[:name].downcase
    sanitized_input[:password] = input[:password].downcase
    Success(sanitized_input)
  end

  def validate(sanitized_input)
    try!  do
      raise "Not allow empty name" if sanitized_input[:name].empty?
      raise "Not allow empty password" if sanitized_input[:password].empty?
    end.map_err { |n| Failure(n.message) }
  end

  def connect_db
    try! do
      raise "Error connection to db" if rand(0..1) == 1
    end.map_err { |n| Failure(n.message) }
  end

  def get_user(sanitized_input)
    user = FAKEDB.find do |_k, v|
      sanitized_input[:name] == v[:name] && sanitized_input[:password] == v[:password]
    end
    user.nil? ? Failure("Name or password error") : Success(user)
  end

  def print_response(user)
    Success(logger.info("Login successful id: #{user[0]} name: #{user[1][:name]}"))
  end
end

Foo.new.call(:name => "foo", :password => "bar")

While refactoring my codebase, I needed each action to live in a well‑defined context.
That’s when I discovered the excellent gem LightService. It gives me exactly what I was looking for:

  • a clean separation between business concerns and orchestration logic
  • a simple way to arrange actions in a pipeline
  • the freedom to place every action in its own class, each with its own contextual data
class Foo
  extend LightService::Organizer

  def self.call(name: "", password: "")
    result = with(:name => name, :password => password).reduce(actions)
    logger.warn(result.message) if result.failure?
  end

  def self.actions
    [
      Sanitize,
      Validate,
      ConnectDb,
      GetUser,
      PrintResponse
    ]
  end
end

class Sanitize
  extend LightService::Action
  expects :name, :password
  promises :sanitized_input

  executed do |ctx|
    sanitized_input = {}
    sanitized_input[:name] = ctx.name.downcase
    sanitized_input[:password] = ctx.password.downcase
    ctx.sanitized_input = sanitized_input
  end
end

class Validate
  extend LightService::Action
  expects :sanitized_input

  executed do |ctx|
    ctx.fail_and_return!("Not allow empty name") if ctx.sanitized_input[:name].empty?
    ctx.fail_and_return!("Not allow empty password") if ctx.sanitized_input[:password].empty?
  end
end

class ConnectDb
  extend LightService::Action

  executed do |ctx|
    raise "Error connection to db"
  rescue StandardError => e
    ctx.fail!(e.message) if rand(0..1) == 1
  end

  # private_class_method :..
end

class GetUser
  extend LightService::Action
  expects :sanitized_input
  promises :user

  executed do |ctx|
    user = FAKEDB.find do |_k, v|
      ctx.sanitized_input[:name] == v[:name] && ctx.sanitized_input[:password] == v[:password]
    end
    ctx.fail_and_return!("Name or password error") if user.nil?
    ctx.user = user
  end
end

class PrintResponse
  extend LightService::Action
  expects :user

  executed do |ctx|
    logger.info("Login successful id: #{ctx.user[0]} name: #{ctx.user[1][:name]}")
  end
end

Foo.call(:name => "foo", :password => "bar")

The switch to LightService came at a price: I missed the functional‑programming super‑powers that Deterministic had given me.
So I asked myself, *why not enjoy the best of both worlds?*
That question led me to create this gem. Now I can keep all the conveniences LightService offers—action pipelines, clear contexts—while still coding in a fully functional style with expressive monads.

class Foo
  extend FunctionalLightService::Organizer

  def self.call(name: "", password: "")
    result = with(:name => name, :password => password).reduce(actions)
    logger.warn(result.message) if result.failure?
  end

  def self.actions
    [
      Sanitize,
      Validate,
      ConnectDb,
      GetUser,
      PrintResponse
    ]
  end
end

class Sanitize
  extend FunctionalLightService::Action
  expects :name, :password
  promises :sanitized_input

  executed do |ctx|
    name = ctx.name
    password = ctx.password
    ctx.sanitized_input = downcase(name, password).value
  end

  def self.downcase(name, password)
    ctx.try! do
      {
        :name => name.downcase,
        :password => password.downcase
      }
    end.map_err { ctx.fail!("Error nel method downcase") }
  end

  private_class_method :downcase
end

class Validate
  extend FunctionalLightService::Action
  expects :sanitized_input

  executed do |ctx|
    validate_params(ctx.sanitized_input).match do
      None() { ctx.Success(0) }
      Some() { |errors| ctx.fail_and_return!(errors) }
    end
  end

  def self.validate_params(params)
    return ctx.Some("Not allow empty name") if ctx.Option.any?(params[:name]).none?
    return ctx.Some("Not allow empty password") if ctx.Option.any?(params[:password]).none?

    ctx.None
  end

  private_class_method :validate_params
end

class ConnectDb
  extend FunctionalLightService::Action

  executed do |ctx|
    ctx.try! do
      raise "Error connection to db" if rand(0..1) == 1
    end.map_err { |n| ctx.fail!(n.message) }
  end
end

class GetUser
  extend FunctionalLightService::Action
  expects :sanitized_input
  promises :user

  executed do |ctx|
    user = Success(ctx.sanitized_input[:name]) >> method(:fetch_name) >> method(:check_password)
    ctx.user = user.value
  end

  def self.fetch_name(name)
    records = FAKEDB.select { |_k, v| name == v[:name] }
    ctx.fail_and_return!("Name not found in DB") if records.empty?

    Success(records)
  end

  def self.check_password(records)
    record = records.select { |_k, v| ctx.sanitized_input[:password] == v[:password] }
    return ctx.fail_and_return!("Password is not correct") if record.empty?

    Success(record)
  end

  private_class_method :fetch_name, :check_password
end

class PrintResponse
  extend FunctionalLightService::Action
  expects :user

  executed do |ctx|
    id = ctx.user.keys[0]
    name = ctx.user.values[0][:name]
    logger.info("Login successful id: #{id} name: #{name}")
  end
end

Foo.call(:name => "foo", :password => "bar")

Stopping the Series of Actions

When everything goes smoothly, the organizer returns a successful context.
You can check it like this:

class SomeController < ApplicationController
  def index
    result_context = SomeOrganizer.call(current_user.id)

    if result_context.success?
      redirect_to foo_path, :notice => "Everything went OK! Thanks!"
    else
      flash[:error] = result_context.message
      render :action => "new"
    end
  end
end

Sometimes, though, things don’t go as planned — an external API is down or a business rule fails.
In those cases, you can short‑circuit the pipeline in two ways:

  1. Fail the context – aborts execution and returns a Failure() monad with an error message.
  2. Skip the remaining actions – stops further actions but keeps the context successful, allowing graceful exits without raising an error.

Failing the Context

When an action hits an unrecoverable error, call context.fail! to mark the context as failed (context.failure? #=> true) and abort the pipeline.
You can pass an optional message to describe what went wrong:

context.fail!("Validation failed")

If you also need to leave the executed block immediately, you have two options:

  • next context – after fail!, simply return the context.
  • context.fail_and_return!(msg) – a one‑liner that sets the failure state and exits the block.

Here is an example:

class SubmitsOrderAction
  extend FunctionalLightService::Action
  expects :order, :mailer

  executed do |context|
    unless context.order.submit_order_successful?
      context.fail_and_return!("Failed to submit the order")
    end

    # This won't be executed
    context.mailer.send_order_notification!
  end
end

fail-actions

In the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd had a failure, that pushed the context into a failure state and the 4th action was skipped.

Skipping the rest of the actions

To short‑circuit the pipeline without marking the context as failed, call context.skip_remaining!. It behaves like fail!, but the context remains successful, so downstream code can still treat the result as OK.

Typical use case: you run the first few actions, perform a check, and if everything is already fine you can avoid processing the rest.

class ChecksOrderStatusAction
  extend FunctionalLightService::Action
  expects :order

  executed do |context|
    if context.order.send_notification?
      context.skip_remaining!("Everything is good, no need to execute the rest of the actions")
    end
  end
end

skip-actions

In the example above, the organizer invokes four actions. The first two run successfully; the third calls skip_remaining!, so the fourth is never executed, yet the overall context stays successful.

Benchmarking Actions with Around Advice

When you need to profile a pipeline, adding timing code inside every single action clutters your business logic.
Instead, use the organizer’s around_each hook, which wraps each action call as it is reduced in order.

class LogDuration
  def self.call(context)
    start_time = Time.now
    result = yield           # run the wrapped action
    duration = Time.now - start_time
    FunctionalLightService::Configuration.logger.info(
      :action   => context.current_action,
      :duration => duration
    )

    result
  end
end

class CalculatesTax
  extend FunctionalLightService::Organizer

  def self.call(order)
    with(:order => order).around_each(LogDuration).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end
end

Any object you pass to around_each must implement:

def self.call(context, &block)
  # …before logic…
  result = yield   # executes the action
  # …after logic…
  result
end

This design lets you measure—or audit—every action without polluting the actions themselves.

Before and After Action Hooks

Sometimes you need to run code right before or right after each action.
FunctionalLightService lets you do that with the before_actions and after_actions hooks.
Each hook accepts one (or many) lambdas that will be invoked by the organizer, keeping instrumentation neatly separated from business logic.

Example without hooks

class SomeOrganizer
  extend FunctionalLightService::Organizer

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
      OneAction,
      TwoAction,
      ThreeAction
    ]
  end
end

class TwoAction
  extend FunctionalLightService::Action
  expects :user, :logger

  executed do |ctx|
    # Logging information
    if ctx.user.role == 'admin'
       ctx.logger.info('admin is doing something')
    end

    ctx.user.do_something
  end
end

Logging overwhelms the real work in TwoAction. Let’s move that concern into hooks.

Option 1 — declare hooks inside the organizer

class SomeOrganizer
  extend FunctionalLightService::Organizer
  before_actions (lambda do |ctx|
                           if ctx.current_action == TwoAction
                             return unless ctx.user.role == 'admin'
                             ctx.logger.info('admin is doing something')
                           end
                         end)
  after_actions (lambda do |ctx|
                          if ctx.current_action == TwoAction
                            return unless ctx.user.role == 'admin'
                            ctx.logger.info('admin is DONE doing something')
                          end
                        end)

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
      OneAction,
      TwoAction,
      ThreeAction
    ]
  end
end

class TwoAction
  extend FunctionalLightService::Action
  expects :user

  executed do |ctx|
    ctx.user.do_something
  end
end

Now TwoAction is pure business logic. Because ctx.current_action holds the class of the action being run, the hooks fire only for TwoAction, not OneAction or ThreeAction.

Option 2 — attach hooks from the outside

SomeOrganizer.before_actions =
  lambda do |ctx|
    if ctx.current_action == TwoAction
      return unless ctx.user.role == 'admin'
      ctx.logger.info('admin is doing something')
    end
  end

These ideas are originally from Aspect Oriented Programming, read more about them here.

Expects and Promises

Two handy macros define the contract of every action:

Macro Purpose
expects Declares which keys must be present before the action runs.
promises Declares which keys must exist after the action finishes.

If either rule is violated, FunctionalLightService raises a dedicated exception.

Basic usage

class FooAction
  extend FunctionalLightService::Action

  expects   :baz
  promises  :bar

  executed do |context|
    baz = context.fetch(:baz)   # guaranteed to be present
    context[:bar] = baz + 2     # fulfils the promise
  end
end

Built‑in readers and writers

The macros do more than validation: expects adds an accessor reader, so you can reference keys directly. promises adds an accessor writer, so you can assign without touching the hash. Refactored, the action is cleaner:

class FooAction
  extend FunctionalLightService::Action

  expects   :baz
  promises  :bar

  executed do |context|
    context.bar = context.baz + 2
  end
end

Want to see it in practice? Check out this spec test file.

Key Aliases

Need to wire together actions that use different key names?
Declare key mappings once in the organizer with the aliases macro and every action can read or write the value under its preferred name.

class AnOrganizer
  extend FunctionalLightService::Organizer

  aliases :my_key => :key_alias

  def self.call(order)
    with(:order => order).reduce(
      AnAction,
      AnotherAction,
    )
  end
end

class AnAction
  extend FunctionalLightService::Action
  promises :my_key

  executed do |context|
    context.my_key = "value"
  end
end

class AnotherAction
  extend FunctionalLightService::Action
  expects :key_alias

  executed do |context|
    context.key_alias # => "value"
  end
end

Logging

Turning on logging is the easiest way to see what happens inside a pipeline:
which organizer is called, which actions run, which keys appear in the context, and when something goes wrong.

Logging is disabled by default. Enable it in your app’s configuration:

FunctionalLightService::Configuration.logger = Logger.new(STDOUT)

To silence it, point the logger at nil or /dev/null:

FunctionalLightService::Configuration.logger = Logger.new('/dev/null')

Run an organizer and you’ll see output like:

I, [DATE]  INFO -- : [FunctionalLightService] - calling organizer <TestDoubles::MakesTeaAndCappuccino>
I, [DATE]  INFO -- : [FunctionalLightService] -     keys in context: :tea, :milk, :coffee
I, [DATE]  INFO -- : [FunctionalLightService] - executing <TestDoubles::MakesTeaWithMilkAction>
I, [DATE]  INFO -- : [FunctionalLightService] -   expects: :tea, :milk
I, [DATE]  INFO -- : [FunctionalLightService] -   promises: :milk_tea
I, [DATE]  INFO -- : [FunctionalLightService] -     keys in context: :tea, :milk, :coffee, :milk_tea
I, [DATE]  INFO -- : [FunctionalLightService] - executing <TestDoubles::MakesLatteAction>
I, [DATE]  INFO -- : [FunctionalLightService] -   expects: :coffee, :milk
I, [DATE]  INFO -- : [FunctionalLightService] -   promises: :latte
I, [DATE]  INFO -- : [FunctionalLightService] -     keys in context: :tea, :milk, :coffee, :milk_tea, :latte

The log provides a blueprint of the series of actions. You can see what organizer is invoked, what actions are called in what order, what do the expect and promise and most importantly what keys you have in the context after each action is executed.

Failures are logged at WARN level:

W, [DATE]  WARN -- : [FunctionalLightService] - :-((( <TestDoubles::MakesLatteAction> has failed...
W, [DATE]  WARN -- : [FunctionalLightService] - context message: Can't make a latte from a milk that's too hot!

Skipping the remaining actions is also reported:

I, [DATE]  INFO -- : [FunctionalLightService] - calling organizer <TestDoubles::MakesCappuccinoSkipsAddsTwo>
I, [DATE]  INFO -- : [FunctionalLightService] -     keys in context: :milk, :coffee
I, [DATE]  INFO -- : [FunctionalLightService] - ;-) <TestDoubles::MakesLatteAction> has decided to skip the rest of the actions
I, [DATE]  INFO -- : [FunctionalLightService] - context message: Can't make a latte with a fatty milk like that!

Need different log destinations per organizer? Override the global logger:

class FooOrganizer
  extend FunctionalLightService::Organizer
  log_with Logger.new("/my/special.log")
end

Error Codes

Sometimes you need more structure than a free‑text error message. fail! and fail_and_return! accept an error_code: keyword so you can branch on well‑defined codes later.

class FooAction
  extend FunctionalLightService::Action

  executed do |context|
    result = external_service.call

    unless result.success?
      context.fail!(
        "Service call failed",
        error_code: 1001
      )
    end

    unless entity.save
      context.fail!(
        "Saving the entity failed",
        error_code: 2001
      )
    end
  end
end

Organizers or downstream actions can then react to specific codes:

result = FooOrganizer.call

case result.error_code
when 1001 then retry_later
when 2001 then alert_ops_team
end

Action Rollback

Sometimes an action must undo its work if a later step fails.
Example: one action saves records to the database, the next calls an external API. If the API call blows up, you want to delete the records you just saved. That’s exactly what the rolled_back macro is for.

class SaveEntities
  extend FunctionalLightService::Action
  expects :user

  executed do |context|
    context.user.save!
  end

  rolled_back do |context|
    context.user.destroy
  end
end

Trigger a rollback by calling context.fail_with_rollback!. Rollback begins with the failing action and walks back through the already executed actions in reverse order.

class CallExternalApi
  extend FunctionalLightService::Action

  executed do |context|
    api_call_result = SomeAPI.save_user(context.user)

    context.fail_with_rollback!("Error when calling external API") if api_call_result.failure?
  end
end

Declaring rolled_back is optional. If an action makes no persistent changes, there’s nothing to undo—skip it.

Using rollbackable actions standalone

When an action is executed outside an organizer via .execute, any fail_with_rollback! will raise a FailWithRollbackError (an organizer needs the exception to traverse the chain).

If you don’t want to wrap the call in begin … rescue, check whether the action is running inside an organizer:

class FooAction
  extend FunctionalLightService::Action

  executed do |context|
    # context.organized_by will be nil if run from an action,
    # or will be the class name if run from an organizer
    if context.organized_by.nil?
      context.fail!
    else
      context.fail_with_rollback!
    end
  end
end

For a full example, see this acceptance test

Localizing Messages

FunctionalLightService integrates with I18n out of the box, so you can translate success or failure messages without extra plumbing.
If your app needs something more advanced, you can swap in a custom localization adapter.

class FooAction
  extend FunctionalLightService::Action

  executed do |context|
    unless service_call.success?
      context.fail!(:exceeded_api_limit)

      # The failure message used here equates to:
      # I18n.t(:exceeded_api_limit, scope: "foo_action.light_service.failures")
    end
  end
end

Nested classes

Look‑ups follow ActiveSupport’s underscore, just like Rails models inside modules:

module PaymentGateway
  class CaptureFunds
    extend FunctionalLightService::Action

    executed do |context|
      context.fail!(:funds_not_available) if api_service.failed?
      # resolves to:
      # I18n.t(:funds_not_available,
      #        scope: "payment_gateway/capture_funds.light_service.failures")
    end
  end
end

Interpolation variables

Pass a hash for dynamic values:

module PaymentGateway
  class CaptureFunds
    extend FunctionalLightService::Action

    executed do |context|
      if api_service.failed?
        context.fail!(:funds_not_available, last_four: "1234")
      end
    end
  end
end
# en.yml
payment_gateway:
  capture_funds:
    light_service:
      failures:
        funds_not_available: "Unable to process your payment for account ending in %{last_four}"

Custom adapter

Need a different lookup scheme? Subclass the built‑in adapter and set it in the configuration:

# config/initializers/light_service.rb
FunctionalLightService::Configuration.localization_adapter = MyLocalizer.new

# lib/my_localizer.rb
class MyLocalizer < FunctionalLightService::LocalizationAdapter
  # change default scope to: "light_service.failures.<class_path>"
  def i18n_scope_from_class(action_class, type)
    "light_service.#{type.pluralize}.#{action_class.name.underscore}"
  end
end

Retrieving the message

After an action halts with fail! or succeed!, read the translated text via:

result = FooAction.execute(baz: 1)
puts result.message   # ⇒ "Exceeded API limit" (or localized equivalent)

Logic in Organizers

The Organizer - Action combination works really well for simple use cases. However, as business logic gets more complex, or when FunctionalLightService is used in an ETL workflow, the code that routes the different organizers becomes very complex and imperative. Let's look at a piece of code that does basic data transformations:

class ExtractsTransformsLoadsData
  def self.run(connection)
    context = RetrievesConnectionInfo.call(connection)
    context = PullsDataFromRemoteApi.call(context)

    retrieved_items = context.retrieved_items
    if retrieved_items.empty?
      NotifiesEngineeringTeamAction.execute(context)
    end

    retrieved_items.each do |item|
      context[:item] = item
      TransformsData.call(context)
    end

    context = LoadsData.call(context)

    SendsNotifications.call(context)
  end
end

Declarative version

class ExtractsTransformsLoadsData
  extend FunctionalLightService::Organizer

  def self.call(connection)
    with(:connection => connection).reduce(actions)
  end

  def self.actions
    [
      RetrievesConnectionInfo,
      PullsDataFromRemoteApi,
      reduce_if(->(ctx) { ctx.retrieved_items.empty? }, [
        NotifiesEngineeringTeamAction
      ]),
      iterate(:retrieved_items, [
        TransformsData
      ]),
      LoadsData,
      SendsNotifications
    ]
  end
end

The declarative style is shorter, easier to scan, and keeps flow control out of your actions.

Organizer constructs

Construct Declarative “equivalent” What it does (in one line)
reduce_until while loop Keeps reducing the listed steps until the lambda returns true.
reduce_if if/else Reduces its sub‑steps only if the lambda returns true.
iterate each loop Loops over a collection key; each element is exposed under the singular name.
execute one‑off lambda Runs an inline lambda for quick context tweaks (add keys, transform values, etc.).
with_callback streaming callback Defers execution like a SAX parser—great for huge inputs without loading everything in RAM.
add_to_context N/A (context inject) Injects key–value pairs into the context just before the following steps run.
add_aliases key aliasing Creates an alias so actions can read/write the same value under different names.

All seven are covered by acceptance tests in spec/acceptance/organizer/*_spec.rb.

Tip: When iterating, the collection must already be in the context. iterate(:items) expects context; it then places each element under context.item for the inner actions.

iterate(:items, [ProcessItem])
# Inside ProcessItem → context.item

Need a quick context mutation? Use execute:

execute(->(c) { c[:some_values] = c.some_hash.values })

ContextFactory for Faster Action Testing

As workflows grow more complex, building a realistic FunctionalLightService::Context for unit tests can become painful. Factory objects help, but the data you assemble by hand may still differ from what earlier actions really produce—especially in ETL pipelines where each step mutates the context.

Example pipeline:

class SomeOrganizer
  extend FunctionalLightService::Organizer

  def self.call(ctx)
    with(ctx).reduce(actions)
  end

  def self.actions
    [
       ETL::ParsesPayloadAction,
       ETL::BuildsEnititiesAction,
       ETL::SetsUpMappingsAction,
       ETL::SavesEntitiesAction,
       ETL::SendsNotificationAction
    ]
  end
end

You should test your workflow from the outside, invoking the organizer’s call method and verify that the data was properly created or updated in your data store. However, sometimes you need to zoom into one action, and setting up the context to test it is tedious work. This is where ContextFactory can be helpful.

Enter ContextFactory

FunctionalLightService::Testing::ContextFactory can generate a pre-populated context that mirrors real runtime data, letting you focus on the behaviour you want to test.

require "spec_helper"
require "light-service/testing"

RSpec.describe ETL::SetsUpMappingsAction do
  let(:context) do
    FunctionalLightService::Testing::ContextFactory
      .make_from(SomeOrganizer)          # build the full pipeline
      .for(described_class)              # stop right before our action
      .with(payload: File.read("spec/data/payload.json"))
  end

  it "sets up mappings correctly" do
    result = described_class.execute(context)
    expect(result).to be_success
  end
end

No more 20-line fixture setup—just a realistic context ready to go.

If your organizer contains additional logic in its own call method, create a test-only organizer inside your specs. See acceptance test for a full example.

Functional Programming

FunctionalLightService lets you write confident, side-effect-aware Ruby by offering monads and algebraic data types (ADTs) you can compose and pattern-match without boilerplate.

Pattern Overview

Monad / ADT When to use it Typical flow control
Result (Success / Failure) An operation can succeed or fail and the value matters either way. Short-circuit on the first Failure.
Option (Some / None) An operation may return a value or nothing, and why it’s missing doesn’t matter. Think collections or cache hits. Run every step, keep only the Some results.
Maybe Wrap any object that might be nil to avoid endless nil? checks. Chain safe calls; Null swallows method calls.
Enums (custom ADTs) Define your own tagged unions when the built-ins don’t fit. Full pattern-matching support.

Usage

Result – Success / Failure

Success(1).to_s                        # => "1"
Success(Success(1))                    # => Success(1)

Failure(1).to_s                        # => "1"
Failure(Failure(1))                    # => Failure(1)

Mapping and binding

Success(1).fmap { |v| v + 1 }                     # => Success(2)
Failure(1).bind { |v| Success(v - 1) }            # => Success(0)

Success(1).map     { |n| Success(n + 1) }         # => Success(2)
Failure(1).map_err { |n| Success(n + 1) }         # => Success(2)

Flow helpers

Success(1).and Success(2)                         # => Success(2)
Success(1).and_then { Success(2) }                # => Success(2)

Failure(1).or Success(99)                         # => Success(99)
Failure(1).or_else { |n| Success(n + 1) }         # => Success(2)

Exception capturing

include FunctionalLightService::Prelude::Result

try! { 1 }                             # => Success(1)
try! { raise "hell" }                  # => Failure(#<RuntimeError: hell>)
try! { risky_call }                    # => Success(result) or Failure(err)

Result Chaining

You can easily chain the execution of several operations. Here we got some nice function composition. The method must be a unary function, i.e. it always takes one parameter - the context, which is passed from call to call.

The following aliases are defined

alias :>> :map
alias :<< :pipe

This allows the composition of procs or lambdas and thus allow a clear definiton of a pipeline.

Success(params) >>
  validate >>
  build_request << log >>
  send << log >>
  build_response

Complex Example in a Builder Action

class Foo
  extend FunctionalLightService::Action
  expects :params
  alias :m :method

  executed do |ctx|
    Success(ctx.params) >> m(:validate) >> m(:send)
  end

  def self.validate(params)
    # do stuff
    Success(validate_and_cleansed_params)
  end

  def self.send(clean_params)
    # do stuff
    Success(result)
  end
end

class Bar
  extend FunctionalLightService::Organizer

  def self.call(params)
    with(:params => params).reduce(Foo)
  end
end

Bar.call # Success(3)

Chaining works with blocks (#map is an alias for #>>)

Success(1).map {|ctx| Success(ctx + 1)}

it also works with lambdas

Success(1) >> ->(ctx) { Success(ctx + 1) } >> ->(ctx) { Success(ctx + 1) }

and it will break the chain of execution, when it encounters a Failure on its way

def works(ctx)
  Success(1)
end

def breaks(ctx)
  Failure(2)
end

def never_executed(ctx)
  Success(99)
end

Success(0) >> method(:works) >> method(:breaks) >> method(:never_executed) # Failure(2)

#map aka #>> will not catch any exceptions raised. If you want automatic exception handling, the #try aka #>= will catch an error and wrap it with a failure

def error(ctx)
  raise "error #{ctx}"
end

Success(1) >= method(:error) # Failure(RuntimeError(error 1))

Pattern matching

Now that you have some result, you want to control flow by providing patterns. #match can match by

  • success, failure, result or any
  • values
  • lambdas
  • classes
Success(1).match do
  Success() { |s| "success #{s}"}
  Failure() { |f| "failure #{f}"}
end # => "success 1"

Note1: the variant's inner value(s) have been unwrapped, and passed to the block.

Note2: only the first matching pattern block will be executed, so order can be important.

Note3: you can omit block parameters if you don't use them, or you can use _ to signify that you don't care about their values. If you specify parameters, their number must match the number of values in the variant.

The result returned will be the result of the first #try or #let. As a side note, #try is a monad, #let is a functor.

Guards

Success(1).match do
  Success(where { s == 1 }) { |s| "Success #{s}" }
end # => "Success 1"

Note1: the guard has access to variable names defined by the block arguments.

Note2: the guard is not evaluated using the enclosing context's self; if you need to call methods on the enclosing scope, you must specify a receiver.

Also you can match the result class

Success([1, 2, 3]).match do
  Success(where { s.is_a?(Array) }) { |s| s.first }
end # => 1

If no match was found a NoMatchError is raised, so make sure you always cover all possible outcomes.

Success(1).match do
  Failure() { |f| "you'll never get me" }
end # => NoMatchError

Matches must be exhaustive, otherwise an error will be raised, showing the variants which have not been covered.

Option

Some(1).some?                          # #=> true
Some(1).none?                          # #=> false
None.some?                             # #=> false
None.none?                             # #=> true

Maps an Option with the value a to the same Option with the value b.

Some(1).fmap { |n| n + 1 }             # => Some(2)
None.fmap { |n| n + 1 }                # => None

Maps a Result with the value a to another Result with the value b.

Some(1).map  { |n| Some(n + 1) }       # => Some(2)
Some(1).map  { |n| None }              # => None
None.map     { |n| Some(n + 1) }       # => None

Get the inner value or provide a default for a None. Calling #value on a None will raise a NoMethodError

Some(1).value                          # => 1
Some(1).value_or(2)                    # => 1
None.value                             # => NoMethodError
None.value_or(0)                       # => 0

Add the inner values of option using +.

Some(1) + Some(1)                      # => Some(2)
Some([1]) + Some(1)                    # => TypeError: No implicit conversion
None + Some(1)                         # => Some(1)
Some(1) + None                         # => Some(1)
Some([1]) + None + Some([2])           # => Some([1, 2])

Coercion

Option.any?(nil)                       # => None
Option.any?([])                        # => None
Option.any?({})                        # => None
Option.any?(1)                         # => Some(1)

Option.some?(nil)                      # => None
Option.some?([])                       # => Some([])
Option.some?({})                       # => Some({})
Option.some?(1)                        # => Some(1)

Option.try! { 1 }                      # => Some(1)
Option.try! { raise "error"}           # => None

Some(1).match {
  Some(where { s == 1 }) { |s| s + 1 }
  Some()                 { |s| 1 }
  None()                 { 0 }
}                                      # => 2

Maybe

The simplest NullObject wrapper there can be. It adds #some? and #null? to Object though.

require 'functional-light-service/functional/maybe' # you need to do this explicitly
Maybe(nil).foo        # => Null
Maybe(nil).foo.bar    # => Null
Maybe({a: 1})[:a]     # => 1

Maybe(nil).null?      # => true
Maybe({}).null?       # => false

Maybe(nil).some?      # => false
Maybe({}).some?       # => true

Enums (custom ADTs)

All the above are implemented using enums, see their definition, for more details.

Threenum = FunctionalLightService::enum {
            Nullary()
            Unary(:a)
            Binary(:a, :b)
           }

Threenum.variants                      # => [:Nullary, :Unary, :Binary]

Initialize

n = Threenum.Nullary                   # => Threenum::Nullary.new()
n.value                                # => Error

u = Threenum.Unary(1)                  # => Threenum::Unary.new(1)
u.value                                # => 1

b = Threenum::Binary(2, 3)             # => Threenum::Binary(2, 3)
b.value                                # => { a:2, b: 3 }

Pattern matching

Threenum::Unary(5).match {
  Nullary() {        0 }
  Unary()   { |u|    u }
  Binary()  { |a, b| a + b }
}                                      # => 5

# or
t = Threenum::Unary(5)
Threenum.match(t) {
  Nullary() {        0 }
  Unary()   { |u|    u }
  Binary()  { |a, b| a + b }
}                                      # => 5

If you want to return the whole matched object, you'll need to pass a reference to the object (second case). Note that self refers to the scope enclosing the match call.

def drop(n)
  match {
    Cons(where { n > 0 }) { |h, t| t.drop(n - 1) }
    Cons()                { |_, _| self }
    Nil() { raise EmptyListError }
  }
end

See the linked list implementation in the specs for more examples

With guard clauses

Threenum::Unary(5).match {
  Nullary() {     0 }
  Unary()   { |u| u }
  Binary(where { a.is_a?(Fixnum) && b.is_a?(Fixnum) }) { |a, b| a + b }
  Binary()  { |a, b| raise "Expected a, b to be numbers" }
}                                      # => 5

Add methods with impl

FunctionalLightService::impl(Threenum) {
  def sum
    match {
      Nullary() {        0 }
      Unary()   { |u|    u }
      Binary()  { |a, b| a + b }
    }
  end

  def +(other)
    match {
      Nullary() {        other.sum }
      Unary()   { |a|    self.sum + other.sum }
      Binary()  { |a, b| self.sum + other.sum }
    }
  end
}

Threenum.Nullary + Threenum.Unary(1)   # => Unary(1)

All matches must be exhaustive; otherwise NoMatchError is raised.

Usage

Based on the refactoring example above, just create an organizer object that calls the actions in order and write code for the actions. That's it.

For further examples, please visit the project's Wiki.

Upgrading to 6.0

Version 6.0 requires Ruby >= 3.1 and ships a few breaking changes plus new guarantees. They come from a full technical audit (see AUDIT-functional-light-service.md).

Breaking changes

  • Context#fetch now honours the Hash#fetch contract: fetch(:missing) without a default raises KeyError (it used to return nil) and fetch never writes to the context anymore.
  • Aliases are pure alternative names: reads and writes on an alias resolve to the original key. assign_aliases no longer copies values, so to_h contains only the original keys.
  • Key collisions raise: declaring expects :size (or any key that clashes with an existing Hash/Context method) raises ReservedKeysInContextError instead of silently returning the wrong value. Access such data via ctx[:size] instead.
  • Some(nil) raises ArgumentError: absence is expressed with None.
  • Context#outcome is read-only: use succeed!/fail! to change the outcome.
  • The infrastructure keys :_aliases, :_before_actions and :_after_actions are reserved and cannot be used in expects/promises.

New guarantees and features

  • Declarative hooks are stable: before_actions/after_actions declared on an organizer now apply to every call (they used to disappear after the first one).

  • Rollback is complete even when the same action class appears more than once in the pipeline.

  • Native pattern matching: every enum variant supports case/in:

    case result
    in FunctionalLightService::Result::Success[value] then value
    in FunctionalLightService::Result::Failure[error] then handle(error)
    end
    

    For hot paths prefer case/in (or success?/value) over the match DSL: it is roughly two orders of magnitude faster.

  • skip_remaining! is scoped: inside iterate/reduce_if/reduce_until it skips the remaining steps of the current sub-pipeline (for iterate: of the current item), then the outer flow continues. The outcome message set by skip_remaining! is preserved.

  • Deprecations (still working, warn once on stderr): Maybe()/Null (use Option), Result#>= (use try), Result#<< (use pipe), Result#+/Option#+. Silence them with FunctionalLightService::Deprecations.silenced = true.

Threading contract

A Context is a per-call object: create it inside each organizer call (which is what with does) and do not share a live context between threads. Class-level state (hooks, aliases, logger) is read-only at call time, so calling the same organizer from multiple threads (Puma, Sidekiq) is safe.

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

Huge thanks to the contributors!

Changelog

Follow the changelog in this document.

Thank You

A very special thank you to Attila Domokos for his fantastic work on LightService. A very special thank you to Piotr Zolnierek for his fantastic work on Deterministic. FunctionalLightService is inspired heavily by the concepts put to code by Attila and add some functionality taken from the excellent work of mario Piotr.

License

FunctionalLightService is released under the MIT License.