Khipu : a Boilerplate-Code-Free and Declarative Implementation of Clean Architecture in Swift | by Manuel Meyer

A deep dive into this fully declarative and immutable solution for everyday projects

1*0 lN E 2WKB8wyfLOkDpJA
An original Incan Khipu. Image Credits: Wikimedia Commons

Announcement: I will be talking about this and other aspects of declarative coding in my talk “Declarative Axiomatic and Provable Correct Systems in Swift” at the “Declarative Amsterdam” conference at the CWI on Nov 8th, 2022. Please join!

MVVM-(X), VIP, VIPER — all these so-called architectures have two things in common: They all trace their ancestry to Robert C. Martin’s Clean Architecture. I have not seen one of those being valid architectures in iOS. They do not have absolute independence from the UI (Martin: “The UI has to be an I/O Device“) or allow you to defer decisions to a later point in time, possibly infinitely.

Now, in iOS, the question for a long time was, “Where to put the ViewController” — making the architecture answer questions it should never be bothered with. And with the SwiftUI, this question has shifted to “Building (MVVM|VIPER|VIP) in SwiftUI” — coupling the UI from the get-go to any other code — and violating everything an architecture stands for.

One thing all these non-architectures also have in common is the fact that they all require different objects to be implemented:

  • Coordinator
  • Router
  • Presenter
  • ViewModel
  • DI-Frameworks

All these objects just exist as a need for the invalid architecture — they do not contribute to the app’s domain at all — with the domain being the reason why the app was commissioned in the first place. No customer orders an app by demanding it will contain a certain amount of Routers and Coordinators. Therefore, all these objects must be counted as accidental complexity or boilerplate codes.

Another way to look at boilerplate codes and accidental complexities:
On a fundamental level, all apps are very similar:

  • a user uses the user interface to trigger an action
  • the logic of that app processes this action and generates a new state
  • the user interface changes to reflect the changed state

Let’s call that ”Intrinsic Design.”

Every app’s intrinsic design
Every app’s intrinsic design

For a simplified minimal and accidental complexities-free architecture, you would expect to be able to identify this design. If architecture is not easily mappable, it contains higher amounts of accidental complexities. We will compare the following architecture to this later — making this the litmus test for accidental complexity.

Now that we have seen that most “architectures” aren’t valid and full of boilerplate code, let me introduce “Khipu” — a valid implementation of Robert C. Martin’s “Clean Architecture” that doesn’t have any accidental complexities codes at all.

Khipu achieves this by embracing simplicity everywhere.

It only allows data to flow in one direction and is nearly completely immutable. Both of the following make the code simple:

  • Unidirectional flow of data: easy to understand and reason about. If data can only flow in one direction, it simplifies the architecture tremendously
  • Immutable data types: as we will see in a moment, I propose to only use immutable data types. There could be a lot said about why this is beneficial, but I want to keep it short: If something cannot be changed on purpose, it also can’t change accidentally — eliminating the most likely bugs that will crash the system.

The imperatively structured code cluttered among a bazillion classes that pass as object-oriented nowadays suggests that increasing complexity is an appropriate reaction to existing complexity. It is like cleaning a room by dumping the next room’s trashcans into it. This effect is greatly amplified by using classes at the center of systems.

Classes offer a multitude of interactions. They allow (virtually) any number of methods. They are subclassable and mutable. And while these are all things we don’t need for modules, the one thing we need them to be able to do, they don’t do well. Classes have been advertised as the perfect tool for black boxing for decades, though they offer grey boxing at best, which is proven by the fact that one often requires extra subclassing information beyond the class’ signatures.

Classes fail at their core promises. Also, they cannot provide a flow-through as expected from a module — implementing this requires pattern magic. I join Ilya Suzdalnitski and argue that, by far, most patterns are only necessary to deal with OOP’s (and classes’) shortcomings:

“There’s no objective and open evidence that OOP is better than plain procedural programming.

The bitter truth is that OOP fails at the only task it was intended to address. It looks good on paper — we have clean hierarchies of animals, dogs, humans, etc. However, it falls flat once the complexity of the application starts increasing.

Instead of reducing complexity, it encourages promiscuous sharing of mutable state and introduces additional complexity with its numerous design patterns. OOP makes common development practices, like refactoring and testing, needlessly hard.”

And because of the Spiderman Principle, we will not rely on classes at the center of the system but rather on a technique that isn’t well-known in OO but predates it by decades: Partial Application. Well, let’s start with that.

Partial application is a technique where a function is called, its body executed, and finally, another function is returned. This function can now be written to a named reference (variable or constant). This function is partially applied as it has access to the objects in the outer function’s body.

createAdder takes an Int value and returns another function. That returned function accesses and changes the value by adding a newly provided integer and returning the new value — partial application is a way to manage side effects and state (side note: therefore, it does not belong in your functional programming toolkit).

Combining partially applied functions with tuples of functions, we can do the following:

  • Stack<T> is defined as a tuple of a push and a pop function
  • createStack creates an array, which it’s push and pop function will access and change. A Stack<T>-tuple is returned
  • As we see from line 8 onwards, using this custom tailored objects does not differ from using a class or struct instance

In Khipu, we use the following custom-tailored object for storing the app state (dynamically typed<S>) and changing it with dynamic type <C>:

This defines a store as a tuple of generic functions for accessing, changing, resetting, and destroying the stored state. It also allows callbacks to be registered and informed of changes — a simple subscriber pattern.

The implementation for disk storing on Apple platforms, <S> becomes AppState, <C> AppState.Change — which will be explained later:

I mentioned earlier that Khipu could be close to absolute immutable. The reassignments to variables state and callbacks in lines 10, 11, and 12 are the only such assignments in the project — all other codes can and should be immutable.

We want our codes — including our model data types — to be immutable, reducing the possibility of error many folds.

The following datatype models are a light for a smart lighting application (think: Phillips Hue). As we can see in lines 19 to 30, all properties are typed let — therefore, we know this struct is immutable. To reflect changes, like turning it on or off, changing color temperature or hue, saturation, and brightness, we need to create new versions of the same object.

Here we achieve it by using the alter method (line 42), which takes a Change-Value, as defined in the Change enum at line 2.
Change defines the following values:

  • .renaming(.it(to:<newname>))
  • .turning(.it(.on)) and .turning(.it(.off))
  • .adding(.mode(<lightmode>))
  • .toggling(.display(to:<interface>))
  • .setting(.hue(to:<value>))
  • .setting(.saturation(to:<value>))
  • .setting(.brightness(to:<value>))
  • .setting(.temperature(to:<value>))

These values describe the desired behaviour — the Change enum encodes behaviour.

The behaviour is then decoded by pattern matching in the alter method (line 48), where all cases follow the same pattern:

  • on the left-hand side of each case statement’s colon (lhs : rhs), the intent is extracted by decoding the value via pattern matching. On the right-hand side of that colon, the corresponding action is defined
    lhs:case .turning(.it(.on))
    rhs:return .init(id,name,.on,b,s,h,ct,display,modes,selectedMode)
    What we see here is an axiom: The compiler is being taught, what .turning(.it(.on)) means — the previous onOrOff value will be replaced with .on, while all other values will be reused. This is known as “Constructor Recursion.”

We can combine those eight axioms into more complex behaviour, as shown below:

Here a new light is created and immediately altered to reflect certain values for brightness, saturation, hue, and temperature and turning it on.
If you look closely, you will notice that this resembles spoken English quite closely, as you can read below:

let light be: Light with id and name “01”,
alter by
* setting brightness to 0.5
* setting hue to 0.5
* setting saturation to 0.5
* setting temperature to 200 mirek
* turning it on

The model objects are kept in AppState — an immutable struct that works just as the model types — decoding behaviour in a Change enum and implement it via pattern matching in an alter method:

AppState’s Change enum allows for the following DSL:

The AppState itself is immutable, too, which means we have to keep it in a reference type. I have shown you the Store earlier. It is a tuple of functions, and those must be functions, as a tuple itself is a value type, while the functions are the needed reference type.

The Store is used as follows:

Now that we have seen how to deal with our models in an immutable fashion, let’s move on and have a look at the UseCases — the main building block of Martin’s Clean Architecture.

Khipu’s UseCases are inspired by Martin’s Clean Architecture UseCases, which follow the following setup:

  • a UseCase has a Request type, which is used to encode requests for this single UseCase
  • an incoming request will be analysed and translated into calls to the UseCase’s Interactor object, which will be created with all the dependencies it needs. This includes stuff as networking and disk handling
  • once the Interactor is done, it will report back to the UseCase, which will use the newly gathered data to create a Response Type object
  • Request and Response are unique for each UseCase — ensuring separation of concerns
1*P dkc 79f3PHAtD IhVWcA

In Swift, we express it as the following:

Here, the interactor is an implementation detail, allowing you to implement it in many different ways — for most of the simplest use cases, including not implementing it at all.

This is a PAT — “Protocol with Associated Types,” and we implement it as follows:

This Dimmer UseCase allows for different values of a light to be changed incrementally. Note that for this simple UseCase an Interactor isn’t needed — and as it is an implementation detail, we can omit it.

The following code shows examples of dimmer’s requests and responses:

Now, let us assemble several use cases into the lighting feature:

As we can see, a feature is just, again, a partially applied function that takes any dependency (here: a light stack class to control lights wirelessly and a store object) and an Output (aka callback) during creation and returns a function that is an Input — a function that takes a Message value (we will revisit that in a moment) as a parameter and returns nothing.

In the body, we see the use cases being instantiated with the dependencies each one needs — the LightStack (network gateway to the system’s hub) and a store — and callback functions that will call the output if needed.

The returned function just pattern matches. If the Message is meant for the lighting feature and forwards it if true to a locally defined function execute(command:), which pattern matches each possible message and calls the use cases accordingly.

So, while UseCases implement the logic of an app and communicate with their own Request and Response Types — their specific DSLs—features listen for messages and translate them for their use cases if needed. and UseCase Responses are likewise translated into messages — Message being the DSL for system-wide interfeature communication.

We have seen Messages being mentioned several times before, so let’s have a look at them:

This Message encodes values for three features:

The values for the lighting feature are encoded in Message._Lighting (line 30), for the dashboard feature in Message._Dashboad (line 41), and for logging, unsurprisingly, Message._Logging (line 48).

While this looks a bit more impressive, it is still the very same idea we have seen in the Change-DSL of our Light data type and the Request and Response types in our UseCases: Nested annotated enums encode behaviour and — even more so — express intent.

But where Change, Request and Response where meant to interact with smaller components of the system, the Message enum type is meant to connect all features. Therefore, all features share one Message type. The Message type encodes the complete vocabulary of the app.

Some example Message values are:

Now we need to assemble all features into the AppDomain — short: App.

The AppDomain is just the list of all features, and its sole task is to receive messages and forward them to each feature. Here’s the code:

Just like the features, we use partial application.

createAppDomain takes — next to any dependencies — an output. It creates all features by calling their create functions with the needed dependencies and returns an input function — the AppDomain object. The body of this returned function has just one line, in which it iterates over all features and calls them with the received Message.

So by calling createAppDomain we will create a fully functioning system in which we only have one function that takes one Message value at a time — a minimal surface area. But how do we use that? Well, let’s see.

For this example, we will connect the AppDomain to a SwiftUI interface.
For that, we create a ViewState class to serve as the observable object.

It takes a store and subscribes to its notification (line 9). Therefore, each change in store leads to process(appstate) to be called, which in turn will update all variables.

Now we have everything to assemble an app:

We create a store that will be passed as a dependency to view state and during appDomain creation. createAppDomain also takes an array of receivers, which need to be or have a message handling function. We use viewState handle(msg:) method to connect it. It will be informed for every message that flows through the system. This can be used for handling messages that do not change the store — but isn’t used in my example, as that method is empty.

The last parameter is the roothandler. It is defined as a function wrapping roothandler. This closes the circle: The roothandler becomes the starting point but also the output that is being used for features.

The ContentView might look like making the viewState available as environment object.

Now, the LightsCell is a much bigger code. I am not pouring that on you here, but you’ll find the following method in it:

It invokes the roothandler with a message to change certain values.
It might be used like this:

Different values might be decreased via

and by

For the full UI implementation, please visit my repository.

If you want to use Khipu with UIKit, you can set it up via UINotifications. As soon as a change occurs, send a notification. Add the current state as an object. Implement a base view controller that knows how to extract the state from the notification and call a method that processes the new state and populates the UI. Overwrite it in subclasses if needed.

News Credit

%d bloggers like this: