Hey,
I just found out about Realm while researching database options for a new hobby project I’m working on. From what I’ve read so far, I love everything about it and I am genuinely excited to try it!
I have a few questions regarding some best practices around the way I’m planning on using the framework. I’d massively appreciate any input on this
The Setup
Let’s say I have a simple DogDTO
object that is used within Realm and a Dog
struct that the app interacts with:
// Internal DTO object used to store in a Realm database
class DogDTO: Object {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name: String
convenience init(name: String) {
self.init()
self.name = name
}
}
// Model that the app uses
public struct Dog: Sendable {
public var id: String
public var name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
As I’m reading through the documentation (especially the parts about Swift Concurrency and actors), I’m wondering how to best implement an interface for the app to interact with the database, and what actors I should use in which case.
Observing Changes
I’m a big fan of using AsyncStreams
that the SwiftUI views or view models of the apps can consume to continuously obtain data… So I’m thinking about having a RealmReader
class that internally has its own Realm
instance and offers a few public methods that create an AsyncThrowingStream
to observe changes to database objects. Something like this:
@MainActor public class RealmReader {
private let realm: Realm
public init() throws {
self.realm = try Realm()
}
public func dogListStream() -> AsyncThrowingStream<[Dog], Error> {
.init { continuation in
let dogs = realm.objects(DogDTO.self)
let token = dogs.observe { _ in
continuation.yield(dogs.map { Dog(id: $0._id.stringValue, name: $0.name) })
}
continuation.onTermination = { _ in
token.invalidate()
}
}
}
public func dogStream(dogId dogIdString: String)-> AsyncThrowingStream<Dog, Error> {
return .init { continuation in
do {
let dogId = try ObjectId(string: dogIdString)
let dog = realm.object(ofType: DogDTO.self, forPrimaryKey: dogId)!
let token = dog.observe { _ in
continuation.yield(Dog(id: dog._id.stringValue, name: dog.name))
}
continuation.onTermination = { _ in
token.invalidate()
}
} catch {
continuation.finish(throwing: NSError(domain: "dummy", code: 0))
}
}
}
}
For example, the app could then do the following:
do {
let reader = try RealmReader() // Initialize a new reader (this would happen in a slightly more global place, ideally)
for try await content in reader.dogListStream() { // Observe changes to the dogs in the database
print("Update: \(content)")
// Update view state or whatever
}
} catch {
// Handle error
}
I know there are specialized SwiftUI property wrappers that can do this as well, but I usually like to fully separate the UI from the actual data source.
I’m not sure if a MainActor isolation for the read access is appropriate in this case . Maybe it should be done on a dedicated actor instead.
Writing Data
However, my gut tells me that writing data to the database should probably be happen in a background thread, to not block the UI. So I’m thinking about having a RealmWriter
class isolated to a global background actor, like this:
@globalActor public actor RealmBackgroundActor: GlobalActor {
public static let shared = RealmBackgroundActor()
}
@RealmBackgroundActor public class RealmWriter {
private let realm: Realm
public init() async throws {
realm = try await Realm(actor: RealmBackgroundActor.shared)
}
public func insertDog(name: String) async throws {
let dog = DogDTO(name: name)
try await realm.asyncWrite {
realm.add(dog)
}
}
}
The usage in the app would look like this:
do {
let writer = await RealmWriter() // Initialize a new writer
try await writer.insertDog(name: "Foo") // Insert data
} catch {
// Handle error
}
This does appear to work fine in some fairly basic first tests I did, but I’m not sure if I’m going to run into a wall later on. That can obviously always happen, but I thought I’d ask if there is something obvious I’m missing here.
My Questions
- A more general question: Do you think there is anything inherently wrong/dangerous with this approach, that I don’t see?
- Is it be fine to have multiple instances of a
RealmReader
orRealmWriter
in the app? I.e. that each module/feature or whatever uses its own reader and writer to observe and write data. My understanding is that initialising multipleRealm
instances is fairly cheap and no real problem, but maybe I’m wrong? - Is it a good idea to isolate the
RealmReader
to the MainActor? Or in other words, would it also be fine to isolate it to its own actor and force asynchronous reading access (which happens anyways, because of theAsyncStream
I guess)? If so, should there be two different actors (one for reading, one for writing)? Would it hurt to have two? - Something I’ve been wondering: Is there a scenario where
try Realm()
fails in a recoverable way? No database access kind of feels like a “app can’t continue” scenario, but maybe there’s a situation where another attempt might work?
I’d really appreciate any input on this Thanks a lot!