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