Pattern
A collection of lightweight, standardized, rails-oriented patterns used by RubyOnRails Developers @ Selleo
- Query - complex querying on active record relation
- Service - useful for handling processes involving multiple steps
- Collection - when in need to add a method that relates to the collection as whole
- Form - when you need a place for callbacks, want to replace strong parameters or handle virtual/composite resources
- Calculation - when you need a place for calculating a simple value (numeric, array, hash) and/or cache it
Installation
# Gemfile
#...
gem "rails-patterns"
#...
Then bundle install
Query
When to use it
One should consider using query objects pattern when in need to perform complex querying on active record relation. Usually one should avoid using scopes for such purpose. As a rule of thumb, if scope interacts with more than one column and/or joins in other tables, it should be moved to query object. Also whenever a chain of scopes is to be used, one should consider using query object too. Some more information on using query objects can be found in this article.
Assumptions and rules
- Query objects are always used by calling class-level
.callmethod - Query objects require
ActiveRecord::RelationorActiveRecord::Baseas constructor argument - Default relation (see above) can be defined by using
queriesmacro - Query objects have to implement
#querymethod that returnsActiveRecord::Relation - Query objects provide access to consecutive keyword arguments using
#optionshash
Other
Because of the fact, that QueryObject implements .call method, those can be used to construct scopes if required. (read more...)
Examples
Declaration
class RecentlyActivatedUsersQuery < Patterns::Query
queries User
private
def query
relation.active.where(activated_at: date_range)
end
def date_range
.fetch(:date_range, default_date_range)
end
def default_date_range
Date.yesterday.beginning_of_day..Date.today.end_of_day
end
end
Usage
RecentlyActivatedUsersQuery.call
RecentlyActivatedUsersQuery.call(User.without_test_users)
RecentlyActivatedUsersQuery.call(date_range: Date.today.beginning_of_day..Date.today.end_of_day)
RecentlyActivatedUsersQuery.call(User.without_test_users, date_range: Date.today.beginning_of_day..Date.today.end_of_day)
class User < ApplicationRecord
scope :recently_activated, RecentlyActivatedUsersQuery
end
Service
When to use it
Service objects are commonly used to mitigate problems with model callbacks that interact with external classes (read more...). Service objects are also useful for handling processes involving multiple steps. E.g. a controller that performs more than one operation on its subject (usually a model instance) is a possible candidate for Extract ServiceObject (or Extract FormObject) refactoring. In many cases service object can be used as scaffolding for replace method with object refactoring. Some more information on using services can be found in this article.
Assumptions and rules
- Service objects are always used by calling class-level
.callmethod - Service objects have to implement
#callmethod - Calling service object's
.callmethod executes#calland returns service object instance - A result of
#callmethod is accessible through#resultmethod - It is recommended for
#callmethod to be the only public method of service object (besides state readers) - It is recommended to name service object classes after commands (e.g.
ActivateUserinstead ofUserActivation)
Other
A bit higher level of abstraction is provided by business_process gem.
Examples
Declaration
class ActivateUser < Patterns::Service
def initialize(user)
@user = user
end
def call
user.activate!
NotificationsMailer.user_activation_notification(user).deliver_now
user
end
private
attr_reader :user
end
Usage
user_activation = ActivateUser.call(user)
user_activation.result # <User id: 5803143, email: "[email protected] ...
Collection
When to use it
One should consider using collection pattern when in need to add a method that relates to the collection a whole.
Popular example for such situation is for paginated collections, where for instance #current_page getter makes sense only in collection context.
Also collections can be used as a container for mapping or grouping logic (especially if the mapping is not 1-1 in terms of size).
Collection might also act as a replacement for models not inheriting from ActiveRecord::Base (e.g. StatusesCollection, ColorsCollection etc.).
What is more, collections can be used if we need to encapsulate "flagging" logic - for instance if we need to render a separator element between collection elements based on some specific logic, we can move this logic from view layer to collection and yield an additional flag to control rendering in view.
Assumptions and rules
- Collections include
Enumerable - Collections can be initialized using
.new,.fromand.for(aliases) - Collections have to implement
#collectionmethod that returns object responding to#each - Collections provide access to consecutive keyword arguments using
#optionshash - Collections provide access to first argument using
#subject
Examples
Declaration
class ColorsCollection < Patterns::Collection
AVAILABLE_COLORS = { red: "#FF0000", green: "#00FF00", blue: "#0000FF" }
private
def collection
AVAILABLE_COLORS
end
end
class CustomerEventsByTypeCollection < Patterns::Collection
private
def collection
subject.
events.
group_by(&:type).
transform_values{ |events| events.map{ |e| e.public_send(.fetch(:label_method, "description")) }}
end
end
Usage
ColorsCollection.new
CustomerEventsByTypeCollection.for(customer)
CustomerEventsByTypeCollection.for(customer, label_method: "name")
Form
When to use it
Form objects, just like service objects, are commonly used to mitigate problems with model callbacks that interact with external classes (read more...).
Form objects can also be used as replacement for ActionController::StrongParameters strategy, as all writable attributes are re-defined within each form.
Finally form objects can be used as wrappers for virtual (with no model representation) or composite (saving multiple models at once) resources.
In the latter case this may act as replacement for ActiveRecord::NestedAttributes.
In some cases FormObject can be used as scaffolding for replace method with object refactoring. Some more information on using form objects can be found in this article.
Assumptions and rules
- Forms include
ActiveModel::Validationsto support validation. - Forms include
Virtus.modelto supportattributestatic method with all corresponding capabilities. - Forms can be initialized using
.new. - Forms accept optional resource object as first constructor argument.
- Forms accept optional attributes hash as latter constructor argument.
- Forms have to implement
#persistmethod that returns falsey (if failed) or truthy (if succeeded) value. - Forms provide access to first constructor argument using
#resource. - Forms are saved using their
#saveor#save!methods. - Forms will attempt to pre-populate their fields using
resource#attributesand public getters forresource - Form's fields are populated with passed-in attributes hash reverse-merged with pre-populated attributes if possible.
- Forms provide
#asbuilder method that populates internal@form_ownervariable (can be used to store current user). - Forms allow defining/overriding their
#param_keymethod result by using.param_keystatic method. This defaults to#resource#model_name#param_key. - Forms delegate
#persisted?method to#resourceif possible. - Forms do handle
ActionController::Parametersas attributes hash (usingto_unsafe_h) - It is recommended to wrap
#persistmethod in transaction if possible and if multiple model are affected.
Examples
Declaration
class UserForm < Patterns::Form
param_key "person"
attribute :first_name, String
attribute :last_name, String
attribute :age, Integer
attribute :full_address, String
attribute :skip_notification, Boolean
validate :first_name, :last_name, presence: true
private
def persist
update_user and
update_address and
deliver_notification
end
def update_user
resource.update_attributes(attributes.except(:full_address, :skip_notification))
end
def update_address
resource.address.update_attributes(full_address: full_address)
end
def deliver_notification
skip_notification || UserNotifier.user_update_notification(user, form_owner).deliver
end
end
class ReportConfigurationForm < Patterns::Form
param_key "report"
attribute :include_extra_data, Boolean
attribute :dump_as_csv, Boolean
attribute :comma_separated_column_names, String
attribute :date_start, Date
attribute :date_end, Date
private
def persist
SendReport.call(attributes)
end
end
Usage
form = UserForm.new(User.find(1), params[:person])
form.save
form = UserForm.new(User.new, params[:person]).as(current_user)
form.save!
ReportConfigurationForm.new
ReportConfigurationForm.new({ include_extra_data: true, dump_as_csv: true })
Calculation
When to use it
Calculation objects provide a place to calculate simple values (i.e. numeric, arrays, hashes), especially when calculations require interacting with multiple classes, and thus do not fit into any particular one. Calculation objects also provide simple abstraction for caching their results.
Assumptions and rules
- Calculations have to implement
#resultmethod that returns any value (result of calculation). - Calculations do provide
.set_cache_expiry_everymethod, that allows defining caching period. - When
.set_cache_expiry_everyis not used, result is not being cached. - Calculations return result by calling any of following methods:
.calculate,.result_foror.result. - First argument passed to calculation is accessible by
#subjectprivate method. - Arguments hash passed to calculation is accessible by
#optionsprivate method. - Caching takes into account arguments passed when building cache key.
- To build cache key,
#cache_keyof each argument value is used if possible. - By default
Rails.cacheis used as cache store.
Examples
Declaration
class AverageHotelDailyRevenue < Patterns::Calculation
set_cache_expiry_every 1.day
private
def result
reservations.sum(:price) / days_in_year
end
def reservations
Reservation.where(
date: (beginning_of_year..end_of_year),
hotel_id: subject.id
)
end
def days_in_year
end_of_year.yday
end
def year
.fetch(:year, Date.current.year)
end
def beginning_of_year
Date.new(year).beginning_of_year
end
def end_of_year
Date.new(year).end_of_year
end
end
Usage
hotel = Hotel.find(123)
AverageHotelDailyRevenue.result_for(hotel)
AverageHotelDailyRevenue.result_for(hotel, year: 2015)
TotalCurrentRevenue.calculate
AverageDailyRevenue.result