Object Oriented Design Principles And Patterns
Contents |
7-ways-to-decompose-fat-activerecord-models suggestions for code organization.
You can organize domain (controller, model and view) into separate folders drawers
https://thoughtbot.com/upcase/intermediate-ruby-on-rails https://code.tutsplus.com/articles/a-beginners-guide-to-design-patterns–net-12752
Three basic kind of design pattern:
- structural
- creational
- behavioral
https://www.youtube.com/watch?v=3SGRjUWScRw&feature=youtu.be
Instead of
class Member < ActiveRecord::Base
has_many :email_addresses
has_one :primary_email_address, conditions: { primary: true }
end
Use belongs_to on members
class Member < ActiveRecord::Base
has_many :email_addresses
belongs_to :primary_email_address, class_name: "EmailAddress", foreign_key: :email_address_id
end
https://thoughtbot.com/blog/handling-associations-on-null-objects
DHH
- epipsode 1: do not use comments but method names or constants… follow
table of content: method definition should be in same order as they are used
in the class (in before callbacks)
administered concern define methods on associations. Also use
or
|
to join two arrays
module Account::Administered
extend ActiveSupport::Concern
included do
has_many :administratorships, dependent: :delete_all do
def grant(person)
create_or_find person: person
end
def revoke(person)
where(person_id: person.id).destroy_all
end
end
has_many :administrators, through: :administratorships, source: :person
end
def all_administrators
administrators | all_owners
end
def administrator_candidates
people.users.
where.not(id: administratorships.pluck(:person_id)).
where.now(id: ownerships.pluck(:person_id)).
where.not(id: owner_person.id)
end
end
- episode 2: use callbacks to initiate background jobs
- episode 3: use globals in request/response cycle. For background jobs you need to pass them as params.
- episode 4
Rails Patterns
Pawel Daborwski Patterns are used for benefit of:
- isolation: db/payment should be tested separately
- readability: class name should tell what given code is doing
- extendability: it should be easy to modify by changing in single place
- single responsibility: one method for one action
Policy object
It is used to check if someone is allowed to do the action. File name should use
_policy
suffix. Return only boolean value. Inside method, only call methods on
passed objects.
class UsersPolicy
def initialize(user)
@user = user
end
def admin?
@user.role == 'admin'
end
end
Query object
Complex queries should be extracted and tested separatelly. File name could be
app/queries/users/list_active_users_query.rb
for single query, or
app/queries/users_query.rb
for multiple #active
and #suspended
methods.
For single query you can use class methods
module Users
class ListActiveUsersQuery
def self.call
User.where(status: 'active').where.not(email: nil)
end
end
end
for multiple you can use poro
class UsersQuery
def initialize(users = User.all)
@users = users
end
def active
@users.where(active: true)
end
def pending
@users.where(pending: true)
end
end
and you can chaining query = UsersQuery.new
and query.active.pending
.
You can use https://apidock.com/rails/ActiveRecord/QueryMethods/extending to
extend scopes.
Service objects
Always define only one public method: call
, perform
or process
# app/services/match_posts.rb
def MatchPosts
def initialize(posts)
@posts = posts
end
def perform
end
end
You should return object that can be success and hold some data
# app/models/result.rb
class Result
attr_accessor :message, :data
# you can return in service like:
# return Result.new 'Next task created', next_task: next_task
# and use in controller:
# if result.success? && result.data[:next_task] == task
def initialize(message, data = {})
@message = message
@data = data
end
def success?
true
end
end
# app/models/error.rb
class Error < Result
def success?
false
end
end
and you can rescue from exceptions:
# app/services/my_service.rb
class MyService
# Some custom exception if needed
class ProcessException < Exception
end
def initialize(h)
@user = h[:user]
end
def process(posts)
success_message = do_something posts
Result.new success_message
rescue ProcessException => e
Error.new e.message
end
private
def do_something(posts)
raise ProcessException, "Error: empty posts" unless posts
"Done with do_something"
end
end
# main.rb
require './my_service.rb'
my_service = MyService.new user: 'me'
puts my_service.process(1).success? # true
puts my_service.process(1).message # Done with do_something
puts my_service.process(false).success? # false
puts my_service.process(false).message # empty posts
Observer
We an use observable objects to send notifications implementation in 3 languages It could looks like action as a distance antipattern but if we explicitly add than is it fine. It is called publish/subscriber pattern. It allows you to observe one object and when it’s changed then trigger certain code in other objects that are subscribing the main object.
module ObservableImplementation
def observers
@observers ||= []
end
def notify_observers(*args)
observers.each do |observer|
observer.update(*args)
end
end
def add_observer(object)
observers << object
end
end
class Task
include ObservableImplementation
attr_accessor :counter
def initialize
self.counter = 0
end
def tick
self.counter += 1
notify_observers(counter)
end
end
class PutsObserver
def initialize(observable)
observable.add_observer self
end
def update(counter)
puts "Count has increased by #{counter}"
end
end
class DotsObserver
def initialize(observable)
observable.add_observer self
end
def update(counter)
puts "." * counter
end
end
task = Task.new
task.tick
DotsObserver.new(task)
task.tick # ..
PutsObserver.new(task)
task.tick # ...
# Count has increased by 3
In ruby there are
https://docs.ruby-lang.org/en/2.2.0/Observable.html
where you call changed
and notify_observers params
(which will set changed
to false).
You need to call task.add_observer DotsObserver.new
.
Interactor
Service object is similar to Command pattern which is implemented in gem https://github.com/collectiveidea/interactor
When does its single purpose, it affects tis given context
context.user = user
context.fail! error: 'Boom'
# .call will swallow exception Interactor::Failure
context.success? # false
Before and after hooks for preparing data. Before hooks are invoked in the order they were defined.
before do
context.emails_sent = 0
end
after :reload # note that this will not be run if `fail!` is called
Example
# app/interactors/authenticate_user.rb
class AuthenticateUser
include Interactor
def call
if user = User.authenticate(context.email, context.password)
context.user = user
context.token = user.secret_token
else
context.user = User.new email: context.email
context.user.errors.add :email, 'wrong email or password'
context.fail!(message: "authenticate_user.failure")
end
end
end
and use in controller like
result = AuthenticateUser.call(session_params)
if result.success?
session[:user_token] = result.token
redirect_to dashboard_path
else
@user = result.user
render :new
end
Second type of interactor is Organizer which is used to run other interactors.
class PlaceOrder
include Interactor::Organizer
organize CreateOrder, ChargeCard
end
In this case then ChargeCard
fails we can use CreateOrder#rollback
method to
revert changes.
In test, you can stub other method calls and cover 100% of unit interactor tests than write integration or acceptance tests to test wires.
Decorator
It is used to decorate object, ie catch method missing and send to object in initializer on runtime. This is different than subclassing since it happens in run-time.
# app/decorators/user_profile_decorator < SimpleDelegator
class UserProfileDecorator < SimpleDelegator
def model
__getobj__
end
def name
"#{model.first_name} #{model.last_name}"
end
end
usage
user = Struct.new(:first_name, :last_name).new("John", "Doe")
decorator = UserProfileDecorator.new(user)
decorator.first_name # => "John"
decorator.name # => "John Doe"
Gem that supports decorating collections and associations https://github.com/drapergem/draper
Another use case if for dependency injection, for example you have two pdf generators than create graphs and you want to create another pdf that include both graphs. https://www.honeybadger.io/blog/decoupling-ruby-delegation-dependency-injection/
class PrawnWrapper < SimpleDelegator
def initialize(document: nil)
document ||= Prawn::Document.new(...)
super(document)
end
end
and call others using injection
class OverviewReport < PrawnWrapper
...
def render
sales = SaleReport.new(..., document: self)
sales.sales_table
costs = CostReport.new(..., document: self)
costs.costs_pie_chart
...
end
end
Facade pattern
page 37
Tips
- Builder pattern is usefull to provide flexibility for example https://github.com/rails/rails/blob/main/actionview/lib/action_view/helpers/tags/label.rb#L7