DEV Community

Cover image for Action Runner iOS Architecture
Ivan
Ivan

Posted on Edited on Originally published at ivanthinking.net

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 using React, 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 {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

With a call site like:

let action = LoginAction(email: "...", password: "...")
try await action.execute()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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) {
       /// ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

For an action runner, the durable boundary is more important than the queue implementation: define what an action may read, write, and report, then make execution results replayable. That gives SwiftUI a simple observable state model while keeping side effects testable outside the view layer.