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!

more presentations here

 

@apneadiving

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.

  • 5,502
Loading comments...

More from Benjamin Roth