FunctionalLightService
Table of Content
- Requirements
- Installation
- Why FunctionalLightService?
- Stopping the Series of Actions
- Benchmarking Actions with Around Advice
- Before and After Action Hooks
- Key Aliases
- Logging
- Error Codes
- Action Rollback
- Localizing Messages
- Logic in Organizers
- ContextFactory for Faster Action Testing
- Functional programming
- Usage
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.)
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.) }
end
def connect_db
try! do
raise "Error connection to db" if rand(0..1) == 1
end.map_err { |n| Failure(n.) }
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.) 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.) 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.) 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.) }
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.
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:
- Fail the context – aborts execution and returns a
Failure()monad with an error message. - 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

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

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. = 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. # ⇒ "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. # => 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#fetchnow honours theHash#fetchcontract:fetch(:missing)without a default raisesKeyError(it used to returnnil) 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_aliasesno longer copies values, soto_hcontains only the original keys. - Key collisions raise: declaring
expects :size(or any key that clashes with an existingHash/Contextmethod) raisesReservedKeysInContextErrorinstead of silently returning the wrong value. Access such data viactx[:size]instead. Some(nil)raisesArgumentError: absence is expressed withNone.Context#outcomeis read-only: usesucceed!/fail!to change the outcome.- The infrastructure keys
:_aliases,:_before_actionsand:_after_actionsare reserved and cannot be used inexpects/promises.
New guarantees and features
-
Declarative hooks are stable:
before_actions/after_actionsdeclared 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) endFor hot paths prefer
case/in(orsuccess?/value) over thematchDSL: it is roughly two orders of magnitude faster. -
skip_remaining!is scoped: insideiterate/reduce_if/reduce_untilit skips the remaining steps of the current sub-pipeline (foriterate: of the current item), then the outer flow continues. The outcome message set byskip_remaining!is preserved. -
Deprecations (still working, warn once on stderr):
Maybe()/Null(useOption),Result#>=(usetry),Result#<<(usepipe),Result#+/Option#+. Silence them withFunctionalLightService::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
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Added some feature') - Push to the branch (
git push origin my-new-feature) - 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.