Code Ruby like you build Lego
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003533/Lego_Clip_Art_5798.jpg)
What do I mean?
Every Lego is an assembly of bricks.
Every piece of software is an assembly of parts.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003592/lego-plan.jpg)
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.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003539/Lego_dimensions.svg.png)
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.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003629/lego-detail.png)
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.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003692/lego_workmen_harddrive.jpg)
# 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)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2280746/grey.png)
# 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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2280746/grey.png)
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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2004860/lego_random.jpg)
Our Goal
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2004861/lego_bricks.jpg)
Controller
AcceptInvitation
CreateUserFromInvitation
PayAffiliate
How to elegantly assemble?
Unix has pipes
Elixir has pipes and streams
What Ruby has to offer?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003614/lego-bricks.jpg)
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.
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003706/ship7.jpg)
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
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2280746/grey.png)
- 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!
![](https://s3.amazonaws.com/media-p.slid.es/uploads/187318/images/2003758/the-lego-movie-awesome-e1392309318427.png)
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,163