This Gem introduces an additional service layer for Rails: Operations. An operation is in most cases a business action or use case and may or may not involve one or multiple models. Rails Ops allows creating more modular applications by splitting them up into their different operations. Each operation is specified in a single, testable class.
To achieve this goal, this Gem provides the following building blocks:
Various operation base classes for creating operations with a consistent interface and no boilerplate code.
A way of abstracting model classes for a specific business action.
rails > 4
- RailsOps only works with Rails applications, with the following Rails versions being tested in the CI:
- Rails 6.0.x
- Rails 6.1.x
- Rails 7.0.x
- Rails 7.1.x
- Rails 7.2.x
- Rails 8.0.x
- Additionally, the following Ruby versions are covered by our unit tests:
- 2.7.8
- 3.0.1
- 3.1.0
- 3.2.0
- 3.3.0
- 3.4.0
- Please see the unit test workflow for the combinations of the Rails & Ruby versions, as only compatible versions are tested with each other.
- Prior Rails and Ruby versions may be supported but they are not tested in the CI.
- Rails Ops' model operations require ActiveRecord but are database / adapter agnostic
Add the following to your Rails application's
Gemfile:gem'rails_ops'
Create an initializer file
config/initializers/rails_ops.rbwith the following contents:# Replace this with your authorization backend.require'rails_ops/authorization_backend/can_can_can.rb'RailsOps.configuredo |config| # Replace this with your authorization backend.config.authorization_backend='RailsOps::AuthorizationBackend::CanCanCan'end
Optional: If you want your operations to reside inside of
app/operationsand be scoped in theOperationsnamespace, create the directoryapp/operationsand add the following code inside of the previously created initializer (after theRailsOps.configureblock):# Remove the folder from the autoload pathsapp_operations="#{Rails.root}/app/operations"ActiveSupport::Dependencies.autoload_paths.delete(app_operations)# Define the Operations modulemoduleOperations;end# Add the folder to the autoloader, but namespacedloader=Rails.autoloaders.mainloader.push_dir(app_operations,namespace: Operations)# Add the folder to the watched directories (for re-loading in development)Rails.application.config.watchable_dirs.merge!({app_operations=>[:rb]})
Taken from this github issues comment.
Operations generally reside in
app/operationsand can be nested using various subdirectories. They're all inside of theOperationsnamespace.Operations operating on a specific model should generally be namespaced with the model's class name. So for instance, the operation
Createfor theUsermodel should generally live underapp/operations/user/create.rband therefore should be calledOperations::User::Create.Operations inheriting from other operations should generally be nested within their parent operation. See the next section for more details.
Operation classes should always be named after an action, such as
Create,MoveToPositionand so on. Do not name an operation something likeUserCreatororCreateUserOperation.
As explained in the previous section, operations should be namespaced properly.
Operations can either live within a module or within a class. In most cases,
operations are placed in the Operation module or rather one of its
sub-modules. If, in some special case, operations are nested, they can reside
inside of another operation class (but not inside of its file) as well.
When declaring an operation within a namespace,
Determine whether the namespace you're using is a module or a class. Make sure you don't accidentally redefine a module as a class or vice-versa.
If the operation resides within a module, make a module definition on the first line and the operation class on the second. Example:
moduleOperations::Frontend::NavigationclassDetermineActionsForStructureElement < RailsOps::Operation ... endend
If the operation resides within a class, use a single-line definition:
classOperations::User::Create::FromApi < Operations::User::Create ... end
Note that, when defining a namespace of which a segment is already known as a (model) class, you cannot just use the model classes name to refer to it:
moduleOperations::UserclassCreate < RailsOps::Operationdefperform# This DOES NOT work as `User` in this case refers to the module of# the same name defined on the first line of code.User.create(params)# This works as it takes an absolute namespace:
::User.create(params)endendendEvery single operation follows a few basic principles:
They inherit from {RailsOps::Operation}.
They are called using the
runorrun!methods.They are parameterized using a
paramshash (and nothing else).They define a protected
performmethod which actually executes the operation. This is usually overridden in each operation and called exclusively byrunorrun!.They have a Context. See the respective chapter for more information.
So, an example of a very simple operation would be:
classOperations::PrintHelloWorld < RailsOps::Operationdefperformputs"Hello #{params[:name]}"endendThere are various ways of instantiating and running an operation. The most basic way is the following:
op=Operations::PrintHelloWorld.new(name: 'John Doe')op.runThere is even a shortcut for this:
Operations::PrintHelloWorld.run(name: 'John Doe')As you have noticed, there are two methods for running operations: run and
run!. They behave exactly like save and save! of ActiveRecord: While the
run! method raises an exception if there is a validation error, run would
just return false (or true on success). As not every operation deals with
models or ActiveRecord models, run does not only catch the
ActiveRecord::RecordInvalid exception but also every exception that derives
from {RailsOps::Exceptions::ValidationFailed}.
If you'd like to catch a custom exception if the operation is called using
run, you can either derive this exception from
{RailsOps::Exceptions::ValidationFailed} or else override the
validation_errors method:
classOperations::PrintHelloWorld < RailsOps::Operation# Returns an array of exception classes that are considered as validation# errors.defvalidation_errorssuper + [SomeCustomException]endendAll operations have the same call signatures: run always returns true or
false while run! always returns the operation instance (which allows easy
chaining). If you need to access data that has been generated / processed /
fetched in the operation, create custom accessor methods:
classOperations::GenerateHelloWorld < RailsOps::Operationattr_reader:resultdefperform@result="Hello #{params[:name]}"endendputsOperations::GenerateHelloWorld.run!(name: 'John Doe').resultOperations don't have to involve models. Use the base RailsOps::Operation
class for business logic, background tasks, or service calls:
moduleOperations::CacheclassRebuild < RailsOps::Operationschema3doboo?:include_archived,default: falseboo?:rebuild_counters,default: trueend# Internal use only (called from background jobs)without_authorizationprotecteddefperformrebuild_countersifosparams.rebuild_countersrebuild_search_indexendprivatedefrebuild_countersCategory.find_eachdo |category|
Category.reset_counters(category.id,:articles)endenddefrebuild_search_indexArticle.where(indexed: false).find_each(&:update_search_index!)endendendA common pattern for idempotent operations that either find an existing record
or create a new one. Since this doesn't fit neatly into Model::Create or
Model::Load, use the base RailsOps::Operation and expose the result via
attr_reader:
moduleOperations::TagclassFindOrCreate < RailsOps::Operationschema3dostr!:namestr?:colorendwithout_authorizationattr_reader:modelprotecteddefperform@model= ::Tag.find_or_create_by!(name: osparams.name)do |tag|
tag.color=osparams.color || '#000000'endrescueActiveRecord::RecordNotUnique# Race condition: another process created the record between# our SELECT and INSERT. Just find the existing one.@model= ::Tag.find_by!(name: osparams.name)endendendEach single operation can take a params hash. Note that it does not have to be
in any relation with ActionController's params - it's just a plain ruby hash
called params (in fact, it is a Object::HashWithIndifferentAcces, more on
that later).
Params are assigned to the operation via their constructor:
Operations::GenerateHelloWorld.new(foo: :bar)If no params are given, an empty params hash will be used. If a
ActionController::Parameters object is passed, it will be permitted using
permit! and converted into a regular hash.
For accessing params within an operation, you can use params or osparams.
While params directly returns the params hash, osparams converts them into
an OpenStruct first. This allows easy access using the 'dotted notation':
defperform# Access a param using the `params` methodparams[:foo]# Access a param using the `osparams` methodosparams.fooendNote that both params and osparams return independent, deep duplicates of
the original params hash to the operation, so the hashes do not correspond.
The hash accessed via params is always an Object::HashWithIndifferentAccess.
You're strongly encouraged to perform a validation of the parameters passed to an operation, as unvalidated params pose a security threat. This can be done in several ways:
Using a schemacop schema:
classOperations::PrintHelloWorld < RailsOps::Operationschema3dostr!:nameenddefperformputs"Hello #{params[:name]}"endend
This is the recommended way of performing basic params validation. Please see the next section Schema best practices for more information.
See documentation of the gem
schemacopfor more information on how to specify schemata.Manually using a policy (see chapter Policies):
classOperations::PrintHelloWorld < RailsOps::Operationpolicydounlessosparams.name && osparams.name.is_a?(String)fail'You must supply the "name" argument.'endenddefperformputs"Hello #{params[:name]}"endend
Using a business model (see chapter Model Operations).
As previously mentioned, using schema from the schemacop gem is the recommended way to validate params passed in to an operation. In general, it's recommended to use version 3 of schemacop, i.e. either use schema3 to specify the schema, or set the default schema version to 3:
# config/initializers/rails_ops.rbRailsOps.configuredo |config|
config.default_schemacop_version=3endWhen writing a schema for an operation which is only used internally (e.g. called from another operation, or called from a part of the code where you control the params, e.g. a rake task), it's recommended to specify the types of all items, as this will catch any mismatched data. For example:
classOperations::PrintHelloWorldWithId < RailsOps::Operationschema3doint!:idstr!:nameenddefperformputs"Hello #{params[:name]}, your ID is: #{params[:id]}"endendOn the other hand, operations which are called within controllers (e.g. to encapsulate an update operation of a model) should not assume any types, and instead use model validations (if applicable) to validate the correctness of the data. In this case, the schema should only be used to filter the params. As such, it's recommended to use obj to specify params which are not strings, as this will allow anything (but only the specified values). An example would be:
moduleOperations::UserclassUpdate < RailsOps::Operation::Model::Updateschema3doint!:idhsh?:userdoobj!:agestr!:first_nameobj!:is_activeendendendendValidating that age is an integer and is_active then should be done with a validation in the User model, as this will also populate the model errors, which in turn will display the error in the form. If you were to validate the type of the data here, it would raise a Schemacop::Exceptions::ValidationError exception, which you would need to handle seperately.
Finally, when additional, obsolete params are supplied, the schema validation would also fail. To have a similar behaviour to the strong params from Rails, which drop non-whitelisted params without raising an exception, you can use the ignore_obsolete_properties option. This will simply ignore and drop any params which are not explicitly whitelisted:
moduleOperations::UserclassUpdate < RailsOps::Operation::Model::Updateschema3ignore_obsolete_properties: truedoint!:idhsh?:userdoobj!:agestr!:first_nameobj!:is_activeendendendendWhen an operation is called from a controller (via the run or run! method) and a schema validation exception occurs, the controller will respond with an empty body and a status code 400 (bad request). This behaviour is enabled by default, but can be disabled with the rescue_validation_error_in_controller config option:
# config/initializers/rails_ops.rbRailsOps.configuredo |config|
config.rescue_validation_error_in_controller=falseendGenerally, this should be left enabled, as sending invalid data to the controller should not result in an internal server error, but rather in a "client error".
Please note that this behaviour is disabled in development mode, as the full exception messages are useful for debugging purposes.
Policies are nothing more than blocks of code that run either at operation
instantiation or before / after execution of the perform method and can be
used to check conditions such as params or permissions.
Policies are specified using the static method policy, inherited to any
sub-classes and executed in the order they were defined.
classOperations::PrintHelloWorld < RailsOps::Operationpolicydoputs'This runs first'endpolicydoputs'This runs second'enddefperformputs'This runs third'puts'Oh, and hello world'endendThe basic idea of policies is to validate input data (the params hash) or
other conditions such as authorizations or locks.
Some checks might still need to be performed directly within the perform
method. Use policies as much as possible though to keep things separated.
The return value of the policies is discarded. If a policy needs to fail, raise an appropriate exception.
As mentioned above, policies can be executed at various points in your operation's lifecycle. This is possible using policy chains:
:before_attr_assignPolicies in this chain run before assigning the attributes to the model. This chain is only run in
Modeloperations, which at some point call theassign_attributesmethod. This chain is the only chain in which the model is in the state before the passed in params are assigned. If you need to run any code which needs the state of the model from the database (e.g. to run custom authentications), this is the correct place.:on_initPolicies in this chain run after the operation class is instantiated.
:before_performPolicies in this chain run immediately before the
performmethod is called. Obviously this is never called if the operation is just instantiated and never run. This is the default chain.:before_model_saveThis only applies to operations deriving from
RailsOps::Operation::Modeland its descendants. Policies in this chain run after nested model operations are performed immediately before the "main" model is saved.:before_nested_model_opsThis only applies to operations deriving from
RailsOps::Operation::Modeland its descendants. Policies in this chain run after nested model operations are performed before performing any nested model operations.:before_model_validationThis only applies to operations deriving from
RailsOps::Operation::Modeland its descendants. Policies in this chain run right beforemodel.validate!is called insideperform_nested_model_ops!. This is the correct place for attribute cleanup and sanitization — for example, nilling out attributes that are irrelevant based on another attribute's value (e.g. role-dependent fields after a form reload). At this point the model's attributes are already assigned, so you can inspect and modify them before validation runs.:after_performPolicies in this chain run immediately after the
performmethod is called. Obviously this is never called if the operation is just instantiated and never run. Also, this does not run if an exception occurs while performing the operation.
The policy chain (default is :before_perform) can be specified as the first
argument of the policy class method:
classMyOp < RailsOps::Operationpolicy:on_initdoputs'This is run once the operation has been instantiated.'endpolicydoputs'This is run before the operation is performed.'endendThe order inside the same policy chain depends on the time when a block was added.
You can prepend an action to a policy chain by setting :prepend_action to true:
classMyOppolicy:on_init,prepend_action: truedoputs'This is run first the operation has been instantiated.'endIn this case the model is not yet set. That will happen later in the :on_init chain.
It is also important to note, that this block is
not guaranteed to be run first in the chain, if multiple blocks have set :prepend_action to true.
Use policies to validate preconditions based on the model's state. This is
particularly useful in model operations where you want to reject the operation
before perform runs:
moduleOperations::ArticleclassPublish < RailsOps::Operation::Model::Updateschema3doint!:idendmodel ::Article# Ensure the article is still a draft before publishing.# Runs at instantiation time (i.e. when the model is loaded).policy:on_initdounlessmodel.draft?fail'Only draft articles can be published.'endend# Ensure the article has required content.# Runs just before perform is called.policydoifmodel.body.blank?failRailsOps::Exceptions::ValidationFailed,'Article body cannot be empty.'endendprotecteddefperformmodel.status='published'model.published_at=Time.currentsuperendendendUse :on_init for checks that should prevent even displaying a form, and
:before_perform (the default) for checks that should prevent submitting it.
It is possible and encouraged to call operations within operations if necessary. As the basic principle is to create one operation per business action, there are cases where nesting operations can be very beneficial.
Let's say we have an operation User::Create that creates a new user. The
operation should also assign the newly created user to a default Group after
creation. In this case, we basically have two separate operations that should
not be combined in one. For this case, use sub-operations:
classOperations::User::Create < RailsOps::Operationdefperformuser=User.create(params)run_sub!AssignToGroup,user: user,group: Group.defaultendendEvery operation offers the methods {RailsOps::Mixins::SubOps.run_sub}, {RailsOps::Mixins::SubOps.run_sub!} and {RailsOps::Mixins::SubOps.sub_op}. The latter one just instantiates and returns a sub operation.
So why don't we just create and call the sub-operation directly? The reason lies within the context that is automatically adapted and passed to the sub-operation and enables to maintain the complete call stack and allows to pass on context information such as the current user.
As always when calling operations, you can decide whether an execution should
raise an exception on validation errors or else just return false by using the
bang or non-bang methods.
For nested operations, we must give this fact a little more thought. Consider the following case:
- Operation A is called using
run. - Operation A calls operation B using
run_sub!. - Operation B throws a validation exception.
In this case, it is now expected that A returns non-gracefully, even though it's called using the non-bang method. The reason is that A explicitly used the bang-method for calling the sub-op.
However, as calling A catches any validation errors, it will also catch the
validation errors raised by a sub-operation. For this case, calling run_sub!
catches any validation errors and re-throws them as
{RailsOps::Exceptions::SubOpValidationFailed} which is not caught by the
surrounding op.
Here is a realistic example of an operation that uses sub-operations to compose a complex workflow:
Building on the Article::Publish example from the Policies section, here
is a version that also triggers sub-operations after publishing:
moduleOperations::ArticleclassPublishWithNotification < RailsOps::Operation::Model::Updateschema3doint!:idendmodel ::Articleprotecteddefperformmodel.status='published'model.published_at=Time.currentsuper# Save the articlerun_sub!Operations::Cache::Rebuild,rebuild_counters: truerun_sub!Operations::Notification::Send,template: 'article_published',record_id: model.id,record_type: 'Article'endendendValidation errors raised by either sub-operation propagate up — run_sub!
re-raises any validation_errors as
RailsOps::Exceptions::SubOpValidationFailed, which escapes the parent's
run and rolls back the surrounding transaction. If the parent is invoked
via run (non-bang), the savepoint added in 1.8.0 ensures super's save
is rolled back too. See section Transactions for more details.
Most operations make use of generic parameters like the current user or an
authorization ability. Sure this could all be passed using the params hash,
but as this would have to be done for every single operation call, it would be
quite cumbersome.
For this reason, Rails Ops provides a feature called Contexts. Contexts are simple instances of {RailsOps::Context} that may or may not be passed to operations. Contexts can include the following data:
A user object
This is meant to be the user performing the operation. In a controller context, this usually referred to as
current_user.The session object
This is the rails
sessionobject (can be nil).An ability object
This is an ability object (i.e. cancan(can)) which holds the permissions currently available. This is used for authorization within an operation.
The operations chain
The operations chain contains the call stack of operations. This is automatically generated when calling a sub-op or triggering an op using an event (see chapter Events for more information on that).
URL options
Rails uses a hash named
url_optionsfor generating URLs with correct prefix. This information usually comes from a request and is automatically passed to the operation context when calling an operation from a controller. This hash is used by {RailsOps::Mixins::Routes}.View context
If the operation has been created from within a controller, the property
viewincludes the current view context. Only use this for frontend operations that will always be called from a controller.Called via hook
called_via_hookis a boolean indicating whether or not this operation was called by a hook (true) or by a regular method call (false). We will introduce hooks below.
Contexts behave like a traditional model object and can be instantiated in multiple ways:
context=Context.new(user: current_user,params: {foo: bar})# Another waycontext=Context.newcontext.user=current_userContexts are assigned to operations via the operation's constructor:
my_context=RailsOps::Context.newop=Operations::PrintHelloWorld.new(my_context,foo: :bar)For your convenience, contexts also provide run and run! methods:
my_context.runOperations::PrintHelloWorld,foo: :barWhen calling a sub-operation either using the corresponding sub-operation methods or else using events, a new context is automatically created and assigned to the sub-operation. This context includes all the data from the original context. Also, the operations chain is automatically complemented with the parent operation.
This is called context spawning and is performed using the {RailsOps::Context.spawn} method.
In some cases, certain actions must be hooked in after execution of an operation. While this can certainly be done with sub-operations, it is not always desirable as the triggering operation should not always know of the additional ones it's triggering:
Operations::User::Createcreates a user, but also creates a group object usingOperations::Group::Create. This is an example for sub-ops.Operations::User::Createcreates a user. Whenever a user is created, another part of the application needs to generate a todo for the admin to approve this user. This would be an example for hooks.
Hooks are pretty simple: Using the file config/hookup.rb, you can
specify which operations should be triggered after which operations. These
operations are then automatically triggered after the original operation's
perform (in the run method).
Hooks are defined in a file named config/hookup.rb in your local application.
In development mode, this file is automatically reloaded on each request so
there is no need to restart the application server for this while developing.
Defining hooks is as simple as defining a target operation and one or more source operations.
RailsOps.hookup.drawdorun'Operations::Notifications::User::SendWelcomeEmail'doon'Operations::User::Create'endrun'Operations::Todos::GenerateUserApprovalTodo'doon'Operations::User::Create'endrun'Operations::Notification::SendTodoNotification'doon'Operations::Todos::GenerateUserApprovalTodo'endendOperations hooks are always performed in the order they are defined.
Each operation can throw different events. The event :after_run is
automatically triggered after each operation's execution and should be
sufficient for most cases. However, it is also possible to trigger custom events
in the perform method:
defperformtrigger:custom_event_name,{some: :params}endThis can be hooked by specifying the custom event name in your hookup configuration:
onOperations::User::Create,:custom_event_namedoperformOperations::Notifications::User::SendWelcomeEmailendIn most cases though, situations like these should rather be handled by explicitly calling a sub-operation.
For each hook that is called, at set of parameters is passed to the respective
operations. When calling events manually (see section Events), you can
manually specify the parameters. For the default event :after_run, the set of
parameters is defined by the operation method after_run_trigger_params. In the
default case, this returns an empty array. Some operation base classes, like for
instance RailsOps::Operation::Model, override this method to supply a custom
set of parameters. See your respective base class for more information.
Be advised: It is not usually desirable to provide a very custom param set that is tailored to one particular target operation. Trigger parameters should be as generic as possible as specific cases should rather be handled using sub-ops.
Operations can be used to write adapters (glue operations) in order to hook into an operation with incompatible parameters. Create a glue operation that hooks into the source operation and prepares the params specifically for the target operation, which is then called using a sub-operation or the hooking system.
You can determine whether your operation has been (directly) called via a hook
using the called_via_hook context method:
defperformputs'Called via hook'ifcontext.called_via_hookendNote that this property never propagates, so when calling a sub-operation from
an operation that has been called using a hook, called_via_hook of the
sub-operation is set to false again.
Operations called via hooks perform normal authorization per default. You can
turn this off by switching off the global option
config.trigger_hookups_without_authorization.
Rails Ops offers backend-agnostic authorization using so-called authorization backends.
Authorization basically happens by calling the method authorize! (or
authorize_only!, more on that later) within an operation. What exactly this
method does depends on the authorization backend specified.
Authorization backends are simple classes that supply the method authorize!.
This method, besides the operation instance, can take any number of arguments
and is supposed to perform authorization and raise if the authorization failed.
The authorization backend can be configured globally using the
authorization_backend configuration setting, which can be set to the name of
your backend class.
Example initializer:
RailsOps.configuredo |config|
config.authorization_backend='RailsOps::AuthorizationBackends::CanCanCan'endRailsOps ships with the following backend:
RailsOps::AuthorizationBackend::CanCanCanOffers integration of the
cancancangem (which is a fork of thecancangem).
Authorization is generally performed by calling authorize! in an operation.
The arguments, along with the operation instance, are passed on to the
authorize! method of your authorization backend. Basically, you can call
authorize! anywhere in your operation, but bear in mind that if your
authorization requires certain data (i.e. the params hash), your authorization
calls should occur after that certain data is available.
classMyOp < RailsOps::Operationdefperformauthorize!:read,:some_areaendendUsually though, authorization, as other pre-conditions, are called within policies:
classMyOp < RailsOps::Operationpolicydoauthorize!:read,:some_areaendendIn many cases, you'd like the authorization to run no matter if the operation
ever runs. For this case, use the :on_init policy chain:
classMyOp < RailsOps::Operationpolicy:on_initdoauthorize!:read,osparams.some_recordendendSee section Policy chains for more information.
As it is a very common programming mistake to mistakenly omit calling authorization, Rails Ops offers a solution for making sure that authorization has been called in every operation.
This is done by calling ensure_authorize_called! on your operation. This will
raise an exception if no authorization has been performed. This method is
automatically called in run or run! after the execution of the perform
method.
This method only applies if authorization is currently enabled (see next section), otherwise it does nothing.
It is implemented so that every call to authorize! sets an instance variable
of the respective operation to true, and ensure_authorize_called! checks
this instance variable on calling.
Sometimes you might want to call authorization that should not count for this
check, i.e. some base authorization that needs to be complemented with some
specific authorization code. In these cases, use authorize_only!:
defperformauthorize_only!:foo,:bar# The following will fail as authorize_only! calls do not count as authorized.ensure_authorize_called!endThis method otherwise does exactly the same as authorize! (in fact, it's the
underlying method used by it).
Using the static operation method authorize_param, you can perform additional
authorization checks when specific params are passed to the operation. This
allows you to disallow certain params, i.e. when updating a model and wanting to
restrict the user to certain fields.
When using non-model operations (operations not inheriting from
RailsOps::Operation::Model or one of its subclasses), authorize_param
requires you to specify an action and optional, additional args or a block
that performs custom authorization:
classOperations::User::DoSomething < RailsOps::Operationschemadoopt:userdoopt:nameopt:group_idendend# Example with passing an action and additional argsauthorize_param%i(usergroup_id),:update_group_id,:some_subject# Example with passing a blockauthorize_param%i(usergroup_id)do# This is executed in the context of the op instancefail'Some message'unlessuser_has_permission?endThe first param always provides the path to the param to be checked for
existence. Note that this only works with nested hash structures, but not with
arrays and other objects. The first level of the params hash is always using
indifferent access, so it does not matter whether you pass a symbol or a string
as the first path segment. For additional path segments, it needs to match the
actual type that is used as hash key. For example: [:user, 'group_id'].
For model operations, you only need to pass a path and an action if you want
to perform authorization on your model:
classOperations::User::Create < RailsOps::Operation::Model::Createschemadoopt:userdoopt:nameopt:group_idendendauthorize_param%i(usergroup_id),:assign_group_idSometimes you don't want a specific operation to perform authorization, or you don't want to perform any authorization at all.
For this reason, Rails Ops allows you to disable authorization globally, per
operation or per operation call (i.e. an operation should generally perform
authorization, but not in a specific case). If authorization is disabled, all
calls to authorize! won't have any effect and will never fail. Also, it is not
ensured that authorization has been performed as it would always fail (see
previous section).
Rails Ops offers multiple ways of disabling authorization:
By not configuring any authorization backend.
By calling the class method
without_authorization:classMyOp < RailsOps::Operationwithout_authorizationend
If the operation is invoked using controller integration, this also disables the controller-side check that makes sure an authorization method is called.
This does not disable authorization for any sub operations. See the next section for information on how to disable sub operation authorization.
By invoking one or more operations in a
RailsOps.without_authorizationblock:RailsOps.without_authorizationdo# Authorization will be disabled even if `SomeOperation` itself would# otherwise perform authorization.SomeOperation.runend
Within operations, you can also use the instance method
without_authorizationwhich does the same thing as the global one (it is just a shortcut and can therefore be used interchangeably):classMyOp < RailsOps::Operationdefperformwithout_authorizationdorun_sub!SomeOtherOperationendendend
Note that when calling
without_authorizationthis does not only apply to other operations called, but also to the operation you're currently in:classMyOp < RailsOps::Operationdefperformwithout_authorizationdo# The following line does nothing, as authorization is currently# disabled.authorize!:read,:some_areaendendend
However, please note that the block form of
authorize_paramis still executed, as there might be code in the block that does not rely on the authorization backend:classMyOp < RailsOps::Operationdefperformwithout_authorizationauthorize_param%i[usergroup_id]do# This block will be calledfailifENV['GROUP_ID'].blank?endendend
If you want to skip the block, use
authorization_enabled?to check whether the authorization is enabled:classMyOp < RailsOps::Operationdefperformwithout_authorizationauthorize_param%i[usergroup_id]donextunlessauthorization_enabled?# Do authorization callsendendend
One of the key features of RailsOps is model operations. RailsOps provides multiple operation base classes which allow convenient manipulation of active record models.
All of the model operation classes, including more specialized base classes, inherit from {RailsOps::Operation::Model} (which in turn inherits from {RailsOps::Operation} as every operation base class).
The key principle behind these model classes is to associate one model class and one model instance with a particular operation.
Using the static method model, you can assign a model class that is used in
the scope of this operation.
classSomeOperation < RailsOps::Operation::ModelmodelUserendYou can also directly extend this class by providing a block. If given, this will automatically create a new, anonymous class that inherits from the given base class and run the given block in the static context of this class:
classSomeOperation < RailsOps::Operation::ModelmodelUserdo# This code only runs in a dynamically created subclass of `User` and does# not affect the original model class.validates:name,presence: trueendendYou do not even have to specify a base class. In this case, the class returned
by the static method default_model_class (default: {ActiveType::Object}) will
be used as base class:
classSomeOperation < RailsOps::Operation::Modelmodeldo# See ActiveType documentation for more information on virtual attributes.attribute:nameendendModel instances can be obtained using the instance method model, which is
not to be confused with the class method of the same name. Other than the
class method, the instance method instantiates and returns a model object with
the type / base class specified using the model class method:
classSomeOperation < RailsOps::Operation::ModelmodelUserdefperform# This returns an instance of the 'User' class. To be precise: This example# does not work out-of-the-box as this base class is abstract and does not# implement the `build_model` method. But more on that later.modelendendThe instance method model only instantiates a model once and then caches it in
the instance variable @model. Therefore, you can call model multiple times
and always get back the same instance.
If no cached instance is found, one is built using the instance method
build_model. Note that this method is not provided by the Model base class
but only implemented in its subclasses. You can implement and override this
method to your liking though.
Using the base operation class {RailsOps::Operation::Model::Load}, a model can
be loaded. This is done by implementing the build_model mentioned above. In
this particular case, the find method of the statically assigned model class
is used in conjunction with an ID extracted from the operation's params.
classOperations::User::Load < RailsOps::Operation::Model::LoadmodelUserend# The operation does not have to be performed to access the model instance.op=Operations::User::Load.new(id: 5)op.model.id# => 5Note that this base class is a bit of a special case: It does not provide an
implementation of the perform method and does not need to be run at all in
order to load a model (in fact, it cannot be run unless you override the
perform method). This is very useful when, for example, displaying a form
based on a model instance without actually performing any particular action such
as updating a model.
Per default, the model instance is looked up using the field id and the ID
obtained from the method params using params[:id]. However, you can customize
this field name by overriding the method model_id_field:
classOperations::User::Load < RailsOps::Operation::Model::LoadmodelUserdefmodel_id_field:some_other_id_fieldendendBy default, Load operations look up the model using the class specified
via the model DSL method. If you need to customize the lookup — for
example to apply a scope, join additional tables, or restrict visibility
— you can override the protected method find_model_relation.
The conditions from the returned relation are merged into the
operation's model class, so the loaded record is always an instance of
the correct (possibly extended) model type. This means model extensions
defined via model do ... end (e.g. validations, callbacks) are always
preserved.
Since Update and Destroy operations inherit from Load, this hook
is available in all of them. For example, you can scope an Update
operation so that it only finds records belonging to the current user's
organization:
classOperations::User::Update < RailsOps::Operation::Model::UpdatemodelUserprotecteddeffind_model_relationUser.where(organization: context.user.organization)endendLocking and eager loading via model_includes are applied on top of
the merged relation.
In most cases when you load a model, you might want to lock the corresponding database record. RailsOps is configured to automatically perform this locking at time of loading. However, you can override the default behavior using the option {RailsOps.config.lock_models_at_build}.
This behavior can also be overwritten per operation using the
lock_model_at_build class method:
classOperations::User::Update < RailsOps::Operation::Model::Updatemodel ::Userlock_model_at_buildfalse# Takes `true` if no argument is passedendPlease note that for performance reasons, the Load operation (and any
operations inheriting from it) use a shared lock, i.e. it issues
an LOCK IN SHARE MODE / FOR SHARE statement. The Update and Destroy
operations (as well as operations inheriting from it) however use the default
lock method of ActiveRecord, which will issue an exclusive lock.
If you want to change the mode, you can use the lock_mode DSL method, which
has two possible modes:
:sharedfor the shared lock mode:exclusivefor the exclusive lock mode
For example, if you have an operation loading a record which you'd want to lock exclusively, you'd need to write the following:
classOperations::User::Update < RailsOps::Operation::Model::Loadmodel ::Userlock_mode:exclusiveendOne caveat is that shared locking is only supported for MySQL (MariaDB), PostgreSQL and Oracle DB databases, any other database will always use an exclusive lock.
You can also dynamically enable or disable locking by creating an instance
method lock_model_at_build?:
classOperations::User::Update < RailsOps::Operation::Model::Loadmodel ::Userprotecteddeflock_model_at_build?# Example: Lock based on a parameterosparams.lockendendFor creating models, you can use the base class {RailsOps::Operation::Model::Create}.
This class mainly provides an implementation of the methods build_model and
perform.
The build_model method builds a new record using the operation's parameters.
See section Parameter extraction for create and update for more information on
that.
The perform method saves the record using save!.
classOperations::User::Create < RailsOps::Operation::Model::Createschemadoreq:userdoopt:first_nameopt:last_nameendendmodel ::UserendAs this base class is very minimalistic, it is recommended to fully read and comprehend its source code.
While in many cases there is no need for overriding the perform method, this
can be useful i.e. when assigning or altering properties manually:
defperformmodel.some_value=42model.first_name.upcase!super# Saves the recordendFor updating models, you can use the base class
{RailsOps::Operation::Model::Update} which is an extension of the Load base
class.
This class mainly provides an implementation of the methods build_model and
perform.
The build_model method updates a record using the operation's parameters. See
section Parameter extraction for create and update for more information on
that.
The perform method saves the record using save!.
classOperations::User::Update < RailsOps::Operation::Model::Updateschemadoreq:idreq:userdoopt:first_nameopt:last_nameendendmodel ::UserendAs this base class is very minimalistic, it is recommended to fully read and comprehend its source code.
As with Create operations, the perform method can be overwritten at your
liking.
For destroying models, you can use the base class
{RailsOps::Operation::Model::Destroy} which is an extension of the Load base
class.
This class mainly provides an implementation of the method perform, which
destroys the model using its destroy! method.
classOperations::User::Destroy < RailsOps::Operation::Model::Destroyschemadoreq:idendmodel ::UserendAs this base class is very minimalistic, it is recommended to fully read and comprehend its source code.
Here is a complete, minimal CRUD set for a single model using schema3:
# app/operations/category/load.rbmoduleOperations::CategoryclassLoad < RailsOps::Operation::Model::Loadschema3doint!:idendmodel ::Categoryendend# app/operations/category/create.rbmoduleOperations::CategoryclassCreate < RailsOps::Operation::Model::Createschema3dohsh?:categorydostr?:namestr?:descriptionendendmodel ::Categoryendend# app/operations/category/update.rbmoduleOperations::CategoryclassUpdate < RailsOps::Operation::Model::Updateschema3doint!:idhsh?:categorydostr?:namestr?:descriptionendendmodel ::Categoryendend# app/operations/category/destroy.rbmoduleOperations::CategoryclassDestroy < RailsOps::Operation::Model::Destroyschema3doint!:idendmodel ::CategoryendendFor Create and Update, parameter extraction happens automatically: the
params nested under the model's param_key (:category) are assigned to the
model. No perform method is needed — the base class handles save!.
Override build_model to set default values or assign associations that aren't
part of the user's input:
moduleOperations::ArticleclassCreate < RailsOps::Operation::Model::Createschema3dohsh?:articledostr?:titlestr?:bodyint?:category_idendendmodel ::Articleprotecteddefbuild_modelsuper# Builds the model and assigns params from :article keymodel.author=context.usermodel.status='draft'endendendsuper in build_model creates a new model instance and assigns the
attributes from params. After super, you can set additional attributes.
In Update operations, build_model first loads the record (via Load), then
assigns the params. Override it to modify the model after loading:
moduleOperations::TokenclassMarkUsed < RailsOps::Operation::Model::Updateschema3doint!:idendmodel ::Tokenwithout_authorizationprotecteddefbuild_modelsuper# Loads the record and assigns paramsmodel.used_at=Time.currentendendendNormally, when inheriting from RailsOps::Operation::Model::Load (as well as from the
Update and the Destroy operations respectively), RailsOps only loads the instance
of the model specified by the id parameter. In some cases, you'd want to eagerly load
associations of the model, e.g. when you need to access associated records.
For this, RailsOps provides the model_includes DSL method, with which you can
pass-in associations to eager load (the value will simply be passed on to an includes
call). See the following code snipped for an example:
classOperations::User::Load < RailsOps::Operation::Model::Loadschema3doint!:id,cast_str: trueendmodel ::User# This will result in RailsOps eagerly loading the `posts`# association, as well as the comments and authors of the# comments.# The call that RailsOps will create is:# User.includes(posts: { comments: :author }).find_by(id: params[:id])model_includesposts: {comments: :author}endA common pattern for "show" pages: a Load operation that provides helper
methods for loading related data in the view:
moduleOperations::Frontend::ArticlesclassShow < RailsOps::Operation::Model::Loadmodel ::Articlemodel_includes[:tags,:category,{comments: :author}]defrecent_comments(limit: 10)model.comments.order(created_at: :desc).limit(limit)enddefrelated_articles@related_articles ||= ::Article.where(category_id: model.category_id).where.not(id: model.id).limit(5)endprotected# Load operations don't need perform logic, but the base class# raises NotImplementedError, so we override with a no-op.defperform;endendendIn the controller:
defshowopOperations::Frontend::Articles::Show# In the view: op.model, op.recent_comments, op.related_articlesendAs mentioned before, the Create and Update base classes provide an
implementation of build_model that assigns parameters to a model.
The attributes are determined by the operation instance method
extract_attributes_from_params - the name being self-explaining. See its
source code for implementation details.
While you can use the standard authorize! method (see chapter Authorization)
for authorizing models, RailsOps provides a more convenient integration.
Model authorization can be performed via the operation instance methods
authorize_model! and authorize_model_with_authorize_only! (see chapter
Authorization for more information on the difference between these two).
These two methods provide a simple wrapper around authorize! and
authorize_only! that casts the given model class or instance to an active
record object. This is necessary if the given model class or instance is a
(possibly anonymous) extension of an active record class for certain
authorization backends to work. Therefore, use the specific model authorization
methods instead of the basic authorization methods for authorizing models.
If no model is given, the model authorization methods automatically obtain the
model from the instance method model.
All model operation classes provide the operation instance method
model_authorization which is automatically run at model instantiation (this is
done using an :on_init policy). The purpose of this method is to perform an
authorization check based on this model.
While you can override this method to perform custom authorization, RailsOps
provides a base implementation. Using the class method
model_authorization_action (or load_model_authorization for operations
inheriting from RailsOps::Operation::Model::Load), you can specify an action
verb that is used for authorizing your model.
classOperations::User::Load < RailsOps::Operation::Model::LoadmodelUser# This automatically calls `authorize_model! :read` after operation# instantiation.load_model_authorization:readendAnother example for an update operation:
classOperations::User::Update < RailsOps::Operation::Model::UpdatemodelUser# This automatically calls `authorize_model! :read` after operation# instantiation.load_model_authorization:read# This automatically calls `authorize_model! :update` after operation# instantiation.model_authorization:updateendNote that using the different model base classes, this is already set to a sensible default. See the respective class' source code for details.
Please note that using lazy model update authorization is deprecated any may be removed in a future release. See the changelog for instructions on how to adapt your application.
In case of operations inheriting from RailsOps::Operation::Model::Update, you
can specify the model_authorization_action to be lazy, meaning that it will
only be checked when performing the operation, but not on initialization. This
can be useful for displaying readonly forms to users which have read-permissions
only:
classOperations::User::Update < RailsOps::Operation::Model::UpdatemodelUser# This automatically calls `authorize_model! :read`. Because it is set to be# `lazy`, the authorization will only run when the operation is actually# *performed*, and not already at instantiation.model_authorization_action:update,lazy: trueendFor operations beyond standard CRUD (e.g., archiving, classifying, publishing), specify custom authorization actions:
moduleOperations::ArticleclassArchive < RailsOps::Operation::Model::Updateschema3doint!:idendload_model_authorization_action:readmodel_authorization_action:archivemodel ::Articleprotecteddefperformmodel.archived=truemodel.archived_at=Time.currentmodel.archived_by=context.usersuperendendendIn your ability file, define the custom action:
can:archive,Articledo |article|
article.author_id == user.id || user.admin?endUsing active record, multiple nested models can be saved at once by using
accepts_nested_attributes_for. While this is generally supported by RailsOps,
you may want to consider saving nested models using their own operation.
For this case, RailsOps' create and update model operations provide the method
nest_model_op.
classOperations::User::Create < RailsOps::Operation::Model::Createschemadoopt:userdoopt:nameopt:group_attributesendendmodel ::Usernest_model_op:group,Operations::Group::CreateendclassOperations::Group::Create < RailsOps::Operation::Model::Createschema:groupdoopt:nameendmodel ::GroupendIn this example, the parent operation Operations::User::Create automatically
instantiates a Group::Create operation and passes all the parameters to it
that the parent operation received under group_attributes. The group is saved
first. If this is successful, the user is saved.
Note that this feature only works with belongs_to associations with autosave
set to false and is not compatible with accepts_nested_attributes_for:
classUserbelongs_to:group,autosave: falseendWhen nesting a model operation, the sub operation is called automatically by
RailsOps. For this purpose, it needs to know which param_key to use for
calling the sub operation, e.g. user: { name: 'Jane Doe' }. Normally, this is
derived by calling <sub-op-model-class>.model_name.param_key. If your
operation for some reason expects a different param key, you can specify it
using the option param_key, e.g.:
# Operation Operations::Group::Create will receive the following params:# { my_custom_key: { ... } }nest_model_op:group,Operations::Group::Create,param_key: :my_custom_keyIn the above examples, all group_attributes are automatically passed to the
sub operation. To customize this further, provide a block to the nest_model_op
method:
nest_model_op:group,Operations::Group::Createdo |params|
params.merge(custom_override: :some_value)endThis block receives the params hash as it would be passed to the sub operation and allows to modify it. The block's return value is then passed to the sub-operation. Do not change the params inplace but instead return a new hash.
Model operations also support STI models (Single Table Inheritance). However,
there is the caveat that if you do extend your model in the operation (e.g.
model Animal do { ... }), RailsOps automatically creates an anonymous subclass
of the given class (e.g. Animal). Operations will always load / create models
that are instances of this anonymous class.
Consider the following operation:
classAnimal < ApplicationRecord;endclassBird < Animal;endclassMouse < Animal;endclassLoadAnimal < RailsOps::Operation::Model::LoadmodelAnimaldo# Somethingendendbird=Bird.createop=LoadAnimal.new(id: bird.id)bird.class# => Bird (extending Animal)op.model.class# => Anonymous class extending Animal, not BirdRailsOps provides powerful features for extending ActiveRecord models and creating virtual records without affecting your actual model classes. This is achieved through the use of ActiveType and anonymous class generation.
Virtual models are non-persisted models that behave like ActiveRecord models but exist only in memory. They're useful for:
- Form objects that don't map directly to database tables
- Temporary data structures for complex operations
- Aggregating data from multiple sources
RailsOps provides RailsOps::VirtualModel which extends ActiveType::Object:
classOperations::Contact::Create < RailsOps::Operation::Model::Createmodeldo# Virtual attributesattribute:full_name,:stringattribute:email,:stringattribute:message,:textattribute:newsletter_opt_in,:boolean,default: false# Validations work just like regular modelsvalidates:full_name,:email,:message,presence: truevalidates:email,format: {with: URI::MailTo::EMAIL_REGEXP}enddefperform# Process the virtual model dataContactMailer.contact_form(name: model.full_name,email: model.email,message: model.message).deliver_later# Optionally subscribe to newsletterifmodel.newsletter_opt_inNewsletterService.subscribe(model.email)endendendWhen you specify a model with a block in an operation, RailsOps creates an anonymous subclass that extends your model without modifying the original:
classOperations::User::Import < RailsOps::Operation::Model::CreatemodelUserdo# These changes only apply within this operationattribute:import_source,:stringattribute:skip_notifications,:boolean,default: falsevalidates:import_source,presence: true# Override methodsdefname=(value)super(value.strip.titleize)end# Add callbacks specific to this operationbefore_save:normalize_phone_numberprivatedefnormalize_phone_numberself.phone=PhoneNumberService.normalize(phone)ifphone.present?endenddefperformmodel.imported_at=Time.currentsuperunlessmodel.skip_notificationsUserMailer.welcome(model).deliver_laterendendendVirtual attributes allow you to add non-persisted attributes to your models that behave like regular attributes:
classOperations::Order::Checkout < RailsOps::Operation::Model::UpdatemodelOrderdo# Virtual attributes for checkout processattribute:card_number,:stringattribute:card_cvv,:stringattribute:card_exp_month,:integerattribute:card_exp_year,:integerattribute:save_card,:boolean,default: false# Validations for virtual attributesvalidates:card_number,presence: true,length: {is: 16}validates:card_cvv,presence: true,length: {in: 3..4}validates:card_exp_month,inclusion: {in: 1..12}validates:card_exp_year,numericality: {greater_than_or_equal_to: Date.current.year}# Virtual attribute for computed valuesattribute:total_with_tax,:decimalbefore_validation:calculate_total_with_taxprivatedefcalculate_total_with_taxself.total_with_tax=total * (1 + tax_rate)endenddefperform# Process payment with virtual attributespayment_result=PaymentGateway.charge(amount: model.total_with_tax,card_number: model.card_number,cvv: model.card_cvv,exp_month: model.card_exp_month,exp_year: model.card_exp_year)ifpayment_result.success?model.payment_id=payment_result.transaction_idmodel.paid_at=Time.currentsuper# Save the order# Optionally save card for future useifmodel.save_cardCreatePaymentMethod.run!(user: model.user,token: payment_result.card_token)endelsefailPaymentError,payment_result.error_messageendendendA very common use case is adding virtual datetime attributes for form inputs that need to be transformed before saving:
moduleOperations::EventclassCreate < RailsOps::Operation::Model::Createschema3dohsh?:eventdostr?:titlestr?:virtual_start_datetimestr?:virtual_end_datetimeboo?:all_dayendendmodel ::Eventdoattribute:virtual_start_datetime,:datetimeattribute:virtual_end_datetime,:datetimevalidates:virtual_start_datetime,presence: true,unless: :all_day?validates:virtual_end_datetime,presence: true,unless: :all_day?validates:virtual_end_datetime,comparison: {greater_than_or_equal_to: :virtual_start_datetime},if: ->{ !all_day? && virtual_start_datetime.present?}endprotecteddefbuild_modelsuperifmodel.all_day?model.start_date=model.virtual_start_datetime&.beginning_of_daymodel.end_date=model.virtual_end_datetime&.end_of_dayelsemodel.start_date=model.virtual_start_datetimemodel.end_date=model.virtual_end_datetimeendendendendYou can create operations that work with both persisted and virtual data:
classOperations::Report::Generate < RailsOps::Operation::Modelmodeldoattribute:start_date,:dateattribute:end_date,:dateattribute:include_archived,:boolean,default: falseattribute:format,:string,default: 'pdf'validates:start_date,:end_date,presence: truevalidate:end_date_after_start_dateprivatedefend_date_after_start_datereturnunlessstart_date && end_dateerrors.add(:end_date,'must be after start date')ifend_date < start_dateendenddefperformscope=Order.where(created_at: model.start_date..model.end_date)scope=scope.includes(:archived)ifmodel.include_archivedreport_data=ReportBuilder.new(scope).generatecasemodel.formatwhen'pdf'ReportPdfGenerator.new(report_data).to_pdfwhen'csv'ReportCsvGenerator.new(report_data).to_csvelsereport_dataendendendWhen working with database operations, it's crucial to ensure data consistency using transactions, especially when multiple models are involved.
It's important to understand that RailsOps model operations do NOT automatically start any transactions.
To ensure all operations succeed or fail together, you must explicitly wrap them in a transaction:
classOperations::Order::Process < RailsOps::Operation::Model::UpdatedefperformActiveRecord::Base.transactiondomodel.status='processing'model.processed_at=Time.currentsuper# Saves the order# Now if this fails, everything is rolled backOrderItem.where(order: model).update_all(status: 'processing')InventoryService.reserve_items(model.items)endendendTypically though, transactions are opened on a higher level and outside of operations, e.g. in controller methods.
Since version 1.8.0, RailsOps::Operation#run (the non-bang variant)
automatically wraps the operation in a SAVEPOINT whenever a database
transaction is already open. If the operation raises a validation error
partway through perform, the savepoint is rolled back before run
catches the error and returns false. This eliminates the most common
reason to reach for with_rollback_on_exception.
classOperations::User::Create < RailsOps::Operation::Model::Createdefperformsuper# Saves the usersomething_else!# If this raises ActiveRecord::RecordInvalid, the# save above is rolled back automatically when the# operation is invoked through `run`.endend# Caller (controller, job, etc.):ActiveRecord::Base.transactiondoifOperations::User::Create.run(user: {name: 'Alice'})# success pathelse# validation error — partial writes have already been rolled backendendA savepoint is only created if a transaction is already open at the time
of the call. Without an outer transaction (rake tasks, console sessions,
some background jobs), run calls run! directly and behavior is
unchanged: the caller is expected to open a transaction if it wants
atomicity. run! itself is never wrapped — exceptions propagate naturally
and the surrounding transaction (if any) rolls back.
run_sub (non-bang) benefits from the same protection transitively, since
it delegates to .run on the sub-operation.
The savepoint above only protects against validation errors
(RailsOps::Exceptions::ValidationFailed and
ActiveRecord::RecordInvalid). For other StandardError subclasses that
should also trigger a rollback when run is used, the
with_rollback_on_exception helper re-raises them as
RailsOps::Exceptions::RollbackRequired, which is not part of
validation_errors and therefore propagates up through run and rolls
back the surrounding transaction:
classOperations::User::ComplexUpdate < RailsOps::Operation::Model::Updatedefperformsuper# Saves the user — validation errors here are handled by the# automatic savepoint in `run`.with_rollback_on_exceptiondoExternalApi.call!(model)# raises a custom StandardError on failure;# converted into RollbackRequired so the# outer transaction rolls back even when# the operation is invoked via `run`.endendendclassUsersController < ApplicationControllerdefupdateActiveRecord::Base.transactiondoifrunOperations::User::ComplexUpdaterenderjson: {status: :success}elserenderjson: {status: :validation_error}endendendendImportant: with_rollback_on_exception only works within an existing
transaction. It doesn't create a transaction — it just ensures exceptions
cause rollback:
classOperations::Order::Process < RailsOps::Operation::Model::Updatedefperform# PROBLEMATIC: Each save creates its own transactionsuper# Order is saved and committed in its own transactionwith_rollback_on_exceptiondoExternalApi.charge!(model)# Raises a custom StandardError on failure;# the order has already been committed and# cannot be rolled back.endendend# CORRECT: Wrap in a transactionclassOperations::Order::Process < RailsOps::Operation::Model::UpdatedefperformActiveRecord::Base.transactiondosuper# Order is saved within the surrounding transactionwith_rollback_on_exceptiondoExternalApi.charge!(model)# Custom StandardError is converted into# RollbackRequired, which propagates# through `run` and rolls back the# transaction including `super`.endendendendWhen no explicit transaction is used, each save! opens and commits its own
transaction. You can use Rails' after_commit callbacks in your model
extensions for actions that should only run after successful database commits:
# Using after_commit callbacks in model extensionclassOperations::User::Create < RailsOps::Operation::Model::CreatemodelUserdoafter_commit:send_notifications,on: :createprivatedefsend_notificationsUserMailer.welcome(self).deliver_laterCrmSyncJob.perform_later(self)endendend# Or handle it manually after the operationclassOperations::Order::Complete < RailsOps::Operation::Model::UpdatedefperformActiveRecord::Base.transactiondomodel.status='completed'model.completed_at=Time.currentsuperend# This runs after the transaction commits successfully# If there was an exception, we never get hereOrderMailer.completed(model).deliver_laterendendNote: Be careful with after_commit callbacks when using transactions. They fire after each transaction commits, not after all nested transactions complete.
Validation Errors: When
run(without bang) is called inside an open transaction, RailsOps wraps the operation in a SAVEPOINT so that any partial writes are rolled back before the caught validation error is converted into afalsereturn value.run_subbenefits from the same protection. Userun!/run_sub!when you want validation errors to propagate and roll back the surrounding transaction directly.External Services: Be careful when calling external services within transactions. Long-running external calls can cause database locks:
defperformActiveRecord::Base.transactiondomodel.save!# DON'T: This could lock the database for a long time# ExternalApi.slow_request(model)end# DO: Call external services after the transactionExternalApi.slow_request(model)end
Nested Transactions: Rails uses savepoints for nested transactions, which are fully supported by RailsOps operations.
While RailsOps certainly does not have to be used from a controller, it provides a mixin which extends controller classes with functionality that lets you easily instantiate and run operations.
Controller integration is designed to be non-intrusive and therefore has to be
installed manually. Add the following inclusion to the controllers in question
(usually the ApplicationController base class):
classApplicationControllerincludeRailsOps::ControllerMixinendThe basic concept behind controller integration is to instantiate and potentially run a single operation per request. Most of this guide refers to this particular use case. See section Multiple operations per request for more advanced solutions.
The following example shows the simplest way of setting and running an operation:
classSomeController < ApplicationControllerdefsome_actionrun!Operations::SomeOperationendendIn the previous example, we instantiated and ran an operation in a single statement. While this might be feasible for some "fire-and-forget" controller actions, you might want to separate instantiation from actually running an operation.
For this reason, RailsOps' controller integration is designed to always use
a two-step process: First the operation is instantiated and assigned to the
controller instance variable @op, and then it's possibly executed.
In the following example, we do exactly the same thing as in the previous one, but with separate instantiation and execution:
classSomeController < ApplicationControllerdefsome_action# The following line instantiates the given operation and assigns the# instance to `@op`.opOperations::SomeOperation# The following line runs the operation previously set using `op` using# the operations `run!` method. Note that `run` is available as well.run!endendThe methods run and run! always require you to previously instantiate an
operation using the op method.
This can be particularly useful for "combined" controller methods that either display a form or submit, i.e. based on the HTTP method used.
defupdate_username# As above operation extends RailsOps::Model, we can already access op.model# (i.e. in a form) without ever running the operation. Therefore, we# instantiate the operation even if it is a GET request.opOperations::User::UpdateUsername# In this example, the operation is only run on POST requests.ifrequest.post? && runredirect_tousers_pathendendUsing the method op?, you can check whether an operation has already been
instantiated (using op).
RailsOps conveniently provides you with a model instance method, which is a
shortcut for op.model. This is particularly useful since this is available as
a view helper method as well, see next section.
You can check whether a model is available by using the model? method, which
is available in both controllers and views.
The following controller methods are automatically provided as helper methods which can be used in views:
opmodelop?
It is very common to use model for your forms:
= form_for model do |f|
- # Form code goes here
As you've probably noticed in previous examples, we did not provide any parameters to the operation.
Per default, the params hash is automatically provided to the operation at
instantiation. To be more precise: The params hash is filtered not to include
certain fields (see {RailsOps::ControllerMixin::EXCEPT_PARAMS}) that are most
commonly not used by operations (e.g. the authenticity_token).
This is achieved using the private op_params method. Overwrite it to your
needs if you have to adapt it for the whole controller.
Alternatively, you can pass entirely custom params to an operation via the op
method:
opSomeOperation,some_param: 'some_value'You can also combine these two approaches:
# This example takes the pre-filtered op_params hash and applies another, custom# filter before passing it to the operation.opSomeOperation,some_param: op_params.slice(:some_param,:some_other_param)For security reasons, RailsOps automatically checks after each action whether authorization has been performed. This is to avoid serving an action's response without ever authorizing.
The check is run in the after_action named
ensure_operation_authorize_called! and only applies if an operation class has
been set.
Note that this check also doesn't apply if the corresponding operation uses
without_authorization (see section Disabling authorization for more
information on this).
You can disable authorization ensuring by setting the global config option
config.ensure_authorize_called = false.
When using the op method to instantiate an operation, a context is
automatically created. The following fields are set automatically:
params(as described in subsection Parameters)user(usescurrent_usercontroller method if available, otherwisenil)ability(usescurrent_abilitycontroller method if available, otherwisenil)session(uses thesessioncontroller method)url_options(uses theurl_optionscontroller method)
RailsOps does not currently support calling multiple operations in a single controller action out-of-the-box. You need to instantiate and run it manually.
Another approach is to create a parent operation which calls multiple sub-operations, see section Calling sub-operations for more information.
Operations support standard Ruby class inheritance. This is useful when multiple models share the same operation pattern. Create an abstract base operation and then inherit from it for each model:
# app/operations/base/toggle_active.rbmoduleOperations::BaseclassToggleActive < RailsOps::Operation::Model::Updateschema3doint!:idendprotecteddefperformmodel.active= !model.activesuperendendend# app/operations/category/toggle_active.rbmoduleOperations::CategoryclassToggleActive < Operations::Base::ToggleActivemodel ::Categoryendend# app/operations/tag/toggle_active.rbmoduleOperations::TagclassToggleActive < Operations::Base::ToggleActivemodel ::TagendendThe base class defines the common schema and behavior. Subclasses only need to
specify the model. This avoids duplicating logic across many operations.
Schemas, policies, and authorization settings are all inherited. Subclasses can add additional policies or override methods as needed.
Another common base class is for bulk insert operations:
moduleOperations::BaseclassBulkCreate < RailsOps::OperationBATCH_SIZE=500without_authorizationprotecteddefperformunique_ids=ids_to_insert.uniqreturnifunique_ids.empty?now=Time.currentunique_ids.each_slice(self.class::BATCH_SIZE)do |batch|
records=build_records(batch,now)target_class.insert_all!(records)rescueActiveRecord::RecordNotUnique# Race condition: filter out already-existing records and retryexisting=existing_ids_for(batch)new_records=records.reject{ |r| existing.include?(r[id_column])}target_class.insert_all!(new_records)ifnew_records.any?endendprivatedefids_to_insertfailNotImplementedErrorenddefbuild_records(_batch,_now)failNotImplementedErrorenddeftarget_classfailNotImplementedErrorenddefid_column:idenddefexisting_ids_for(_batch)failNotImplementedErrorendendendRailsOps features a generator to easily create a structure for common CRUD-style constructs. The generator creates the CRUD operations, some empty view files, a controller and adds an entry in the routing file.
This is e.g. useful when adding a new model to an application, as the basic structure is usually rather similar.
Run the generator using the operation generator, specifying the name of the
operation class:
railsgoperationUserThis will generate the following operations:
app/operations/user/load.rbapp/operations/user/create.rbapp/operations/user/update.rbapp/operations/user/destroy.rb
as well as the controller app/controllers/users_controller.rb and the following
empty view files:
app/views/users/index.html.hamlapp/views/users/show.html.hamlapp/views/users/new.html.hamlapp/views/users/edit.html.haml
It will also add the entry resources :users to the config/routes.rb file.
If you want to skip the controller, the views or the routes, you can do so using the flags:
--skip-controller--skip-routes--skip-views
Or if you want to skip them all: --only-operations.
If you want to skip a certain action, you can do so using the flags:
--skip-index--skip-show--skip-create--skip-update--skip-destroy
This will skip the creation of the respective route, controller action, view file and the operation itself.
For --skip-create, the new action will also be skipped and for --skip-update, the edit action will be skipped respectively.
You can also add a module as a namespace, all generated files will be put in
the proper subfolders and modules by using the --module option.
As an example:
railsgoperationUser --moduleAdminThis will generate the following operations:
app/operations/admin/user/load.rbapp/operations/admin/user/create.rbapp/operations/admin/user/update.rbapp/operations/admin/user/destroy.rb
These operations will be namespaced in the Admin module, e.g. app/operations/admin/user/load.rb will define Operations::Admin::User::Load.
It will also generate the controller app/controllers/admin/users_controller.rb and the following
empty view files:
app/views/admin/users/index.html.hamlapp/views/admin/users/show.html.hamlapp/views/admin/users/new.html.hamlapp/views/admin/users/edit.html.haml
Both lower- and uppercase will generate the same files (i.e. --module Admin and --module admin are equal).
You can even nest the generated files deeper, --module Admin::Foo and --module admin/foo will both work.
Of course, at this point, the operations will need some adaptions, especially the parameter schemas, and the controllers need the logic for the success and failure cases, as this depends on your application.
RailsOps provides the following Rails Lazy Load Hooks:
rails_ops_op_model_createforRailsOps::Operation::Model::Createrails_ops_op_model_destroyforRailsOps::Operation::Model::Destroyrails_ops_op_model_loadforRailsOps::Operation::Model::Loadrails_ops_op_model_updateforRailsOps::Operation::Model::Updaterails_ops_opforRailsOps::Operation
Example usage:
ActiveSupport.on_load(:rails_ops_op_model_create){includeMyMixin}Eager loading operation classes containing models with nested models or
operations can be very slow in performance. In production mode, the same process
is very fast and not an issue at all. To work around this problem, make sure you
exclude your operation classes (i.e. app/operations) in your
config.eager_load_paths of development.rb. Make sure not to touch this
setting in production mode though.
This gem is heavily inspired by the trailblazer gem which provides a wonderful, high-level architecture for Rails – beyond just operations. Be sure to check this out when trying to decide on an alternative Rails architecture.
Copyright © 2017 - 2026 Sitrox. See LICENSE for further details.