Handling mutable models in Swift
One of the most important aspects of any app's architecture is how data and models are dealt with. Building a robust model structure and establishing well-defined data flows can really help us avoid a large set of bugs and problems, but at the same time we also need to make our model code easy to use and maintain.
Things become especially tricky when dealing with mutable models. How mutability should be handled, what parts of the code base that should be allowed to perform mutations, and how changes should be propagated throughout an app are questions that at times can be hard to come up with good answers to.
This week, let's take a look at a few different techniques that can help us answer that kind of questions and make code dealing with mutable models a bit more predictable.
Partial models
Sometimes we need to create a model without having all of its required information available. For example, our app might have a sign-up flow in which we ask the user to provide the information required to create an account through a series of separate screens, and we need to have some form of partial user model that we pass between each view controller in that flow.
A very common solution in situations like this is to use optionals for the data that's not directly available when we create our model. In this case, we might start by asking the user to create a username, while we ask for the user's region and email address at later stages - so we set up our User
model like this:
struct User {
let id: UUID
let name: String
var region: Region?
var email: String?
}
While the above setup is very nice and convenient when building our sign-up flow (we can simply fill in new data as the user progresses through the flow), it makes all the other places in which we're using the user's region or email more complicated. Since they're both non-optional optionals, we'll probably end up with a lot of guard
statements like this:
guard let region = user.region else {
preconditionFailure("Not supposed to happen")
}
The else
clause in the above guard is essentially never supposed to be entered, but it's still code that we have to keep around and maintain. Whenever we have to create this kind of control flow that's just there to make the compiler happy, it's usually a pretty strong indication that our model setup is flawed.
Instead of introducing optionals like above, let's instead create dedicated models for each use case. For our sign-up flow, we'll create a partial version of our User
model that only contains the data that we'll actually have available, and then we'll add a method to complete the model once we're able to:
struct PartialUser {
let id: UUID
let name: String
}
extension PartialUser {
typealias CompletionInfo = (region: Region, email: String)
func completed(with info: CompletionInfo) -> User {
return User(
id: id,
name: name,
region: info.region,
email: info.email
)
}
}
With the above PartialUser
model in place, our sign-up flow now has a simple model to work with, and can easily create the final User
model when ready - which in turn can be a clearly defined type without any undesired optionals:
struct User {
let id: UUID
let name: String
var region: Region
var email: String
}
We can now get rid of all those awkward guard
statements, and only maintain code that we are actually expecting to use 👍.
Partial immutability
While Swift structs are usually a great fit for model code, since we get a lot of nice features (like value semantics, built-in mutability handling, etc) for free - they also come with a set of challenges. Since structs are value types, we need to be careful with how we store our models, so that we don't end up with outdated references when the original changes.
However, it's very rare for all model properties to be mutable, so we don't necessarily need to make all of our code that deals with models respond to updates and mutations. What would be really nice is to clearly define what parts of a given model that are safe to treat as immutable, and what parts that we need to use in a more dynamic way.
Thankfully, this is something that we can easily do using protocols. Looking at our User
model from before, we have two immutable properties - id
and name
. Let's create an ImmutableUser
protocol that contains those two properties, like this:
protocol ImmutableUser {
var id: UUID { get }
var name: String { get }
}
extension User: ImmutableUser {}
That way we can easily enforce that types that treat a User
as immutable only gets access to immutable information:
class MessageViewController: UIViewController {
// It's safe for this view controller to store a copy of
// the user, since it's guaranteed to be immutable.
private let user: ImmutableUser
init(user: ImmutableUser) {
self.user = user
...
}
}
Not only does the above approach make it very clear what types only need immutable data, but it also improves the separation of concerns in our model layer. Just like how we created separate APIs for reading and writing to a database in "Separation of concerns using protocols in Swift", with this technique we can limit the access certain types have to our models, which will usually make things more predictable.
Observing mutations
However, we probably won't be able to get away with avoiding mutations completely, so we also need to setup a nice way to handle model updates as well. Our User
model contains two mutable properties - region
and email
, and ideally we'd like to have a dedicated API to handle changes for those properties, just like what we did for the immutable ones.
To accomplish that, let's take some inspiration from Functional reactive programming - which usually involves using dedicated observable types to react to events and streams of values - and create a simple Observable
type that we can attach observers to. We'll use the observation handling code from Observers in Swift, and add a method for updating the value that's being observed, like this:
class Observable<Value> {
private var value: Value
private var observations = [UUID : (Value) -> Void]()
init(value: Value) {
self.value = value
}
func update(with value: Value) {
self.value = value
for observation in observations.values {
observation(value)
}
}
func addObserver<O: AnyObject>(_ observer: O,
using closure: @escaping (O, Value) -> Void) {
let id = UUID()
observations[id] = { [weak self, weak observer] value in
// If the observer has been deallocated, we can safely remove
// the observation.
guard let observer = observer else {
self?.observations[id] = nil
return
}
closure(observer, value)
}
// Directly call the observation closure with the
// current value.
closure(observer, value)
}
}
Note how we don't expose the underlying observable value directly, but instead require it to be observed using a closure in order to be accessed. That way we can guarantee that all code that reads a mutable value also includes update handling, since we'll always be able to call the closure again once the model changes.
Exclusive access
With the two above mechanisms in place - a protocol for getting access to an immutable version of our User
model, and an Observable
type for getting access to its mutable properties - we can make a lot of our model handling code a lot more explicit. All we need now is some type to hold both the immutable and mutable versions of our model, that we can then pass around as a dependency.
One approach is to simply create a dedicated holder type, which has one property for each variant of our model, like this:
class UserHolder {
let immutable: ImmutableUser
let mutable: Observable<User>
init(user: User) {
immutable = user
mutable = Observable(value: user)
}
}
The benefit of having such a holder type, is that it becomes very easy to do "the right thing" when it comes to handling immutable and mutable data. Since any type that's interested in reading mutable data has to become an observer, we get a nice guarantee that outdated data won't be used, while still making it simple to read immutable data. As an example, here's how a ProfileViewController
can use both the mutable and immutable API to set up its UI:
class ProfileViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Immutable data can be read directly in a simple way
nameLabel.text = userHolder.immutable.name
// Reading mutable data requires an observation, which
// lets us guarantee that the view controller gets
// updated whenever the underlying model changes.
userHolder.mutable.addObserver(self) { (vc, user) in
vc.regionLabel.text = user.region.string
vc.emailLabel.text = user.email
}
}
}
Conclusion
Designing APIs that make it easy to both use immutable and mutable model data in an easy and predictable way can be really difficult. Often it comes down to tradeoffs between correctness and convenience, but creating dedicated APIs for both immutable and mutable handling can be a great way to achieve a good mix between code that is both easy to work with and easy to maintain.
Being inspired by paradigms like Functional Reactive Programming can also lead us to solutions that enable us to easily set up observations of values, even though we don't switch our entire app to use an FRP framework like RxSwift or ReactiveCocoa. We'll continue exploring other FRP techniques and concepts in future blog posts as well.
What do you think? How do you currently handle mutable models? Do you currently use any of the techniques from this post or is it something you'll try out? Let me know - along with your questions, comments and feedback - on Twitter @johnsundell.
Thanks for reading! 🚀