The Dark Side of Unidirectional Architectures in Swift

Luis Recuenco
The Swift Cooperative
8 min readMay 10, 2024
Photo by Stormseeker on Unsplash

Introduction

A year ago, I wrote a long article comparing different flavors of unidirectional architectures in Swift. Here, I will present what I consider to be the main problem of them all. Well, it’s not a problem of unidirectional architectures per se. Rather, it’s a problem of modeling the actions or events as values. I usually call it the “ping-pong problem”. And it’s all about the “jumps” we have to do between different places of the code to have a cohesive understanding of the whole flow. Let’s see a simple example first.

func handle(event: Event) {
switch event {
case .onAppear:
state = .loading
return .task {
let numbers = try await apiClient.numbers()
await send(.numbersDownloaded(numbers))
}

case .numbersDownloaded(let values):
state = .loaded(values)
return .none
}
}

Even if that code is pretty easy to read, there’s an even simpler version of that:

func onAppear() {
Task {
state = .loading
let numbers = try await apiClient.numbers()
state = .loaded(numbers)
}
}

It’s not only less code. It’s more cohesive and understandable code.

When modeling events as values, we lose the ability to read all the code from top to bottom as a cohesive piece of behavior. We now have to go event by event to form the whole understanding of a particular flow: some input event triggers some feedback event, which might trigger another feedback event, etc… There can be lots of different back and forths between those events, which makes it harder to understand the code.

But as always, applying any architecture to any trivial example will look like overengineering.

Imagine using something like CLEAN architecture in the previous code.

func onAppear() {
Task {
state = .loading
let numbers = try await usecase.numbers()
state = .loaded(values)
}
}

class UseCase {
let repository: RepositoryContract

func numbers() async throws -> [Int] {
repository.numbers()
}
}

protocol RepositoryContract {
func numbers() async throws -> [Int]
}

class Repository: RepositoryContract {
let apiClient: APIClient

func numbers() async throws -> [Int] {
apiClient.numbers()
}
}

In both cases, we’ve introduced unneeded indirection. We have to jump to lots of different places to understand the code.

Even if we need layers and abstractions to manage complexity, allow flexibility, and facilitate testing, we shouldn’t forget that we are also losing an important trait in software: “locality of behavior” (also called local reasoning). As always, everything is a trade-off.

Modeling events as values has great advantages, like having full traceability of the state changes and what events produced them. But allowing that comes with risks in readability. And remember, code should always be optimized for readability.

Let’s see now a more complex example, inspired by the Mobius Workflow from Spotify.

The ping-pong problem in practice

Taking the architecture I proposed in my previous blog post about unidirectional architectures, let’s implement a simple login screen with the following requirements:

  • The user can enter their email and password to log in.
  • In case of no internet, the user won’t be allowed to log in.
  • The email has both a local validation first and a remote validation after that.
  • The password has no validation.
  • Before attempting to log in, the user will be prompted for confirmation.

We’ll have three different implementations, but first, let’s have the two common pieces: the state and the effects.

struct State: Equatable {
var online: Bool = true
var email: Email = .init()
var password: String = ""
var loggingIn: Bool = false

var canLogin: Bool {
online && email.valid && password.count > 8
}

struct Email: Equatable {
var rawValue: String = ""
var valid: Bool = false
var currentValidation: Validation? = nil

enum Validation {
case local
case remote
}
}
}

enum Effects {
static func login() async throws -> String {
"dummy token"
}

static func localEmailValidation(_ email: String) -> Bool {
email.contains("@")
}

static func remoteEmailValidation(_ email: String) async -> Bool {
return Bool.random()
}

static func showConfirmation(text: String, yes: () async -> Void, no: () async -> Void) async {}
static func onlineStream() -> AsyncStream<Bool> {}
}

Case 1: Complete unidirectional architecture

class ViewReducer: Reducer {
enum Input {
case onAppear
case emailInputChanged(String)
case passwordInputChanged(String)
case loginButtonClicked
}

enum Feedback {
case internetStateChanged(online: Bool)
case loginSuccessful(token: String)
case loginFailed
case emailLocalValidation(valid: Bool)
case emailRemoteValidation(valid: Bool)
case loginAlertConfirmation(confirm: Bool)
}

enum Output {
case showErrorToast
case loginFinished(_ token: String)
}

func reduce(message: Message<Input, Feedback>, into state: inout State) -> Effect<Feedback, Output> {
switch message {
// MARK: - Input events
case .input(.onAppear):
return .run { send in
for await online in Effects.onlineStream() {
await send(.internetStateChanged(online: online))
}
}

case .input(.emailInputChanged(let value)):
state.email.rawValue = value
state.email.valid = false
state.email.currentValidation = .local

let email = state.email.rawValue
return .run { send in
let valid = Effects.localEmailValidation(email)
await send(.emailLocalValidation(valid: valid))
}

case .input(.passwordInputChanged(let value)):
state.password = value
return .none

case .input(.loginButtonClicked):
guard state.canLogin else {
fatalError("Shouldn't be here")
}

return .run { send in
await Effects.showConfirmation(text: "Are you sure?") {
await send(.loginAlertConfirmation(confirm: true))
} no: {
await send(.loginAlertConfirmation(confirm: false))
}
}

// MARK: - Feedback events
case .feedback(.emailLocalValidation(valid: let valid)):
guard valid else {
state.email.currentValidation = nil
return .none
}
state.email.currentValidation = .remote
let email = state.email.rawValue
return .run { send in
let valid = await Effects.remoteEmailValidation(email)
await send(.emailRemoteValidation(valid: valid))
}

case .feedback(.emailRemoteValidation(valid: let valid)):
state.email.valid = valid
state.email.currentValidation = nil
return .none

case .feedback(.loginAlertConfirmation(true)):
state.loggingIn = true
return .run { send in
do {
let token = try await Effects.login()
await send(.loginSuccessful(token: token))
} catch {
await send(.loginFailed)
}
}

case .feedback(.loginAlertConfirmation(false)):
return .none

case .feedback(.loginSuccessful(token: let token)):
state.loggingIn = false
return .output(.loginFinished(token))

case .feedback(.loginFailed):
state.loggingIn = false
return .output(.showErrorToast)

case .feedback(.internetStateChanged(let online)):
state.online = online
return .none
}
}
}

As you can see, to figure out all the login flow, we need to understand how all these different events are handled:

  • emailInputChanged input -> emailLocalValidation feedback -> emailRemoteValidation feedback.
  • loginButtonClicked input -> loginAlertConfirmation feedback -> loginSuccessful feedback.

There are quite a few events and back and forth between them to understand what happens when the user enters their email and when the login button is tapped. Even if we put together the related events in the switch statement to minimize the pain of jumping between them, it’s still very cumbersome and requires painful jumps and indirections.

The feedback events are the ones producing all this back and forth. 🤔 What would happen if we remove them? Let’s see.

Case 2: Removing feedback events

Let’s now write a view model from scratch. This time, only modeling the input and output events as values.

class ViewModel {
enum Input {
case onAppear
case emailInputChanged(String)
case passwordInputChanged(String)
case loginButtonClicked
}

enum Output {
case showErrorToast
case loginFinished(_ token: String)
}

private(set) var state: State
let stream: AsyncStream<Output>
private let continuation: AsyncStream<Output>.Continuation

init() {
let (stream, continuation) = AsyncStream.makeStream(of: Output.self)
self.stream = stream
self.continuation = continuation
self.state = .init()
}

func send(_ input: Input) {
switch input {
case .onAppear:
Task {
for await online in Effects.onlineStream() {
self.state.online = online
}
}

case .emailInputChanged(let value):
state.email.rawValue = value
state.email.valid = false
state.email.currentValidation = .local
let valid = Effects.localEmailValidation(value)

guard valid else {
state.email.currentValidation = nil
return
}

state.email.currentValidation = .remote
Task {
let valid = await Effects.remoteEmailValidation(value)
state.email.valid = valid
state.email.currentValidation = nil
}

case .passwordInputChanged(let value):
state.password = value

case .loginButtonClicked:
guard state.canLogin else {
fatalError("Shouldn't be here")
}

Task {
await Effects.showConfirmation(text: "Are you sure?") {
state.loggingIn = true
defer {
state.loggingIn = false
}
do {
let token = try await Effects.login()
continuation.yield(.loginFinished(token))
} catch {
continuation.yield(.showErrorToast)
}
} no: {
// Nothing
}
}
}
}
}

Removing the feedback events has simplified the code quite a bit. We can now read top to bottom again and the related parts are together, having a much more cohesive code than before.

Case 3: Removing events as values

But, to be honest… unless we need to log or have traceability with those input events, it’s much simpler to avoid that massive switch and rely on plain, simple functions to replace them.

class ViewModel {
enum Output {
case showErrorToast
case loginFinished(_ token: String)
}

private(set) var state: State
let stream: AsyncStream<Output>
private let continuation: AsyncStream<Output>.Continuation

init() {
let (stream, continuation) = AsyncStream.makeStream(of: Output.self)
self.stream = stream
self.continuation = continuation
self.state = .init()
}

func onAppear() {
Task {
for await online in Effects.onlineStream() {
self.state.online = online
}
}
}

func emailInputChanged(value: String) {
state.email.rawValue = value
state.email.valid = false
state.email.currentValidation = .local

let valid = Effects.localEmailValidation(value)

guard valid else {
state.email.currentValidation = nil
return
}

state.email.currentValidation = .remote
Task {
let valid = await Effects.remoteEmailValidation(value)
state.email.valid = valid
state.email.currentValidation = nil
}
}

func passwordInputChanged(value: String) {
state.password = value
}

func loginButtonClicked() {
guard state.canLogin else {
fatalError("Shouldn't be here")
}

Task {
await Effects.showConfirmation(text: "Are you sure?") {
state.loggingIn = true
defer {
state.loggingIn = false
}
do {
let token = try await Effects.login()
continuation.yield(.loginFinished(token))
} catch {
continuation.yield(.showErrorToast)
}
} no: {
// Nothing
}
}
}
}

In my opinion, this last code reads much better than any other.

Conclusion

As you can see, feedback events and the back-and-forth between them can impact the readability of your code, making it quite hard to understand and build a cohesive understanding of the whole functionality.

Take into account that the example is thoroughly presented to maximize these problems. We could have other examples where having a more constrained way of controlling our state, by only allowing it to mutate via events, results in a much more understandable code.

This article only wanted to highlight the price we are paying when adopting these kinds of architectures. Not only because of the ping-pong problem but also because of the impedance mismatch when working with SwiftUI, where bindings and other property wrappers don’t fit naturally and require extra boilerplate.

Not only that, the impedance mismatch happens as well in terms of Xcode and the static analysis and navigation ergonomics it has. We lose the ability to jump directly to the definition of the function (we now jump to the case in the enum, having to search that case in the switch statement to see the implementation of that particular event), or use handy features like “call hierarchy” in Xcode.

In my opinion, a safer approach for most people would be to rely on a humble view model (or some other kind of thin controller/coordinator layer) and move as much logic as possible to a properly modeled domain. Interestingly enough, this was what I proposed back in 2017, in my first blog post about architecture 😅. But that “loose architecture” could very easily get out of hand if not careful, whereas unidirectional architectures usually have much stronger constraints and are more opinionated about where and how state and effects are handled. As always, everything has its pros and cons. Choose wisely.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.