TLDR; Simplify state management by respecting the frame lifecycle: load data at the start of the frame and save data at the end of the frame.
The IO Ritual
Business applications often codify “business logic” into a pure and self contained code, sometimes referred to as a “domain model”. Typically domain models are backed by persistent storage, usually a database.
Calling upon a domain model requires that we load it from storage, mutate it in-memory, and save it back to storage when we are done.
In asynchronous systems there is no clear state lifecycle. This can lead to a lot of boilerplate since mutations of the model must be wrapped by IO. This result is several lines of code to load the model, several to save the model, and one line somewhere between to mutate it. Without clever magic like decorators or macros, this ritual becomes rather painful rather quickly. Every time you want to write some code that calls the model, it must be load, mutated and saved. This is the “IO ritual”.
This can be observed in HTTP applications and callback oriented GUIs. Both of which are asynchronous systems. Logic handlers must wrap access to the model in IO because the state lifecycle is not predictable outside of the handler itself.
A Rational Solution
However Gio gives us access to a synchronous event loop. When we have access to an event loop we have a known point at which mutations can occur, and a single state context. This means that we can load and save once, at a known point before and after the frame event.
Gio generates frame events when it wants to draw a new frame. Once a frame event is received, our code can proceed to layout the GUI. This creates an implicit frame lifecycle wherein user actions are processed during layout, and therefore user-driven state mutations can only occur during the layout (goroutines not withstanding).
Given we know application state can only be mutated during the frame but not before or after it, we can simplify state management by loading state at the start of the frame and saving it at the end.
var state State
for _, event := range events {
// Load state from storage.
Load(&state)
// Layout frame using state.
// Possibly mutate state in response to UI events.
Layout(&state)
// Save state back to storage.
Save(&state)
}
Using this approach, we can mutate the model however we please in response to UI events and it will always be persisted at the end of the frame without doing IO busywork.
Consider the following code synthesized from kanban:
We implement pure Kanban logic in it’s own package, hide storage details behind an interface (letting us swap out caching strategies), and mutate the Kanban state as we please during the frame. We know the state will be persisted at the end of the frame so the IO ritual can be specified exactly once for the entire application!
// ui.go
package main
// UI is the high level object that contains UI-global state.
//
// Anything that needs to integrate with the external system is allocated on
// this object.
//
// UI has three primary methods "Loop", "Update" and "Layout".
// Loop starts the event loop and runs until the program terminates.
// Update changes state based on events.
// Layout takes the UI state and renders using Gio primitives.
type UI struct {
// Window is a reference to the window handle.
*app.Window
// Storage driver responsible for allocating Project objects.
Storage storage.Storer
// Projects is an in-memory list of the projects.
// Refreshed from Storage before every frame.
// Save to Storage after every frame.
Projects Projects
// Project is the currently active kanban Project.
// Contains the state and methods for kanban operations.
// Points to memory allocated by the storage implementation.
// nil value implies no active project.
Project *kanban.Project
}
// Loop runs the event loop until terminated.
func (ui *UI) Loop() error {
var (
ops op.Ops
events = ui.Window.Events()
)
projects, err := ui.Storage.List()
if err != nil {
return fmt.Errorf("loading projects: %v", err)
}
ui.Projects = projects
for event := range events {
switch event := (event).(type) {
case system.DestroyEvent:
return event.Err
case system.FrameEvent:
gtx := layout.NewContext(&ops, event)
ui.Load()
ui.Update(gtx)
ui.Layout(gtx)
ui.Save()
event.Frame(gtx.Ops)
}
}
return ui.Shutdown()
}
// Update processes UI events and updates state acoordingly.
func (ui *UI) Update(gtx C) {
// --snip--
for _, t := range ui.TicketStates {
if t.NextButton.Clicked() {
ui.Project.ProgressTicket(t.Ticket)
}
}
}
// --snip--
// kanban.go
package kanban
// Project is a context for a given set of tickets.
type Project struct {
Name string
Stages []Stage
Finalized []Ticket
}
// Stage in the kanban pipeline, can hold a number of tickets.
type Stage struct {
Name string
Tickets []Ticket
}
// Ticket is a kanban ticket representing some task to complete.
type Ticket struct {
ID uuid.UUID
Title string
Summary string
Details string
Created time.Time
}
// ProgressTicket moves a ticket to the "next" stage.
func (p *Project) ProgressTicket(ticket Ticket) {
for ii, s := range p.Stages {
if s.Contains(ticket) {
if ii < len(p.Stages)-1 {
_ = p.Stages[ii+1].Assign(p.Stages[ii].Take(ticket))
}
break
}
}
}
// --snip--
// storage.go
// Package storage specifies a storage interface for Kanban Projects.
// Sub packages implement the interface providing different storage strategies.
package storage
import (
"git.sr.ht/~jackmordaunt/kanban"
)
// Storer persists Project entities.
type Storer interface {
// Create a new Project.
Create(kanban.Project) error
// Save one or more existing Projects, updating the storage device.
Save(...kanban.Project) error
// Load updates the Projects using data from the storage device.
// Allows caller to allocate and control memory.
// Avoids copying.
Load([]kanban.Project) error
// Lookup a Project by name.
Lookup(name string) (kanban.Project, bool, error)
// List all existing Projects.
List() ([]kanban.Project, error)
}
Drawbacks
If we read and write to storage every frame that would generate a lot of IO. Not good. The way to avoid this is twofold. First, read from a cache. Second, only write to storage when a change in the state is detected. This does require a CPU tradeoff to diff the state and detect changes, but this is far cheaper than IO and there are many ways to optimize.
Furthermore, this beats the alternative of wrapping every mutation in the IO ritual. Let the CPU do that work unless you have a clear reason to do it yourself.
Contact
If you have any thoughts or feedback on this approach please drop me a line at ~jackmordaunt/public-inbox@lists.sr.ht
Code snippets are licensed MIT/Unlicense.