Consolidating Singletons with the Maestro Pattern

Aubrey Goodman
Motiv Engineering
Published in
3 min readMar 15, 2019

Topics: iOS, Swift, software architecture, design patterns.

When designing high level application architecture, we often find the Singleton pattern used by a large number of development teams. Singletons make it easy for developers to access commonly used components from anywhere in the app. In our experience at Motiv, heavy use of singletons has led to limitations in our automated testing efforts. As a result, we’ve reorganized our app to facilitate rich automated testing without unexpected side effects from singletons. We call our solution the Maestro pattern.

As our app grew in complexity, we decided to isolate components into discrete functional layers — Comms, Data, and UI. These layers delineate the boundaries between different types of components within our ecosystem. The Comms layer represents all the domain-specific Bluetooth code for interacting with Motiv Ring peripherals. The Data layer encapsulates the Motiv data model and managed object context for persistent storage, as well as calculator objects responsible for updating the data model in response to information received from peripherals. The UI layer defines the user experience, reactive components for displaying user data, and interactive components.

We introduced the Maestro component to orchestrate all the interactions between objects spanning across the entire app. Early prototypes of the app could not rely on this modern architecture, and some of our legacy code had (*gasp*) Bluetooth code directly in a view controller. The first round of cleanup introduced provider components to manage life cycle for specific object models. For example, we have a provider responsible for tracking changes to the user, represented by the UserProvider class. Initial implementations of this class involved a singleton, accessible by any object in the ecosystem, like this:

let userProvider = UserProvider.instance

This has hidden side effects. It’s difficult to write tests against your app when any random component in your app might be reacting to changes. We’ve encountered plenty of scenarios where we wrote a new test, only to discover a strange unexpected intermittent failure. This was most pronounced when writing tests for our calculator components. When new data is received from a Motiv Ring device, we may need to invoke a calculator to recompute sleep or activity metrics. In a test case, we want to create some new data records and make sure the appropriate calculator is triggered. This is difficult to do when using singletons, especially as the number of components increases, doubly so when the components interact with each other.

Our solution involves reducing the number of singletons from N (one per provider) down to one (only the Maestro object itself). By introducing a Maestro object which itself owns the provider objects, we have only one singleton. We use a clever Swift technique to achieve this, as follows:

class Maestro {
fileprivate static let instance = Maestro()
fileprivate let userProvider = UserProvider()
fileprivate let ringProvider = RingProvider()
}
extension Maestro {
class var User { return instance.userProvider }
class var Ring { return instance.ringProvider }
}

Now, components can access providers using the following:

let userProvider = Maestro.User

Moreover, since the Maestro instance itself is now the only publicly accessible singleton, all the providers can share a common life cycle. By configuring the Data and UI layer components to react dynamically to provider state, governed through a lightweight Maestro object, we achieve a clean chain of custody of the user’s data. This removes ambiguity, minimizes latency, and simplifies downstream logical considerations to follow a common set of rules.

--

--