Granite Framework¶
Granite is an architecture for business actions in Rails applications, combining user interaction (attributes and validations), context (preconditions), and permissions (authorization policies).
What problems does Granite solve¶
Granite employs patterns to increase productivity in developing growing applications. Instead of bloating the controller and model, business logic is placed in the app/actions
directory.
These atomic actions process data and execute arbitrary operations in response to user requests or programmatically, such as by a background worker or another action.
Business actions¶
The fundamental concept of Granite is the business action, which can be initiated with a simple execute_perform!
method.
Hello World¶
In essence, a business action is an ActiveModel-like class (form object) designed to execute a sequence of commands. The basic business action takes the following form:
class Action < Granite::Action
private def execute_perform!(*)
puts 'Hello World'
end
end
There are 2 ways to execute a recently defined business action: #perform!
, or try_perform!
:
-
perform!
raises an exception when encountering errors. -
try_perform!
is comparable toperform!
but doesn't execute the action if preconditions are not met.
Note: Business actions can currently also be executed with #perform
method. It behaves the same as try_perform!
, is deprecated and will be removed in next major version.
Transactions¶
To ensure proper data management, each action execution is enclosed in a DB transaction using ActiveRecord::Base.transaction(requires_new: true)
.
pry(main)> Action.new.perform! # the same for `try_perform!`
(0.3ms) BEGIN
Hello World
(0.1ms) COMMIT
=> true
You can explicitly use Granite::Action.transaction
and encapsulate any logic within a transaction:
Granite::Action.transaction do
some_other_logic
Action.new.perform!
AnotherAction.new.perform!
end
Callbacks¶
after_initialize
¶
This callback is triggered after an action has been initialized.
class Action < Granite::Action
attribute :name, String
after_initialize do
self.name = 'Default'
end
# OR
# after_initialize :method_to_trigger
end
Action.new.name
# => 'Default'
after_commit
¶
This callback is triggered after DB transaction has been committed.
class Action < Granite::Action
...
after_commit do
# any logic that relies on action results being in the database,
# such as scheduling jobs
puts 'after_commit triggered'
end
# OR
# after_commit :method_to_trigger
end
pry(main)> Action.new.perform!
(0.3ms) BEGIN
Hello World
(0.1ms) COMMIT
after_commit triggered
=> true
before and after execute_perform
¶
class Action < Granite::Action
...
set_callback(:execute_perform, :before) do
puts 'before execute_perform'
end
set_callback(:execute_perform, :after, :after_execute_perform)
def after_execute_perform
puts 'after execute_perform'
end
end
pry(main)> Action.new.perform!
(0.3ms) BEGIN
before execute_perform
Hello World
after execute_perform
(0.1ms) COMMIT
=> true
Context and performer¶
Each business action has a context, represented by a hash that can be assigned using the .with
class method before the business action is initialized. The context is typically used to pass the performer of the action, which is so common that specific methods are defined to access and set the performer
.
action = MyAction.with(performer: Admin.first).new(params)
action.ctx #=> #<Granite::ContextProxy::Data performer: Admin.first>
action.performer #=> Admin.first
action = MyAction.as(Admin.first).new(params)
action.ctx #=> #<Granite::ContextProxy::Data performer: Admin.first>
action.performer #=> Admin.first
If your application requires additional attributes in the context, you can override the BaseAction.with
and BaseProjector.with
methods.
module GraniteContext
class Data < Granite::ContextProxy::Data
def initialize(performer: nil, custom: false)
super(performer: performer)
@custom = custom
end
end
def with(data)
Granite::ContextProxy::Proxy.new(self, GraniteContext::Data.wrap(data))
end
end
BaseAction.extend GraniteContext
BaseProjector.extend GraniteContext
BaseAction.with(performer: performer, custom: true)
Attributes¶
The next step involves defining action attributes, which come in various types provided by the granite-form
gem:
class Action < Granite::Action
attribute :name, String
collection :ids, Integer
private def execute_perform!(*)
puts "Hello #{name}! We have the following ids: #{ids}"
end
end
For comprehensive information on the available types and usage examples, please refer to the Granite Form documentation.
The behavior of the attributes is similar to that of Granite::Form
objects, with the exception of represents
.
Representing¶
With Granite Form objects, when a model attribute is exposed via represents
and the Active Record object changes, the exposed attribute is immediately updated.
In contrast, Granite actions use assign_data
to update the represented attribute.
Assigning the data¶
assign_data
can be used to set blocks and methods that are invoked before the business action is validated. In practice, it can be implemented as follows:
class CreateBook < Granite::Action
attribute :name, String
attribute :year, Integer
represents :author, of: :book
assign_data :set_name
assign_data do
book.year = year
end
private def set_name
book.name = name
end
end
In this example, before the business action is validated, Granite will invoke the assign_data
callbacks and set the book's author, name, and year (in that order).
Associations¶
Granite actions can also define several associations:
class CreateBook < Granite::Action
attribute :name, String
references_one :author
embeds_many :reviews
end
For comprehensive information on the available associations and usage examples, please refer to the Granite Form documentation.
Nested actions¶
Some business actions call other actions as part of their own execution. In such cases, we need to define a memoizable method that returns an instance of the sub-action:
memoize def subaction
MySubactionClass.new
end
Sub-actions validate their data and check preconditions when performed. However, it is not recommended to rely on this behavior. It is better to validate the sub-action when the main action is validated and check the preconditions of the sub-action when the preconditions of the main action are checked. For this, we use:
precondition embedded: :subaction
validates :subaction, nested: true
Subject¶
The definition of the subject does three things:
-
Defines a
references_one
association. -
Aliases its methods to common names (
subject
andsubject_id
) -
Modifies the action initializer to provide the ability to pass the subject as the first argument and restricts subject-less action initialization.
Let's take a look to an example below:
class Action < Granite::Action
subject :user
private def execute_perform!(*); end
end
pry(main)> Action.new
=> ArgumentError
pry(main)> Action.new(User.first)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
pry(main)> Action.new(1)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
pry(main)> Action.new(user: User.first)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
pry(main)> Action.new(subject: User.first)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
pry(main)> Action.new(user_id: 1)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
pry(main)> Action.new(id: 1)
=> #<Action user: #<ReferencesOne #<User id: 1...>, user_id: 1>
Notice that the method #user
has been assigned to the alias #subject
, and #user_id
to #id
. Furthermore, a subject
call takes any combination of references_one
possible options.
Policies, preconditions, and validations¶
When deciding how to structure policies, preconditions, and validations, there are some simple rules to follow:
-
If the condition depends on any user-provided attribute values except for the subject, it is a validation.
-
If the condition depends on the subject or any value that depends on the subject, it is a precondition.
-
Otherwise, if it is related to the performer, choose a policy.
Policies¶
Policies are used to define restrictions on the performer of an action. The allow_if
method can be used to specify a condition that must be met for the action to be allowed.
For example, the following code specifies that an action can only be performed if the performer is present:
class Action < Granite::Action
allow_if { performer.present? }
allow_self # equal to allow_if { performer == subject }
end
There is also an allow_self
method that is equivalent to allow_if { performer == subject }
, which allows an action to be performed by the subject itself.
Granite policies also support strategies:
-
By default, the
AnyStrategy
is used, which allows an action to be performed if any policy allows it. -
Other built-in strategies include
AlwaysAllowStrategy
, which allows all actions, -
And
RequiredPerformerStrategy
, which requires that a performer be present for all actions.
You can also write your own custom policy strategy.
To use a custom policy strategy, you can set the _policies_strategy
class variable to the desired strategy, like so:
class Action < Granite::Action
self._policies_strategy = MyCustomStrategy
end
Preconditions¶
Preconditions are used for subject-related pre-validation and work similarly to validations with blocks. However, instead of using errors.add
, the decline_with
method is preferred.
For example, you can use a precondition to check if the subject is active before performing an action:
precondition do
decline_with(:inactive) unless subject.active?
end
If you have sub-actions that are performed within your main action, you can easily check their preconditions by embedding them:
precondition embedded: :my_custom_action
You can specify conditions for when the precondition block should be executed using the :if
and unless
statements:
precondition if: -> { subject.active? } do
decline_with(:too_young) if subject.age < 30
end
Preconditions as objects¶
The precondition
method can also accept a class that inherits from Granite::Action::Precondition
. When defining a precondition this way, you can pass additional parameters to the precondition object, making it more reusable.
The precondition method with a class argument supports the same options (:if
and :unless
) as defining a precondition as a block:
class AgeCheck < Granite::Action::Precondition
description 'Must be old enough'
def call(**)
decline_with(:too_young) if subject.age < 30
end
end
This precondition can be used like this:
precondition AgeCheck, if: -> { subject.active? }
Validations¶
Granite supports using any of the validations provided by Active Model.
Context validations¶
Granite supports and encourages the use of context validations, which can be specified using the on:
key with any validation to declare the context in which the validation should be executed. This means that these validations will only be triggered when the provided context is explicitly specified.
To specify a context when using the built-in ActiveModel methods valid?
and invalid?
, simply provide the context as the first argument. When using perform!
, or try_perform!
, pass the name of the context as a keyword argument context:
.
Context validations should be used when different validation behavior is required in different scenarios (e.g., by a staff member and a non-staff user). For example, consider a simplified business action for updating a user's portfolio:
class BA::User::UpdatePortfolio < Granite::Action
subject :user
represents :full_name, of: :subject
validates :full_name, presence: true, on: :user
private def execute_perform!(*)
# ...
end
end
By default, running this business action using perform!
won't require the full_name
attribute to be present. However, if you want to enforce this validation, you can add a context argument to the perform call: perform!(context: :user)
.
Exception handling¶
Granite provides a built-in mechanism for exception handling, similar to the rescue_from
method used in Action Controller. You can register handlers for any exception type using the handle_exception
method.
class Action < Granite::Action
handle_exception ThirdPartyLib::APIError do |error|
decline_with(:third_party_lib_failed)
end
private def execute_perform!(*)
ThirdPartyLib.api_call
end
end
ThirdPartyLib::APIError
is caught, and the handler block adds an error to the action object with the message :third_party_lib_failed
. It's important to add errors to the action object because, when a handled exception is raised, Granite::Action::ValidationError
is raised with the same backtrace as the original error.
Dependency Injection¶
Dependency Injection is a programming technique that allows you to remove hard-coded dependencies from your code and instead provide them externally. Granite's default attribute assignment mechanism may not always be suitable for this, but you can use custom initializers to achieve DI:
class Action < Granite::Action
attribute :name, String
private attr_reader :my_dep
def initialize(*args, my_dep: Foo.new, **kwargs, &block)
@my_dep = my_dep
super(*args, **kwargs, &block)
end
end
Action.new(name: "Jane") # uses default value for `my_dep'
Action.new(name: "Jane", my_dep: Bar.new) # uses custom value for `my_dep'
In the example code, my_dep
is a dependency that is provided to the action through the initialize method, rather than being hardcoded in the attribute definition. The my_dep
dependency is set to a default value of Foo.new
, but it can be overridden by passing a my_dep
keyword argument to the constructor.
By using this technique, you can easily provide dependencies to your Granite actions from an external source, making your code more modular and testable.
I18n¶
When using the I18n feature, if an identifier is prefixed with a dot (t('.foobar')
), translations will be looked up in the following order:
granite_action.#{granite_action_name}.foobar
granite_action.granite/action.foobar
foobar
It's important to note that the lookup rules are different when performing an I18n lookup within a projector context. See the section on I18n lookup inside a projector context for more information.
Generators¶
You can use the granite generator to create a starting point for your action. To do so, pass the name and path of your action as the first argument using the following syntax:
rails g granite SUBJECT/ACTION [PROJECTOR]
If you want to generate a collection action where the subject is not known at initialization, use the -C
or --collection
option.
You can also specify the projector name as a second argument when using the generator.
Here are some examples of using the rails g granite command:
-
rails g granite user/create
This command generates a new action called "create" for the "user"
subject
. It creates three files:apq/actions/ba/user/create.rb
,apq/actions/ba/user/business_action.rb
, andspec/apq/actions/ba/user/create_spec.rb
. -
rails g granite user/create -C
Adding the
-C
option generates a collection action where the subject is not known at initialization. This command generates two files:apq/actions/ba/user/create.rb
andspec/apq/actions/ba/user/create_spec.rb
. -
rails g granite user/create simple
Adding a second argument, such as "simple" specifies the name of the projector to use. This command generates a new directory called simple within the
apq/actions/ba/user/create directory
, as well as the same files as the first example:apq/actions/ba/user/create.rb
,apq/actions/ba/user/business_action.rb
, andspec/apq/actions/ba/user/create_spec.rb
.
Conclusion¶
We hope this introduction to Granite has piqued your interest and given you a glimpse into the power and simplicity of this framework. Give it a try and see how Granite can streamline your business logic and take your Ruby on Rails applications to the next level.