Architecture in SwiftUI

2019 brought Combine alongside SwiftUI - Apples answer to Rx. Personally I was very happy to see this as I've been a fan of Rx for years and interpreted this as Apple embracing reactive programming. But 3 years on things look very different, 2 consecutive WWDCs with no updates to Combine and all the focus on Swift's new concurrency model, including AsyncStream for transforming sequences of values over time - exactly what Combine does. So it isn’t looking good for Combine at this point. We’ve been using SwiftUI since the beginning of 2020. One of the first questions we asked was what architecture pattern we should be using. After a bit of googling we stumbled on a great post on a modern approach to MVVM using Combine by Vadim Bulavin. We’ve been using this on our own app ever since. But what with this question mark hanging over the future of Combine, and the suite of new concurrency features in Swift 5.7, it felt like time to reconsider this approach.

We’ve really become fans of a finite state machine approach to managing app events and state transitions (see Vadim’s post for a great overview of the benefits), so the first question was, can we rewrite the Combine-based feedback loop with AsyncStream? This led us to a library called Actomaton, which describes itself as a “Swift async/await & actor-powered effectful state-management framework inspired by Elm and swift-composable-architecture”. I’d recently been talking to someone about something he called TCA - turns out this is what he was talking about.

The ideas behind TCA (The Composable Architecture) are very close to finite-state machines (FSMs) or finite-state automata (FSA) and the state design pattern. Vadim’s post lays out an implementation of a state machine driven by a Combine feedback loop which involves an event (or action in TCA terminology) being passed through a reducer to create a new state, then allowing Feedbacks (or effects in TCA - side effects). Vadim describes this approach within the context of MVVM, assigning the output of the reducer to a @Published property as the view binding mechanism. TCA formalises the various elements of the state design pattern, which gives you all the building blocks to structure apps via clear state management, composition, and testing, approaching the app architecture question from a clean slate, focussed on the unique properties of a SwiftUI app, which MVVM never had in mind. You can actually watch the authors consider these issues in their weekly videos over a period of 9 months as the library evolved. They describe the pattern as opinionated, as it has very explicit requirements about how (where) any side effect can happen.

A big benefit of TCA is that it also considers the matter of dependencies. This is usually an additional consideration many (most?) architectures (including MVVM) are agnostic about. This generally results in domain-specific conventions about which objects are ‘allowed’ to be tightly coupled to dependencies (concrete types). Sometimes this is the AppDelegate or SceneDelegate, or in a more SwiftUI context this can be the App instance - the top level object in each case. In other cases it can be coordinators, routers, service directories, compositors, etc. In terms of how dependencies get from point a to b (or x!), it is often a burden to pass lower level dependencies down through all their ancestors, and so often objects will reach up to this top-level object to get what they need. With SwiftUI the Environment provides a tempting alternative but can quickly devolve into a dumping ground with all the common pitfalls associated with global state.

My personal preference has been to use a dedicated Dependency Injection framework, such as Swinject, which introduces a concept of assemblies, which are simple types that tell another object (called a Resolver) that they can provide a concrete instance of a specified type. This allows other objects to ask the resolver for a dependency without any knowledge of the concrete implementation it will get, meaning they remain loosely coupled to this dependency, meaning it can be switched out without any change to business logic. The resolver retains all registered assemblies and has the responsibility of invoking the appropriate one when a request for an instance of an abstract type is made. Circular dependencies can be a problem, and need specific consideration to prevent runtime crashes, a fact that often leads to more domain-specific rules about where/when it is acceptable to access the resolver (or some type that wraps it).

In any case, managing dependencies is a burden, and over time, often, an object or objects evolve to manage more dependencies than may be necessary for a specific subset of functionality, which can make modularisation harder than it needs to be. This brings me back to TCA, which has a concept it also calls Environment. This is a simple value type that contains dependencies, and along with state and action, is accessible by a reducer. The C in TCA is Composable, and the way this is achieved is by designing the various parts of the app feature by feature, with each feature having its own reducer, state, actions, and environment. These are then composed by an app level reducer. What this means is that you have a very clear separation of concerns, where each feature has access to only the dependencies it needs. So for example, making an app clip feature, becomes trivial from a dependency perspective.

Another important consideration is testing. The library comes with a dedicated test support module which makes it possible not only to make assertions on state but also that certain side effects occurred. There are also some cool tools like time manipulation mechanisms to prevent the need for physical waits for asynchronous task completion within tests, keeping them as fast as possible, which can be a real problem with testing asynchronous logic. Debugging is also possible with a debug operator than can be attached to a reducer to show state diffs in a familiar (git like) format so that you can easily follow the flow of state transitions (or lack thereof) in response to a certain action - this is a real time saver.

Although I came into this investigation completely unaware of TCA (so I’m not biased), in an attempt to remain balanced I should probably mention downsides. As with any opinionated paradigm, one can usually say the learning curve is high. This is certainly true for Combine, which is probably why Apple seems to be done with it (more on that below), but also for architectures like VIPER. MVVM is so widely used that you don’t need to even consider sections in your repo’s readme describing it. Whilst many of the concepts may be unfamiliar to developers accustomed to a more imperative paradigm (it is influenced heavily by functional frameworks like Elm and Redux), the documentation is very good, there is an active community, and there is a comprehensive 4 part video overview on pointfree.co describing the library in detail. In fact their weekly video series is one of the best resources I’ve found for advanced swift concepts. But beyond this, I really don’t consider this a valid criticism, since the reality is we’re in a transitional time on multiple fronts in iOS. Apple have made their position clear: SwiftUI is the best way to develop apps on Apple platforms. This is a move from imperative to declarative, which means many of the tools that were appropriate in an imperative context - from dataflow mechanisms to architectures - may no longer be the best tools for the job. I would guess functional/declarative principles become much more widely used in Apple platforms over the coming years (and more broadly in mobile in general since a similar transition is happening in Android with the move to Jetpack Compose).

What I consider to be a much bigger cause for caution (if not concern), is what I alluded to above that Apple look to be done with Combine. Combine is the backbone of TCA, and its usage will therefore be a core part of any app using it. When starting this investigation, my goal was to find a way to reimplement the feedback loop FSM in pure Swift using the new concurrency model (which has emerged since TCA). There’s just no denying that many asynchronous problems can be solved more clearly and with much less boilerplate than with Combine, and with async-algorithms providing a lot of the functionality for operating on sequences of emissions over time, it becomes even harder to make a case for it. Dropping this dependency would also allow TCA to be used in other places Swift is used (like server apps) where Combine cannot be used. Thankfully this is on the roadmap (see this discussion), so I don’t consider it a deal-breaker. Actually I was interested to see their current topic is just this: concurrency’s past (threads), present (queues and combine), and future (the new concurrency model, starting with tasks). So it’s great to see them clearly acknowledge a move to the new model is preferable (they also state as much in the above discussion). But why not Actomaton (which I stumbled on before TCA)? Since this is much newer, it has a smaller community with fewer contributors, and less learning resources. It looks like a very nice implementation, although I haven’t explored it enough to really comment on that. I’ll definitely be keeping an eye on this, but I’ve really been enjoying the pointfree videos - there’s almost 200 episodes of very high quality content, so I’m keen to see how the library evolves to incorporate the new concurrency features. On top of this there’s a lot of activity on the repo, with some great discussions, so it feels like less of a chance that development will suddenly stop.

In summary, TCA provides a formalised method of state transitions, side effect performance, dependency management, composition, and testing - all with a single dependency, from authors with a sense of accountability. I’m looking forward to exploring this library in more depth over time.

Next
Next

SwiftUI: Is it ready yet?