Service has only one `success`
Most of the time it is relatively easy to follow this rule.
Consider the following service:
class BuildDisplayName
include ::ConvenientService::Standard::Config
attr_reader :person
def initialize(person:)
@person = person
end
def result
return error("Person can't be blank") if person.blank?
success(display_name: "#{person.first_name} #{persom.last_name}")
end
end
It currently has no conditional logic to build the display_name
.
After a while, our system started to grow and it appears that some people would like to see their pseudo in place of display names.
Let's reflect on that change in the code using a naive approach:
class BuildDisplayName
include ::ConvenientService::Standard::Config
attr_reader :person
def initialize(person:)
@person = person
end
def result
return error("Person can't be blank") if person.blank?
if person.has_pseudonym?
success(display_name: person.pseudonym)
else
success(display_name: "#{person.first_name} #{persom.last_name}")
end
end
end
Having two success
calls inside the result
is what we try to avoid.
Why?
-
It encourages future devs to write deeply nested conditionals.
-
It interferes with the layering of information.
You don't need to know the details of the implementation until it is really necessary.
The code above tells you about pseudonyms too early, even if you are not interested in them.
If you refactor BuildDisplayName
like this:
class BuildDisplayName
include ::ConvenientService::Standard::Config
def initialize(person:)
@person = person
end
def result
return error("Person can't be blank") if person.blank?
success(display_name: display_name)
end
private
def display_name
@display_name ||= person.has_pseudonym? ? person.pseudonym : "#{person.first_name} #{persom.last_name}"
end
end
Then the result
describes which data it may return and when it is NOT successful, just that.
Only when you are curious about how display_name
is exactly built, feel free to check private methods.
This is how the layering of information works in practice.
Nesting is replaced by a flat ternary operator.
It is time to have a look at the specs:
# spec/services/assert_file_exists_spec.rb
require "spec_helper"
RSpec.describe BuildDisplayName do
include ConvenientService::RSpec::Matchers::Results
example_group "class methods" do
describe ".result" do
let(:result) { described_class.result(person: person) }
context "when building of display name is NOT successful" do
context "when `person` is blank" do
let(:person) { nil }
it "returns `error`" do
expect(result).to be_error.with_message("Person can't be blank").of_service(described_class).without_step
end
end
end
context "when building of display name is successful" do
context "when `person` does NOT have pseudonym" do
let(:person) { Person.new("John", "Doe") }
it "returns `success` with first name and last name as display name" do
expect(result).to be_success.with_data(display_name: "John Doe").of_service(described_class).without_step
end
end
context "when `person` has pseudonym" do
let(:person) { Person.new("John", "Doe", preudonym: "Gorilla") }
it "returns `success` with pseudonym as display name" do
expect(result).to be_success.with_data(display_name: "Gorilla").of_service(described_class).without_step
end
end
end
end
end
end
As you can see, specs still have two contexts for successful cases and that is completely OK.
Now, we are ready for the conclusion:
A fact that a Service has only one success
actually means that the service calls success
inside result
only once.
But that success
can still have multiple variations of data values, just like display_name
in BuildDisplayName
service.