Every app with even minimal complexity has what I like to call a pre-flight checklist. Is the user logged in? Do you need to hit a health check endpoint and respond accordingly? Is there a first-run experience that you need to skip? Seemingly unrelated questions like these need to be answered in one fell swoop during the start-up process.
Having been in this fray numerous times, I have seen (and written) initialization logic that ranges from spaghetti code to overly rigid and inflexible constructs that would require a manual to comprehend fully.
By now, my team and I have landed on a flexible, elegant, portable, and battle-tested paradigm that has thrived in the stresses of development and production. Let’s see what it looks like.
When you think about something long and hard enough, you tend to invent (or dig up) names that provide a clear meaning to your description. Here are the names I often use when describing this solution:
- Workflow: The solution to the problem posed in this article. It is the coordinated set of steps that the application runs during initialization from beginning to end to satisfy all of the requirements before a user may begin using the app.
- Step: A small, fully-contained check or set of checks designed to satisfy one of the pre-launch requirements.
- Pipeline: A tool used to organize and run a collection of ordered steps to satisfy the overall pre-launch requirements.
Note that the workflow is more of the conceptual solution behind this problem that embodies the idea and its components. The pipeline and steps are the concrete, coded manifestations of these concepts. We’ll see them in action soon.
Depending on the size of your app and the number of components you intend to check and initialize, this section of your codebase will reflect that complexity by its size. Nevertheless, having a pragmatic approach to your logic will help ensure your initialization logic can scale as your app matures.
Here is a direct picture of the package structure we use:
Notice how everything is rooted inside of a package called `workflow`. I'll explain all of these classes later on, but I wanted to give you the 5000-foot view before going into the specifics. I have a `services` package for networking calls and a `steps` package for each of the concrete questions my app needs to ask.
RxJava & The Pipeline
Perhaps this component isn’t a surprise since the title already gave it away. RxJava acts as the primary driver of the entire initialization process. I use this as the pipeline to do the overall coordination of the steps. Truthfully, it is responsible for much of the heavy lifting and thereby makes my job much easier.
Don’t forget to include it in your project before continuing.
It helps to have everyone’s responsibilities defined in advance. Therefore, I created an interface to declare every class in my project capable of running a workflow. Although it isn’t technically required, it certainly makes things more clear.
Remember what I said at the beginning of the article about this solution being portable? That is one reason why I opted to create this interface. It is used in several places in the app I build professionally and searching for `WorkflowRunner` reveals each of those instances in my IDE.
It would be unrealistic to expect perfection from such a complex part of our application. Thus, having a reusable error handling paradigm for each step to throw an error and respond accordingly is a good idea. Later on, you’ll see how each of the steps can respond to their errors.
Every once in a while, you need to hold onto some data. That kind of memory can last anywhere from the lifetime of the workflow to as long as the application is installed. I have a tiered set of storage services designed just for that purpose:
- `SharedData`: A class that gets passed from one step onto the next for any downstream step which needs to know any pertinent information gathered from an earlier step. It is destroyed whenever the workflow ends.
- `StaticSharedData`: Like the above class, except it persists until the app is closed or explicitly cleared.
- `PersistentSharedData`: Like static data, except these changes are written to disk and survive app closures.
Here is a place to put data when you only care to see it last until the pipeline finishes. You, of course, may add more properties to this is class, but here is what I recommend at a minimum:
I provide and track the pipeline’s name to provide a unique identifier to each of its manifestations. I also track whether each step ran or didn’t run so that I can debug my code should something go wrong. My pipeline finishes with a service that can extract and display this data. More on that later.
Like the above storage class, this one puts its properties in a different scope. That way, you can refer to the retained information later on.
This class is effectively a wrapper around Shared Preferences. It works with Kotlin’s getters and setters. A Java implementation would look rather similar. Here is an example of something you might store in persistent data.
No one likes digging through code to find a small configuration value to change. Thus, we keep all workflow, pipeline, and step-related configuration inside of a single object (or `static class`, for you Java folk) called `WorkflowConstants`. As your workflow codebase grows, the value of having this kind of convention will quickly pay for itself.
This class is the concrete implementation of logic to answer one of the start-up questions in its totality. It has several specific capabilities:
- Decide whether it should run
- If it should run, here is what it is to do
- If implementing the `ErrorHandler` interface, deal with any errors it causes
- Retain any pertinent information in `SharedData`
- Log its run/not run decision
That last point is a big deal, especially when debugging a pipeline that isn’t functioning properly. It provides a roadmap of where things worked and then suddenly went awry.
Perhaps this is not a surprise, but the `Step` class is abstract. Of course, we want other, narrowly-focused classes to handle all of the specifics. This base class removes much of the boilerplate to do that.
The `shouldRunStep()` method assumes that the developer wants to run this step by default. However, if you want to limit its execution to specific scenarios, you may override this method with custom logic in the implementing class.
Notice how I have a convenience function that honors whether or not to run, and it collects all of the logging on my behalf — more on that in the next component.
This formatter is used to extract the run log recorded by each `Step` from memory and print out a post-mortem of what happened – whether good or bad – to ensure the engineer can peer deep into every detail.
Putting It to Work
Let’s see how all of these concepts work by creating a basic pipeline.
For those of you who are versed in RxJava, this is nothing profound. We are simply applying the above-mentioned constructs:
- `SharedData` is injected at the top with a workflow name.
- The `onNext()` handler in the `subscribe()` method is the happy path, where each `Step` did what was intended without interruption. Notice the `StepLogFormatter` class takes the given data and sends it to the console for the engineer.
- The `onError()` handler, of course, captures fatal errors from the chain. It provides a way to stop the whole flow and throw it back to the step which caused it for proper handling. This is the last resort and can be considered a form of bailing out. An example of when this is useful is if your backend reports itself as down or the user's app version is no longer supported.
- The `onComplete()` method is for the off chance that you want to terminate the pipeline early, but not because of an error. This is useful for scenarios where you only need to run a pipeline once per session.
A Simple Step
What would a basic step look like? Here is an example:
Notice how lightweight this `Step` is. It is a no-frills implementation that allows you to focus on just the task at hand.
An Error Handling Step
How can you raise a fatal error and stop the pipeline to address it? Here is one way of doing that, by using the `ErrorHandler` interface.
A Step That Terminates the Flow Early
I have one case in my production app that only needs to run a pipeline once per user session. Again, since this solution is portable, I’m not just using it during app launch. The app’s central dashboard has a lot of decision-making, and this kind of approach allows me to ensure I only execute this pipe once.
More Complex Run Methods
Sometimes you need more information from the outside world to execute a `Step` than you can get from the `SharedData` object. If I can't inject the necessary objects into the constructor, I override the `runStep()` method with custom properties that the caller can late inject with the required components.
I’ll reuse the `HealthCheck`, in this case, to provide an option to re-run the workflow to see if the backend services are online again.
Putting the Steps in the Pipeline
Now that I have four example steps to work with, I then integrate them into the pipeline.
Notice how I switch my threads from time to time in the pipeline. Here is the rule of thumb I use when deciding onto which thread I should place any given `Step`:
- Navigation and UI-altering `Step` classes go on the UI thread (i.e., the `mainThread()`). I also ensure my `subscribe()` callbacks happen on the UI thread since all of the work is done.
- All other cases go onto a lightweight background thread, like RxJava’s `io` thread pool.
Also, notice the exception is handled in RxJava’s error callback and is thrown right back to the class that caused it. That way, everything is kept neat and orderly so that the pipeline can focus on driving the steps.
After several years of trial and error, this model has not only proved to be easy and understandable but also extensible and adaptable to everything we’ve thrown at it.
Perhaps one of its primary advantages is that we can build and adapt the fundamentals of the library specifically to our needs.
Its supporting base classes are lightweight and simple to comprehend. The implementing steps follow a familiar paradigm. Plus, the only third-party library that it needs is RxJava, a tool so well used that it has faced nearly every production-grade battle imaginable.
If you find that your startup logic is too difficult to understand, isn’t portable enough, or has become too unruly, I encourage you to try this approach. You may find it helpful enough to tame other parts of your app.
In doing so, not only will you have a solid pre-flight checklist under your belt, but also a dependable in-flight and perhaps even a post-flight checklist. Happy flying.