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 anexecutelike:otherAction.execute()(this gets even cooler later when we pass throughRunContext)
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
ActionRunnerfor testing so that dependencies are stubbed in a testingRunContext - 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.