Tjeerd in 't Veen
— 10 min read
Imagine features capable of functioning independently, without relying on their parent too much, if at all. They can handle their tasks autonomously, with no need to be micromanaged by an ancestor class.
Let’s explore some concepts on what this would look like.
Self-sufficient features offer flexibility, making it easier to integrate them into various app flows, even unforeseen ones. If a feature isn’t bound to a specific location within the app, we can even move it to a different module, if we so desire.
But that’s not all. A self-sufficient feature is a crucial ingredient in making complex screens more manageable. By enabling seamless embedding of one feature into another, with no hassle, it substantially reduces the difficulty of delivering a complex screen.
Self-sufficient features streamline support for interruptive actions or deep linking. When you can seamlessly present a feature at any point in any navigation, and it just works™, it makes implementation much more effortless.
Another key aspect is ensuring features can gracefully handle errors and recover from them. When features can handle their own issues rather than passing them to a parent, implementation becomes smoother, reducing the burden on the parent component.
You could think of self-sufficient features as drag-and-drop components, widgets, or even standalone SDKs that ‘just work.’
The question then is: ‘Is it worth investing extra time to make a feature self-sufficient?’
Fortunately, you can achieve these benefits without a significant upfront investment; It’s mostly about awareness and avoiding shortcuts while implementing a feature. It also doesn’t mean you have to apply every “self-sufficient principle” from the start.
In this blog-post specifically, we’ll focus on high-level design considerations. Let’s consider the problem when features aren’t self-sufficient, and let’s focus on how self-sufficient features work with error handling and navigation.
To better understand self-sufficient features, let’s consider the opposite end of the spectrum: dependent features.
Often, we create a feature, like a screen, where we pass in a value to display. This creates a dependency, either explicitly or implicitly, between the owner (parent) of the view and the view itself.
Let’s consider a classic example of a master-detail layout, also known as a two-pane layout. This illustrates a common scenario where tight-coupling occurs: one view loads and displays a list, and upon tapping an element, the other view presents the details of that element.
Now, imagine this master-detail setup displaying a list of workouts on the left, and pressing on one element opens the details on the right in the shape of a
WorkoutView. We need to pass an instance of
Workout data from this list (
WorkoutListView) to the detail view,
WorkoutView in this case.
Whether intentionally or unintentionally, this results in a feature that’s incomplete on its own.
WorkoutView relies on a second type to function fully, because
WorkoutView can’t retrieve its own data; it depends on a list-view to supply a
But now, imagine we want to take the detail screen,
WorkoutView in this case, and move it around to a different flow. It won’t “just work” because it can’t load the data itself; it relies on the parent (the list) to function fully. Now, this new location is also burdened with figuring out how to load a
Similarly, if we want to open
WorkoutView quickly from a deep link, it can’t “just work” because it lacks the
Workout data it needs, all it might have is an ID.
To offer deep linking in this scenario, we’d have to recreate the master-detail hierarchy first, or come up with yet another solution to load workouts. For instance, the deep linking code may now need to load a
Workout itself before presenting a
WorkoutView, making the deep linking code a bit more complex.
This means the parent of
WorkoutView must prepare and fetch the data.
On a local level, it’s relatively easier with declarative UI to grab a (sub)view, extract it, and move it around. However, across flows involving loading and data, such as with features or screens, it’s typically more challenging to move a view around since it affects the app more on a structural level.
“Wouldn’t it be nice if we can easily cut-and-paste features around in our code-base?”
Just like how, in real life, a parent needs to help their young child to get dressed; Sometimes in code, a parent needs to help a child-feature — such as
WorkoutView — to function.
But imagine, what if we can make features self-sufficient? Meaning that they don’t need any outside help to function, a grown-up feature instead of a toddler feature. A feature that can tie their own shoes.
Another reason we might implicitly tightly couple a feature to a parent is when we don’t let features handle their own errors, instead they require a parent to solve it.
Did the connection drop? Pass the error up to a parent.
Loading failed? Pass the error up to a parent.
The user made a mistake? Pass the error up to a parent.
In the case of
WorkoutView, if it can’t load data, it might want to forward its error to its parent, such as the master-detail screen. But again, the parent is now burdened with some responsibility of
But, the parent view may not always be equipped to handle a child’s error in the most effective manner.
There is typically more distance between the error and the parent view than there is between the error and the child view. The parent view might lack the context or capability to handle the error. Typically, that means the parent will present the error as an alert for the user to deal with.
It’s often preferable for a child-view to manage the error itself. This type is closer to where the problem originates, for this reason they often have a more relevant context of the error as well.
By default, consider it the responsibility of the child-view to handle their own errors.
When a child has a minor scratch, it alleviates the parent if the child can grab their own band-aid. The parent will still be involved, but doesn’t have to perform all steps to put the band-aid on.
The same goes with features. It’s nicer for the parent view (and developer) if the feature – the child view in this case – can handle their own errors. The parent might be alerted to issues, but doesn’t have to handle all of them.
Even if you’re convinced to let a feature — such as
WorkoutView — handle its own error, we have the next challenge: Merely presenting an alert can be problematic.
For instance, consider a feature that involves uploading an image. While testing in a controlled office environment with comfy air-conditioning and perfect network-connections, we may not always test issues like connection drops in our apps.
However, users deal with a less-reliable app while on the go, such as when traveling via train, or while spelunking in underground caves.
It’s more likely that a connection failure will occur in day-to-day life. But some apps would present a big UI-blocking “The upload failed” prompt.
However, we can do better than merely presenting UI-blocking alerts. Let’s explore some alternative approaches:
There are many alternatives beyond simply presenting an alert. They are great to get your feature up and running, but aren’t always the best UX.
The problem of presenting alerts is exacerbated when dealing with multiple-errors at the same time. Because only one alert can be displayed at a time.
Even if your app manages multiple alerts effectively by queuing them, consecutive UI-blocking alerts can be disruptive and overwhelming for users who may not bother reading them all.
This issue is especially apparent when embedding views into a larger view, as each embedded view may trigger its own alerts upon encountering an error.
Having multiple features battle over presenting alerts might cause bugs on the hosting screen. Therefore, try to favor inline errors over alerts. Since they provide a more seamless experience and help maintain the overall integrity of the interface.
Not to mention, it makes a view more self-sufficient since we can more easily embed them in larger views. As opposed to a view that wants to hijack the parent view by presenting alerts on top of it.
Often, a better approach is to ensure that features, such as
WorkoutView, do not interfere with their parents’ navigation-stack, whether that’s by presenting multiple alerts or otherwise.
The worst offenders are features that inspect the current navigation-stack and modify it willy-nilly. A feature may check whether it is presented, or pushed and mutate a navigation-stack accordingly.
Worst case: a feature may even clear a parent’s navigation-stack! Once features start modifying navigation-stacks behind the scenes, hilarious bugs ensue.
Instead, consider sending signals to a parent, this parent can be the single source-of-truth for the navigation-stack and act accordingly.
Otherwise, you end up with a “Two captains on one ship” problem where multiple views are trying to mutate the same navigation-stack.
This parent could be a coordinator, router, presenter, or something else entirely. Pick your poison.
Let’s consider how working with signals would look.
For instance, imagine we have a
WorkoutView that wants to push and present screens. Instead of “just” mutating the navigation-stack, it should send signals to the parent. “The user pressed the details button” or “The user wants to open the image picker”. Then the parent can push or present the appropriate views.
WorkoutView might trigger
openInstructions(), which underwater would trigger a closure that calls
showCoaches() on the parent.
The parent would then pass, or inject, the closures to
WorkoutView, and call its own methods accordingly. This will keep
WorkoutView flexible and unaware of how the navigation is glued together.
These triggers can come in many shapes and forms; Closures, delegate calls, injected interface methods, bindings, etc.
What the parent is, is irrelevant from the perspective of
WorkoutView. Today, it could be a master-detail screen or a tab bar. But, tomorrow it could be a coordinator, router, or
WorkoutPresenter. Which is why we should design
WorkoutView more as a standalone view, that works regardless of the parent.
WorkoutView knows is “The user pressed on a button, and I am triggering this closure”.
WorkoutView unaware of navigation and more self-sufficient. It can be moved around and reused in various locations. If it’s put inside a larger navigation-stack, it will work. If it’s presented anywhere, it will work too. If it’s embedded in a large tablet-screen, it will still work. If it is presented from a deep link, it doesn’t have to check “Am I presented or pushed on a navigation-stack?”, and if it can load itself, the parent hardly has to do any work.
Unfortunately, turning navigation-logic into triggers does place some burden on the parent to hook up its navigation. But, unlike loading-data, it makes more sense in this scenario, since the parent owns the navigation.
With the approaches we covered, a feature can live anywhere without hassle.
Self-sufficient features can be very powerful, and it doesn’t always have to be more work. Often it’s about awareness. If we can make a feature load itself, it already unburdens the parent (and us as developers). We have to build the loading functionality anyway, so why not put it near the feature instead of the parent?
Pushing errors out of a view is one aspect that hinders self-sufficient features. Consider making inline errors versus merely presenting them. This way, it makes it easier to move, or even embed, a feature in a larger screen.
One common challenge is when a feature modifies a parent’s navigation-stack, as it creates a ‘two captains on one ship’ scenario. Opting for a single source-of-truth is often a better idea, as it mitigates the risk of navigation falling out-of-sync between multiple entities.
These are a few suggestions on how to make a feature self-sufficient. If you’re interested in learning more and seeing these techniques applied on a code-level, consider getting the Mobile System Design book.
Want to learn more?
From the author of Swift in Depth
Buy the Mobile System Design Book.
Suited for mobile engineers of all mobile platforms.