Lazy-evaluated filter map

(1..).lazy.filter_map { |i| i * 2 if i.even? }.first(5) => [4, 8, 12, 16, 20]

Lazy-evaluated filter map

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.

💆
Ah! Please, don't focus on how I named things here. This is just an example. I'm not trying to convey a naming pattern but instead an idea to solve a problem. Given your context, you will likely apply different names, structures, and so forth. Jorge Manrubia put out a nice write-up called "Vanilla Rails is plenty". You should check it out if you are using Rails and want some inspiration to name and organize things within your project.

See you around! 👋