substation
Think of substation
as some sort of domain level request router. It assumes
that every usecase in your application has a name and is implemented in a dedicated
object that will be referred to as an action. The only protocol such actions must
support is #call(request)
.
The contract for actions specifies that when invoked, they can
receive arbitrary input data which will be available in request.input
.
Additionally, request.env
contains an arbitrary object that
represents your application environment and will typically provide access
to useful things like a logger and probably some sort of storage engine
abstraction object.
The contract further specifies that every action must return an instance
of either Substation::Response::Success
or
Substation::Response::Failure
. Again, arbitrary data can be associated
with any kind of response, and will be available in response.output
. To
indicate wether invoking the action was successful or not, you can use
response.success?
. In addition to that, response.request
contains
the request object used to invoke the action.
Substation::Dispatcher
stores a mapping of action names to the actual
objects implementing the action, as well as the application environment.
Clients can use Substation::Dispatcher#call(name, input)
to dispatch to
any registered action. For example, a web application could map an http
route to a specific action name and pass relevant http params on to the
action.
Actions
Here's an example of a valid action.
module App
class SomeUseCase
# Perform the usecase
#
# @param [Substation::Request] request
# the request passed to the registered action
#
# @return [Substation::Response]
# the response returned when calling the action
#
# @api private
def self.call(request)
data = perform_work
if data
request.success(data)
else
request.error("Something went wrong")
end
end
end
end
It is up to you how to implement the action. Another way of writing an action could involve providing an application specific baseclass for all your actions, which provides access to methods you frequently use within any specific action.
module App
# Base class for all actions
#
# @abstract
class Action
# Perform the usecase
#
# @param [Substation::Request] request
# the request passed to the registered action
#
# @return [Substation::Response]
# the response returned when calling the action
#
# @api private
def self.call(request)
new(request).call
end
def initialize(request)
@request = request
@env = @request.env
end
def call
raise NotImplementedError, "#{self.class}##{__method__} must be implemented"
end
private
def success(data)
@request.success(data)
end
def error(data)
@request.error(data)
end
end
class SomeUseCase < Action
def call
data = perform_work
if data
success(data)
else
error("Something went wrong")
end
end
end
end
Observers
Sometimes, additional code needs to run wether your action was
successful or not. Observers provide you with a place for that code.
Again, the contract for observers is very simple: all they need to
implement is call(response)
and substation
will make sure that the
response
param will be the response returned from invoking your
action.
It is therefore possible to dispatch to different observers based on
wether the action was successful or not by utilizing
response.success?
. By accepting a response
object, observers also
have access to the original input
and env
the action was invoked
with, as well as the output
that the action produced. These objects
are made available via response.input
, response.env
and
response.output
.
Here's an example of a simple observer:
module App
class SomeUseCaseObserver
def self.call(response)
# your code here
end
end
end
A more involved observer could dispatch based on the success of the invoked action:
module App
class SomeUseCaseObserver
def self.call(response)
klass = response.success? ? Success : Failure
klass.new(response).call
end
def initialize(response)
@response = response
end
class Success < self
def call
# your code here
end
end
class Failure < self
def call
# your code here
end
end
end
end
Configuration
Since an application will most likely involve more than one usecase, we
need a way to inform substation
about all the usecases it should handle.
For this purpose, we can instantiate a Substation::Dispatcher
and hand
it a configuration hash that describes the various actions by giving
them a name, a class that's responsible for implementing the actual
usecase, and a list of 0..n
observers that should be invoked depending
on the action response. In addition to that, we must pass an instance of
the application's environment. More about application environments can
be found in the next paragraph.
An example configuration for an action without any observers:
# short form
dispatcher = Substation::Dispatcher.coerce({
'some_use_case' => 'App::SomeUseCase'
}, env)
# long form
dispatcher = Substation::Dispatcher.coerce({
'some_use_case' => { 'action' => 'App::SomeUseCase' }
}, env)
An example configuration for an action with one observer:
dispatcher = Substation::Dispatcher.coerce({
'some_use_case' => {
'action' => 'App::SomeUseCase',
'observer' => 'App::SomeUseCaseObserver'
}
}, env)
An example configuration for an action with multiple observers:
dispatcher = Substation::Dispatcher.coerce({
'some_use_case' => {
'action' => 'App::SomeUseCase',
'observer' => [
'App::SomeUseCaseObserver',
'App::AnotherObserver'
]
}
}, env)
The above configuration examples are tailored towards being read from a
(yaml) config file and therefore accept strings as keys and values. It's
also possible to use symbols as keys (and values). Values correspond to
action or observer "handlers" and can also be given as either constants,
symbols, or procs. In any case, handlers must respond to call(object)
.
An example configuration using symbol keys and constants for handlers:
# short form (without observers)
dispatcher = Substation::Dispatcher.coerce({
:some_use_case => App::SomeUseCase
}, env)
# long form
dispatcher = Substation::Dispatcher.coerce({
:some_use_case => {
:action => App::SomeUseCase,
:observer => App::SomeUseCaseObserver
}
}, env)
An example configuration using symbol keys and procs for handlers:
# short form (without observers)
dispatcher = Substation::Dispatcher.coerce({
:some_use_case => Proc.new { |request| request.success(:data) }
}, env)
dispatcher = Substation::Dispatcher.coerce({
:some_use_case => {
:action => Proc.new { |request| request.success(:foo) },
:observer => Proc.new { |response| do_something }
}
}, env)
Application environments
In order to provide your actions with objects typically needed during the course of performing a usecase (like a logger or a storage engine abstraction), you can encapsulate these objects within an application specific environment object, and send that along to every action.
Here's a complete example with an environment that encapsulates a very primitive storage abstraction object, one simple business entity, and a few simple actions.
module App
class Database
include Equalizer.new(:relations)
def initialize(relations)
@relations = relations
end
def [](relation_name)
Relation.new(relations[relation_name])
end
protected
attr_reader :relations
class Relation
include Equalizer.new(:tuples)
include Enumerable
def initialize(tuples)
@tuples = tuples
end
def each(&block)
return to_enum unless block_given?
tuples.each(&block)
self
end
def all
tuples
end
def insert(tuple)
self.class.new(tuples + [tuple])
end
protected
attr_reader :tuples
end
end
module Models
class Person
include Equalizer.new(:id, :name)
attr_reader :id
attr_reader :name
def initialize(attributes)
@id, @name = attributes.values_at(:id, :name)
end
end
end # module Models
class Environment
include Equalizer.new(:storage)
attr_reader :storage
def initialize(storage)
@storage = storage
end
end
class Storage
include Equalizer.new(:db)
include Models
def initialize(db)
@db = db
end
def list_people
db[:people].all.map { |tuple| Person.new(tuple) }
end
def load_person(id)
Person.new(db[:people].select { |tuple| tuple[:id] == id }.first)
end
def create_person(person)
relation = db[:people].insert(:id => person.id, :name => person.name)
relation.map { |tuple| Person.new(tuple) }
end
protected
attr_reader :db
end
class App
include Equalizer.new(:dispatcher)
def initialize(dispatcher)
@dispatcher = dispatcher
end
def call(name, input = nil)
@dispatcher.call(name, input)
end
end
# Base class for all actions
#
# @abstract
class Action
include AbstractType
def self.call(request)
new(request).call
end
def initialize(request)
@request = request
@env = @request.env
@input = @request.input
end
abstract_method :call
private
attr_reader :request
attr_reader :env
attr_reader :input
def db
@env.storage
end
def success(data)
@request.success(data)
end
def error(data)
@request.error(data)
end
end
module Actions
class ListPeople < Action
def call
success(db.list_people)
end
end
class LoadPerson < Action
def initialize(request)
super
@id = input
end
def call
success(db.load_person(@id))
end
end
class CreatePerson < Action
def initialize(request)
super
@person = input
end
def call
success(db.create_person(@person))
end
end
end # module Actions
module Observers
LogEvent = Proc.new { |response| response }
SendEmail = Proc.new { |response| response }
end
DB = Database.new({
:people => [{
:id => 1,
:name => 'John'
}]
})
actions = {
:list_people => Actions::ListPeople,
:load_person => Actions::LoadPerson,
:create_person => {
:action => Actions::CreatePerson,
:observer => [
Observers::LogEvent,
Observers::SendEmail
]
}
}
storage = Storage.new(DB)
env = Environment.new(storage)
dispatcher = Substation::Dispatcher.coerce(actions, env)
APP = App.new(dispatcher)
end
response = App::APP.call(:list_companies)
response.success? # => true
response.output # => [#<App::Models::Person attributes={:id=>1, :name=>"John"}>]
Chains
In a typical application scenario, a few things need to happen before an actual use case (an action) can be invoked. These things will often include the following steps (probably in that order).
- Authentication
- Authorization
- Input data sanitization
- Input data validation
We only want to invoke our action if all those steps succeed. If any of the above steps fails, we want to send back a response that provides details about what exactly prevented us from further processing the request. If authentication fails, why try to authorize. If authorization fails, why try to sanitize. And so on.
If, however, all the above steps passed, we can
- Invoke the action
Oftentimes, at this point, we're not done just yet. We have invoked our action and we probably got back some data, but we still need to turn it into something the caller can easily consume. If you happen to develop a web application for example, you'll probably want to render some HTML or some JSON.
a) If you need to return HTML, you might
- Wrap the response data in some presenter object
- Wrap the presenter in some view object
- Use that view object to render an HTML template
b) If you need to return JSON, you might just
- Pass the response data to some serializer object and dump it to JSON
To allow chaining all those steps in a declarative way, substation
provides an object called Substation::Chain
. Its contract is dead
simple:
#call(Substation::Request) => Substation::Response
#result(Substation::Response) => Substation::Response
You typically won't be calling Substation::Chain#result
yourself, but
having it around, allows us to use chains in incoming handlers,
essentially nesting chains. This makes it possible to construct one
chain up until the pivot handler, and then reuse that same chain in one
usecase that takes the response and renders HTML, and in another that
renders JSON.
To construct a chain, you need to pass an enumerable of so called
handler objects to Substation::Chain.new
. Handlers must support two
methods:
#call(<Substation::Request, Substation::Response>) => Substation::Response
#result(Substation::Response) => <Substation::Request, Substation::Response>
Incoming handlers
All steps required before processing the action will potentially
produce a new, altered, Substation::Request
. Therefore, the object
passed to #call
must be an instance of Substation::Request
.
Since #call
must return a Substation::Response
(because the chain
would halt and return that response in case calling its #success?
method would return false
), we also need to implement #result
and have it return a Substation::Request
instance that can be passed
on to the next handler.
The contract for incoming handlers therefore is:
#call(Substation::Request) => Substation::Response
#result(Substation::Response) => Substation::Request
By including the Substation::Chain::Incoming
module into your handler
class, you'll get the following for free:
def result(response)
Request.new(response.env, response.output)
end
This shows that an incoming handler can alter the incoming request in any
way that it wants to, as long as it returns the new request input data in
Substation::Response#output
returned from #call
.
The pivot handler
Pivot is just another fancy name for the action in the context of a
chain. It's also the point where all subsequent handlers have to further
process the Substation::Response
returned from invoking the action.
The contract for the pivot handler therefore is:
#call(Substation::Request) => Substation::Response
#result(Substation::Response) => Substation::Response
By including the Substation::Chain::Pivot
module into your handler
class, you'll get the following for free:
def result(response)
response
end
This reflects the fact that a pivot handler (since it's the one actually producing the "raw" response, returns it unaltered.
Outgoing handlers
All steps required after processing the action will potentially
produce a new, altered, Substation::Response
instance to be returned.
Therefore the object passed to #call
must be an instance of
Substation::Response
. Since subsequent outgoing handlers might further
process the response, #result
must be implemented so that it returns a
Substation::Response
object that can be passed on to the next handler.
The contract for outgoing handlers therefore is:
#call(Substation::Response) => Substation::Response
#result(Substation::Response) => Substation::Response
By including the Substation::Chain::Outgoing
module into your handler
class, you'll get the following for free:
def result(response)
response
end
This shows that an outgoing handler's #call
can do anything with
the Substation::Response#output
it received, as long as it makes
sure to return a new response with the new output properly set.
Example
substation-demo implements a
simple web application using Substation::Chain
.
The demo implements a few of the above mentioned incoming handlers for
- Sanitization using ducktrap
- Validation using vanguard
and some simple outgoing handlers for
- Wrapping response output in a presenter
- Serializing response output to JSON
The handlers are called processors in that app, and encapsulate the actual handler performing the job. That's a common pattern, because you typically will have to adapt to the interface your actual handlers provide.
Have a look at the base actions that are then used to either produce HTML or JSON.
Finally it's all hooked up behind a few sinatra routes
Credits
Contributing
See CONTRIBUTING.md for details.
Copyright
Copyright © 2013 Martin Gamsjaeger (snusnu). See LICENSE for details.