Building a modular multiple flows wizard in Ruby

September 12, 2022 – Rémy Hannequin – 13-minute read

Wizards are a common component in a lot of applications. Either for signing up new users, creating products, purchasing items and many more.

They can be tricky to manage once they get bigger and more complex. At Getaround, we have several wizards which don’t share their architecture. A common architecture cannot fit every use case with different needs, flow, and user experience.

To list new cars on our platform, hosts provide multiple pieces of information on the vehicle, themselves, and their needs. We may ask for more information or skip some steps. Such constraints lead to complexity and difficulty in handling and testing every variation.

After multiple iterations, we ended up with a modular architecture that was less strict than a decision tree and allowed us to design wizards with complex or simple logic.

In this article, I’ll try to guide you through building such a modular architecture. We’ll use a form object for each step and a Manager to orchestrate everything.

Form object interface

ActiveModel provides convenient modules to create custom form objects and manipulate attributes. We are going to use the following modules:

Using these modules will also allow us to use Rails form helpers as if the manipulated object were an actual model. Many thanks to Intrepidd for sharing code that led to this base form.

Our BaseForm would then look like this:

require "active_model"

class BaseForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations

  attribute :car

  def complete?
    raise NotImplementedError
  end

  def next_step
    raise NotImplementedError
  end

  def submit(params)
    params.each { |key, value| public_send("#{key}=", value) }

    perform if valid?
  end

  private

  def perform
    raise NotImplementedError
  end
end

Let’s take the time to explain this code. First, we’re declaring attribute :car because our form objects will be initialized with a Car model. This object will be the source of truth, the one we will fill with new data and rely on to determine what’s missing from it.

complete? will be the method called to know if a step has been successfully completed. In this method we can for example check if a particular attribute has been filled in on our car record.

next_step handles the logic to compute what the next step will be. A step knows what the next one is because it will rely on what was submitted previously.

submit is the method to submit our params from the associated form. It will only call perform, supposed to be implemented on each form object, if all validations passed.

With this public interface, we can create as many steps as we want and they will create the flow by themselves using perform to save data, and then complete? and next_step to handle going from one step to another.

Manager

Having steps handling themselves is great, but we still need some logic to initiate the wizard and determine which step the user is currently on.

Our Manager object will handle this logic. It also can manage having available steps, and non-available steps. For instance, if we have steps A, B and C, with step A already being submitted. The next step to submit is B, but I should be allowed to access step A again if I want to correct what I submitted. Step C is not accessible as long as step B is not complete, it shouldn’t even be visible to the user.

Our Manager could then look like this:

class Manager
  FIRST_STEP = :country

  STEP_FORMS = {
    country: CountryForm,
    insurance_provider: InsuranceProviderForm,
    mileage: MileageForm,
  }.freeze

  def initialize(car:)
    @car = car
    @instantiated_forms = {}
    @possible_steps = compute_possible_steps
  end

  def current_step
    @possible_steps.find do |step|
      return nil if step.nil?

      form = find_or_instantiate_form_for(step)
      !form.complete?
    end
  end

  def form_for(step)
    STEP_FORMS.fetch(step)
  end

  private

  def compute_possible_steps
    steps = []
    steps_path(FIRST_STEP, steps)
    steps
  end

  def steps_path(starting_step, steps_acc)
    steps_acc.push(starting_step)

    return if starting_step.nil?

    form = find_or_instantiate_form_for(starting_step)
    steps_path(form.next_step, steps_acc) if form.complete?
  end

  def find_or_instantiate_form_for(step)
    @instantiated_forms.fetch(step) do
      form_for(step)
        .new(car: @car)
        .tap { |form| @instantiated_forms[step] = form }
    end
  end
end

Every step is declared with its associated form object. At initialization, all possible steps are computed using the public complete?, calculating one step after another with next_step. The method form_for will allow the controller to manipulate the right form object from the manager.

As we support multiple flows, the last step may not be the last one defined in the list. We then expect next_step to return nil when there’s no step left.

Steps

In the manager, I mentioned three steps, country, insurance_provider and mileage. Let’s build them and see how with only 3 steps we can already have multiple flows.

Country

This step will simply save the selected country on the car record. However, its next step will depend on what country was selected.

class CountryForm < BaseForm
  ALLOWED_COUNTRIES = %w[CA ES PK JP].freeze
  COUNTRY_REQUIRING_INSURANCE_PROVIDER = %w[ES].freeze

  attribute :country, :string

  validates_inclusion_of :country, in: ALLOWED_COUNTRIES

  def perform
    car.update!(country: country)
  end

  def complete?
    !car.country.nil?
  end

  def next_step
    if COUNTRY_REQUIRING_INSURANCE_PROVIDER.include?(car.country)
      :insurance_provider
    else
      :mileage
    end
  end
end

First we can see how readable the attributes and validations are thanks to ActiveModel. country is a string attribute and we expect it to be one of the allowed countries, defined in a constant. The perform method will only be called if the requirements are met, if valid? returns true.

complete? only checks if country has been successfully saved on the car record.

Finally, next_step depends on the country selected. If an insurance provider is required in the country, then we’ll need the user to provide this data. If not, we decide to go directly to the next one, mileage.

Of course we could improve things here, especially in perform. We probably don’t want to use update! which raises when it fails, but we still want to be sure what’s inside this method is properly executed. For the sake of simplicity I didn’t add such a logic here, but we can easily play with errors available thanks to ActiveModel::Validations.

Insurance provider

Nothing particular to say about this one, except that it will be displayed only if the car’s country requires an insurance provider.

The next step is defined as mileage, but from here we could imagine another branch in the decision tree, multiple flows, multiple possibilities.

class InsuranceProviderForm < BaseForm
  attribute :insurance_provider, :string

  def perform
    car.update!(insurance_provider: insurance_provider)
  end

  def complete?
    !car.insurance_provider.nil?
  end

  def next_step
    :mileage
  end
end

Mileage

In our example, mileage is the last step. Once a mileage integer is submitted, validated and saved, there’s no other step to go to. next_step returns nil to announce that the wizard is finished.

class MileageForm < BaseForm
  attribute :mileage, :integer

  validates :mileage, numericality: { greater_than: 0 }

  def perform
    car.update!(mileage: mileage)
  end

  def complete?
    !car.mileage.nil?
  end

  def next_step
    nil
  end
end

Controller and routes

We only need two routes to support this wizard: show and update. show will display the step to the user while update will handle the step submission.

# config/routes.rb

resources :car_wizards, only: %i[show update]

On the controller side, we’re supposed to let all the logic come from the manager and only handle rendering, form submission and redirections.

class CarWizardController < ApplicationController
  before_action :initialize_variables, only: %i[show update]

  def show
    if @step == :current
      redirect_to car_wizard_path(@car, @step_manager.current_step)
    elsif @step_manager.possible_step?(@step)
      set_form
      render @step
    else
      render "errors/not_found", status: :not_found
    end
  end

  def update
    if !@step_manager.possible_step?(@step)
      return render("errors/not_found", status: :not_found)
    end

    set_form

    if @form.submit(params[:car])
      redirect_to car_wizard_path(@car, @form.next_step)
    else
      render @step
    end
  end

  private

  def initialize_variables
    @car = current_user.cars.incomplete.find(params[:car_id])
    @step = params[:id].to_sym
    @step_manager = Manager.new(car: @car)
  rescue ActiveRecord::RecordNotFound
    redirect_to root_path, error: "Car not found"
  end

  def set_form
    @form ||= @step_manager.form_for(@step).new(car: @car)
  end
end

View

Finally, each step has its associated view. A step view only needs a form helper instance, based on the form object, to display form fields. At Getaround, we also have an associated presenter for each step, which allows us to share information between web, web mobile and mobile apps.

simple_form_for @form, as: :car, url: car_wizard_path(@car, @step), method: :put do |f|
  = f.select :mileage, car_mileage_options_for_select, label: t("car_wizard.steps.mileage.attributes.mileage.label")

Pros and cons

With this architecture, we can build a complex wizard, with multiple flows. A user can stop and resume it any time, and it is possible to have many flows with many rules without having to write the entire logic in one single file, which would be much harder to understand and maintain.

Each step having its own logic allows us to test the flow step by step, independently. The simple public API helps us to test service perfoming logic and attribute validation separately.

It is easy to integrate into our MVC pattern with a very simple controller and basic views. The wizard manager itself is only a simple algorithm to compute possible steps.

However, having most logic inside the form objects forces us to read each step to understand how the flows work. If the whole wizards gets too complicated, computing each step could begin to take some time, so this is something to watch out on the long term.

Also, the step completion is based on saving things on database records. Having informational steps is a challenge to handle because we need to find other ways to store state to indicate that they have been seen, or rely on the state of adjacent steps.

For the simplicity of the article we haven’t show all the features we have based on the car wizard. For instance, we have a logic to handle tracking on each step automatically. Also, the mobile wizard is driven by the backend, based on a dedicated API. With this in mind, such an architecture allows us to reorder, add or remove any step without having to deploy a new version of our mobile apps. Well, to be honest, it’s a little bit more complicated than that depending on what the mobile app supports, but you get the idea.

Conclusion

This modular flow is one solution to the wizard problem. It won’t suit every need, but a similar architecture has its advantages if you seek to manage multiple flows with complex decision trees.

Feel free to comment and let us know what you think of this architecture and how we could improve it.

Cheers.

Did you enjoy this post? Join Getaround's engineering team!
View openings