Action Runner iOS Architecture

Around mid-2021, I’d started developing SwiftUI apps in a new way. I’d previously considered architectures like MVC, MVVM, VIPER, etc and many of them never felt right. I often found myself spending time considering where to put business logic. With SwiftUI, it wasn’t entirely clear where data-level changes should be taking place.

Controllers?

Most (or any?) business logic certainly shouldn’t be living in the views, but should I be building controllers to manage data? When I did that, I ended up struggling to define the boundaries of each controller. Dependencies would start to get messy, and I’d start getting circular dependencies across controllers that called each other. Controllers would start to get really big, especially for authentication, and I’d often struggle to quickly find code I was looking for.

What’s an Action?

In my work with React Native, the one-way data flow in Redux and how it handled actions was an idea that came to mind to try to tame these large controllers I was noticing in my projects. I started with adding a simple struct in Swift that would work like an action that a view or another action could call:

struct LoginAction {
    let email: String
    let password: String

    func execute() async throws {
        // ...
    }
}

With a call site like:

let action = LoginAction(email: "...", password: "...")
try await action.execute()

I essentially pulled out each function from my controllers and now had them each as their own structure.

Having a dedicated struct for each action gave me some nice things:

  • I can create helper functions for the main execute() without exposing them to other actions, whereas if this were all part of a controller it would be less isolated
  • If I wanted to expose that helper function to other actions, I could do so with its own dedicated Action
  • I could write unit tests for that specific action without needing to worry about dependencies that a full controller might carry
  • Chaining Actions could be done within an execute like: otherAction.execute() (this gets even cooler later when we pass through RunContext)

What about dependencies? In the case of this LoginAction, I’d have to bring in some sort of API to actually make an HTTP request and execute that request.

Enter ActionRunner and RunContext

struct ActionRunner: Sendable {
    let runContext: RunContext

    func run<A: RunnableAction>(_ action: A) async throws -> A.Output {
        try await action.execute(in: runContext)
    }
}

protocol RunnableAction: Sendable {
    associatedtype Output
    func execute(in context: RunContext) async throws -> Output
}

struct RunContext: Sendable {
    let services: ApiServices
}

And then we update LoginAction to:

struct LoginAction: RunnableAction {
    let email: String
    let password: String

    func execute(in context: RunContext) async throws -> LoginResult {
        let service = context.services.account
        let response = try await service.login(email: email, password: password)
        // ...
    }
}

An ActionRunner can be added to SwiftUI’s EnvironmentValues to make them easy to get from your views:

struct LoginView: View {
    @Environment(\.actionRunner) private var runner
    // ...

    private func login() async {
        do {
            let loginAction = LoginAction(email: "...", password: "...")
            self.loginResult = try await runner.run(loginAction)
        } catch {
            // ...
        }
    }
}

And this is how chaining would look now:

struct LoginAction: RunnableAction {
    func execute(in context: RunContext) async throws -> LoginResult {
        let service = context.services.account
        let response = try await service.login(email: email, password: password)

        let storeTokenAction = StoreTokenAction(token: response.token)
        try await storeTokenAction.execute(in: context)

        // ...
    }
}

Analytics

I ended up adding a helper function as an extension to RunnableAction:

extension RunnableAction {
    func withAnalytics<Result>(_ event: AnalyticsEvent, _ perform: () async throws -> Result) async rethrows -> Result {
        // ...
    }
}

So we can call it in our Actions thusly:

struct LoginAction: RunnableAction {
    let email: String
    let password: String

    func execute(in context: RunContext) async throws -> LoginResult {
        let service = context.services.account
        return try await withAnalytics(.login) {
            let response = try await service.login(email: email, password: password)
            // ...
        }
    }
}

Action Runners

I’ve been calling this the Action Runner Architecture. It’s served me well across multiple projects in offline-first apps, CoreData apps, API driven apps, and App Clips. I’ve codified it into its own package, and I continue to use it in my projects today.

There are still issues that are not yet fully addressed here:

  • Infinite recursion can happen with what we have now
  • We need to define an ActionRunner for testing so that dependencies are stubbed in a testing RunContext
  • I group related Actions in a single file, but I still need make decisions on where things go

Who else has done this?

I found it interesting that Whatnot, in 2022, came up with a similar pattern that they show in their blog post: Reacting to Native: How we rebuilt a unicorn iOS app in 4 months.

Their example of their BookmarkLivestreamAction smells very similar to what I’ve done with my own pattern:

class BookmarkLivestreamAction {
    // ...

    func run(completion: @escaping FollowLivetreamRequestCompletionHandler) {
       /// ...
    }
}

A more modern implementation would likely use Swift’s structured concurrency rather than the escaped closure, and I prefer to make sure my actions don’t hold state, so mine are always structs.