Maintaining model consistency in Swift
When designing the model layer of any app or system, establishing a ”single source of truth” for each state and piece of data that we’re dealing with is often essential in order to make our logic behave predictably.
However, ensuring that each state is stored in just a single place can often be easier said than done — and it’s very common to end up with bugs and errors resulting from inconsistent model data, especially when such models are passed around and mutated in several different places.
While some of those errors are bound to happen outside of the models themselves, this week, let’s take a look at how we can improve the internal consistency within each of our models — and how doing so can let us establish a much stronger foundation for our codebase as a whole.
Deriving dependent states
The overall model layer of any given system can most often be described as a hierarchy, in which more high-level pieces of data depend on some form of underlying state. As a simple example, let’s say that we’re working on a contact management app, and that we have a Contact
model which contains each person’s contact information — such as their name and email address:
struct Contact {
let id: ID
var firstName: String
var lastName: String
var fullName: String
var emailAddress: String
...
}
At first glance, the above might look like any standard data model, but there is actually a substantial risk for it to become inconsistent. Since we have three separate properties for firstName
, lastName
and fullName
, we’re going to always have to remember to update a contact’s fullName
whenever we make any changes to either the firstName
or lastName
property — otherwise, we’ll end up ambiguous information.
Since fullName
is, at the end of the day, a convenience — a higher-level state that makes building our UI simpler — let’s make it derivative. Rather than implementing it as a separate, stored property, let’s turn it into a computed one instead:
struct Contact {
let id: ID
var firstName: String
var lastName: String
var fullName: String { "\(firstName) \(lastName)" }
var emailAddress: String
...
}
That way, we no longer have to worry about our model becoming inconsistent, since a contact’s fullName
will now be re-computed each time that it’s accessed according to the current firstName
and lastName
.
However, always re-computing a dependent state each time that it gets accessed is not always practical — especially if that state depends on a potentially large collection of elements, or if the required computation involves a bit more than simply combining a few underlying values.
Like we took a look at in “Utilizing value semantics in Swift”, in those situations, maintaining a separate stored property might again be the best approach — but if we prevent that property from being externally mutated, and if we make it auto-update whenever its underlying state changes, then we can still ensure that its containing model remains consistent.
Here’s how we might use a property observer to do that for a Leaderboard
model, which contains high score entries for the top players of a game, as well as the current average score among those players:
struct Leaderboard {
typealias Entry = (name: String, score: Int)
var entries: [Entry] {
// Each time that our array of entries gets modified, we
// re-compute the current average score:
didSet { updateAverageScore() }
}
// By marking our property as 'private(set)', we prevent it
// from being mutated outside of this type:
private(set) var averageScore = 0
init(entries: [Entry]) {
self.entries = entries
// Property observers don't get triggered as part of
// initializers, so we have to call our update method
// manually here:
updateAverageScore()
}
private mutating func updateAverageScore() {
guard !entries.isEmpty else {
averageScore = 0
return
}
let totalScore = entries.reduce(into: 0) { score, entry in
score += entry.score
}
averageScore = totalScore / entries.count
}
}
The patterns used above don’t only improve the consistency of our models, they also make those models easier to use and understand — since we no longer have to be aware of rules like “always remember to update X when you modify Y” — as that sort of logic is now baked into the models themselves.
Consistent collections
While maintaining a 1:1 relationship between two pieces of state can be challenging enough, things get even trickier when we have to ensure that multiple collections remain consistent with each other. Going back to the example of a contact management app from before, let’s say that we’re now building a ContactList
class — which will store a set of contacts, while also enabling those contacts to be organized into groups, and to be marked as favorites:
class ContactList {
var name: String
var contacts = [Contact.ID : Contact]()
var favoriteIDs = Set<Contact.ID>()
var groups = [Contact.Group.Name : Contact.Group]()
init(name: String) {
self.name = name
}
}
Above we’re making use of nested types in order to make types like Group
and Name
more contextual and “self-documenting”.
Similar to the earlier example in which we were required to manually keep a contact’s names in sync, the above model also makes each of its call sites responsible for keeping it consistent. For example, when removing a contact, we also have to remember to remove its ID from our set of favoriteIDs
— and when renaming a group, we always have to update its key within the groups
dictionary as well.
Both of the two functions below fail to do that, and even though they might look perfectly valid, they’re both causing the ContactList
they’re mutating to become inconsistent:
func removeContact(_ contact: Contact) {
// If the removed contact was also added as a favorite, its
// ID will still remain in that list, even after it was removed.
contactList.contacts[contact.id] = nil
}
func renameGroup(named currentName: Contact.Group.Name,
to newName: Contact.Group.Name) {
// The renamed group's key will now be incorrect, since
// it's still referring to the group's previous name.
contactList.groups[currentName]?.name = newName
}
An initial idea on how to avoid the above kind of inconsistencies might be to take the same private(set)
approach that we used on our Leaderboard
model from before, and prevent our collections from being mutated outside of the ContactList
type:
class ContactList {
var name: String
private(set) var contacts = [Contact.ID : Contact]()
private(set) var favoriteIDs = Set<Contact.ID>()
private(set) var groups = [Contact.Group.Name : Contact.Group]()
...
}
However, in this case we actually need to be able to mutate our collections somehow — so the above approach would require us to replicate several of our underlying collections’ APIs, in order to make mutations like adding and removing contacts possible:
extension ContactList {
func add(_ contact: Contact) {
contacts[contact.id] = contact
}
func remove(_ contact: Contact) {
contacts[contact.id] = nil
favoriteIDs.remove(contact.id)
}
func renameGroup(named currentName: Contact.Group.Name,
to newName: Contact.Group.Name) {
guard var group = groups.removeValue(forKey: currentName) else {
return
}
group.name = newName
groups[newName] = group
}
}
The above might work as long as we only have to mutate our collections in very simple ways, and as long as we won’t add any new pieces of data to our model, but it’s not a very flexible solution overall. Requiring a brand new API to be created for each mutation is, in general, not a great design — so let’s see if we can find a more dynamic and future-proof approach.
If we think about it, keeping our ContactList
data in sync really just requires us to be able to react to any changes to the property that’s also used as an element’s key (id
in the case of Contact
and name
in the case of Contact.Group
), and to be able to perform an update whenever an element was removed (so that we can make sure that no removed contacts still remain in the favoriteIDs
set).
Let’s add both of those two capabilities by implementing a very lightweight wrapper around Dictionary
. Our wrapper, let’s call it Storage
, will use Swift’s key paths mechanism in order to keep our keys in sync — and will also enable us to attach a keyRemovalHandler
closure to get notified whenever a key was removed:
extension ContactList {
struct Storage<Key: Hashable, Value> {
fileprivate var keyRemovalHandler: ((Key) -> Void)?
private let keyPath: KeyPath<Value, Key>
private var values = [Key : Value]()
fileprivate init(keyPath: KeyPath<Value, Key>) {
self.keyPath = keyPath
}
}
}
Our initializer and keyRemovalHandler
are marked as fileprivate
in order to prevent instances of our new Storage
type to be created outside of the file that ContactList
is defined in, further strengthening the consistency of our model code.
To make Storage
behave like a real Swift collection, we have two options. We could either make it conform to the full Collection
protocol or, if we only need to be able to iterate over it, we could simply make it conform to Sequence
— by forwarding the call to makeIterator()
to its underlying dictionary:
extension ContactList.Storage: Sequence {
func makeIterator() -> Dictionary<Key, Value>.Iterator {
values.makeIterator()
}
}
With the above in place, we will still be able to write for
loops over our collections, and to use APIs like forEach
, map
and filter
on them — just like when using Dictionary
directly.
Next, to enable Storage
to be mutated, we’re going to add a subscript
implementation that both ensures that an element’s key gets updated in case the property its key is based on was changed, and that also calls our keyRemovalHandler
when a key was removed:
extension ContactList.Storage {
subscript(key: Key) -> Value? {
get { values[key] }
set {
guard let newValue = newValue else {
return remove(key)
}
let newKey = newValue[keyPath: keyPath]
values[newKey] = newValue
if key != newKey {
remove(key)
}
}
}
private mutating func remove(_ key: Key) {
values[key] = nil
keyRemovalHandler?(key)
}
}
Just like that, our collection wrapper is done, and we’re ready to update ContactList
to use it — by storing our contacts and groups using our new type, and by registering a keyremovalHandler
that ensures that our favoriteIDs
set stays in sync with our collection of contacts:
class ContactList {
var name: String
var contacts = Storage(keyPath: \Contact.id)
var favoriteIDs = Set<Contact.ID>()
var groups = Storage(keyPath: \Contact.Group.name)
init(name: String) {
self.name = name
contacts.keyRemovalHandler = { [weak self] key in
self?.favoriteIDs.remove(key)
}
}
}
Note how Swift is able to infer the generic Key
and Value
types of our Storage
instances based on the key paths that are passed into them — beautiful!
With our new implementation, we’ll still be able mutate our collections by adding and removing values, just like when using Dictionary
directly — only now we’re ensuring that our data remains consistent, completely automatically.
As an added bonus, since we now have a custom collection type in place, we could go one step further and make it even nicer to use — by adding convenience APIs for adding and removing values without having to worry about what key to use:
extension ContactList.Storage {
mutating func add(_ value: Value) {
let key = value[keyPath: keyPath]
values[key] = value
}
mutating func remove(_ value: Value) {
let key = value[keyPath: keyPath]
remove(key)
}
}
Using the above APIs and our subscript
implementation from before, we’re now free to decide how we want to add and remove values within each situation, without impacting the consistency of our model in any way:
// Adding values:
contactList.contacts[contact.id] = contact
contactList.contacts.add(contact)
// Removing values:
contactList.contacts[contact.id] = nil
contactList.contacts.remove(contact)
While writing a custom collection isn’t always appropriate, whenever we want to add new behaviors to one of the data structures that the standard library offers, creating lightweight wrappers that are each tailored to a very specific domain can be a great approach.
Conclusion
In many ways, in order to make a code base really robust and capable, we have to start by making its core data models as predictable and consistent as we possibly can — since those models often act as the foundation on top of which the rest of our code base is built.
By not requiring various states to be synced manually, and by preventing inconsistent states from being stored in the first place, we not only end up with a much stronger foundation — but one that’s often easier to work with as well, as our model architecture ends up doing much of that synchronization work for us.
What do you think? How do you currently ensure that your models remain as consistent as possible, and will any of the techniques covered in this article help you get closer to that goal? Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀