Structure and chain your POROs

Benjamin Roth

Disclaimer

I wont provide any truth

credit @ Christy Justice 

But hopefuly some directions

credit videobash

PORO ???

Poro, a secret society of Sierra Leone

and Liberia (credit wikimedia)

 

Poor Old Ruby Object

- terrible naming

- but ...

Good Old Ruby Object

              => GORO

Poor Old Ruby Object

GORO (from Mortal Kombat)

Poor Old Ruby Object

Anyway...

  • naming in programming...

  • encapsulates logic

  • leading to popular use...

Service Object

also named:

- Application Service

- Operation

- Command/Strategy pattern

- Method Object

(- sigh, naming ...)

Definition (sort of)

  • Shows what your app does
    => Express Business Logic
  • Framework independent
  • Single purpose == specialized
  • Knows context by definition
    (no conditional callbacks, no conditional validations etc...)

Debated for years

Its a design pattern

many articles in the ruby/rails community

Gems

Books!

Mostly agreed on

  • naming (!)

  • few public methods
  • test

Naming

Named out of a verb: it's an action

(LaunchProject, CreateContact)

Few public methods

As few as possible:

- one to trigger the object

- ... then it depends on what shape you choose, examples to come later in the slides

Test

- reusable (ex: Builder Service as factory)

- since Service Objects represent user real actions, you can setup you tests by chaining them to create a realistic context

Mostly disputed 

  • triggering the action

  • args coercion

  • file structure

  • response

  • failure handling

Triggering

# INSTANCE BASED

# args in initializer
ExtractPictures.new(docx_file).call
ExtractPictures.new(docx_file).run
ExtractPictures.new(docx_file).perform

# args to performing method
ExtractPictures.new.call(docx_file)
ExtractPictures.new.run(docx_file)
ExtractPictures.new.perform(docx_file)
# CLASS BASED 

ExtractPictures.call(docx_file)
ExtractPictures.run(docx_file)
ExtractPictures.perform(docx_file)

ExtractPictures.(docx_file)

Args coercion

Virtus and the like

is it even necessary?

File Structure 

(in Rails...)

app
├── services
│   ├── create_account_service.rb => ::CreateAccountService

app
├── services
│   ├── create_account.rb         => ::CreateAccount

app
├── concepts
│   ├── account
│   │   ├── operation
│   │   │   ├── create.rb         => ::Account::Create (load trick involved)

app
├── services
│   ├── services
│   │    ├── create_account.rb    => ::Services::CreateAccount

Response?

Plenty of options here:

- dedicated response object

- boolean

- the service itself with public methods

- none: event based

Handling Failure

How to get proper flow control?

Optimistic code

class Service
  def initialize(user_id)
    @user_id = user_id
  end

  def call
    response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    response.body
  end
end

useful_data = Service.new(1).call
puts(useful_data)

# and hope for the best
class ExceptionService
  class RequestError < StandardError; end

  def initialize(user_id)
    @user_id = user_id
  end

  def call
    response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    if response.success?
      response.body
    else
      raise RequestError.new("Error status #{response.code}")
    end
  end
end

begin
  useful_data = ExceptionService.new(1).call
  puts(useful_data)
rescue ExceptionService::RequestError => e
  puts(e.message)
end

Exception

class BooleanService
  attr_reader :body, :error
  def initialize(user_id)
    @user_id = user_id
  end

  def call
    response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    if response.success?
      @body = response.body
      @success = true
    else
      @error = "Error status #{response.code}"
      @success = false
    end
  end

  def success?; !!@success; end
end

service = BooleanService.new(1)
service.call
if service.success?
  puts(service.body)
else
  puts(service.error)
end

Boolean

class EventService
  include Wisper::Publisher
  def initialize(user_id)
    @user_id = user_id
  end

  def call
    response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    if response.success?
      broadcast(:success, response.body)
    else
      broadcast(:error, "Error status #{response.code}")
    end
  end
end

service = EventService.new(1)
service.on(:success) {|res| puts res }
service.on(:error)   {|err| puts err }
service.call

Event based

class MonadicService
  def initialize(user_id)
    @user_id = user_id
  end

  def call
    response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    if response.success?
      Success(response.body)
    else
      Failure("Error status #{response.code}")
    end
  end
end

Success({})
  .bind { MonadicService.new(1).call) }
  .bind {|res| puts(res);   true  }
  .or   {|err| puts(error); false }

Monads

class WfService
  include Waterfall
  def initialize(user_id)
    @user_id = user_id
  end

  def call
    chain do
      @response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}")
    end
    when_falsy { @response.success? }
      .dam { "Error status #{@response.code}" }
    chain(:body) { @response.body }
  end
end

Wf.new
  .chain(result: :body) { WfService.new(1) }
  .chain  {|outflow| puts(outflow.result)  }
  .on_dam {|error|   puts(error)   }

Waterfall

Chaining Properly

Padlocks, Pont des arts - Paris

Why?

pdf      = ConvertFile.new(file: file, format: 'pdf').call
pictures = ExtractPictures.new(file: pdf).call
segments = ExtractTranslationSegments.new(file: pdf).call

Useful whenever you have small specialized/reusable service objects

Exception

begin
  useful_data1 = ExceptionService.new(1).call
  useful_data2 = ExceptionService.new(404).call
  puts(useful_data1, useful_data2)
rescue ExceptionService::RequestError => e
  puts(e.message)
end
service1 = BooleanService.new(1)
service1.call
if service1.success?
  service2 = BooleanService.new(404)
  service2.call
  if service2.success?
    puts(service1.body, service2.body)
  else
    puts service2.error
  end
else
  puts service1.error
end

Boolean

service1 = EventService.new(1)
service1.on(:success) do |res1|
  service2 = EventService.new(404)
  service2.on(:success) {|res2| puts(res1, res2) }
  service2.on(:error)   {|err| puts err }
  service2.call
end
service1.on(:error) {|err| puts err }
service1.call

Event based

Success({})
  .bind {|outflow| MonadicService.new(1).call.bind   {|r| outflow.merge({ value1: r })}}
  .bind {|outflow| MonadicService.new(404).call.bind {|r| outflow.merge({ value2: r })}}
  .bind {|outflow| puts(outflow[:value1], outflow[:value2]); true }
  .or   {|error|   puts(error); false }

Monads

Wf.new
  .chain(result1: :body) { WfService.new(1)   }
  .chain(result2: :body) { WfService.new(404) }
  .chain  {|outflow| puts(outflow.result1, outflow.result2) }
  .on_dam {|error|   puts(error)  }

Waterfall

Recap

  • first step to nicer architecture

  • actually no matter the architecture you choose, SO serve as facade and remain untouched

 

 

Structure and chain your POROS

By Benjamin Roth

Structure and chain your POROS

Current state of the art of POROS + chaining guidance

  • 5,288