Skip to content

Commit

Permalink
Document steps DSL
Browse files Browse the repository at this point in the history
  • Loading branch information
pabloh committed Oct 24, 2017
1 parent 6c90a8a commit 85e36b1
Showing 1 changed file with 116 additions and 33 deletions.
149 changes: 116 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,20 @@ Pathway helps you separate your business logic from the rest of your application
The main concept Pathway relies upon to build domain logic modules is the operation, this important concept will be explained in detail in the following sections.


Pathway also aims to be easy to use, stay lightweight and modular, avoid unnecessary heavy dependencies, keep the core classes clean from monkey patching and help yielding an organized and uniform codebase.
Pathway also aims to be easy to use, stay lightweight and extensible (by the use of plugins), avoid unnecessary dependencies, keep the core classes clean from monkey patching and help yielding an organized and uniform codebase.

## Usage

### Core API and concepts

As mentioned earlier the operation is a crucial concept Pathway leverages upon. Operations not only structure your code (using steps as will be explained later) but also express meaningful business actions. Operations can be thought as use cases too: they represent an activity -to be perform by an actor interacting with the system- which should be understandable by anyone familiar with the business regardless of their technical expertise.


Operations should ideally don't contain any business rules but instead orchestrate and delegate to other more specific subsystems and services. The only logic present then should be glue code or any adaptations required to make interactions with the inner system layers possible.

#### Function object protocol (the `call` method)

Operations works as function objects, they are callable and hold no state, as such, any object that responds to `call` and returns a result object can be a valid operation and that's the minimal protocol they needs to follow.
The result object must follow its own protocol as well (and a helper class is provided for that end) but we'll talk about that in a minute.
The result object must follow its protocol as well (and a helper class is provided for that end) but we'll talk about that in a minute.

Let's see an example:

Expand Down Expand Up @@ -73,10 +72,10 @@ Note first we are not inheriting from any class nor including any module. This w
Also, let's ignore the specifics about `Repository.create(...)`, we just need to know that is some backend service which can return a value.


We now provide a `call` method for our class. It will just check if the result is available and then wrap it into a successful `Result` object when is ok, or a failing one when is not.
And that's it, you can then call the operation object, check whether it was completed correctly with `success?` and get the resulting value.
We then define a `call` method for the class. It only checks if the result is available and then wrap it into a successful `Result` object when is ok, or a failing one when is not.
And that is all is needed, you can then call the operation object, check whether it was completed correctly with `success?` and get the resulting value.

By following this protocol, you will be able to uniformly apply the same pattern on every HTTP endpoint (or whatever means your app has to communicates with the outside world). The upper layer of the application is now offloading all the domain logic to the operation and only needs to focus on the data transmission details.
By following this protocol, you will be able to uniformly apply the same pattern on every HTTP endpoint (or whatever means your app has to communicates with the outside world). The upper layer of the application will offload all the domain logic to the operation and only will need to focus on the HTTP transmission details.

Maintaining always the same operation protocol will also be very useful when composing them.

Expand All @@ -88,9 +87,7 @@ As should be evident by now an operation should always return either a successfu
As we seen before, by querying `success?` on the result we can see if the operation we just ran went well, or you can also call to `failure?` for a negated version.

The actual result value produced by the operation is accessible at the `value` method and the error description (if there's any) at `error` when the operation fails.

To return wrapped values or errors from your operation you can must call to `Pathway::Result.success(value)` or `Pathway::Result.failure(error)`.

To return wrapped values or errors from your operation you must call to `Pathway::Result.success(value)` or `Pathway::Result.failure(error)`.

It is worth mentioning that when you inherit from `Pathway::Operation` you'll have helper methods at your disposal to create result objects easier, for instance the previous section's example could be written as follows:

Expand All @@ -113,12 +110,12 @@ It's use is completely optional, but provides you with a basic schema to communi
```ruby
class CreateNugget < Pathway::Operation
def call(input)
result = Form.call(input)
validation = Form.call(input)

if result.valid?
success(Nugget.new(result.values))
if validation.ok?
success(Nugget.create(validation.values))
else
error(type: :validation, message: 'Invalid input', details: result.errors)
error(type: :validation, message: 'Invalid input', details: validation.errors)
end
end
end
Expand All @@ -145,22 +142,22 @@ It was previously mentioned that operations should work like functions, that is,

Context data can be thought as 'request data' on an HTTP endpoint, values that aren't global but won't change during the executing of the request. Examples of this kind of data are the current user, the current device, a CSRF token, other configuration parameters, etc. You will want to pass this values on initialization, and probably pass them along to other operations down the line.

You must define your initializer to accept a `Hash` with this values, which is what every operation is expected to do, but as before, when inheriting from `Operation` you have the helper method `context` handy to make it easier for you:
You must define your initializer to accept a `Hash` with this values, which is what every operation is expected to do, but as before, when inheriting from `Operation` you have the helper class method `context` handy to make it easier for you:

```ruby
class CreateNugget < Pathway::Operation
context :current_user, notify: false

def call(input)
result = Form.call(input)
validation = Form.call(input)

if result.valid?
nugget = Nugget.new(owner: @current_user, **result.values)
if validation.valid?
nugget = Nugget.create(owner: current_user, **validation.values)

Notifier.notify(:new_nugget, nugget) if @notify
success(nugget)
else
error(type: :validation, message: 'Invalid input', details: result.errors)
error(type: :validation, message: 'Invalid input', details: validation.errors)
end
end
end
Expand All @@ -174,50 +171,136 @@ On the example above `context` is defining `:current_user` as a mandatory argume

Both of these parameters are available through accessors (and instance variables) inside the operation. Also there is a `context` private method you use to get all the initialization values as a frozen hash, in order to pass then along easily.

#### Alternative invocation syntax

If you don't care about keeping the operation instance around you can execute the operation directly on the class. To do so, use `call` with the initialization context first and then the remaining parameters:

```ruby
user = User.first(session[:current_user_id])
context = { current_user: user }

CreateNugget.call(context, params[:nugget]) # Using 'call' on the class
```

Also you have Ruby's alternative syntax to invoke the `call` method: `CreateNugget.(context, params[:nugget])`. On any case you'll get the operation result like when invoking `call` on the operation's instance.

Mind you that a context must always be provided for this syntax, if you don't need any initialization use an empty hash.

There's also third way to execute an operation, made available through a plugin, and will be explained later.

#### Steps

Finally the steps, these are the heart of the `Operation` class and the main reason you will want to inherit your own classes from `Pathway::Operation`.

##### Operation execution state
So far we know that every operation needs to implement a `call` method and return a valid result object, `pathway` provides another option: the `process` block DSL, this method will define `call` behind the scenes for us, while also providing a way to define a business oriented set of steps to describe our operation's behavior.

Every step should be cohesive and focused on a single responsibly, ideally by offloading work to other subsystems. Designing steps this way is the developer's responsibility, but is made much simpler by the use of custom steps provided by plugins as we'll see later.

##### Process DSL

Lets start by showing some actual code:

```ruby
# ...
# Inside an operation class body...
process do
step :authorize
step :validate
set :create_nugget, to: :nugget
step :notify
end
# ...
```

As it may be evident by now, when using the steps DSL, every step method receives a structure representing the current execution state. This structure is similar to a `Hash` and responds to its key methods (`:[]`, `:[]=`, `:fetch`, `:store` and `:include?`). It also contains the value to be returned when the operation succeed (at the `:value` attribute by default and also available through the `result` method).
To define your `call` method using the DSL just call to `process` and pass a block, inside it the DSL will be available.
Each `step` (or `set`) call is referring to a method inside the operation class, superclasses or available through a plugin, that will be eventually invoked.
All of the steps constitute the operation use case and must follow a series of conventions in order to carry the process state along the execution process.

When an operation is executed, before running the first step, an initial state is created by coping all the values from the initialization context. Note that these values can be replaced on later steps but it won't mutate the context object itself since is always frozen.
When you run the `call` method, the auto-generated code will save the provided argument at the `input` key within the execution state. Subsequent steps will receive this state and will be able to update it, setting the result value or communicating with the next steps on the execution path.

Each step (as the operation as whole) can succeed of fail, when the latter happens execution is halted, and the operation `call` method returns immediately.
To signal a failure you must return with `failure` or `error` in the same fashion as when defining `call` directly.

A state object can be easily splatted on method definitions, in the same fashion as a `Hash`, in order to cherry pick the attributes we are interested for the current step:
If you return `success(...)` or anything that's not a failure the execution carries on but the value is ignored. If you want to save the result value, you must use `set` instead of `step` at the process block, that will save your wrapped value, into the key provided at `to:`.
Also non-failure return values inside steps are automatically wrapped so you can use `success` for clarity sake but it's optional.
If you omit the `to:` keyword argument when defining a `set` step, the result key value will be used by default, but we'll explain more on that later.

##### Operation execution state

In order to operate with the execution state, every step method receives a structure representing the current state. This structure is similar to a `Hash` and responds to its key methods (`:[]`, `:[]=`, `:fetch`, `:store` and `:include?`).

When an operation is executed, before running the first step, an initial state is created by coping all the values from the initialization context (and also including `input`).
Note that these values can be replaced on later steps but it won't mutate the context object itself since is always frozen.

A state object can be splatted on method definition in the same fashion as a `Hash`, allowing to cherry pick the attributes we are interested for a given step:

```ruby
# ...
# This step only takes the values it needs to do its and doesn't change the state.
# This step only takes the values it needs and doesn't change the state.
def send_emails(user:, report:, **)
ReportMailer.send_report(user.email, report)
end
# ...
```

#### Alternative invocation syntax
Note the empty double splat at the end of the parameter list, this Ruby-ism means: grab the mentioned keys and ignore all the rest. If you omit it when you have outstanding keys Ruby's `Hash` destructing will fail.

If you don't care about keeping the operation instance around you can execute the operation directly on the class. To do so, use `call` with the initialization context first and then the remaining parameters:
##### Successful operation result

On each step you can access or change the operation result for a successful execution.
The value will be stored at one of the attributes within the state.
By default the state `:value` key will hold the resulting value, but if you prefer to use another name you can specify it through the `result_at` operation class method.

##### Full example

Let's now go through an operation with steps example:

```ruby
user = User.first(session[:current_user_id])
context = { current_user: user }
class CreateNugget < Pathway::Operation
context :current_user

CreateNugget.call(context, params[:nugget]) # Using 'call' on the class
```
process do
step :authorize
step :validate
set :create_nugget
step :notify
end

Also you have Ruby's alternative syntax to invoke the `call` method: `CreateNugget.(context, params[:nugget])`. On any case you'll get the operation result like when invoking `call` on the operation's instance.
result_at :nugget

Mind you that a context must always be provided for this syntax, if you don't need any initialization use an empty hash.
def authorize(**)
unless current_user.can? :create, Nugget
error(:forbidden)
end
end

There's also third way to execute an operation, made available through a plugin, and will be explained later.
def validate(state)
validation = NuggetForm.call(state[:input])

if validation.ok?
state[:params] = validation.values
else
error(type: :validation, details: validation.errors)
end
end

def create_nugget(:params,**)
Nugget.create(owner: current_user, **params)
def

def notify(:nugget, **)
Notifier.notify(:new_nugget, nugget)
else
end
```

On a final note, you may be thinking that the code could be bit less verbose; also, shouldn't very common stuff like validation or authorization be simpler to use?; and maybe, why specify the result key?, is possible you could infer it from the surrounding code. We will address all these issues on the next section by using plugins, `pathway`'s extension mechanism.

### Plugins

#### `DryValidation` plugin
#### `SequelModels` plugin
#### `SimpleAuth` plugin
#### `SequelModels` plugin
#### `Responder` plugin

### Plugin architecture
Expand Down

0 comments on commit 85e36b1

Please sign in to comment.