The content of this article comes from work I have been doing on three different projects: GetHuman, Swish, and a new secret app for my upcoming ng-conf 2018 talk called Super-Powered, Server-Rendered Progressive Native Apps.
In an ideal world, you could build one application that works consistently and perfectly on all platforms (i.e. web, iOS, Android, etc.) without worrying about the unique details of any one individual platform. Just code it up in some generic way and it just magically works everywhere. Unfortunately, we don’t live in that world today and any attempt to build a large multi-platform app under any other impression will inevitably lead to a world of pain, anguish and frustration (and/or a low quality app).
The problem at its core is that each platform has its own unique strengths that are often diminished or even completely eliminated by heavy abstractions. That doesn’t mean multi-platform development is bad. It’s just that (in most cases) it makes more sense to follow a slightly different approach. One that focuses more on building multi-platform products instead of multi-platform apps.
Before we go over this solution, however, let’s first talk in a little more detail about the problem.
The Problem: Heavy Abstractions
As mentioned, the main problem with multi-platform development is when a higher level abstraction (i.e. a facade) is too heavy and ends up adversely affecting some of the strengths of the underlying platforms. The term “too heavy” in this context, however, is completely subjective and depends entirely on the requirements of whichever app is using a particular facade.
So, that begs the question: how do we determine whether or not a multi-platform facade is too heavy for an app?
When Facades are Good
Facades that abstract multiple platforms can be really useful and valuable AS LONG AS the following conditions are met:
- Key features available — All of the key features of underlying platforms that are needed by an app need to be easily available (i.e. without jumping through a lot of hoops).
- Performance sufficient — The performance characteristics that matter for an app must be close enough between the facade and the underlying platforms.
- Easy to implement and maintain — The facade needs to save you time and money by making development easier. Otherwise, why bother?
When Facades are Bad
In general, facades are more likely to be a poor fit for more apps when:
- The facade tries to do too much — The more features in the facade and the more the it tries to do, the harder it is to get right. Conversely, the thinner the facade, the greater the chances of success.
- The facade is too high level — A generic wrapper around low level utilities for different platforms is usually pretty easy to build and maintain. A generic frontend view that is meant to abstract different platform specific views is really hard to pull off well.
- The facade is custom — This may seem counter intuitive at first. You may think that if a company builds a thing, they will ensure it meets all their own requirements. Perhaps this is true sometimes, but in my experience a more likely outcome is that custom multi-platform facades degrade quickly over time into a complex mess of delicate hacks that no one wants to touch for fear of breaking the entire system.
Why “write once, run everywhere” is an anti-pattern
The idea of “write once, run everywhere” is an anti-pattern when you treat it as the goal instead of a means to an end. When your primary objective is to maximize code re-use, you are more likely to choose facades that are missing key features and/or have significant performance issues and/or are difficult to maintain.
Your true goal should really be building the best possible version of your product. That may or may not involve multiple platforms and that may or may not involve different levels of code sharing.
The Solution: Multi-platform Product with Single Platform Apps
This article uses a lot of terminology that likely has different meanings to different people. The solution I am proposing for multi-platform development requires that we are all have the same definition of a platform, a product and an app as well as how they should all fit together.
What’s a Platform?
A “platform” for the purposes of this discussion is a software environment that provides a set of constraints and low level capabilities for visual rendering.
One important thing to keep in mind is that there are different levels of platforms. Higher level platforms act as facades for lower level platforms. Here are some examples of different platform levels:
- Level 1— iOS, Android, web browser
- Level 2 — NativeScript, React Native (abstractions on top of iOS and Android)
- Level 3 — Ionic (an abstraction on top of Cordova which is itself an abstraction on top of iOS and Android)
What’s a Product?
A “product” for the purposes of this discussion is one or more frontend apps created with the same language, managed together and designed to satisfy the specific needs of a particular market segment or mission.
The key point here is that a product is not just one app. In many cases, a product involves a number of different apps working together for a common goal. For example, one product could include both a NativeScript app and a separate Progressive Web App as well as an administrator console on the web and a static marketing website.
What’s an App?
An “app” for the purposes of this discussion is the smallest possible deployable package of frontend views that solve a specific problem for a set of users on one platform.
There is quite a bit to unpack here, but I would imagine that this definition doesn’t necessarily match up with your own. Most people jam way too many features and give way too much responsibility to individual apps. In general, it seems much easier to simply add another feature to an existing app rather than build a new one. That is why there is a tendency to build a large, custom multi-platform facade that eventually becomes an albatross around the neck of an organization.
So…let’s not do that.
Instead of creating one massive app, let’s create one product that consists of many small apps where each deployable app unit does one thing really well for one platform.
How should all these pieces fit together?
This folder structure here depicts the code in a product monorepo that has a “platform sandwich” architecture:
- apps — A thin, platform-specific set of apps at the very top. In this example, there is a “viewer” app for NativeScript (called nativescript-viewer) and a separate “viewer” app for the web (called web-viewer). The functionality for each viewer app is similar, but the code is unique to the target platform.
- libs — All the platform-agnostic, shared code in the middle. This layer has business logic and utilities that can be shared among all apps.
- xplat — A thin, platform-specific set of low level libraries at the bottom. This layer contains shared code that can be used by all apps for a given platform as well as platform-specific adapters that can override generic stubs in libs (ex. uploadFile() in xplat/NativeScript can override the no-op uploadFile() stub in libs).
Again, this is just an example of how to set up your product code. It is not the only way.
Benefits of this Approach
At a high level, the goal with this approach is to get the best of both worlds: easy access to the full capabilities of any given platform while still maximizing code reuse. Specific benefits include:
- Platform-App Fit — You can choose the appropriate platform level for your app. Sometimes it makes sense to go with Ionic. Other times it makes more sense to have separate iOS and Android apps.
- Apps are Lightweight and Disposable — It’s soooooooo nice to be able to quickly build a new frontend quickly for an existing product. The world of frontend tech moves quickly and keeping your apps thin gives you a lot of flexibility to refactor/replace as needed.
- Easier to Scale Team — As your team grows larger, it gets harder and harder to avoid team members stepping on each other toes. Using this approach, team members can work on separate apps and are much less likely to inadvertently block each other.
- Easier to Understand — I have been using a version of this approach with my team at Swish for the past few months and it has been amazing how quickly new developers can get productive and how few bugs slip into production. A huge win.
I started this article with the premise that almost any non-trivial, multi-platform app cannot be built in a completely generic way (i.e. the app will inevitably have logic for the underlying platforms somewhere).
But…things can change.
10 years ago, web developers often had to write code that specifically targeted Internet Explorer. Perhaps in 10 years from now, the web will make another huge leap to become a true first class citizen on all devices.
Who knows if that will happen, but if it does, then building for just one platform (i.e. the web) may actually become viable one day.
As I mentioned earlier, we use the techniques described in this article for our spending tracker app at Swish, but I also have some other related upcoming events:
- Nathan Walker and I are building a new secret app with this approach that we are going to reveal during our talk at ng-conf 2018 called Super-Powered, Server-Rendered Progressive Native Apps.
- I am working on a blog post that will dive into the technical details of implementing all this with Angular. Follow me on twitter to hear about when that goes out.
- I am also working on a blog post to specifically talk about SSR apps and how the approach outlined in this article plays really nicely with an AMP-to-PWA UX.
If you have thoughts on this topic, please post a comment here or reach out. I am doing a lot of work in this space and could use any and all feedback. Thanks!