Going back to architectural basics to solve my problems with SwiftData
I’ve been working on Garner, my career focused reflective journaling app for a couple months now, having learned the basics of SwiftData last year.
SwiftData has given me a lot for free, it’s allowed me to iterate and experiment with the data model for my app, easily set up cross-device sync with CloudKit (and to understand the data model needs for that) and I’ve been able to quickly get something into the hands of people to test my idea.
I have however started to see some downsides to the abstraction develop:
- It’s great for basic Create, Read, Update & Delete (CRUD) operations on a list of items but as soon as you introduce relationships into that data model it becomes a lot harder to work with
- It’s hard to test and the more complex your app’s logic the harder it will be to verify that everything is set up as expected, the testing for SwiftData happens via UI tests so you can verify a set of actions result in an outcome but you won’t be able to check the database records are set up, that the correct logging was fired off or the right analytics events were sent
- The testing process was so annoying I ended up relying on executing manual tests for the app as I could then see the logging output and the analytics events being fired which is a massive time sink and not as scalable as automated tests
- There are a few bugs in SwiftUI where subviews wouldn’t update properly, which while it wasn’t SwiftData exactly it was hard to see if the problem was with the database not being updated or the view, something that would be easier to diagnose with better testing
It eventually got to a point where the pain was enough to have me look into how I could get observability into my data model and test it precisely to ensure that I wasn’t introducing bugs at that level.
After doing a bit of reading I came across a great post on Hacking With Swift that suggested using the MVVM pattern to make it easier to test your SwiftData code.
I didn’t follow this exactly as I wanted to ensure re-use of my “View Model” across multiple views so I decided to encapsulate the concepts of my app into services that could then be used within my views to perform actions.
Creating a Service and using it in place of @Query
A Service is just a class that provides an abstraction for achieving a set of conceptually related tasks but for it to update SwiftUI you need the class to conform to the ObservableObject
protocol and any properties that will drive SwiftUI view updates will need to be marked with the @Published
decorator.
To use the Service within SwiftUI you will need to create an instance of the Service and make it available to the view. In the Hacking With Swift post I read this is done using a @State
object but as I wanted to use my Service to be available across views I set it as an @EnvironmentObject
in my app’s Scene.
With the Service available to the views as an environment object the view that wants to use the Service defines a variable for it using the @EnvironmentObject
decorator and reads properties and calls the methods on the Services it needs to show the data.
Testing a Service
The main benefit of using a Service to abstract away the actions needed to fulfil the tasks in your app is that you have a standalone unit that can be tested without the need to spin up the UI.
When creating your Service it’s a good idea to think about the dependencies that Service has as being able to control these dependencies will be key to writing reliable unit tests, using a technique called Dependency Injection.
For my abstraction over working with SwiftData my example Service has a dependency on the ModelContext
used to interact with the database but in my app I also had dependencies on UserDefaults
, UserNotificationCenter
and Logger
.
By passing in instances of these dependencies into my Service’s initialisation it means that my unit test code can interact with the same instances to verify that the Service’s methods have performed the correct actions with that dependency, such as adding a new record as expected or not updating another record when changing a linked record’s properties.
Dependency Injection also means that Services that depend on other system resources like UserNotificationCenter or UserDefaults can be tested using test doubles which allow for the unit test code to verify those APIs we called with the correct parameters and that the application code will respond to different responses from those APIs correctly.
Result
Moving from using SwiftData directly to using a Service abstraction has given me a lot more confidence in the way that my app’s data model works and helped me fix a number of bugs.
The Service or MVVM abstraction may add too much complexity for apps with a simple enough data model but I think it’s important to not “forget the old ways” because Apple is trying to market an approach in order to attract new developers to their platform.