Every Lego is an assembly of bricks.
Every piece of software is an assembly of parts.
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.
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.
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
- 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
# 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
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
Controller
AcceptInvitation
CreateUserFromInvitation
PayAffiliate
Unix has pipes
Elixir has pipes and streams
What Ruby has to offer?
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.
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
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
.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
}
Check the github repository.
Contains full api details, examples of code, examples of how to spec.
Ping me @apneadiving