Skip to content

Defining Models

Jens Ayton edited this page Apr 25, 2020 · 3 revisions

Just as Events and Effects, Model objects are opaque to the Mobius framework itself. They should have value semantics, but other than that they can be anything.

Since the Update function in Mobius represents state transitions in a state machine, it’s natural to see the model as representing the current state of that machine. When defining a model for the state machine, a spectrum of approaches is available to us, ranging from a strict finite-state-machine approach, to a more loosely defined “put everything in a bucket” approach.

Mutually exclusive cases for all states

If we want our model to accurately reflect the underlying finite-state machine it should be composed of mutually exclusive cases for each state.

Let's draft a small example of this using enumerations with associated values:

enum Model {
    case waitingForData
    case loaded(data: String)
    case error(message: String)
}

As you see, the data field only exists in the loaded case, so you don’t have to unwrap an optional to access it, because you will only be in the loaded case if data is non-nil. This approach is perfect for small loops with few states, or when you want to be assured that all corner cases are covered.

However, there are some drawbacks to this approach, particularly when there are many states that start overlapping. For example, if there is an “offline” state, you might want to distinguish offline-but-no-data from offline-but-with-data ‒ this quickly leads to an explosion of the number of states and state transitions that must covered, and you might end up with plenty of boilerplate just to copy data from one state to another.

All states use the same struct

This approach is on the other end of the spectrum compared to the previous one. You use flags to keep track of whether data is loaded, etc., and store everything at the object’s “top level”.

Let’s use a struct for this example, and let’s include offline as an extra flag, too:

struct Model: Copyable {
    var loaded: Bool
    var error: Bool
    var offline: Bool

    var data: String?
    var errorMessage: String?
}

Note: You might end up with a lot of properties that can be nil. There can also be invalid state combinations (in the case above, both loaded and error can be true at the same time), or cases with both data and an error message. This is of course an exaggerated case, but when you approach this end of the spectrum, you might get more special cases that must be handled carefully.

This kind of model tends to be easier to modify than the previous approach when requirements change and new states are required, and it is a lot easier to create new versions of model objects from old ones.

It is often advantageous to start with this kind of model, as it’s the most straightforward one to create and the easiest one to evolve as requirements change.

Hybrid approach

One good way to gain the conveniences of a single model, but still avoid invalid states, is to borrow some ideas from both previous approaches and go for a hybrid solution.

The first model provided a good way to deal with the regular states, and it was its offline scenario that messed things up. So instead of duplicating all states of the first model, let’s combine the first approach with the second one:

enum LoadingState {
    case waitingForData
    case loaded(data: String)
    case error(message: String)
}

struct Model {
    let offline: Bool
    let loadingState: LoadingState
}

Now it’s possible to be both loaded and offline at the same time! We’ve combined two state machines by putting them next to each other ‒ one keeps track of data loading, and the other keeps track of whether you’re offline. Also, this approach scales up to multiple parallel state machines, or even state-machines-within-state-machines.

Note that this isn’t necessarily a perfect model: for example, maybe the waiting-for-data and offline states are incompatible. If it’s really important for you to deal with this state in the model, you’d have to go for something a bit more like the first approach, but if it’s just a single combination that is troublesome now, the hybrid solution is often a worthwhile trade-off.

The hybrid provides a more flexible model that is easier to modify when requirements change, and you’re still avoiding most edge cases (for example, in this version data is never nil, and you can’t have both data and an error message).