Services with `or` conditionals
Convenient Service shines very brightly when you have and
conditional logic.
It is very easy to construct such behavior using the step
DSL.
Check out the following service:
class RefreshSubscription
include ::ConvenientService::Standard::Config
attr_reader :user, :subscription_id
step IsSubscriptionOwner,
in: [:user, :subscription_id]
step FindSubscription,
in: :subscription_id,
out: :subscription
step RegenerateSubscriptionTokens
in: :subscription,
out: :tokens
step UploadSubscriptionTokens
in: :subscription,
out: :tokens
def initialize(:user, :subscription_id)
@user = user
@subscription_id = subscription_id
end
end
Let's use the pseudo notation below to describe a sequence of steps from RefreshSubscription
as and
conditionals:
`IsSubscriptionOwner`
and `FindSubscription`
and `RegenerateSubscriptionTokens`
and `UploadSubscriptionTokens`.
Pretty simple and straightforward.
But, but, but 😢...
What should we do when we have some or
conditionals?
For example, two additional requirements are introduced to the one we had before:
-
Super admin can refresh any user subscriptions.
-
A user with a special ability can refresh other user's subscriptions.
-
User can refresh its own subscription (already existing requirement).
In our pseudo notation it will look like this:
`IsSuperAdmin` or `HasAbility`or `IsSubscriptionOwner`
and `FindSubscription`
and `RegenerateSubscriptionTokens`
and `UploadSubscriptionTokens`
It is not the time to give up ðŸ˜.
The list of to-do actions is not so long:
- Create a new service which name generalizes all
or
conditions, for instance -IsAuthorizedForSubscriptionRefresh
.
Then the updated pseudo notation can be displayed as:
`IsAuthorizedForSubscriptionRefresh`
and `FindSubscription`
and `RegenerateSubscriptionTokens`
and `UploadSubscriptionTokens`
Now, we can utilize all the step
DSL s usual.
class RefreshSubscription
# ...
step IsAuthorizedForSubscriptionRefresh,
in: [:user, :subscription_id]
step FindSubscription,
in: :subscription_id,
out: :subscription
step RegenerateSubscriptionTokens
in: :subscription,
out: :tokens
step UploadSubscriptionTokens
in: :subscription,
out: :tokens
# ...
end
- Prepare a common template for a service with
or
conditionals.
Here it is for our particular case:
class IsAuthorizedForSubscriptionRefresh
include ::ConvenientService::Standard::Config
attr_reader :user, :subscription_id
def initialize(:user, :subscription_id)
@user = user
@subscription_id = subscription_id
end
def result
# TODO: Implement
end
private
def is_super_admin_result
@is_super_admin_result ||= IsSuperAdmin.result(user: user)
end
def has_ability_result
@has_ability_result ||= HasAbility.result(user: user, ability: :refresh_someone_else_subscription)
end
def is_subscription_owner_result
@is_subscription_owner_result ||= IsSubscriptionOwner.result(user: user, subscription_id: subscription_id)
end
end
- Implement the
result
method.
Before we move on to the final polished chuck of code, let's discuss the common anti-patterns.
Please, welcome deeply nested if statements and multiple success calls.
Zombie version 🧟​
# bad - zombie
def result
if is_super_admin_result.not_success?
if has_ability_result.not_success?
if is_subscription_owner_result.not_success?
error(
"All conditions are not satisfied:"
+ " and "
+ is_super_admin_result.message
+ " and "
+ has_ability_result.message
+ " and "
+ is_subscription_owner_result.message
)
else
success
end
else
success
end
else
success
end
end
Although, this code is "perfect" from the performance point of view and it works exactly as needed according to the requirements...
Please, do not commit it.
Just imagine what will happen with an additional fourth or fifth condition.
How can we make it simpler and more readable?
Use or_step
.
- Source
- RSpec
class CreateButton
include ConvenientService::Standard::Config
step CreateWebButtonFactory,
in: :app,
out: :button
or_step CreateAndroidButtonFactory,
in: :app,
out: {action: :button}
or_step CreateIosButtonFactory,
in: :app,
out: {control: :button}
or_step CreateDesktopButtonFactory,
in: :app,
out: {knob: :button}
step :result,
in: :app,
out: :button
attr_reader :app
def initialize(:app)
@app = app
end
def result
success(button: button)
end
end
require "spec_helper"
RSpec.describe CreateButton do
include ConvenientService::RSpec::Matchers::Results
example_group "class methods" do
describe ".result" do
subject(:result) { described_class.result(app: app) }
let(:app) { App.new(platform: :cli) }
context "when `CreateButton` is NOT successful" do
context "when all alternatives are NOT successful" do
it "returns intermediate step" do
expect(result).to be_not_success.of_service(described_class).of_step(CreateDesktopButtonFactory)
end
end
end
context "when `CreateButton` is successful" do
context "when `CreateWebButtonFactory` is successful" do
let(:app) { App.new(platform: :web) }
let(:button) { App::UI::Web::Button.new }
it "returns success" do
expect(result).to be_success.with_data(button: button).of_service(described_class).of_step(:result)
end
end
context "when `CreateAndroidButtonFactory` is successful" do
let(:app) { App.new(platform: :android) }
let(:button) { App::UI::Android::Action.new }
it "returns success" do
expect(result).to be_success.with_data(button: button).of_service(described_class).of_step(:result)
end
end
context "when `CreateIosButtonFactory` is successful" do
let(:app) { App.new(platform: :ios) }
let(:button) { App::UI::Ios::Control.new }
it "returns success" do
expect(result).to be_success.with_data(button: button).of_service(described_class).of_step(:result)
end
end
context "when `CreateDesktopButtonFactory` is successful" do
let(:app) { App.new(platform: :desktop) }
let(:button) { App::UI::Desktop::Knob.new }
it "returns success" do
expect(result).to be_success.with_data(button: button).of_service(described_class).of_step(:result)
end
end
end
end
end
end