{"id":13482582,"url":"https://github.com/adomokos/light-service","last_synced_at":"2025-05-14T14:08:04.446Z","repository":{"id":5278882,"uuid":"6458053","full_name":"adomokos/light-service","owner":"adomokos","description":"Series of Actions with an emphasis on simplicity.","archived":false,"fork":false,"pushed_at":"2025-05-08T23:13:58.000Z","size":1053,"stargazers_count":855,"open_issues_count":14,"forks_count":69,"subscribers_count":16,"default_branch":"main","last_synced_at":"2025-05-08T23:28:06.121Z","etag":null,"topics":["aop","ruby","workflow"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/adomokos.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2012-10-30T13:56:13.000Z","updated_at":"2025-05-08T23:10:51.000Z","dependencies_parsed_at":"2023-12-01T00:24:59.224Z","dependency_job_id":"f8dad4fe-d8b1-4b34-80bf-f035924b0506","html_url":"https://github.com/adomokos/light-service","commit_stats":{"total_commits":642,"total_committers":40,"mean_commits":16.05,"dds":0.3099688473520249,"last_synced_commit":"0ee0be5cca0067617bc7cc34ff4719cd48acd97c"},"previous_names":[],"tags_count":49,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adomokos%2Flight-service","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adomokos%2Flight-service/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adomokos%2Flight-service/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adomokos%2Flight-service/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/adomokos","download_url":"https://codeload.github.com/adomokos/light-service/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254159490,"owners_count":22024562,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aop","ruby","workflow"],"created_at":"2024-07-31T17:01:03.513Z","updated_at":"2025-05-14T14:08:04.426Z","avatar_url":"https://github.com/adomokos.png","language":"Ruby","readme":"![LightService](https://raw.githubusercontent.com/adomokos/light-service/master/resources/light-service.png)\n\n[![Gem Version](https://img.shields.io/gem/v/light-service.svg)](https://rubygems.org/gems/light-service)\n[![CI Tests](https://github.com/adomokos/light-service/actions/workflows/project-build.yml/badge.svg)](https://github.com/adomokos/light-service/actions/workflows/project-build.yml)\n[![Codecov](https://codecov.io/gh/adomokos/light-service/branch/main/graph/badge.svg)](https://codecov.io/gh/adomokos/light-service)\n[![License](https://img.shields.io/badge/license-MIT-green.svg)](http://opensource.org/licenses/MIT)\n[![Download Count](https://img.shields.io/badge/download%3A-~5%20million-blue)](https://rubygems.org/gems/light-service)\n[![Code Climate](https://codeclimate.com/github/adomokos/light-service.svg)](https://codeclimate.com/github/adomokos/light-service)\n\nLightService is a powerful and flexible service skeleton framework with an emphasis on simplicity\n\n## Table of Contents\n- [Table of Contents](#table-of-contents)\n- [Why LightService?](#why-lightservice)\n- [Getting started](#getting-started)\n  - [Requirements](#requirements)\n  - [Installation](#installation)\n  - [Your first action](#your-first-action)\n  - [Your first organizer](#your-first-organizer)\n- [Stopping the Series of Actions](#stopping-the-series-of-actions)\n  - [Failing the Context](#failing-the-context)\n  - [Skipping the rest of the actions](#skipping-the-rest-of-the-actions)\n- [Benchmarking Actions with Around Advice](#benchmarking-actions-with-around-advice)\n- [Before and After Action Hooks](#before-and-after-action-hooks)\n- [Expects and Promises](#expects-and-promises)\n  - [Default values for optional Expected keys](#default-values-for-optional-expected-keys)\n- [Key Aliases](#key-aliases)\n- [Logging](#logging)\n- [Error Codes](#error-codes)\n- [Action Rollback](#action-rollback)\n- [Localizing Messages](#localizing-messages)\n  - [Built-in localization adapter](#built-in-localization-adapter)\n  - [I18n localization adapter](#i18n-localization-adapter)\n  - [Custom localization adapter](#custom-localization-adapter)\n- [Orchestrating Logic in Organizers](#orchestrating-logic-in-organizers)\n- [ContextFactory for Faster Action Testing](#contextfactory-for-faster-action-testing)\n- [Rails support](#rails-support)\n  - [Organizer generation](#organizer-generation)\n  - [Action generation](#action-generation)\n  - [Advanced action generation](#advanced-action-generation)\n- [Other implementations](#other-implementations)\n- [Contributing](#contributing)\n- [Release Notes](#release-notes)\n- [License](#license)\n\n## Why LightService?\n\nWhat do you think of this code?\n\n```ruby\nclass TaxController \u003c ApplicationController\n  def update\n    @order = Order.find(params[:id])\n    tax_ranges = TaxRange.for_region(order.region)\n\n    if tax_ranges.nil?\n      render :action =\u003e :edit, :error =\u003e \"The tax ranges were not found\"\n      return # Avoiding the double render error\n    end\n\n    tax_percentage = tax_ranges.for_total(@order.total)\n\n    if tax_percentage.nil?\n      render :action =\u003e :edit, :error =\u003e \"The tax percentage  was not found\"\n      return # Avoiding the double render error\n    end\n\n    @order.tax = (@order.total * (tax_percentage/100)).round(2)\n\n    if @order.total_with_tax \u003e 200\n      @order.provide_free_shipping!\n    end\n\n    redirect_to checkout_shipping_path(@order), :notice =\u003e \"Tax was calculated successfully\"\n  end\nend\n```\n\nThis controller violates [SRP](http://en.wikipedia.org/wiki/Single_responsibility_principle) all over.\nAlso, imagine what it would take to test this beast.\nYou could move the tax_percentage finders and calculations into the tax model,\nbut then you'll make your model logic heavy.\n\nThis controller does 3 things in order:\n* Looks up the tax percentage based on order total\n* Calculates the order tax\n* Provides free shipping if the total with tax is greater than $200\n\nThe order of these tasks matters: you can't calculate the order tax without the percentage.\nWouldn't it be nice to see this instead?\n\n```ruby\n(\n  LooksUpTaxPercentage,\n  CalculatesOrderTax,\n  ProvidesFreeShipping\n)\n```\n\nThis block of code should tell you the \"story\" of what's going on in this workflow.\nWith the help of LightService you can write code this way. First you need an organizer object that sets up the actions in order\nand executes them one-by-one. Then you need to create the actions with one method (that will do only one thing).\n\nThis is how the organizer and actions interact with each other:\n\n![LightService](https://raw.githubusercontent.com/adomokos/light-service/master/resources/organizer_and_actions.png)\n\n```ruby\nclass CalculatesTax\n  extend LightService::Organizer\n\n  def self.call(order)\n    with(:order =\u003e order).reduce(\n        LooksUpTaxPercentageAction,\n        CalculatesOrderTaxAction,\n        ProvidesFreeShippingAction\n      )\n  end\nend\n\nclass LooksUpTaxPercentageAction\n  extend LightService::Action\n  expects :order\n  promises :tax_percentage\n\n  executed do |context|\n    tax_ranges = TaxRange.for_region(context.order.region)\n    context.tax_percentage = 0\n\n    next context if object_is_nil?(tax_ranges, context, 'The tax ranges were not found')\n\n    context.tax_percentage = tax_ranges.for_total(context.order.total)\n\n    next context if object_is_nil?(context.tax_percentage, context, 'The tax percentage was not found')\n  end\n\n  def self.object_is_nil?(object, context, message)\n    if object.nil?\n      context.fail!(message)\n      return true\n    end\n\n    false\n  end\nend\n\nclass CalculatesOrderTaxAction\n  extend ::LightService::Action\n  expects :order, :tax_percentage\n\n  # I am using ctx as an abbreviation for context\n  executed do |ctx|\n    order = ctx.order\n    order.tax = (order.total * (ctx.tax_percentage/100)).round(2)\n  end\n\nend\n\nclass ProvidesFreeShippingAction\n  extend LightService::Action\n  expects :order\n\n  executed do |ctx|\n    if ctx.order.total_with_tax \u003e 200\n      ctx.order.provide_free_shipping!\n    end\n  end\nend\n```\n\nAnd with all that, your controller should be super simple:\n\n```ruby\nclass TaxController \u003c ApplicationContoller\n  def update\n    @order = Order.find(params[:id])\n\n    service_result = CalculatesTax.for_order(@order)\n\n    if service_result.failure?\n      render :action =\u003e :edit, :error =\u003e service_result.message\n    else\n      redirect_to checkout_shipping_path(@order), :notice =\u003e \"Tax was calculated successfully\"\n    end\n\n  end\nend\n```\nI gave a [talk at RailsConf 2013](http://www.adomokos.com/2013/06/simple-and-elegant-rails-code-with.html) on\nsimple and elegant Rails code where I told the story of how LightService was extracted from the projects I had worked on.\n\n## Getting started\n\n### Requirements\n\nThis gem requires ruby 2.x. Use of [generators](#rails-support) requires Rails 5+ (tested on Rails 5.x \u0026 6.x only. Will probably work on\nRails versions as old as 3.2)\n\n### Installation\n\nIn your Gemfile:\n\n```ruby\ngem 'light-service'\n```\n\nAnd then\n\n```shell\nbundle install\n```\n\nOr install it yourself as:\n\n```shell\ngem install light-service\n```\n\n### Your first action\n\nLightService's building blocks are actions that are normally composed within an organizer, but can be run independently.\nLet's make a simple greeter action. Each action can take an optional list of expected inputs and promised outputs. If\nthese are specified and missing at action start and stop respectively, an exception will be thrown.\n\n```ruby\nclass GreetsPerson\n  extend ::LightService::Action\n\n  expects :name\n  promises :greeting\n\n  executed do |context|\n    context.greeting = \"Hey there, #{name}. You enjoying LightService so far?\"\n  end\nend\n```\n\nWhen an action is run, you have access to its returned context, and the status of the action. You can invoke an\naction by calling `.execute` on its class with `key: value` arguments, and inspect its status and context like so:\n\n```ruby\noutcome = GreetsPerson.execute(name: \"Han\")\n\nif outcome.success?\n  puts outcome.greeting # which was a promised context value\nelsif outcome.failure?\n  puts \"Rats... I can't say hello to you\"\nend\n```\n\nYou will notice that actions are set up to promote simplicity, i.e. they either succeed or fail, and they have\nvery clear inputs and outputs. Ideally, they should do [exactly one thing](https://en.wikipedia.org/wiki/Single-responsibility_principle). This makes them as easy to test as unit tests.\n\n### Your first organizer\n\nLightService provides a facility to compose actions using organizers. This is great when you have a business process\nto execute that has multiple steps. By composing actions that do exactly one thing, you can sequence simple\nactions together to perform complex multi-step business processes in a clear manner that is very easy\nto reason about.\n\nThere are advanced ways to sequence actions that can be found later in the README, but we'll keep this simple for now.\nFirst, let's add a second action that we can sequence to run after the `GreetsPerson` action from above:\n\n```ruby\nclass RandomlyAwardsPrize\n  extend ::LightService::Action\n\n  expects :name, :greeting\n  promises :did_i_win\n\n  executed do |context|\n    prize_num  = \"#{context.name}__#{context.greeting}\".length\n    prizes     = [\"jelly beans\", \"ice cream\", \"pie\"]\n    did_i_win  = rand((1..prize_num)) % 7 == 0\n    did_i_lose = rand((1..prize_num)) % 13 == 0\n\n    if did_i_lose\n      # When failing, send a message as an argument, readable from the return context\n      context.fail!(\"you are exceptionally unlucky\")\n    else\n      # You can specify 'optional' context items by treating context like a hash.\n      # Useful for when you may or may not be returning extra data. Ideally, selecting\n      # a prize should be a separate action that is only run if you win.\n      context[:prize]   = \"lifetime supply of #{prizes.sample}\" if did_i_win\n      context.did_i_win = did_i_win\n    end\n  end\nend\n```\n\nAnd here's the organizer that ties the two together. You implement a `call` class method that takes some arguments and\nfrom there sends them to `with` in `key: value` format which forms the initial state of the context. From there, chain\n`reduce` to `with` and send it a list of action class names in sequence. The organizer will call each action, one\nafter the other, and build up the context as it goes along.\n\n```ruby\nclass WelcomeAPotentiallyLuckyPerson\n  extend LightService::Organizer\n\n  def self.call(name)\n    with(:name =\u003e name).reduce(GreetsPerson, RandomlyAwardsPrize)\n  end\nend\n```\n\nWhen an organizer is run, you have access to the context as it passed through all actions, and the overall status\nof the organized execution. You can invoke an organizer by calling `.call` on the class with the expected arguments,\nand inspect its status and context just like you would an action:\n\n```ruby\noutcome = WelcomeAPotentiallyLuckyPerson.call(\"Han\")\n\nif outcome.success?\n  puts outcome.greeting # which was a promised context value\n\n  if outcome.did_i_win\n    puts \"And you've won a prize! Lucky you. Please see the front desk for your #{outcome.prize}.\"\n  end\nelse # outcome.failure? is true, and we can pull the failure message out of the context for feedback to the user.\n  puts \"Rats... I can't say hello to you, because #{outcome.message}.\"\nend\n```\n\nBecause organizers generally run through complex business logic, and every action has the potential to cause a failure,\ntesting an organizer is functionally equivalent to an integration test.\n\nFor further examples, please visit the project's [Wiki](https://github.com/adomokos/light-service/wiki) and review\nthe [\"Why LightService\" section](#why-lightservice) above.\n\n## Stopping the Series of Actions\nWhen nothing unexpected happens during the organizer's call, the returned `context` will be successful. Here is how you can check for this:\n```ruby\nclass SomeController \u003c ApplicationController\n  def index\n    result_context = SomeOrganizer.call(current_user.id)\n\n    if result_context.success?\n      redirect_to foo_path, :notice =\u003e \"Everything went OK! Thanks!\"\n    else\n      flash[:error] = result_context.message\n      render :action =\u003e \"new\"\n    end\n  end\nend\n```\nHowever, sometimes not everything will play out as you expect it. An external API call might not be available or some complex business logic will need to stop the processing of the Series of Actions.\nYou have two options to stop the call chain:\n\n1. Failing the context\n2. Skipping the rest of the actions\n\n### Failing the Context\nWhen something goes wrong in an action and you want to halt the chain, you need to call `fail!` on the context object. This will push the context in a failure state (`context.failure? # will evalute to true`).\nThe context's `fail!` method can take an optional message argument, this message might help describing what went wrong.\nIn case you need to return immediately from the point of failure, you have to do that by calling `next context`.\n\nIn case you want to fail the context and stop the execution of the executed block, use the `fail_and_return!('something went wrong')` method.\nThis will immediately leave the block, you don't need to call `next context` to return from the block.\n\nHere is an example:\n```ruby\nclass SubmitsOrderAction\n  extend LightService::Action\n  expects :order, :mailer\n\n  executed do |context|\n    unless context.order.submit_order_successful?\n      context.fail_and_return!(\"Failed to submit the order\")\n    end\n\n    # This won't be executed\n    context.mailer.send_order_notification!\n  end\nend\n```\n![fail-actions](https://raw.githubusercontent.com/adomokos/light-service/master/resources/fail_actions.png)\n\nIn 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.\n\n### Skipping the rest of the actions\nYou can skip the rest of the actions by calling `context.skip_remaining!`. This behaves very similarly to the above-mentioned `fail!` mechanism, except this will not push the context into a failure state.\nA good use case for this is executing the first couple of action and based on a check you might not need to execute the rest.\nHere is an example of how you do it:\n```ruby\nclass ChecksOrderStatusAction\n  extend LightService::Action\n  expects :order\n\n  executed do |context|\n    if context.order.send_notification?\n      context.skip_remaining!(\"Everything is good, no need to execute the rest of the actions\")\n    end\n  end\nend\n```\n![skip-actions](https://raw.githubusercontent.com/adomokos/light-service/master/resources/skip_actions.png)\n\nIn the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd decided to skip the rest, the 4th action was not invoked. The context was successful.\n\n\n## Benchmarking Actions with Around Advice\nBenchmarking your action is needed when you profile the series of actions. You could add benchmarking logic to each and every action, however, that would blur the business logic you have in your actions.\n\nTake advantage of the organizer's `around_each` method, which wraps the action calls as its reducing them in order.\n\nCheck out this example:\n\n```ruby\nclass LogDuration\n  def self.call(context)\n    start_time = Time.now\n    result = yield\n    duration = Time.now - start_time\n    LightService::Configuration.logger.info(\n      :action   =\u003e context.current_action,\n      :duration =\u003e duration\n    )\n\n    result\n  end\nend\n\nclass CalculatesTax\n  extend LightService::Organizer\n\n  def self.call(order)\n    with(:order =\u003e order).around_each(LogDuration).reduce(\n        LooksUpTaxPercentageAction,\n        CalculatesOrderTaxAction,\n        ProvidesFreeShippingAction\n      )\n  end\nend\n```\n\nAny object passed into `around_each` must respond to #call with two arguments: the action name and the context it will execute with. It is also passed a block, where LightService's action execution will be done in, so the result must be returned. While this is a little work, it also gives you before and after state access to the data for any auditing and/or checks you may need to accomplish.\n\n## Before and After Action Hooks\n\nIn case you need to inject code right before and after the actions are executed, you can use the `before_actions` and `after_actions` hooks. It accepts one or multiple lambdas that the Action implementation will invoke. This addition to LightService is a great way to decouple instrumentation from business logic.\n\nConsider this code:\n\n```ruby\nclass SomeOrganizer\n  extend LightService::Organizer\n\n  def self.call(ctx)\n    with(ctx).reduce(actions)\n  end\n\n  def self.actions\n    [\n      OneAction,\n      TwoAction,\n      ThreeAction\n    ]\n  end\nend\n\nclass TwoAction\n  extend LightService::Action\n  expects :user, :logger\n\n  executed do |ctx|\n    # Logging information\n    if ctx.user.role == 'admin'\n       ctx.logger.info('admin is doing something')\n    end\n\n    ctx.user.do_something\n  end\nend\n```\n\nThe logging logic makes `TwoAction` more complex, there is more code for logging than for business logic.\n\nYou have two options to decouple instrumentation from real logic with `before_actions` and `after_actions` hooks:\n\n1. Declare your hooks in the Organizer\n2. Attach hooks to the Organizer from the outside\n\nThis is how you can declaratively add before and after hooks to the Organizer:\n\n```ruby\nclass SomeOrganizer\n  extend LightService::Organizer\n  before_actions (lambda do |ctx|\n                           if ctx.current_action == TwoAction\n                             return unless ctx.user.role == 'admin'\n                             ctx.logger.info('admin is doing something')\n                           end\n                         end)\n  after_actions (lambda do |ctx|\n                          if ctx.current_action == TwoAction\n                            return unless ctx.user.role == 'admin'\n                            ctx.logger.info('admin is DONE doing something')\n                          end\n                        end)\n\n  def self.call(ctx)\n    with(ctx).reduce(actions)\n  end\n\n  def self.actions\n    [\n      OneAction,\n      TwoAction,\n      ThreeAction\n    ]\n  end\nend\n\nclass TwoAction\n  extend LightService::Action\n  expects :user\n\n  executed do |ctx|\n    ctx.user.do_something\n  end\nend\n```\n\nNote how the action has no logging logic after this change. Also, you can target before and after action logic for specific actions, as the `ctx.current_action` will have the class name of the currently processed action. In the example above, logging will occur only for `TwoAction` and not for `OneAction` or `ThreeAction`.\n\nHere is how you can declaratively add `before_hooks` or `after_hooks` to your Organizer from the outside:\n\n```ruby\nSomeOrganizer.before_actions =\n  lambda do |ctx|\n    if ctx.current_action == TwoAction\n      return unless ctx.user.role == 'admin'\n      ctx.logger.info('admin is doing something')\n    end\n  end\n```\n\nThese ideas are originally from Aspect Oriented Programming, read more about them [here](https://en.wikipedia.org/wiki/Aspect-oriented_programming).\n\n## Expects and Promises\nThe `expects` and `promises` macros are rules for the inputs/outputs of an action.\n`expects` describes what keys it needs to execute, and `promises` makes sure the keys are in the context after the\naction is reduced. If either of them are violated, a `LightService::ExpectedKeysNotInContextError` or\n`LightService::PromisedKeysNotInContextError` exception respectively will be thrown.\n\nThis is how it's used:\n\n```ruby\nclass FooAction\n  extend LightService::Action\n  expects :baz\n  promises :bar\n\n  executed do |context|\n    context.bar = context.baz + 2\n  end\nend\n```\n\nThe `expects` macro will pull the value with the expected key from the context, and\nmakes it available to you through a reader.\n\nThe `promises` macro will not only check if the context has the promised keys, it\nalso sets them for you in the context if you use the accessor with the same name,\nmuch the same way as the expects macro works.\n\nThe context object is essentially a smarter-than-normal Hash. Take a look at [this spec](spec/action_expects_and_promises_spec.rb)\nto see expects and promises used with and without accessors.\n\n### Default values for optional Expected keys\n\nWhen you have an expected key that has a sensible default which should be used everywhere and\nonly overridden on an as-needed basis, you can specify a default value. An example use-case\nis a flag that allows a failure from a service under most circumstances to avoid failing an\nentire workflow because of a non-critical action.\n\nLightService provides two mechanisms for specifying default values:\n\n1. A static value that is used as-is\n2. A callable that takes the current context as a param\n\nUsing the above use case, consider an action that sends a text message. In most cases,\nif there is a problem sending the text message, it might be OK for it to fail. We will\n`expect` an `allow_failure` key, but set it with a default, like so:\n\n```ruby\nclass SendSMS\n  extend LightService::Action\n  expects :message, :user\n  expects :allow_failure, default: true\n\n  executed do |context|\n    sms_api = SMSService.new(key: ENV[\"SMS_API_KEY\"])\n    status  = sms_api.send(ctx.user.mobile_number, ctx.message)\n\n    if !status.sent_ok?\n      ctx.fail!(status.err_msg) unless ctx.allow_failure\n    end\n  end\nend\n```\n\nDefault values can also be processed dynamically by providing a callable. Any values already\nspecified in the context are available to it via Hash key lookup syntax. e.g.\n\n```ruby\nclass SendSMS\n  extend LightService::Action\n  expects :message, :user\n  expects :allow_failure, default: -\u003e(ctx) { !ctx[:user].admin? } # Admins must always get SMS'\n\n  executed do |context|\n    sms_api = SMSService.new(key: ENV[\"SMS_API_KEY\"])\n    status  = sms_api.send(ctx.user.mobile_number, ctx.message)\n\n    if !status.sent_ok?\n      ctx.fail!(status.err_msg) unless ctx.allow_failure\n    end\n  end\nend\n```\n\n**Note** that default values must be specified one at a time on their own line.\n\nYou can then call an action or organizer that uses an action with defaults without specifying\nthe expected key that has a default.\n\n## Key Aliases\nThe `aliases` macro sets up pairs of keys and aliases in an organizer. Actions can access the context using the aliases.\n\nThis allows you to put together existing actions from different sources and have them work together without having to modify their code. Aliases will work with or without action `expects`.\n\nSay for example you have actions `AnAction` and `AnotherAction` that you've used in previous projects.  `AnAction` provides `:my_key` but `AnotherAction` needs to use that value but expects `:key_alias`.  You can use them together in an organizer like so:\n\n```ruby\nclass AnOrganizer\n  extend LightService::Organizer\n\n  aliases :my_key =\u003e :key_alias\n\n  def self.call(order)\n    with(:order =\u003e order).reduce(\n      AnAction,\n      AnotherAction,\n    )\n  end\nend\n\nclass AnAction\n  extend LightService::Action\n  promises :my_key\n\n  executed do |context|\n    context.my_key = \"value\"\n  end\nend\n\nclass AnotherAction\n  extend LightService::Action\n  expects :key_alias\n\n  executed do |context|\n    context.key_alias # =\u003e \"value\"\n  end\nend\n```\n\n## Logging\nEnable LightService's logging to better understand what goes on within the series of actions,\nwhat's in the context or when an action fails.\n\nLogging in LightService is turned off by default. However, turning it on is simple. Add this line to your\nproject's config file:\n\n```ruby\nLightService::Configuration.logger = Logger.new(STDOUT)\n```\n\nYou can turn off the logger by setting it to nil or `/dev/null`.\n\n```ruby\nLightService::Configuration.logger = Logger.new('/dev/null')\n```\n\nWatch the console while you are executing the workflow through the organizer. You should see something like this:\n\n```bash\nI, [DATE]  INFO -- : [LightService] - calling organizer \u003cTestDoubles::MakesTeaAndCappuccino\u003e\nI, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee\nI, [DATE]  INFO -- : [LightService] - executing \u003cTestDoubles::MakesTeaWithMilkAction\u003e\nI, [DATE]  INFO -- : [LightService] -   expects: :tea, :milk\nI, [DATE]  INFO -- : [LightService] -   promises: :milk_tea\nI, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee, :milk_tea\nI, [DATE]  INFO -- : [LightService] - executing \u003cTestDoubles::MakesLatteAction\u003e\nI, [DATE]  INFO -- : [LightService] -   expects: :coffee, :milk\nI, [DATE]  INFO -- : [LightService] -   promises: :latte\nI, [DATE]  INFO -- : [LightService] -     keys in context: :tea, :milk, :coffee, :milk_tea, :latte\n```\n\nThe log provides a blueprint of the series of actions. You can see what organizer is invoked, what actions\nare called in what order, what do the expect and promise and most importantly what keys you have in the context\nafter each action is executed.\n\nThe logger logs its messages with \"INFO\" level. The exception to this is the event when an action fails the context.\nThat message is logged with \"WARN\" level:\n\n```bash\nI, [DATE]  INFO -- : [LightService] - calling organizer \u003cTestDoubles::MakesCappuccinoAddsTwoAndFails\u003e\nI, [DATE]  INFO -- : [LightService] -     keys in context: :milk, :coffee\nW, [DATE]  WARN -- : [LightService] - :-((( \u003cTestDoubles::MakesLatteAction\u003e has failed...\nW, [DATE]  WARN -- : [LightService] - context message: Can't make a latte from a milk that's too hot!\n```\n\nThe log message will show you what message was added to the context when the action pushed the\ncontext into a failure state.\n\nThe event of skipping the rest of the actions is also captured by its logs:\n\n```bash\nI, [DATE]  INFO -- : [LightService] - calling organizer \u003cTestDoubles::MakesCappuccinoSkipsAddsTwo\u003e\nI, [DATE]  INFO -- : [LightService] -     keys in context: :milk, :coffee\nI, [DATE]  INFO -- : [LightService] - ;-) \u003cTestDoubles::MakesLatteAction\u003e has decided to skip the rest of the actions\nI, [DATE]  INFO -- : [LightService] - context message: Can't make a latte with a fatty milk like that!\n```\n\nYou can specify the logger on the organizer level, so the organizer does not use the global logger.\n\n```ruby\nclass FooOrganizer\n  extend LightService::Organizer\n  log_with Logger.new(\"/my/special.log\")\nend\n```\n\n## Error Codes\nYou can add some more structure to your error handling by taking advantage of error codes in the context.\nNormally, when something goes wrong in your actions, you fail the process by setting the context to failure:\n\n```ruby\nclass FooAction\n  extend LightService::Action\n\n  executed do |context|\n    context.fail!(\"I don't like what happened here.\")\n  end\nend\n```\n\nHowever, you might need to handle the errors coming from your action pipeline differently.\nUsing an error code can help you check what type of expected error occurred in the organizer\nor in the actions.\n\n```ruby\nclass FooAction\n  extend LightService::Action\n\n  executed do |context|\n    unless (service_call.success?)\n      context.fail!(\"Service call failed\", error_code: 1001)\n    end\n\n    # Do something else\n\n    unless (entity.save)\n      context.fail!(\"Saving the entity failed\", error_code: 2001)\n    end\n  end\nend\n```\n\n## Action Rollback\nSometimes your action has to undo what it did when an error occurs. Think about a chain of actions where you need\nto persist records in your data store in one action and you have to call an external service in the next. What happens if there\nis an error when you call the external service? You want to remove the records you previously saved. You can do it now with\nthe `rolled_back` macro.\n\n```ruby\nclass SaveEntities\n  extend LightService::Action\n  expects :user\n\n  executed do |context|\n    context.user.save!\n  end\n\n  rolled_back do |context|\n    context.user.destroy\n  end\nend\n```\n\nYou need to call the `fail_with_rollback!` method to initiate a rollback for actions starting with the action where the failure\nwas triggered.\n\n```ruby\nclass CallExternalApi\n  extend LightService::Action\n\n  executed do |context|\n    api_call_result = SomeAPI.save_user(context.user)\n\n    context.fail_with_rollback!(\"Error when calling external API\") if api_call_result.failure?\n  end\nend\n```\n\nUsing the `rolled_back` macro is optional for the actions in the chain. You shouldn't care about undoing non-persisted changes.\n\nThe actions are rolled back in reversed order from the point of failure starting with the action that triggered it.\n\nSee [this acceptance test](spec/acceptance/rollback_spec.rb) to learn more about this functionality.\n\nYou may find yourself directly using an action that can roll back by calling `.execute` instead of using it from within an Organizer.\nIf this action fails and attempts a rollback, a `FailWithRollbackError` exception will be raised. This is so that the organizer can\nrollback the actions one by one. If you don't want to wrap your call to the action with a `begin, rescue FailWithRollbackError`\nblock, you can introspect the context like so, and keep your usage of the action clean:\n\n```ruby\nclass FooAction\n  extend LightService::Action\n\n  executed do |context|\n    # context.organized_by will be nil if run from an action,\n    # or will be the class name if run from an organizer\n    if context.organized_by.nil?\n      context.fail!\n    else\n      context.fail_with_rollback!\n    end\n  end\nend\n```\n\n## Localizing Messages\n\n### Built-in localization adapter\n\nThe built-in adapter simply uses a manually created dictionary to search for translations.\n\n```ruby\n# lib/light_service_translations.rb\nLightService::LocalizationMap.instance[:en] = {\n  :foo_action =\u003e {\n    :light_service =\u003e {\n      :failures =\u003e {\n        :exceeded_api_limit =\u003e \"API limit for service Foo reached. Please try again later.\"\n      },\n      :successes =\u003e {\n        :yay =\u003e \"Yaaay!\"\n      }\n    }\n  }\n}\n```\n\n```ruby\nclass FooAction\n  extend LightService::Action\n\n  executed do |context|\n    unless service_call.success?\n      context.fail!(:exceeded_api_limit)\n\n      # The failure message used here equates to:\n      # LightService::LocalizationMap.instance[:en][:foo_action][:light_service][:failures][:exceeded_api_limit]\n    end\n  end\nend\n```\n\nNested classes will work too: `App::FooAction`, for example, would be translated to `app/foo_action` hash key.\n\n`:en` is the default locale, but you can switch it whenever you want with\n\n```ruby\nLightService::Configuration.locale = :it\n```\n\nIf you have `I18n` loaded in your project the default adapter will automatically be updated to use it.\nBut would you want to opt for the built-in localization adapter you can force it with\n\n```ruby\nLightService::Configuration.localization_adapter = LightService::LocalizationAdapter.new\n```\n\n### I18n localization adapter\n\nIf `I18n` is loaded into your project, LightService will automatically provide a mechanism for easily translating your error or success messages via `I18n`.\n\n\n```ruby\nclass FooAction\n  extend LightService::Action\n\n  executed do |context|\n    unless service_call.success?\n      context.fail!(:exceeded_api_limit)\n\n      # The failure message used here equates to:\n      # I18n.t(:exceeded_api_limit, scope: \"foo_action.light_service.failures\")\n    end\n  end\nend\n```\n\nThis also works with nested classes via the ActiveSupport `#underscore` method, just as ActiveRecord performs localization lookups on models placed inside a module.\n\n```ruby\nmodule PaymentGateway\n  class CaptureFunds\n    extend LightService::Action\n\n    executed do |context|\n      if api_service.failed?\n        context.fail!(:funds_not_available)\n      end\n\n      # this failure message equates to:\n      # I18n.t(:funds_not_available, scope: \"payment_gateway/capture_funds.light_service.failures\")\n    end\n  end\nend\n```\n\nIf you need to provide custom variables for interpolation during localization, pass that along in a hash.\n\n```ruby\nmodule PaymentGateway\n  class CaptureFunds\n    extend LightService::Action\n\n    executed do |context|\n      if api_service.failed?\n        context.fail!(:funds_not_available, last_four: \"1234\")\n      end\n\n      # this failure message equates to:\n      # I18n.t(:funds_not_available, last_four: \"1234\", scope: \"payment_gateway/capture_funds.light_service.failures\")\n\n      # the translation string itself being:\n      # =\u003e \"Unable to process your payment for account ending in %{last_four}\"\n    end\n  end\nend\n```\n\n### Custom localization adapter\n\nYou can also provide your own custom localization adapter if your application's logic is more complex than what is shown here.\n\nTo provide your own custom adapter, use the configuration setting and subclass the default adapter LightService provides.\n\n```ruby\nLightService::Configuration.localization_adapter = MyLocalizer.new\n\n# lib/my_localizer.rb\nclass MyLocalizer \u003c LightService::I18n::LocalizationAdapter\n\n  # I just want to change the default lookup path\n  # =\u003e \"light_service.failures.payment_gateway/capture_funds\"\n  def i18n_scope_from_class(action_class, type)\n    \"light_service.#{type.pluralize}.#{action_class.name.underscore}\"\n  end\nend\n```\n\nTo get the value of a `fail!` or `succeed!` message, simply call `#message` on the returned context.\n\n## Orchestrating Logic in Organizers\n\nThe Organizer - Action combination works really well for simple use cases. However, as business logic gets more complex, or when LightService is used in an ETL workflow, the code that routes the different organizers becomes very complex and imperative.\n\nIn the past, this was solved using Orchestrators. As of [Version 0.9.0 Orchestrators have been deprecated](https://github.com/adomokos/light-service/pull/132). All their functionality is now usable directly within Organizers. Read on to understand how to orchestrate workflows from within a single Organizer.\n\nLet's look at a piece of code that does basic data transformations:\n\n```ruby\nclass ExtractsTransformsLoadsData\n  def self.run(connection)\n    context = RetrievesConnectionInfo.call(connection)\n    context = PullsDataFromRemoteApi.call(context)\n\n    retrieved_items = context.retrieved_items\n    if retrieved_items.empty?\n      NotifiesEngineeringTeamAction.execute(context)\n    end\n\n    retrieved_items.each do |item|\n      context[:item] = item\n      TransformsData.call(context)\n    end\n\n    context = LoadsData.call(context)\n\n    SendsNotifications.call(context)\n  end\nend\n```\n\nThe `LightService::Context` is initialized with the first action, that context is passed around among organizers and actions. This code is still simpler than many out there, but it feels very imperative: it has conditionals, iterators in it. Let's see how we could make it a bit more simpler with a declarative style:\n\n```ruby\nclass ExtractsTransformsLoadsData\n  extend LightService::Organizer\n\n  def self.call(connection)\n    with(:connection =\u003e connection).reduce(actions)\n  end\n\n  def self.actions\n    [\n      RetrievesConnectionInfo,\n      PullsDataFromRemoteApi,\n      reduce_if(-\u003e(ctx) { ctx.retrieved_items.empty? }, [\n        NotifiesEngineeringTeamAction\n      ]),\n      iterate(:retrieved_items, [\n        TransformsData\n      ]),\n      LoadsData,\n      SendsNotifications\n    ]\n  end\nend\n```\n\nThis code is much easier to reason about, it's less noisy and it captures the goal of LightService well: simple, declarative code that's easy to understand.\n\nThe 9 different orchestrator constructs an organizer can have:\n\n1. `reduce_until`\n2. `reduce_if`\n3. `reduce_if_else`\n4. `reduce_case`\n5. `iterate`\n6. `execute`\n7. `with_callback`\n8. `add_to_context`\n9. `add_aliases`\n\n`reduce_until` behaves like a while loop in imperative languages, it iterates until the provided predicate in the lambda evaluates to true. Take a look at [this acceptance test](spec/acceptance/organizer/reduce_until_spec.rb) to see how it's used.\n\n`reduce_if` will reduce the included organizers and/or actions if the predicate in the lambda evaluates to true. [This acceptance test](spec/acceptance/organizer/reduce_if_spec.rb) describes this functionality.\n\n`reduce_if_else` takes three arguments, a condition lambda, a first set of \"if true\" steps, and a second set of \"if false\" steps. If the lambda evaluates to true, the \"if true\" steps are executed, otherwise the \"else steps\" are executed. [This acceptance test](spec/acceptance/organizer/reduce_if_else_spec.rb) describes this functionality.\n\n`reduce_case` behaves like a Ruby `case` statement. The first parameter `value` is the key of the value within the context that will be worked with. The second parameter `when` is a hash where the keys are conditional values and the values are steps to take if the condition matches. The final parameter `else` is a set of steps to take if no conditions within the `when` parameter are met. [This acceptance test](spec/acceptance/organizer/reduce_case_spec.rb) describes this functionality.\n\n`iterate` gives your iteration logic, the symbol you define there has to be in the context as a key. For example, to iterate over items you will use `iterate(:items)` in your steps, the context needs to have `items` as a key, otherwise it will fail. The organizer will singularize the collection name and will put the actual item into the context under that name. Remaining with the example above, each element will be accessible by the name `item` for the actions in the `iterate` steps. [This acceptance test](spec/acceptance/organizer/iterate_spec.rb) should provide you with an example.\n\nTo take advantage of another organizer or action, you might need to tweak the context a bit. Let's say you have a hash, and you need to iterate over its values in a series of action. To alter the context and have the values assigned into a variable, you need to create a new action with 1 line of code in it. That seems a lot of ceremony for a simple change. You can do that in a `execute` method like this `execute(-\u003e(ctx) { ctx[:some_values] = ctx.some_hash.values })`. [This test](spec/acceptance/organizer/execute_spec.rb) describes how you can use it.\n\nUse `with_callback` when you want to execute actions with a deferred and controlled callback. It works similar to a Sax parser, I've used it for processing large files. The advantage of it is not having to keep large amount of data in memory. See [this acceptance test](spec/acceptance/organizer/with_callback_spec.rb) as a working example.\n\n`add_to_context` can add key-value pairs on the fly to the context. This functionality is useful when you need a value injected into the context under a specific key right before the subsequent actions are executed. Keys are also made available as accessors on the context object and can be used just like methods exposed via `expects` and `promises`. [This test](spec/acceptance/organizer/add_to_context_spec.rb) describes its functionality.\n\nYour action needs a certain key in the context but it's under a different one? Use the function `add_aliases` to alias an existing key in the context under the desired key. Take a look at [this test](spec/acceptance/organizer/add_aliases_spec.rb) to see an example.\n\n## ContextFactory for Faster Action Testing\n\nAs the complexity of your workflow increases, you will find yourself spending more and more time creating a context (LightService::Context it is) for your action tests. Some of this code can be reused by clever factories, but still, you are using a context that is artificial, and can be different from what the previous actions produced. This is especially true, when you use LightService in ETLs, where you start out with initial data and your actions are mutating its state.\n\nHere is an example:\n\n```ruby\nclass SomeOrganizer\n  extend LightService::Organizer\n\n  def self.call(ctx)\n    with(ctx).reduce(actions)\n  end\n\n  def self.actions\n    [\n       ETL::ParsesPayloadAction,\n       ETL::BuildsEnititiesAction,\n       ETL::SetsUpMappingsAction,\n       ETL::SavesEntitiesAction,\n       ETL::SendsNotificationAction\n    ]\n  end\nend\n```\n\nYou 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.\n\nIn order to test the third action `ETL::SetsUpMappingAction`, you have to have several entities in the context. Depending on the logic you need to write code for, this could be a lot of work. However, by using the `ContextFactory` in your spec, you could easily have a prepared context that’s ready for testing:\n\n```ruby\nrequire 'spec_helper'\nrequire 'light-service/testing'\n\nRSpec.describe ETL::SetsUpMappingsAction do\n  let(:context) do\n    LightService::Testing::ContextFactory\n      .make_from(SomeOrganizer)\n      .for(described_class)\n      .with(:payload =\u003e File.read(‘spec/data/payload.json’)\n  end\n\n  it ‘works like it should’ do\n    result = described_class.execute(context)\n    expect(result).to be_success\n  end\nend\n```\n\nThis context then can be passed to the action under test, freeing you up from the 20 lines of factory or fixture calls to create a context for your specs.\n\nIn case your organizer has more logic in its `call` method, you could create your own test organizer in your specs like you can see it in this [acceptance test](spec/acceptance/testing/context_factory_spec.rb#L4-L11). This is reusable in all your action tests.\n\n## Rails support\n\nLightService includes Rails generators for creating both Organizers and Actions along with corresponding tests. Currently only RSpec is\nsupported ([PR's for supporting MiniTest are welcome](https://github.com/adomokos/light-service/pulls))\n\nNote: Generators are namespaced to `light_service` not `light-service` due to Rake name constraints.\n\n### Organizer generation\n\n```shell\nrails generate light_service:organizer My::SuperFancy::Organizer\n# -- or\nrails generate light_service:organizer my/super_fancy/organizer\n```\n\nOptions for this generator are:\n\n* `--dir=\u003cSOME_DIR\u003e`. `\u003cSOME_DIR\u003e` defaults to `organizers`. Will write organizers to `/app/organizers`, and specs to `/spec/organizers`\n* `--no-tests`. Default is `--tests`. Will generate a test file matching the namespace you've supplied.\n\n### Action generation\n\n```shell\nrails generate light_service:action My::SuperFancy::Action\n# -- or\nrails generate light_service:action my/super_fancy/action\n```\n\nOptions for this generator are:\n\n* `--dir=\u003cSOME_DIR\u003e`. `\u003cSOME_DIR\u003e` defaults to `actions`. Will write actions to `/app/actions`, and specs to `/spec/actions`\n* `--no-tests`. Defaults is `--tests`. Will generate a test file matching the namespace you've supplied.\n* `--no-roll-back`. Default is `--roll-back`. Will generate a `rolled_back` block for you to implement with [roll back functionality](#action-rollback).\n\n### Advanced action generation\n\nYou are able to optionally specify `expects` and/or `promises` keys during generation\n\n```shell\nrails generate light_service:action CrankWidget expects:one_fish,two_fish promises:red_fish,blue_fish\n```\n\nWhen specifying `expects`, convenience variables will be initialized in the `executed` block so that you don't have to call\nthem through the context. A stub context will be created in the test file using these keys too.\n\nWhen specifying `promises`, specs will be created testing for their existence after executing the action.\n\n## Other implementations\n\n| Language   | Repo                                                                    | Author                                                 |\n| :--------- |:------------------------------------------------------------------------| :------------------------------------------------------|\n| Python     | [pyservice](https://github.com/adomokos/pyservice)                      | [@adomokos](https://github.com/adomokos)               |\n| PHP        | [light-service](https://github.com/douglasgreyling/light-service)       | [@douglasgreyling](https://github.com/douglasgreyling) |\n| JavaScript | [light-service.js](https://github.com/douglasgreyling/light-service.js) | [@douglasgreyling](https://github.com/douglasgreyling) |\n\n\n## Contributing\n1. Fork it\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Added some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n\nHuge thanks to the [contributors](https://github.com/adomokos/light-service/graphs/contributors)!\n\n## Release Notes\nFollow the release notes in this [document](https://github.com/adomokos/light-service/blob/master/RELEASES.md).\n\n## License\nLightService is released under the [MIT License](http://www.opensource.org/licenses/MIT).\n","funding_links":[],"categories":["Abstraction","Ruby","Business logic"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadomokos%2Flight-service","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fadomokos%2Flight-service","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadomokos%2Flight-service/lists"}