Saga pattern for onboarding flows

Sasha Terentev
Picsart Engineering
9 min readDec 1, 2023

When we are developing any UI application and adding some new feature we may want to help our user get into the feature and provide some onboarding flow for it.

During the article I consider two solutions of implementing an onboarding flow.

The first one is the one which may come to our minds first of all. This variant may seem simple when you start implementing the onboarding, but nearer to the release date it may go far and far away from easy maintenance and the product requirements. Therefore, I’m not going to call this variant “simple”, so just “first”.

And then, for the second solution I want to introduce Saga pattern (or a group of similar, from my point of view, patterns) to you and how such patterns may help writing code “as heard” from your product requirements and design.

At the end of the article I’m mentioning a couple of cases where Saga pattern may also be quite helpful or even essential to keep the code simple and maintainable.

And let us begin with an example.

Example

A year ago we were developing a layering panel and an onboarding for it in our Picsart editor. Here is the design:

The key aspects of the feature I want to draw your attention at.

A chain of steps

The whole flow consists of several steps which follow each other in a strict order.

Across components

The onboarding contains different editor components: the main editor screen and the layering panel.

Repeat count limited

We cannot show the onboarding more times than it’s allowed by the product requirements.

Different user actions

Different user or UI actions are involved to switch the steps.

First implementation

I suppose the idea of implementation which comes to our minds first of all may be like the following.

In some global class of the editor we describe the onboarding state:

class SomeGlobalConfigClass {
// ...

static var shared = SomeGlobalConfigClass()

var isLayeringOnboardingEnabled = true
var isLayeringOnboardingInProcess = false
}

First hint

To start the onboarding and show the first hint somewhere in the editor we add something like this:

class EditorComponent {
// ...

func didAppear() {
// ...

if SomeGlobalConfigClass.shared.isLayeringOnboardingEnabled,
!UserDefaults.standard.bool(forKey: "onboarding_shown") {
// a lot of UI code to show the first step

SomeGlobalConfigClass.shared.isLayeringOnboardingInProcess = true
UserDefaults.standard.setValue(true, forKey: "onboarding_shown")
}
}
}

Here we check whether the onboarding is enabled (should be shown) and hasn’t been shown yet.

Once the hint has been shown and the user interacted with that, we open the layering panel.

Second hint

To show the second hint in the layering panel code we check whether we need to show the hint on panel appearing:

class LayeringPanel {
// ...

func didAppear() {
// ...

if SomeGlobalConfigClass.shared.isLayeringOnboardingInProcess {
showHint {
// …
}
}
}

func showHint(completion: @escaping () -> Void) {
// a lot of UI code
}
}

Third hint

First of all, we need to somehow handle the completion of the second hint showing. To do this we implement some callback from the panel to the editor. For instance:

class LayeringPanel {
// ...

func didAppear() {
// ...

if SomeGlobalConfigClass.shared.isLayeringOnboardingInProcess {
showHint {
delegate?.didEndShowingHint()
}
}
}

protocol LayeringPanelDelegate {
func didEndShowingHint()
}
var delegate: LayeringPanelDelegate?

func showHint(completion: @escaping () -> Void) {
// a lot of UI code
}
}

And in the editor:

extension EditorComponent: LayeringPanelDelegate {
func didEndShowingHint() {
// show the final hint

SomeGlobalConfigClass.shared.isLayeringOnboardingInProcess = false
}
}

Problems

If we analyze the resulting code we may notice the following architectural problems:

  • The logic is scattered across all the classes;
  • The scattered peaces of code don’t look like human readable product requirements;
  • The panel and the editor became coupled;
  • State info is separated into different global vars.

In such a simple example the problems may not look too significant, but the situation may be much more deplorable if your onboarding flow:

  • contains more components and interactions with them;
  • the components are much farer and less connected in your app.

All the problems are exacerbated, once:

  • We need to change business logic. For example: increase showing count;
  • Some interruptive events appearing during the flow: other screen showing, display disabling, etc…;
  • We have to deintegrate the code, we almost always have to do that with onboardings. It’s dangerous and not trivial.

To get rid of these and other relating problems let me introduce you Saga pattern.

Saga pattern

In some abstract sense it reminds me of:

  • Mediator;
  • Orchestrator;
  • Process modeling.

Also sometimes I think of Saga usages in manner of one of the key abstractions from the Clean architecture: use cases (user flows). In VIPER it’s called Interactors.

By the way, what is Saga pattern?

I’ve met this pattern as a replacement of transactions for reactive systems in Reactive Design Patterns book by Jamie Allen.

Saga pattern describes and controls some continuous process happening over different system components.

When we need this?

  • Modeling of long-living process over different components;
  • (Not our case) the process result should be discardable.

Originally, in the mentioned book the pattern was used to implement the process of sending an e-mail from one actor to another:

I suggest trying to describe the whole onboarding flow as a saga.

Saga implementation

The class of our onboarding logic should match the following:

  • Incapsulates and manages the whole onboarding lifecycle.
  • Handles interruptive system interruptions and user actions.
  • The input is signals FROM the involved components.
  • The output is commands TO the involved components.
  • This input/output notation perfectly suits the use case abstraction from the Clean Architecture.

Just to remind the flow:

The key traits:

  • Three hints;
  • Two components.

Let’s start with the initialization:

class LayeringOnboarding {
init(showingCount: Int, shownCount: Int) {
self.showingCount = showingCount // the limit of onboarding repeats
self.shownCount = shownCount // the number of repeats the user has seen
// ...

// actualization of the internal state, will be described further
didChangeShownCount()
}

Handling App state transitions

Then let’s handle app state transitions:

class LayeringOnboarding {
init(showingCount: Int, shownCount: Int) {
// ...

self.appIsActive = UIApplication.shared.applicationState == .active


observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
self?.appIsActive = true
})
observers.append(NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: nil) { [weak self] _ in
self?.appIsActive = false
})

// ...
}

deinit {
observers.forEach { NotificationCenter.default.removeObserver($0) }
}

private var observers: [Any] = []

Identifying Hints and keeping Order

I’m not a fan of using enums, but as the cases are not going to change, we may declare hint identifiers as Enum to support the order:

class LayeringOnboarding {
// ...

enum Hint: CaseIterable {
case introduction
case panelCell
case bottomToolbar
}

Connecting Onboarding and Components

Please, take a care look, it is the main (and the only) aspect of the public Onboarding class interface, its Input and Output signals in therms of the Clean architecture:

class LayeringOnboarding {
// ...

func componentBecomeActive(for hint: Hint, show: @escaping ShowHint) {
components[hint] = show
}
func componentBecomeInactive(for hint: Hint) {
components[hint] = nil
}
typealias ShowHint = (_ completion: @escaping () -> Void) -> Void

private var components: [Hint: ShowHint] = [:]

Here we describe a kind of abstraction for any component involved in the onboarding with the following requirements (agreements). Each component:

  • Implements showing (ShowHint block) of corresponding hints (may be more than one hint per a component).
  • Registers itself in the onboarding once it becomes active (visible).
  • Unregisters itself once it resigns active (becomes invisible).

Switching hints

This part of LayeringOnboarding is the key from the logic point of view. It manages the way we switch hints.

And, please, take into account a great feature: all code in this part is completely private.

First of all, I want to describe all possible onboarding states:

class LayeringOnboarding {
// ...

private enum State {
case inactive
case pendingHint(Hint)
case showingHint(Hint)
}

private var state: State = .inactive {
didSet {
showHintIfNeeded()
}
}

private let showingCount: Int
private var shownCount: Int {
didSet {
didChangeShownCount()
// TODO: save the new value to some storage
}
}

And switching of the state:

class LayeringOnboarding {
// ...

private func didChangeShownCount() {
state = shownCount < showingCount ? .pendingHint(.allCases.first!) : .inactive
}

private func showHintIfNeeded() {
guard appIsActive else { return }

switch state {
case .inactive, .showingHint:
// Do nothing, we shouldn't show any new hint
return

case .pendingHint(let hint):
guard let showHint = components[hint] else {
// we have no registered component for the pending hint
return
}

state = .showingHint(hint)
showHint {
let index = Hint.allCases.firstIndex(of: hint)!
if index == Hint.allCases.count - 1 {
self.shownCount += 1
return
}
self.state = .pendingHint(Hint.allCases[index+1])
}
}
}

I’m happy to declare, that’s all with the onboarding logic!

Now we need to attach the components. It shouldn’t be hard, as each component only handles it’s own activity (appearing) methods and implements showing of the associated hints

Attaching Components

Like in the first implementation we still need to keep the onboarding info in some scope which covers (lives longer than) all involved components:

protocol EditorScope {
// ...
var onboarding: LayeringOnboarding { get }
// ...
}

Main editor hints

We have two components (the first and the third) to be shown in the main editor component:

So, we implement the following:

class EditorComponent {
// ...

func didAppear() {
// ...

scope.onboarding
.componentBecomeActive(for: .introduction) { [weak self] completion in
self?.showPanelButtonHint(onClosed: completion)
}

scope.onboarding
.componentBecomeActive(for: .bottomToolbar) { [weak self] completion in
self?.showBottomToolbar(onClosed: completion)
}
}


func willDisappear() {
// ...
scope.onboarding.componentBecomeInactive(for: .introduction)
scope.onboarding.componentBecomeInactive(for: .bottomToolbar)
}
}

Panel hints

And only one hint is left:

class LayeringPanel {
// ...

func didAppear() {
// ...
scope.onboarding.componentBecomeActive(for: .panelCell) { [weak self] completion in
self?.showHint(onClosed: completion)
}
}

func willDisappear() {
// ...
scope.onboarding.componentBecomeInactive(for: .panelCell)
}
}

Congratulations! All the code has been written, let’s explore that!

Outcomes

I suppose, from the final solution we’ve received some cool (from my point of view) outcomes:

  • The logic is fully private.
  • The logic is in a single place.
  • The components are decoupled.
  • The components don’t have state.
  • Elegant handling of any possible app activity and component appearance state.
  • Well-defined signals and state transitions in the code (“write how you hear” it from the design).
  • Very few code in the UI components (and its only UI-related).
  • Easy to delete not breaking the components.

Measuring the solution

I’d like to somehow measure the traits of the solution.

Roughly speaking, the logic code size and complexity are:

  • constant in case of the component count increasing;
  • equal to the complexity of the design product requirements (depends on the signal type count and the relations between the states).

In other words, it keeps the complexity in case of component count increasing, but may be increased dramatically if your designer or PM complicates the logic.

It is the consequences of the fact that our code is “written as heard” from the design.

Where may we use the approach?

Here are, as I promised, some examples from my experience.

Navigation state

I’ve used a similar Saga approach to implement the navigation state and its transition, hints, etc, in the app of one large social network.

The navigation state consists of all app menus (from several app screens and places) which contain entrypoints to all app screens. Examples of the menus: the main tab bar items, side panel items, miniapps and other icons in the SuperApp.

Managing the whole navigation in the one place helped to support consistent switching of the navigation: moving or swapping different entry points from one menu to another.

Also the approach helped to achive synchronization (consistency) of hints, feature toggles and other relating info during the app start and authorization.

Content loading

Implementing a separate entity may be a good idea if you want to track and analyze (in other words, to model in general) a loading process of some content:

  • Loading duration;
  • Loading errors;
  • Appearing on screen state (loaded or not);
  • Visible loading duration.

For instance, if we need to track loading of images (photos or videos) in some scrollable view or screen, we may implement ImageTracker which lifecycle is identical to the modelled loading process:

References

  • Reactive Design Patterns. Jamie Allen
  • Clean Architecture. Robert C. Martin

--

--