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 itenum 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 TodoItemsenum 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 fullenum 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 AppStateChange
forwards an AppState.Change value to the stateReset
sets everything to defaultsCallback
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.