Code Ruby like you build Lego
What do I mean?
Every Lego is an assembly of bricks.
Every piece of software is an assembly of parts.
Ruby Bricks?
The Service Object pattern lets you define classes which represent the parts of your business logic.
By convention each Service Object is named out of a verb: it's an action.
Service Objects are bricks.
What is a service object?
It's a simple Ruby class.
It must have a precise action name (LaunchProject, CreateContact)
It's scope is usually limited: specialised object, reusable and easy to test.
Overengineering?
You might wonder what is the point to create an object like LaunchProject.
This object has something models lack: Context.
No more (conditional) callbacks.
All logic gathered in its own (relevant!) box.
No more fat models nor controllers.
# users controller
def create_from_invitation
if invitation.accepted?
render json: { errors: ['Invitation already accepted'] }, status: 422
else
user = User.build_from_invitation(invitation)
user.save!
invitation.accept
invitation.save!
invitation.pay_affiliation_fee
UserMailer.notify_affiliate_payment(invitation.sender).deliver_later
rescue ActiveRecord::ActiveRecordError => e
render json: { errors: [e.message] }, status: 422
end
#User model
after_create :send_welcome_email
def send_welcome_email
if invitations.any?
UserMailer.affiliate_welcome(self).deliver_later
end
end
#Invitation
def pay_affiliation_fee
# complex logic here
end
What we don't want
- code all over the place
- not reusable
- not (easily) testable
- callback
(and no db transaction)
# users controller
def create_from_invitation
if invitation.accepted?
render json: { errors: ['Invitation already accepted'] }, status: 422
else
user = User.build_from_invitation(invitation)
user.save!
invitation.accept
invitation.save!
invitation.pay_affiliation_fee
UserMailer.notify_affiliate_payment(invitation.sender).deliver_later
rescue ActiveRecord::ActiveRecordError => e
render json: { errors: [e.message] }, status: 422
end
#User model
after_create :send_welcome_email
def send_welcome_email
if invitations.any?
UserMailer.affiliate_welcome(self).deliver_later
end
end
#Invitation
def pay_affiliation_fee
# complex logic here
end
# users controller
def create_from_invitation
if invitation.accepted?
render json: { errors: ['Invitation already accepted'] }, status: 422
else
user = User.build_from_invitation(invitation)
user.save!
invitation.accept
invitation.save!
invitation.pay_affiliation_fee
UserMailer.notify_affiliate_payment(invitation.sender).deliver_later
rescue ActiveRecord::ActiveRecordError => e
render json: { errors: [e.message] }, status: 422
end
#User model
after_create :send_welcome_email
def send_welcome_email
if invitations.any?
UserMailer.affiliate_welcome(self).deliver_later
end
end
#Invitation
def pay_affiliation_fee
# complex logic here
end
1- CreateUserFromInvitation
2- PayAffiliate
AcceptInvitation
Extract Business Logic!
# users controller
def create_from_invitation
if invitation.accepted?
render json: { errors: ['Invitation already accepted'] }, status: 422
else
user = User.build_from_invitation(invitation)
user.save!
invitation.accept
invitation.save!
invitation.pay_affiliation_fee
UserMailer.notify_affiliate_payment(invitation.sender).deliver_later
rescue ActiveRecord::ActiveRecordError => e
render json: { errors: [e.message] }, status: 422
end
#User model
after_create :send_welcome_email
def send_welcome_email
if invitations.any?
UserMailer.affiliate_welcome(self).deliver_later
end
end
#Invitation
def pay_affiliation_fee
# complex logic here
end
Assemble Service Objects?
class AcceptInvitation < Struct.new(:invitation)
def call
create_user = CreateUserFromInvitation.new(invitation).call
raise_error unless create_user.success?
pay_affiliate = PayAffiliate.new(invitation).call
raise_error unless pay_affiliate.success?
invitation.accept
raise_error unless invitation.save
rescue AcceptInvitationError
@success = false
ensure
self
end
class AcceptInvitationError < StandardError; end
def success?; @success != false; end
def raise_error; raise AcceptInvitationError; end
end
this feels like
Our Goal
Controller
AcceptInvitation
CreateUserFromInvitation
PayAffiliate
How to elegantly assemble?
Unix has pipes
Elixir has pipes and streams
What Ruby has to offer?
Assemble with Ruby
I've created Waterfall, a dedicated gem, because I needed this feature.
It keeps on chaining service objects when everything goes well.
It stops the assembly if something goes wrong.
Assemble with Waterfall
class AcceptInvitation < Struct.new(:invitation)
include Waterfall
def call
self
.chain(user: :user) { CreateUserFromInvitation.new(invitation) }
.chain { PayAffiliate.new(invitation) }
.when_falsy { invitation.accept.tap(&:save) }
.dam { invitation.errors }
end
end
# we assume all services include Waterfall
More code down there if you wish
class CreateUserFromInvitation < Struct.new(:invitation)
include Waterfall
include ActiveModel::Validations
validate :correct_invitation_status
def call
self
.when_falsy { valid? }
.dam { errors }
.chain(:user) { User.build_from_invitation(invitation) }
.when_falsy { outflow.user.save }
.dam { outflow.user.errors }
.chain { UserMailer.affiliate_welcome(outflow.user).deliver_later }
end
private
def correct_invitation_status
errors.add(:invitation_status, "Invitation already accepted") if invitation.accepted?
end
end
class PayAffiliate < Struct.new(:invitation)
include Waterfall
def call
self
.chain { pay_affiliation_fee }
.chain { notify }
end
private
def affiliate
invitation.sender
end
def pay_affiliation_fee
# complex logic here based on affiliate
end
def notify
UserMailer.notify_affiliate_payment(affiliate).deliver_later
end
end
# controller
def create_from_invitation
Wf.new
.chain(user: :user) { AcceptInvitation.new(invitation) }
.chain {|outflow| render json: outflow.user }
.on_dam {|errors| render json: { errors: errors.full_messages }, status: 422 }
end
# User model
# => no more code
# Invitation model
# => no more code
Assemble with Waterfall
class AcceptInvitation < Struct.new(:invitation)
include Waterfall
def call
self
.chain(user: :user) { CreateUserFromInvitation.new(invitation) }
.chain { PayAffiliate.new(invitation) }
end
end
# we assume all services include Waterfall
- reusable objects
- testable objects
- model and controller empty
- business logic made clear
Chaining API
.chain(:foo) {
# add `foo` to local outflow, which value will
# be what the block returns
}
.chain(bar: :baz) {
# block returns a waterfall which exposes `baz`
# and we rename it `bar` in the local outflow
}
.chain { |outflow|
# outflow is a mere OpenStruct
# in our example, we could use outflow.foo and outflow.bar
}
.when_falsy { some_expression }
.dam {
# you can dam with anything you want, but let it be explanatory
}
.when_truthy { some_other_expression }
.dam {}
.on_dam {|error_pool|
# the error pool contains the value returned by a `dam` in the chain
# or even by any dam from the chained waterfalls
}
Want to know more?
Check the github repository.
Contains full api details, examples of code, examples of how to spec.
Ping me @apneadiving
Happy Building!
code ruby like you build lego
By Benjamin Roth
code ruby like you build lego
Version 2 with clearer example. Split your app based on business logic bricks and assemble them.
- 8,318