Structure and chain your POROs
Benjamin Roth
Disclaimer
I wont provide any truth
credit @ Christy Justice
But hopefuly some directions
credit videobash
PORO ???
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,498