Architecture & Agility: 04 - Immutable State

Immutable State: Functional Programming’s alternative to hidden state

Object-Orientation and Functional Programming are different solutions to the same problem: Dealing with state in complex systems. Both had identified global mutable state as the source of recurring issues and headaches.

While OOP tackles that problem by hiding mutable state inside of objects and this state should be only accessible through the object’s public interface, FP doesn’t hide the state, rather discourages mutable state. Effectively OOP deleted “global” from “global mutable state” while FP erased “mutable”.

For many years these solutions had been equivalent, and it is fair to say that OOP dominated the scene while FP slowly but steadily grew its discipleship in niche markets like high performance computing and telecommunications.

Then multicore machines became widely available — and that changed everything. Suddenly one of the solutions became better than the other. While functional programs pretty much were able to use any number of cores simply because of the absence of possible side effects created by the hidden state, concurrency is an issue not generally solved for OOP. Different OOP systems offer different solution, like threads and (purpose defeating) locks, dispatching, value over reference types, favouring enumerations of collections over plain iterating.

And although Swift value types like structs are pretty much sufficient to avoid most of concurrencies issues, I still want to encourage you to consider immutable state, as it increases the reasonability and reduces the room for error.

I have written an earlier article on this topic, there will be some overlapping, but in this place I won’t show you journaling, but focus more on the DSL aspect of my design.

Let’s look at an example:

import Foundation

struct TodoItem: Identifiable, Hashable, Codable {

    enum Change {
        case text(String)
        case completed(Bool)
        case due(Date?)
    }

    let text: String
    let completed   : Bool
    let id          : UUID
    let dueDate     : Date?
    let alterDate   : Date?
    let creationDate: Date

    init(text: String) { self.init(text.trimmingCharacters(in: .whitespacesAndNewlines), false, UUID(), nil, Date(), nil) }

    func alter(_ changes: Change...) -> TodoItem { alter(changes) }
    func alter(_ changes: [Change] ) -> TodoItem { changes.reduce(self) { $0.alter($1) } }

    private init (_ text: String, _ completed: Bool, _ id: UUID, _ dueDate:Date?, _ creationDate: Date, _ alterDate:Date?) {
        self.text = text
        self.completed = completed
        self.id = id
        self.creationDate = creationDate
        self.dueDate = dueDate
        self.alterDate = alterDate
    }

    private func alter(_ change:Change) -> TodoItem {
        let alterDate = Date()
        switch change {
        case .text     (let text     ): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        case .completed(let completed): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        case .due      (let dueDate  ): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        }
    }
}

All properties are defined let, so after instantiation there is no way to change them. If we want to reflect a change, we have to create a new object with the changed value.
The simplest way would be to expose all properties through the initialiser and just call that with the previous values wherever needed. But this is error-prone, as it will lead to a lot of code every time to change one property — and more code means more space for errors to hide in. Also it would force us to expose all properties — even those we might not want to make alterable, like the id.
So instead of doing that we introduce a Change type that encodes what we want to change.

enum TodoItem.Change {
    case text(String)
    case completed(Bool)
    case due(Date?)
}

We can use a change value to call one of the pubic alter methods, which will call the private alter method for each change value.

private func alter(_ change:Change) -> TodoItem {
        let alterDate = Date()
        switch change {
        case .text     (let text     ): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        case .completed(let completed): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        case .due      (let dueDate  ): return TodoItem( text, completed, id, dueDate, creationDate, alterDate )
        }
    }
}

This method returns a TodoItem by pattern matches the change value and overwrite the properties name for the case scope.

We saw how we can create an immutable model type with an elegant way to create new versions reflection change. Now we want to bundle our model objects in a state object and follow the same principles.

struct AppState: Codable {
    enum Change {

        case add(_Add)
        case update(_Update)
        case remove(_Remove)
        case replace(_Replace)

        enum _Add {
            case item(TodoItem)
            case entry(Entry)
            case tag(Tag)
        }
        enum _Update  { case item(TodoItem) }
        enum _Replace { case tags([Tag]   ) }
        enum _Remove  { case item(TodoItem) }
    }

    func alter(_ changes:   Change...) -> AppState { changes.reduce(self) { $0.alter($1) } }
    func alter(_ changes: [ Change ] ) -> AppState { changes.reduce(self) { $0.alter($1) } }

    init() {
        self.init([], [], [])
    }

    init(_ changes:  Change...) { self.init(changes) }
    init(_ changes: [Change]  ) {
        let intermediate = AppState().alter(changes)
        self.items = intermediate.items
        self.entries = intermediate.entries
        self.tags = intermediate.tags
    }

    let items: [TodoItem]
    let entries: [Entry]
    let tags: [Tag]
}

private extension AppState {
    private init(_ items: [TodoItem], _ entries: [Entry], _ tags: [Tag]) {
        self.items = items
        self.entries = entries
        self.tags = tags
    }

    private func alter(_ change:Change) -> AppState {
        switch change {
        case .add    (let msg): return add    (msg)
        case .update (let msg): return update (msg)
        case .remove (let msg): return remove (msg)
        case .replace(let msg): return replace(msg)
        }
    }

    private func add(_ change: Change._Add) -> AppState {
        switch change {
        case  .item(let  item): return AppState( items + [item], entries          , tags         )
        case .entry(let entry): return AppState( items         , entries + [entry], tags         )
        case   .tag(let   tag): return AppState( items         , entries          , tags + [tag] )
        }
    }

    private func update(_ change: Change._Update) -> AppState {
        switch change { case .item(let item): return AppState( items.filter{ $0.id != item.id } + [item], entries, tags ) }
    }

    private func remove(_ change: Change._Remove) -> AppState {
        switch change {
        case .item(let item): return AppState( items.filter{ $0.id != item.id }, entries, tags.map( { $0.alter(.remove(item)) } ) )
        }
    }

    private func replace(_ change: Change._Replace) -> AppState {
        switch change { case .tags(let tags): return AppState(items, entries, tags) }
    }
}

This is pretty much the same, though I want to highlight a subtle difference:

TodoItems Change DSL reflects properties to be altered directly, just omitting the id.

AppState has a more interesting Change-DSL:

As all its properties are collections, we have to be able to

  • add to any of it

    enum Change {
    
        case add(_Add)
        enum _Add {
            case item(TodoItem)
            case entry(Entry)
            case tag(Tag)
        }
    }
    

    and through pattern matches in alter, add will be called

    private func add(_ change: Change._Add) -> AppState {
          switch change {
          case  .item(let  item): return AppState( items + [item], entries          , tags         )
          case .entry(let entry): return AppState( items         , entries + [entry], tags         )
          case   .tag(let   tag): return AppState( items         , entries          , tags + [tag] )
          }
      }
    
  • remove objects, currently only implemented for TodoItems

    enum Change {
    
        case remove(_Remove)
        enum _Remove  { case item(TodoItem) }
    }
    

    alter will call

    private func remove(_ change: Change._Remove) -> AppState {
          switch change {
          case .item(let item): return AppState( items.filter{ $0.id != item.id }, entries, tags.map( { $0.alter(.remove(item)) } ) )
          }
      }
    

    in which we filter out the item in question and remove it from any tag

  • replace lets you overwrite the tags in full

    enum Change {
        case replace(_Replace)
        enum _Replace { case tags([Tag]) }
    }
    
    private func replace(_ change: Change._Replace) -> AppState {
        switch change { case .tags(let tags): return AppState(items, entries, tags) }
    }
    

This shows us the flexibility this approach gives us. The DSLs can be very unique. And you can change them at any time. As they are compile time evaluated the compiler will tell you every place you need to change, indeed I have started with the inverted variation:

enum Change {
    case item(_Item)
    case entry(_Entry)
    case tag(_Tag)

    enum _Item {
        case add   (TodoItem)
        case update(TodoItem)
        case remove(TodoItem)
    }

    enum _Entry {
        case add(Entry)
    }

    enum _Tag {
        case add(Tag)
        case replace([Tag])
    }
}

but I like state.alter( .add(.item(item)) ) better than state.alter( .item(.add(item)) ) as the first variant is the more natural, though they are equivalent, so you can choose any. But please: don’t fight holy wars about it.

Storing the State

As AppState is immutable we need to recreate it for every change. This also implies that we need to keep it in a way that different places in our code can have access to it. Just passing around the state won’t work as it is a value type — changes in on place would not be reflected in another place.

Usually we would use the reference type Class for that. But like with the Features, a class does not only offer us the things we need — but more. So again: more space for error. All we actually need is a reference type that holds the state and offers methods to access it.
Let’s build that ourselves, composition-style:

typealias Access   = (                    ) -> AppState
typealias Change   = ( AppState.Change... ) -> ()
typealias Reset    = (                    ) -> ()
typealias Callback = ( @escaping () -> () ) -> ()

typealias StateStore = ( state: Access, change: Change, reset: Reset, updated: Callback )

In the first four lines we define functions to model the behaviour we need.

  • Access returns the AppState
  • Change forwards an AppState.Change value to the state
  • Reset sets everything to defaults
  • Callback will inform listeners about state changes. And yes: We can create simple observer patterns too.

The last line defines StateStore as a tuple of those functions. You might now think: “But tuple is a value type” and would be right. But functions are reference types, and as all function capture the same state object, we are good to go.

Let’s stick a store together, just like we would play with legos:

func createStore(pathInDocuments: String = "state.json", fileManager: FileManager = .default) -> StateStore
{
    var state = readAppSate(from: pathInDocuments, fileManager: fileManager) { didSet { callbacks.forEach { $0() } } }
    var callbacks: [() -> ()] = []
    return (
          state: { state                  },
         change: { state = state.alter($0); write(state: state, to: pathInDocuments, fileManager: fileManager) },
          reset: { state = AppState()     ; write(state: state, to: pathInDocuments, fileManager: fileManager) },
        updated: { callbacks.append($0)   }
    )
}

When createStore is called, it first reads any present state from the disk.
Each time state is set to a new value, didSet will inform each listener by executing the provided callbacks. write will write each new version to disk.

private func write(state: AppState, to pathInDocuments: String, fileManager: FileManager ) {
    do {
        let encoder = JSONEncoder()
        #if DEBUG
        encoder.outputFormatting = .prettyPrinted
        #endif
        let data = try encoder.encode(state)
        try data.write(to: fileURL(pathInDocuments: pathInDocuments, fileManager: fileManager))
    } catch {
        print(error)
    }
}

private func readAppSate(from pathInDocuments: String, fileManager: FileManager) -> AppState {
    do {
        return try JSONDecoder().decode(AppState.self, from: try Data(contentsOf: fileURL(pathInDocuments: pathInDocuments, fileManager: fileManager)))
    } catch {
        print(error)
    }
    return AppState()
}

private func fileURL(pathInDocuments: String, fileManager: FileManager = .default) throws -> URL {
    try fileManager
        .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
        .appendingPathComponent(pathInDocuments)
}

Of course, the functions can be implemented with any persistency solution, like CoreData.

Effectively we have gained a tailor-made store, that we can treat just like a class object.

We can use it like store.change( .add(.item(item)) ), which uses the store to add a new item to the state. or update an item: store.change( .update(.item( item.alter(.completed(true)) )) ).
But store also informs us about updates:

store.updated {
    self.process(appState: store.state())
}

You will have noticed that like Features uses the Message enum, UseCases use specific enum-based Request/Response sets, AppState (and StateStore) uses their own enum-based DSL as-well — every layer in this architecture uses similar ideas. It is fractal.

What about mutable state?

Sure, you are still free to use the OOP-way of dealing with state and hide it rather than seal it.
In that I case my advice would be to push mutability down into the UseCases’ Interactor.

If you decide not to use a central store and rather hide your state all the way you will encounter the fact that that will require more cases in our message DSL, as you have to ask to retrieve any values.

It also might make sense to combine immutable global state and mutable hidden state.
You might only what to have atomic or complete data in your global state and you choose that something more time intensive will be hidden in an UseCase and only added to the state once it completed.

Next…

In the next article I want to look into Behaviour-Driven Development with Core|UI, and also how to adapt this architecture for huge but also tiny projects.

Part 5: BDD

Discuss

Please share your thoughts.