Rails Use Case gem
This gem introduces UseCases and Services to Rails which allow you to keep your Models and Controllers slim.
Read more: https://dev.to/phortx/pimp-your-rails-application-32d0
Clean Architecture suggests to put the business logic in UseCases which are classes, that contain reusable high level business logic which otherwise would normally be located in the controller. UseCases are easy to read, clean, reusable, testable and extendable. Examples are: Place an item in the cart, create a new user or delete a comment.
The purpose of a Service is to contain low level non-domain code like communication with a API, generating an export, upload via FTP or generating a PDF.
Setup
gem 'rails_use_case'
Use Case
The purpose of a UseCase is to contain reusable high level business logic which would normally be
located in the controller. It defines a process via step
definitions. A UseCase takes params
and has a outcome, which is either successful or failed. It doesn't have a configuration file and doesn't
log anything. Examples are: Place an item in the cart, create a new user or delete a comment.
The params should always passed as hash and are automatically assigned to instance variables.
Use Cases should be placed in the app/use_cases/
directory and the file and class name should start with a verb like create_blog_post.rb
.
Steps
Steps are executed in the defined order. Only when a step succeeds (returns true) the next step will
be executed. Steps can be skipped via if
and unless
.
The step either provides the name of a method within the use case or a block. When a block is given, it will be executed. Otherwise the framework will try to call a method with the given name.
You can also have named inline steps: step :foo, do: -> { ... }
which is
equivalent to step { ... }
but with a given name. An existing method foo
will not be called in this case but rather the block be executed.
There are also two special steps: success
and failure
. Both will end the
step chain immediately. success
will end the use case successfully (like there
would be no more steps). And failure
respectively will end the use case with a
error. You should pass the error message and/or code via message:
and/or
code:
options.
Failing
A UseCase fails when a step returns a falsy value or raises an exception.
For even better error handling, you should let a UseCase fail via the shortcut
fail!()
which actually just raised an UseCase::Error
but you can provide
some additional information. This way you can provide a human readable message
with error details and additionally you can pass an error code as symbol, which
allows the calling code to do error handling:
fail!(message: 'You have called this wrong. Shame on you!', code: :missing_information)
.
The error_code can also passed as first argument to the failure
step definition.
Record
The UseCase should assign the main record to @record
. Calling save!
without
argument will try to save that record or raises an exception. Also the
@record
will automatically passed into the outcome.
You can either set the @record
manually or via the record
method. This comes
in two flavors:
Either passing the name of a param as symbol. Let's assume the UseCase
has a parameter called user
(defined via attr_accessor
), then you can assign
the user to @record
by adding record :user
to your use case.
The alternative way is to pass a block which returns the value for @record
like in the example UseCase below.
Example UseCase
class BlogPosts::Create < Rails::UseCase
attr_accessor :title, :content, :author, :skip_notifications, :publish
validates :title, presence: true
validates :content, presence: true
validates :author, presence: true
record { BlogPost.new }
failure :access_denied, message: 'No permission', unless: -> { author.can_publish_blog_posts? }
step :assign_attributes
step :save!
succeed unless: -> { publish }
step :publish, do: -> { record.publish! }
step :notify_subscribers, unless: -> { skip_notifications }
private def assign_attributes
@record.assign_attributes(
title: title,
content: content,
created_by: author,
type: :default
)
end
private def notify_subscribers
# ... send some mails ...
end
end
Example usage of that UseCase:
result = BlogPosts::Create.perform(
title: 'Super Awesome Stuff!',
content: 'Lorem Ipsum Dolor Sit Amet',
author: current_user,
skip_notifications: false
)
puts result.inspect
=> {
success: false, # Wether the UseCase ended successfully
record: BlogPost(...) # The value assigned to @record
errors: [], # List of validation errors
exception: Rails::UseCase::Error(...), # The exception raised by the UseCase
message: "...", # Error message
error_code: :save_failed # Error Code
}
- You can check whether a UseCase was successful via
result.success?
. - You can access the value of
@record
viaresult.record
. - You can stop the UseCase process with a error message via throwing
Rails::UseCase::Error
exception.
Working with the result
The perform
method of a UseCase returns an outcome object which contains a
code
field with the error code or :success
otherwise. This comes handy when
using in controller actions for example and is a great way to delegate the
business logic part of a controller action to the respective UseCase.
Everything the controller has to do, is to setup the params and dispatch the
result.
Given the Example above, here is the same call within a controller action with an case statement.
class BlogPostsController < ApplicationController
# ...
def create
parameters = {
title: params[:post][:title],
content: params[:post][:content],
publish: params[:post][:publish],
author: current_user
}
outcome = BlogPosts::Create.perform(parameters).code
case outcome.code
when :success then redirect_to(outcome.record)
when :access_denied then render(:new, flash: { error: "Access Denied!" })
when :foo then redirect_to('/')
else render(:new, flash: { error: outcome.message })
end
end
# ...
end
However this is not rails specific and can be used in any context.
Behavior
A behavior is simply a module that contains methods to share logic between use cases and to keep them DRY.
To use a behavior in a use case, use the with
directive, like with BlogPosts
.
Behaviors should be placed in the app/behaviors/
directory and the file and module name should named in a way it can be prefixed with with
, like blog_posts.rb
(with blog posts).
Example Behavior
Definition:
module BlogPosts
def notify_subscribers
# ... send some mails ...
end
end
Usage:
class CreateBlogPost < Rails::UseCase
with BlogPosts
# ...
step :build_post
step :save!
step :notify_subscribers, unless: -> { skip_notifications }
# ...
end
Service
The purpose of a Service is to contain low level non-domain code like communication with a API, generating an export, upload via FTP or generating a PDF. It takes params, has it's own configuration and writes a log file.
It comes with call style invocation: PDFGenerationService.(some, params)
Services should be placed in the app/services/
directory and the name should end with Service
like PDFGenerationService
or ReportUploadService
.
Example Service
class PDFGenerationService < Rails::Service
attr_reader :pdf_template, :values
# Constructor.
def initialize
super 'pdf_generation'
prepare
validate_libreoffice
end
# Entry point.
#
# @param [PdfTemplate] pdf_template PdfTemplate record.
# @param [Hash<String, String>] values Mapping of variables to their values.
#
# @returns [String] Path to PDF file.
def call(pdf_template, values = {})
@pdf_template = pdf_template
@values = prepare_variable_values(values)
write_odt_file
replace_variables
generate_pdf
@pdf_file_path
ensure
delete_tempfile
end
end
Configuration
The service tries to automatically load a configuration from config/services/[service_name].yml
which is available via the config
method.
Logging
Each service automatically logs to a separate log file log/services/[service_name].log
. You can write additional logs via logger.info(msg)
.
It's possible to force the services to log to STDOUT by setting the environment variable SERVICE_LOGGER_STDOUT
. This is useful for Heroku for example.
License
MIT