Yalantis: iOS, Android And Web App Development Company

“Tree of Models” as an Alternative App Architecture Model

App developers constantly face problems that can’t be solved by simple architectural patterns. Neither MVVM nor VIPER help us implement error processing or callbacks between modules, or get rid of routine code for dependency injection.

That’s why I want to introduce a different approach that effectively solves all these problems. This is my original idea, and I call my solution a “Tree of Models.” Tree of Models is an app development approach based on recursion.

The traditional approach and its disadvantages

Projects are typically built linearly: first you create one or several services – for example, a CoreData stack and a network client – and then you describe the first module (MVC, MVVM or VIPER), which is essentially a screen on which your services are used. Your next screens will also require services. Therefore you’re faced with two options: you can create singleton services so that all modules have equal access to them, or you can rely on dependency injection.

Singletons have two serious disadvantages: they have shared mutable states, and it is difficult to test objects that use singletons. These two issues can be especially problematic for large projects. That’s why we prefer dependency injection.

With dependency injection, the second module will receive the network client as a dependency during initialization, the third module will receive this client from the second, and so on.

Let's imagine now that a new modal flow is starting from the last module. Even though secondary data output is the primary function of this module, it will also have to work as a source of dependencies for the new flow.

Creating an app according to this structure has the following disadvantages:

  • Routine dependency injection  —  Dependency Injection necessitates a lot of redundant  code.
  • Illusion of dependency injection — In most cases the same objects are being transferred, so switching to singletons doesn’t change anything.
  • Absence of relation between modules that are not connected — For example, you can’t send a callback to the first module from the third module.
  • Code viscosity —The second module can’t easily be removed. Doing so will risk breaking the connection between the first module and the third module.
  • No error processing — Error processing is an important task that often gets postponed.

Another issue that is often overlooked is that life cycles of services and modules are not connected in any way. This means that there’s the possibility for cached data of a previous user to be passed down to a new user.

Tree of Models

Now that we know what issues we want to avoid in our code, we can start working with our app architecture. For the purposes of this article, we’re developing an app that helps students with exam preparation. In this article we will be looking only at model within each module. Here is a possible schema for our exam preparation app:

A session is an object where a user’s data and services are stored. Models are mostly responsible for particular screens. Navigation within the app happens in the following way:

1.The user sees the main flow (a screen with the main controller and a sidebar menu).

2. From the main flow the user can create a new exam by picking a subject, a topic and some additional parameters. This is implemented as a sequence of screens that the user opens one by one.

This approach is based on a hierarchical or tree-like model structure: each model is derived from a “parent” model and has a set of “child” models.

Thus, an instance of class “Model” can only be retained in memory by its “parent”

public init(parent: Model?) {
   self.parent = parent
   parent?.addChild(self)
 }

The Main Flow model has two “child” models: ExamCreationFlow and SideMenu.

A flow that allows users to create an exam has its own “child” models: Subject Picker, Topics Picker, and Options Picker.

There are still two problems remaining:

  1. We have to decide which object will be responsible for retaining the Main Flow.

  2. A session performs the function of a singleton, and all services that are created by it live forever.

To solve these two problems we need to look closer at the responsibilities of the session. Very often the first task you work on when starting a new project is user authentication. The user authentication task requires a model as well. So our session transforms into a model:

Above the Session there is an object called session controller which is responsible for retaining the session (holding it in memory). It is the only singleton in the app. In fact, the session controller is responsible for creating and completing sessions, and also for archiving and unarchiving sessions.

When the session inherits from the Model:

  1. The session is no longer a superobject. Its deallocation will result in deallocation of all tree models.

  2. Session completion will also result in deallocation of all services. By deallocating all services we make sure we clear tokens and queues.

  3. All tree objects, including the session, are the same type. This allows to build powerful recursion-based mechanisms described below.

Recursion

Let’s imagine that the Options Picker model requires a session to use the network client. The model will inquire if the parent model is a session. This starts a chain of calls – a parent model inquires whether their parent model is a session, and the chain continues until the session is found.

This is how recursive access is implemented:

public final func session() -> Session {
   if let session = parent as? Session {
     return session
   } else {
     return parent.session()
   }
 }

Dependency injection

Every model can access the session. That’s why it’s easy to implement dependency injection.

let client: APIClient = session.services.getService()

It’s important that all services are easily available to any tree model. Also in this case DI is ‘fair,’ as the tree is using its own resources.

Propagation of notifications

There are two common approaches for passing callbacks between remote objects:

  1. The callback is passed between objects with the help of an additional dependency.

The disadvantage of this approach is that your code gets high viscosity and gets significantly more complex at certain nodes, such as in the main model that passes callback from various flows.

2. NSNotificationCenter

Transferring messages with the help of NSNotificationCenter works like magic: every message you send instantly goes where you want it to. However, this approach is dangerous and is considered bad style because breaking the flow control complicates the logic of the process, and may result in errors that are hard to diagnose.

Whenever a project is implemented using this approach (manually passing callbacks and NSNotificationCenter), it results in confusing code.

Recursive mechanism

First, let’s pass a notification to the session and request that it propagate the notification down to “child” models.

 public final func raise(
   globalEvent: GlobalEventName,
   withObject object: Any? = nil,
   userInfo: [String: Any] = [:]) {
   let event = GlobalEvent(name: globalEvent, object: object, userInfo: userInfo)
   session.propagate(event)
 }

Every child model will receive the notification and pass it on to its own set of child models. In this way the notification will “visit” all models of the tree and all models that had been signed before will be able to process this notification.

This is what it looks like in code:

 private func propagate(globalEvent: GlobalEvent) {
   if isRegisteredFor(event.name) {
     handleGlobalEvent(event)
   }
   childModels.forEach { $0.propagate(event) }
 }
}

If the model is subscribed for this event, a method is called.

public func handle(globalEvent: GlobalEvent) {}

This approach is flexible and results in code that is easy to refactor. Neither deletion of intermediary elements nor swapping receiver and sender will have any negative impact on its functionality. Plus, it’s effective for projects of any size.

Error processing

Error processing doesn’t get enough attention at the early stages of development. First, most often an error map doesn’t exist at the beginning. Second, error processing in most cases means simply notifying a user 'in place,' without affecting business logic. At the same time, postponing error processing often results in problems when the project is about to be released and there is no time left to create a graceful mechanism for error processing.

A recursive error processing schema prevents these problems. It is similar to broadcasting of notifications, but with one significant difference – the error only moves up, its movement reminiscent of the steeplechase.

To handle an error and stop it from escalating, the model must register as a handler for this kind of error:

registerFor(NetworkError.BadToken)

Here’s the method in the Model class responsible for raising errors:

public func raise(error: Error) {
   if isRegisteredFor(error) {
     handle(error)
   } else {
     parent?.raise(error)
   }
 }

A tree-like models hierarchy is very convenient for error handling. Let’s say that the Additional Params model received the Validation Error:

This error could be handled ad hoc, and the invalid text field could be highlighted.

If the same model receives the “Bad response” error, however, then this is obviously a problem related to the entire exam creation flow, so the model just passes it and the error automatically moves a level higher.

Here, most probably, the flow will show an alert with a corresponding message.

Let’s assume now that the same Options Picker model received the “Subscription expired” error. According to the specification, we must close the current flow and start the Purchases flow to ask the user to pay for a subscription.

The optimal place to catch this error is in the Main Flow (It is parent to the Exam Creation Flow, so it takes the responsibility for closing it and starting another flow).

The last case we have to consider is the “Bad token” error. When that severe error is received, our application must immediately finish its current session and switch to the Welcome Screen.

To achieve such behavior we must make the Session the only subscriber for this error.

This mechanism has one nice side effect: all uncaught errors end up in the Session. Tracking all escapees in one place is very useful at the development stage.

Bubble Notifications

Bubble notifications is another simple mechanism for passing callbacks. It works similarly to the error handling mechanism. Bubble notifications are useful when callbacks are only passed upwards.

For instance, each screen of the Exam Creation Flow has a ‘Show side menu’ button. The Main flow is subscribed to the ‘Show side menu’ bubble, so any model from the Main flow subtree can easily give a command to show the side menu:

raise(BubbleNotification.MainFlow.ShowSideMenu)

Submodels for subtasks

We’re used to building MVC modules around View Controllers. As a View Controller represents the contents of some screen, its model encapsulate all logic related to this screen.

But screens often combine many tasks such as validation, displaying search results, filters, and so on.

For the sake of code simplicity and reusability we can create separate controllers for each task and keep them as models properties – for example, search bar controller, search results datasource, and Validation controller.


These objects will be connected to their model by delegation, which means that each callback must be described explicitly: errors, success, update in, etc. But what if they inherit from the Model class?

This will allow us to connect every controller to the whole tree. Each and every one of these controllers receive the abilities to raise errors and handle events.


Instead of a search controller, we have a search model that can be widely reused in the project. Wherever we place it, it will send errors and callbacks without additional protocols and setup from its parent. Instead of having a passive ‘pile’ of controllers, we’ve added a new green branch to the tree!

This raises an interesting question. Both huge sessions and small search models inherit from the same Model class. Is this correct from the OOP point of view? The answer is yes. This is the core idea of the ‘Tree of Models’ schema: all models are equivalent. Furthermore, if, for instance, a search model is complex enough and uses its own services like GeoNames client, then it can become a local session for its own children. This will make it both an isolated microcosm of the ‘Search’ task and a competent branch of the tree.

Navigation

Most architecture paradigms associate user navigation with a view: users go from screen A to screen B. And most paradigms encourage you to put all transition-related code in the ‘view’ layer. My approach is different. With the Tree of Models, users switch between contexts and menus, but not between screens. In other words, the Tree of Models treats navigation as a task of business logic, so transitions must be described in terms of a program’s core logic.

We’ve already discussed the observable state. Now let’s make the tree growth available for observation. This is how models inform subscribers about changes in the tree:

public let pushChildSignal = Pipe<Model>()

If you’re not familiar with reactive programming, you can imagine a signal as an object with some value and an array of blocks-subscribers. When the value is updated, all blocks are executed with this new value as a parameter.

With a “Pipe” the signal doesn’t retain the passed value, but only informs its subscribers of that value.

So as we can see, a model’s observable state has a new property. Which object will be the subscriber?

We need something that can convert the changes in the tree into changes in the representation stack. What might we call such a thing? A navigation router? Navigation manager? Navigation Controller? Wait a second, we already have such a class in UIKit.

The UINavigationController is often overlooked. But its purpose is not only to show a navigation bar. It’s perfectly suited for handling transitions within a flow:

Let’s define a new UINavigationController inheritor: ExamCreationFlowNavigationController. This may be a bit too verbose, but it perfectly captures the essence of what we're doing. Its model is the ExamCreationFlow.

Transition to the Exam Options screen would happen as follows:

First, the Topics Picker reports its parent (the Exam Creation Flow) that a user has picked an exam topic.

Then the flow creates a child model OptionPickerModel and informs subscribers by firing pushChildSignal:

func child(child: Model, didSelectTopic topic: Topic) {
   exam.topic = topic
   pushChildSignal.sendNext(OptionsPickerModel(parent: self))
 }

Depending on the model type passed through the signal, the navigation controller creates a new view controller, connects it to the passed model and performs a transition (modal or push):

model.pushChildSignal.subscribeNext { [weak self] child in
 if let model = child as? OptionsPickerModel {
     let controller = ExamCreationFlowStoryboard.optionsViewController()
     controller.applyModel(model)
    pushViewController(controller, animated: true)
   }.ownedBy(self)

It’s remarkable that this ‘Tree of models’ doesn’t use additional entities or layers to control the navigation. Everything fits within the MVC pattern. With this approach the Representation layer is fully detached from the Model. It’s possible to use a different UI framework without changing the Business Logic.

Structure control

Because you can ‘visit’ all of a tree’s models with one request starting from the session, I implemented a convenient tool for controlling and evaluating the tree structure.

printSessionTree()

This command can be sent by any model, and prints the entire tree structure hierarchically.

By using various output options it’s possible to explore the tree from a bird’s eye view. For instance, the output can show all errors that models are subscribed for:

model.printSessionTree(withOptions: [.ErrorsVerbous])

Console output:

Conclusion

Organizing your models into a tree-like structure solves many problems we face when developing scalable code.

Passing notifications and callbacks between distant nodes, dependency injection, and error handling doesn’t require writing any routine code. The modularity that we achieve by using submodels keeps our complexity from growing as our codebase grows. Using tree growth as a part of the observable state makes it possible to change the UI framework, and even make the app’s business logic cross-platform.

Sample project: ExamMaster

Read also:
 
Tech

Lightweight iOS View Controllers

Tech

How to Achieve Loose Coupling?

Tech

From JSON to Core Data Fast

Tech

8 Tips on CoreData Migration

Design

Eat. Drink. Track. How We Created Eat Fit iOS Animation Inspired by Google Fit