Lazy-evaluated filter map
(1..).lazy.filter_map { |i| i * 2 if i.even? }.first(5) => [4, 8, 12, 16, 20]
Problem: calculate and return the current step of a user under a very complex onboarding process, which depends on a myriad of conditions. The implementation should be:
- Easy to introduce new steps in-between existing ones;
- Easy to remove existing steps;
- Easy to maintain, iterating over the current behavior of any existing steps;
The idea
class CurrentOnboardingStepCalculator
def initialize(user)
@user = user
end
def self.call(user)
new(user).call
end
private
attr_reader :user
def call
steps.lazy.filter_map { |step| step.call(user) }.first
end
# Keep the steps in the order you want the process to happen.
def steps
[
MissingRequirementStep,
MissingIdentityStep,
MissingAddressStep,
MissingBusinessStructureStep,
MissingPaymentInformationStep,
MissingTermsAcceptanceStep,
# And the list goes on.
]
end
end
Pretty simple, right? Not a single if
statement nor a case
switch. We are telling this class to return the first non-nil value from the array. The lazy.filter_map
chains the operation to be lazy-evaluated.
In other words, consider a hypothetical execution:
- MissingRequirementStep
returns nil
;
- MissingIdentityStep
returns nil
;
- MissingAddressStep
returns :missing_region
;
The execution will stop there and return the symbol without realizing the following steps. Then, an API could return this symbol to the application frontend, which is prepared to handle the situation.
Interesting! Now we are free to implement the rules that determine each step we need, keeping them well encapsulated using separate classes for each one.
Let's see how we could implement the MissingAddressStep
class to calculate and return the possible values which belong to this step.
class MissingAddressStep
delegate :address, :subscription, :suspect_fraud?, to: :user, private: true
delegate :city, :region, :country, to: :address, private: true
delegate :expired_trial?, to: :subscription, private: true
def initialize(user)
@user = user
end
def self.call(user)
new(user).call
end
private
attr_reader :user
def call
return unless request_address?
[
missing_city,
missing_region,
missing_country
].lazy.filter_map(&:call).first
end
def request_address?
[
expired_trial?,
suspect_fraud?
].any?
end
def missing_city
-> { :missing_city if city.blank? }
end
def missing_region
-> { :missing_region if region.blank? }
end
def missing_country
-> { :missing_country if country.blank? }
end
end
Again the lazy.filter_map
pattern is applied, mixed with a few lambda objects responsible for checking in each address field of our theoretical example.
To spice things up, I'm illustrating how we could delay this specific onboarding step by early returning if all preconditions are met. Following the same idea, we could apply an exclusive logic to defer or early request a given field within the lambda implementation.
See you around! 👋