This gem is no longer active and the recommended alternative is dry-transactions.
BusinessPipeline (BP) aim is to help organize your app's logic in a generic way. You define business bricks that you can then plug together to build more eveolved pipelines.
While it was developed with Rails in mind, BP has no dependency upon it and can be used to organize pretty much any Ruby code.
Add this line to your application's Gemfile:
gem 'business_pipeline'
And then execute:
$ bundle
Or install it yourself as:
$ gem install business_pipeline
Let's say you build an API using Rails. Would you agree that most of the time, CRUD actions look pretty much alike?
If your answer is "yes" or "yes but…" BusinessPipeline might be a good fit for you! What if you could write a single process to list resources in a Rails application? Wouldn't it be cool to have a generic code that can adapt to most if not all of your use-cases?
BusinessPipeline has been extracted from applications that did just that. And if you were wondering, no those apps weren't 15-minute blogs 😁.
A typical index process would look like:
module Processes
class Index < ApplicationProcess
step Steps::FetchAll
step Steps::Sort
step Steps::Paginate
end
end
This may look like a trivial example but the power of this simplicity resides in how generic the Steps of your Process are.
We'll start be looking at the basics of working with BusinessPipeline and then move on to how you could leverage its power in a Rails application.
Let's start from the very beginning and define a very focused business process:
class UsersIndexProcess
include BusinessPipeline::Process
step FetchAllUsers
step SortUsers
step PaginateUsers
end
So far so good. Now let write the Steps that our process uses:
class FetchAllUsers
include BusinessPipeline::Step
def call
context.users = User.all
end
end
class SortUsers
include BusinessPipeline::Step
def call
context.users = context.users.order(context.sort)
end
end
class PaginateUsers
include BusinessPipeline::Step
def call
context.users = context.user.page(context.page)
end
end
Before we call our process, let's take a look at what we've got.
A Process is actually a super-Step. This means that a Process can call another Process like any other Step.
class UsersIndexProcess
include BusinessPipeline::Process
step ::IndexProcess
step UsersCustomStep
end
In some occasions that may be a handy solution but you shouldn't need it most of the time.
The first thing that you see is that every Step interacts with something called context
.
This context
is a bag-of-data that is passed from one Step to another. It can be used as an object, just like we did with context.users
but you can also use it like a Hash if necessary so here context[:users]
would also work fine.
context.value = 42
context.value # => 42
context['value'] # => 42
context[:value] # => 42
In our SortUsers
and PaginateUsers
Steps we used values from our context that weren't define so far. We'll see how they came to be when looking at how to call a Process.
When calling a Process, you can provide an initial context by passing a Hash as argument:
UsersIndex.new.perform(page: 1, sort: { created_at: :desc })
You can then use this initial context in all your Steps.
A Process returns the modified context at the end of its execution. You can interrogate this context to know if everything went according to plan:
result = UsersIndex.new.perform(page: 1, sort: { created_at: :desc })
result.success? # => true
result.failure? # => false
result.users # => …sorted and paginated list of users…
So far we've written very narrow focused Processes and Steps but can we rewrite this code so that it becomes generic? Let's see how!
Before we get started we need to first take a look at a Process' initialization. It actually accepts a Hash that will act somewhat like context
but should contain data that define the Process as opposed to context
that is more related to the execution of the Process.
process = IndexProcess.new(collection_name: 'users', model_class: User)
process.perform(page: 1, sort: { created_at: :desc })
Now that we initialized our Process with a config let's change our Process' code and our Steps.
class IndexProcess
include BusinessPipeline::Process
step FetchAll
step Sort
step Paginate
end
So far all we did is remove the User
part of our class names, now we need to modify the code they contain to be indeed generic.
class FetchAll
include BusinessPipeline::Step
def call
collection_name = config.collection_name
model_class = config.model_class
context[collection_name] = model_class.all
end
end
class Sort
include BusinessPipeline::Step
def call
collection_name = config.collection_name
context[collection_name] = context[collection_name].order(context.sort)
end
end
class Paginate
include BusinessPipeline::Step
def call
collection_name = config.collection_name
context[collection_name] = context[collection_name].page(context.page)
end
end
And done! Not bad actually. We leveraged the information passed to the Process config to be able to reuse our Steps for any type of resource.
More often than not, you will want to implement things that are not quiet part of the business process per se, but are necessary for its good execution nonetheless. That's where hooks come in handy.
If you're used to Rails, hooks act like around_action
, before_action
and after_action
.
Hooks can be defined on Processes and Steps alike. They accept blocks, method names or classes:
class IndexProcess
include BusinessPipeline::Process
around do |process, context, config|
puts "Calling process: #{process.class}"
puts "Config is: #{config.inspect}"
puts "Context before call is: #{context.inspect}"
process.call
puts "Context after call is: #{context.inspect}"
end
before :some_method
after SomeAwesomeClass
private def some_method(context, config)
end
end
Important: don’t call process.perform
inside a Hook, it would trigger the hooks and create an infinite loop.
Execution of around hooks will always be the first one. Then the before hooks and to finish the after ones. So writing the following Process
class IndexProcess
include BusinessPipeline::Process
around do |process|
puts 'AROUND 1 START'
process.call
puts 'AROUND 1 END'
end
before { puts 'BEFORE 1' }
before { puts 'BEFORE 2' }
around do |process|
puts 'AROUND 2 START'
process.call
puts 'AROUND 2 END'
end
after { puts 'AFTER 1' }
after { puts 'AFTER 2' }
end
Would result in the following output:
AROUND 1 START
AROUND 2 START
BEFORE 1
BEFORE 2
AFTER 1
AFTER 2
AROUND 2 END
AROUND 1 END
If you inherit from a Process, your new class will inherit the hooks from its parent Process. This is especially useful when you want to centralize specific behaviors across all Processes.
If for instance you wanted to wrap every Process in a transaction (which would be a good idea by the way 😉), you can define it this way:
class TransactionWrapping
def call(process, context, config)
ActiveRecord::Base.transaction { process.call }
end
end
class ApplicationProcess
include BusinessPipeline::Process
around TransactionWrapping
end
class IndexProcess < ApplicationProcess
# …
end
Calling your IndexProcess
will wrap it in a SQL transaction 😍.
If you go generic all the way, you may end-up with interesting Steps like this:
class ExtractAttribute
include BusinessPipeline::Step
def call
attribute_name = config.attribute_name
expose_as = config.expose_as
source = config.source
context[expose_as] = context[source].public_send(attribute_name)
end
end
If for instance you have a user
in your context and you wanted to expose its email
attribute as user_email
the config needed for this to happen would be:
{ source: :user, attribute_name: :email, expose_as: :user_email }
And the whole Step to translate to
class ExtractAttribute
include BusinessPipeline::Step
def call
context.user_email = context.user.email
end
end
Now what happens if you need to call this generic step for several attributes?
Luckily, you can override the Process' config when using a Step and this override will only be active for this specific Step:
class SomeUserProcess < ApplicationProcess
step FindUser
step ExtractAttribute do
source :user
attribute_name :email
expose_as :user_email
end
step ExtractAttribute do
source :user
attribute_name :lastname
expose_as :user_lastname
end
end
At the end of the Process' execution, the context will contain user_email
and user_lastname
with the corresponding values.
Very often you may need to stop the execution of a Process. This can happen for two reasons: an error or an early success.
If you want to stop the process execution because a situation makes it impossible to continue, you can leverage the fail!
method.
class DataCheck
include BusinessPipeline::Step
def call
context.continue == 'yes' || fail!(error: 'Continue is not set to yes')
end
end
class CheckingProcess
include BusinessPipeline::Process
step DataCheck
end
Calling context.fail!
will stop the execution and merge the information you give it to the context.
result = CheckingProcess.new.perform(continue: 'no')
result.success? # => false
result.failure? # => true
result.error # => 'Continue is not set to yes'
Sometimes, you may want to stop the execution of a Process because of an early success. That's what the succeed!
method is for.
For instance if you want to have a find or create behavior you could implement it this way:
class UserCreationProcess
include BusinessPipeline::Process
step FindUser
step CreateUser
step SendAccountConfirmationEmail
end
class FindUser
include BusinessPipeline::Step
def call
user = User.find(context.user_id)
succeed!(user: user) if user
end
end
After checking out the repo, run bin/setup
to install dependencies. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/simonc/business_pipeline. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the BusinessPipeline project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.