Light Ruby gem with everything you need in a "The Clean Architecture" use case scenario.
Many thanks to @tdantas and @junhanamaki and a shout-out to @joaquimadraz and his compel ruby validations gem.
1) Installation
Add this line to your application's Gemfile:
gem 'rest_my_case'
And then execute:
$ bundle
Or install it yourself as:
$ gem install rest_my_case
2) Ideology
Your business logic goes into separated use cases...
class FindPost < RestMyCase::Base
def perform
context.post = Post.find(context.id)
end
end
class ArchivePost < RestMyCase::Base
depends FindPost
def perform
context.post.status = 'archived'
context.result = context.post.save
end
end
web framework should only act as a bridge between the user and your business logic.
class PostsController < ApplicationController
def archive
@context = ArchivePost.perform id: params[:id]
if @context.result
redirect_to @context.post
else
render "archive" #view post.errors
end
end
end
Ideally your business logic should be a ruby gem that can be tested independently from your framework.
Checkout this step by step tutorial: (WIP) on how to isolate your code into a ruby gem and connect it to your rails or sinatra api.
3) Basic usage
class BuildPost < RestMyCase::Base
def perform
puts context.id
puts context.post_attributes
end
end
irb> params = { id: 1, post: { title: 'my first post' } }
irb> context = BuildPost.perform id: params[:id], post_attributes: params[:post]
1
{:title=>"my first post"}
irb> context.id
1
The Hash passed down to BuildPost.perform will be available through an instance method called #context that will return an OpenStruct object initialized with that Hash (see more in section 7).
Executing BuildPost.perform will instantiate your use case and all of its dependencies, build a context with the contents of params, run your use case (and its dependencies) with that context and return it at the end.
3.1) Normal usage
Organize your use cases by single responsibilities and establish your use case flow through "dependencies".
class FindPost < RestMyCase::Base
def perform
context.post = Post.find(context.id)
end
end
class BuildPost < RestMyCase::Base
depends FindPost
def perform
context.post.assign_attributes context.post_attributes
end
end
The class method .depends will make BuildPost dependent of FindPost which means that calling BuildPost.perform will run FindPost#perform first and BuildPost#perform last. Both use cases will share the same context.
irb> params = { id: 1, post: { title: 'my first post' } }
irb> context = BuildPost.perform id: params[:id], post_attributes: params[:post]
irb> context.post.name
"my first post"
4) Lifecycle
4.1) Waiting to be implemented methods
Methods: #setup, #perform, #rollback and #final
class UseCase1 < RestMyCase::Base
def setup
puts 'UseCase1#setup'
end
def perform
puts 'UseCase1#perform'
error if context.should_fail
end
def rollback
puts 'UseCase1#rollback'
end
def final
puts 'UseCase1#final'
end
end
irb> UseCase1.perform #will print
"UseCase1#setup"
"UseCase1#perform"
"UseCase1#final"
Method #rollback will be called after #perform and before #final if #error is invoked inside a #setup of #perform (see more in section 5).
irb> UseCase1.perform(should_fail: true) #will print
"UseCase1#setup"
"UseCase1#perform"
"UseCase1#rollback"
"UseCase1#final"
Method #final will run last and always, no matter how many times #error was called.
4.2) Dependencies
Default behaviour is to run your dependencies (#setup, #perform, #rollback and #final) methods, first.
class UseCase2 < RestMyCase::Base
def perform
puts 'UseCase2#perform'
end
end
class UseCase3 < RestMyCase::Base
def perform
puts 'UseCase3#perform'
end
end
class UseCase1 < RestMyCase::Base
depends UseCase2,
UseCase3
def perform
puts 'UseCase1#perform'
end
end
irb> UseCase1.perform #will print
"UseCase2#perform"
"UseCase3#perform"
"UseCase1#perform"
See section 8 for more examples.
5) Flow control methods
Methods | Behaviour |
---|---|
#abort | Stops other remaining use cases from running and triggers #rollback on already executed use cases (in reverse order). |
#skip | Will prevent #perform (of the use case that called #skip) from running and will not stop other use cases from running nor trigger a #rollback (only works by being used inside a #setup method). |
#error(error_message = '') | Will do the same as #abort but will also push error_message to #context.errors array so you can track down what happen in what use case (see more in section 7). |
*#invoke(use_case_classes) | Does the same as the class method .depends but executes the use cases on demand. Shares the context to them and if they call #abort on their side, the use case that invoked will also abort. |
#skip, #abort, #error and #invoke have a "bang!" version that will raise a controlled exception, preventing the remaining lines of code from running.
class UseCase1 < RestMyCase::Base
def perform
puts 'before #error!'
error!
puts 'after #error!'
end
end
irb> UseCase1.perform #will print only
"before #error!"
6) Configuration methods
Methods | Behaviour |
---|---|
*.depends(use_case_classes) | Adds the use_case_classes array to the use case's dependencies list, that will be executed by order before the actual use case (see more in section 4). |
*.required_context(attributes) | WIP. |
*.context_reader(methods) | Defines getter methods that return context.send method, to help reduce the context.method boilerplate. |
*.context_writer(methods) | Defines setter methods that set context.send "#{method}=", value, to help reduce the context.method = value boilerplate. |
*.context_accessor(methods) | Calls both .context_reader and .context_writer methods. |
.silence_dependencies_abort= | If false once a dependency calls #abort(!) the next in line dependencies will not be called (and #rollback will be called in reverse order) but if true all dependencies will run no matter how many times #abort(!) was called (usefull when you want to run multiple validations (see more in section 9). |
7) #context methods
The returning object is an instance of RestMyCase::Context::Base class that inherits from OpenStruct and implements the following methods:
Methods | Behaviour |
---|---|
#attributes | Alias to #marshal_dump, returns all of the context's stored data. |
#to_hash | Serializes and unserializes #attributes turning any existing ruby objects into serialized strings. |
*#values_at(keys) | Short for #attributes.values_at(*keys), returns an array with correspondent values for each key. |
#valid? | Checks if #errors is empty |
#ok? | Alias to #valid? |
#success? | Alias to #ok? |
#errors | Array that gets 'pushed' with { message: error_message, class_name: UseCase.class.name } (or error_message itself if error_message already a Hash) every time UseCase#error(error_message) is called. |
If defined?(ActiveModel) is true, ActiveModel::Serialization will be included and in turn methods like #to_json(options = {}) and #serializable_hash(options = nil) will become available.
8) Examples
If UseCase1 depends on UseCase2 and UseCase3 in that respective order.
Running UseCase1.perform will pass down the context to each use case in the following manner:
8.1) Given that no use case called the method(s) #error(!)
UseCase2#setup -> UseCase3#setup -> UseCase1#setup ->
UseCase2#perform -> UseCase3#perform -> UseCase1#perform ->
UseCase2#final -> UseCase3#final -> UseCase1#final
8.2) Given that UseCase3#setup calls #skip(!)
UseCase2#setup -> UseCase3#setup -> UseCase1#setup ->
UseCase2#perform -> UseCase1#perform ->
UseCase2#final -> UseCase3#final -> UseCase1#final
8.3) Given that UseCase3#setup calls #error(!)
UseCase2#setup -> UseCase3#setup ->
UseCase3#rollback -> UseCase2#rollback ->
UseCase2#final -> UseCase3#final -> UseCase1#final
8.4) Given that UseCase3#perform calls #error(!)
UseCase2#setup -> UseCase3#setup -> UseCase1#setup ->
UseCase2#perform -> UseCase3#perform ->
UseCase3#rollback -> UseCase2#rollback ->
UseCase2#final -> UseCase3#final -> UseCase1#final
9) RestMyCase::Validator class
WIP
10) RestMyCase::Status module
class UseCase1 < RestMyCase::Base
include RestMyCase::Status
end
Adds following methods:
Methods | Behaviour |
---|---|
#context | Returns an instance of RestMyCase::Context::Status |
#status | Returns context.status (see more in section 10.1) |
#failure(status, error_message = nil) | WIP |
#failure! is also present and does the same as the other flow control "bang!" methods (see section 5).
10.1) RestMyCase::Context::Status
WIP
11) RestMyCase::HttpStatus module (for seamless API integration)
class UseCase1 < RestMyCase::Base
include RestMyCase::HttpStatus
end
Includes the module RestMyCase::Status and #context becomes an instance of RestMyCase::Context::HttpStatus.
11.1) RestMyCase::Context::HttpStatus
WIP