Summary

I'm currently thinking of how to design an onboarding experience. Depending on how forcefully the onboarding has to keep collecting information from the users before they can get started, I have been thinking about a few different approaches, though they seem similar in some ways.

In all approaches, it's based on the idea that a User model gets created and there are attributes or relations that should get set for the app to work best.

Core Problem

I think one of the key elements of what I'm wrestling with is whether to encapsulate all the information in one object and one controller or across multiple. Additionally, these two approaches differ in that the logic for how to proceed to the next step is derived by the form object, or whether it's distributed across multiple controllers. TL;DR: The form object approach seems like it may be both simpler and more flexible of a design, but unclear whether perhaps I’m overlooking something?

Approach 1

One method is to create a wizard (using something like wicked [or I could roll my own]), which encapsulates the flow of logic by creating a controller action for each step (either in the same controller or different ones) and, at the end of a successful update, it redirects to a different controller action.

This could look a bit like the approach in the following twitter thread:

https://twitter.com/ilrock__/status/1630603351636480000?s=20

Approach 2

Another might be to use a concern, e.g. Users::OnboardingConcern, to encapsulate all the persistence logic and the flow to the next step. In this circumstance, I might redirect my users to edit their profile, and show a bar at the top of the page unless all the requisite attributes on the model were persisted. If the user were to remove one of those attributes, then presumably the onboarding bar would show up again.

Approach 3

Another would be to use a form object to encapsulate the logic. In this approach, the form object gets constructed using parameters that include, presumably, some user_id, and one of onboarding criteria information, (e.g. year_of_study). Then, in the view, you have a case statement that renders a partial depending on what the next step is, e.g.

<% case @onboarding_form.current_step %>
<% when 'full_name' %>
  <%= render partial: 'step_one' %>
<% when 'year_of_study' %>
  <%= render partial: 'step_two' %>
 <#% etc... %>
<% end %>

and in the Users::OnboardingFormController (or whatever), you have a create action that looks something like:

def show
  @onboarding_form = OnboardingForm.from_params(params)
end

def create
  @onboarding_form = OnboardingForm.from_params(params)
  if @onboarding_form.valid?
    @onboarding_form.submit!
    if @onboarding_form.next_step
      render :show, user_id: @onboarding_form.user_id
    else
      flash.now[:alert] = "Congrats!"
      redirect_to root_path
    end
  else
    flash.now[:alert] = "Please submit a valid form!"
    redirect_to root_path
  end
end

One seeming benefit to this approach is that you're organizing your view logic clearly and simply, you have a relatively simple controller flow, and all logic about what needs to get persisted is in a single place.

I think I'm leaning towards the simplicity and centralization of the single form object versus distributed over many places in the codebase, since changing order/number of steps/other aspects of the wizard would be in fewer places. Moreover, it seems like you'd be able to relatively simply test the flow, which suggests that the approach be easier to reason about.