Hello everybody,
A quick preface (trigger warning: domain-driven design inside)
I have a few beefy services with tons of business logic, all neatly organized into testable entity classes and aggregates that are persisted using the unit-of-work pattern - sometimes in transactions (if multiple aggregates are involved), always with automatic optimistic concurrency retries. Code is clean, testability is fantastic, things work perfectly fine, and life could be good - but, alas, it’s all written in typescript… (pause for dramatic effect)
So, I know I want to eventually port it over to swift - or at least write new features/services in swift - and I have been thinking about a) is a unit-of-work (or object context if you will) still a good pattern in 2024 and b) how to actually shape it in Swift 6 with modern concurrency.
Let’s ignore a) for now (I still think it is a nice pattern until proven otherwise).
Oddly enough, SwiftData’s ModelContext is kind of that, but without an async API. So, another way of asking this would be: How to async-ify ModelContext
for the server-side?
Imagine I wanted a type like this (for unit-of-work style processing, or more generally an ObjectContext or something like that)
edit: added more details to example code for clarity
protocol Entity: Identifiable, AnyObject {}
final class Context {
var cache = SomeFancyStorageOfNonSendableEntities()
let db: SomeDatabaseClient
// this outer fetch is "not-sendable"
func fetch<M: Entity>(_ type: M.Type = M.self, byId id: M.ID) async throws -> M? {
if let cached = cache.get(type, id) {
return cached
} else {
// this fetch is nonisolated and sendable
let fetched = try await db.fetch(type, byId: id)
cache.add(fetched)
return fetched
}
}
// more stuff like fetching by query, adding, removing, ...
func saveChanges() async throws {
//save it all to DB, with bells and whistles
}
}
If I just naively write it like above, it kind of works, I can do this safely (even in Swift 6 I think):
final class A: Entity {
var id: String = "some new id"
}
final class B: Entity {
var id: String = "some new id"
}
func myOperation() async throws {
let context = Context()
let a = try await context.fetch(A.self, byId: "aaa")
let b = try await context.fetch(B.self, byId: "bbb")
doSomeWork(a, b)
try await context.saveChanges()
}
But now I'd really want to parallelize the fetching, so it would be really nice to spell it like this:
func myFasterOperation() async throws {
let context = Context()
async let a = context.fetch(A.self, byId: "someId")
async let b = context.fetch(B.self, byId: "someId")
try await doSomeWork(a, b)
try await context.saveChanges()
}
Clearly, this won't fly because Context
isn't sendable.
I would need to tell the compiler that that Context.fetch
itself should not actually hop anywhere, and that the "parallelism" introduced by async let
should happen "inside" (if at all).
I have been trying to keep up with the various recent evolution pitches and additions around isolation and regions, but it hasn't really clicked yet if/how I could do such a thing. A quick experiment on the latest nightly using isolation ... = #isolation
as a parameter did not help.
I can kind of see how making Context
an actor
might do the trick, but as I understand it it would require myOperation
to be isolated to a context parameter (right?).
Will this at all be possible with Swift 6 (without actors)?
Would it just be a bad API design anyway?
Will the idiomatic way for something like this be to fully lean into actors
and isolation annotations sprinkled everywhere?