Ziggurat iOS App Architecture
Several steps beyond model view controller
Written by Alan Fineberg.
Heads up, we’ve moved! If you’d like to continue keeping up with the latest technical content from Square please visit us at our new home https://developer.squareup.com/blog
Back in June, I gave a talk on Preventing Massive View Controllers and described an app architecture with a one-way data flow in Swift. At the time, the architecture didn’t have an accompanying blog post or even a name. Now, it has both. I’d like to introduce Ziggurat: a layered, testable architecture pattern incorporating immutable view models and one-way data flow.
This architecture is dubbed “Ziggurat” after the stepped pyramid. Like the steps of this pyramid, data complexity scales down as data flows in a single direction through the layers of the app. This one-way, immutable flow of data reduces cognitive load and results in smaller classes. Compared with Ziggurat, a typical Model-View-Controller architecture provides less guidance, and data and state may be mutated from a variety of places, including the view controllers.
Ziggurat remixes concepts from a variety of talks and articles, which I’ll list in the Further Reading / Watching section. (Spoiler: it’s inspired directly by early talks on Facebook’s React Native and is similar to Flux). Of the alternative architectures referenced herein, any would be a favorable choice versus Model-View-Controller.
I’ll present Ziggurat, describe its background, contrast its tradeoffs, define its components, and show it in action with a sample app.
Problems With MVC
Ziggurat is an alternative pattern to Model-View-Controller (MVC). MVC is a basic way to divide responsibility in an app, but it inadequately separates roles and responsibilities. These are some common problems experienced in MVC apps:
Massive View Controllers
- View Controllers may modify data, manage I/O, fetch from APIs, contain the source of truth for model objects, or otherwise exceed their purpose: managing views and UI events.
Tricky Bugs from Shared Data
- Who is modifying this data and when? Without a careful design, the answer could be almost anyone, at almost any time, with unknown, cascading side effects.
Hard to Test Code with Too Much Responsibility
- Seams which delineate and isolate behavior are murky or missing when the single responsibility principle is flouted.
In practice, MVC’s lack of separation leads to apps without design clarity that become hard to test and debug. Engineers should not need to reverse engineer an app’s design and data model in order to work effectively in a codebase.
Description of Components and One-Way Data Flow
I’ll go over the basics of the Ziggurat pattern, but for a more detailed description see the resources listed below:
- A sample app that implements the pattern with all the components documented in code ishere.
- The sample app also documents the components in text file.
- A talk I gave on Preventing Massive View Controllers
- Square engineer Kat Hawthorne’s talk and slides on one-way data flow, from where I borrowed the slide below:
Ziggurat’s one-way data flow is visualized above (and demonstrated in action here):
- An external trigger (such as a user input) occurs.
- A View Controller notifies a Service that it received user input.
- A Service parses/validates this input and may mutate state (only Services mutate state). Then the Service calls signal().
- signal() indicates to a Renderer that it’s time to update.
- The Renderer calls a Presenter, producing a View Model (Services are not written to during the render loop).
- The Renderer then pushes the View Model into View Controllers.
- The render loop is idle until another external trigger occurs.
The Ziggurat architecture addresses common problems with MVC apps:
- Massive View Controllers
- A many-layered architecture provides a clear home for functionality, which prevents massive view controllers.
- Tricky Bugs From Shared Data
- Reduced statefulness and mutability: the Service layer encapsulates mutability (restricts it to the main thread), and presenters and rendering are stateless and unidirectional. Dependency injection instead of shared state and global singletons.
- Hard to Test Code with Too Much Responsibility
- Fewer, thinner application layers distribute responsibility evenly.
Ziggurat made life better in several ways, and enabled our small team of developers with a mix of iOS experience (ranging from none to several years worth) to hit an ambitious deadline.
For one thing, it clearly defines layers and roles in the app by providing guidelines and guardrails for engineers who are new to the codebase. Ramp-up time is short for new engineers, since the role of each component is well-defined and fits a well-defined mental model. Secondly, the Ziggurat pattern makes it easy to add tests. For example, we use the view model layer to compare structs as expected output. This wouldn’t be feasible in MVC.
One-way data flow prevented MVC spaghetti and shrank view controllers drastically. Immutable types, smaller objects (preferably structs), and more layers reduce mental overhead. It’s also easier now to run apps in a “headless” mode since the view layer is devoid of business logic; all business logic is testable without a view layer; view state can be recreated with point-in-time view model snapshots. And finally, we found that the app was portable. Thanks to layers and dependency injection, it was straightforward to transition from an app to being an applet contained in another app.
Ziggurat made a few things more difficult, like animations. We recommend using Ziggurat in apps which use animation sparingly. View Controller animations could be disrupted by an incomingupdate() call, which could cause flicker unless carefully managed. With Flux, this issue is solved by using it with React Native.
Additionally, some boilerplate code resulted from pushing data through additional layers, although it provided additional compile-time checks. There’s also a potential bottleneck with single data update pipeline. Unlike React, properties are not key-value observed; instead, updates come in piecemeal as view model structs. Some optimizations may be needed, such as discarding extraneous render calls or pruning the view model with diffing.
Dependency injection requires careful design. Our initial approach led to a large object graph. We had circular dependencies, which led to refactoring.
New projects offer an opportunity to try out new ideas. MVC has a number of clear weaknesses and has gained ignominy. Meanwhile, a number of talks on subjects such as Flux, React Native, and using value types more extensively with Swift have gained momentum.
We built out a complete Swift app with an architecture that remixes these ideas. We’re pleased with the results of using one-way data flow, testability, dependency injection, lightweight view controllers, and value-typed view models.
There are tradeoffs of Ziggurat, but it’s served us well so far. I hope you’ll consider adopting some (or maybe all!) of the ideas here in your next project, especially before settling on an MVC architecture.
Further Reading / Watching
Flux is a robust, action-centric pattern that pairs well with React Native. Ziggurat is easily used in an app with minimal animations, but does not provide a solution for complex animations that could be interrupted by unpredictable re-renders. (Flux has this issue too, which React Native addresses.)
We hope you find the Ziggurat pattern helpful, and/or discover one of many better alternatives to MVC covered in the articles below:
- OS Architecture Patterns
- MVVM is Not Very Good
- Introduction to MVVM and MVVM in Swift
- Controlling Complexity in Swift
- Introducing React Native
- Flux (and talk)
- Architecting iOS Apps with VIPER
- Simple static table views for iOS in Swift
Thanks to Ruby Chen for the illustration.