The Declarative Domain Paradigm in Swift

Manuel Meyer
CodeX
Published in
21 min readJun 16, 2024

In this article I want to demonstrate how we can use Swift’s type system to code in a declarative fashion. As we will use Domain Specific Languages (DSL), I call it the “Declarative Domain Paradigm”.

What does declarative mean?
Wikipedia describes it as: “Declarative programming is a programming paradigm that expresses the logic of a computation without describing its control flow”.
In many articles you will read this shortened to: “It describes what to do, not how to do it”.
I would like to add, that our inter human communication is mostly declarative. An example:
You are hosting a dinner party. You are still cooking while the guests are arriving. The door bell rings. As you can’t leave the stove right now, you tell one of your friends: “Please, Frank, open the door”. This is declarartive, as you don’t tell Frank how to do it. You don’t say: “Please place your drink on the table. Stand up. Turn around till you face the door. Place your feet repeatedly in front of each other till you reach the door within an arm length. Grab the door knob. Twist it clockwise,…”. You get the idea.

Domain is the targeted subject area of a computer program. A domain specific language is a language specialized to such a domain.

I will show, how we can declare DSLs within our Swift programs and code fully declarative.

First we will look at the model of a todo list app. After that we will see, how we can assemble the app. Finally I will share some thought about test driven development (TDD) and behaviour driven development (BDD).

Let’s dive in.

The Model

Our model is a TodoList, that contains objects of typeTodoItem.
The TodoItem has an unique id, a title, details, a state (unfinished or finished), a due date, a date when it was created and finally a date when it was changed last.

struct TodoList: Identifiable {
let id : UUID
let items: [TodoItem]
}

struct TodoItem:Identifiable {
let id : UUID
let title : String
let details : String
let state : State
let due : TodoDate
let location: Location
let created : Date
let changed : Date
}

The due date can be unknown, a single date or a time span that either defines a start and an end date or a start date and a duration.

enum TodoDate {
case unknown
case date(Date)
case timeSpan(TimeSpan)
}
enum TimeSpan {
enum TimeComponent {
case seconds
case minutes
case hours
case days
}
enum Start {
case from(Date)
}
enum End {
case unknown
case to(Date)
}

case start (Start,End)
case duration(Start,for:Int,TimeComponent)
}

Example due dates:

let due:TodoDate = .unknown
let due:TodoDate = .date(aDate)
let due:TodoDate = .timeSpan(.from(startDate), .to(endDate))
let due:TodoDate = .timeSpan(.from(startDate), .unknown)
let due:TodoDate = .timeSpan(.duration(from:startDate), for:3,.days)

Location forms a similar rich DSL, it can be unknown, an address, a coordinate or directions, a list of steps.

enum Location {
case unknown
case address (Address )
case coordinate(Coordinate)
case directions(Directions)
}
struct Address {
let street : String
let city : String
let country: String
let zipCode: String
}
struct Coordinate {
let latitude : Double
let longitude: Double
}
struct Directions {
enum Step {
case step(String)
}
let steps: [Step]
}

Example locations:

let location:Location = .address(Address(street:"s",city:"c",country:"c",zipCode:"1"))
let location:Location = .coordinate(Coordinate(latitude:53.92681,longitude:9.09762))
let location:Location = .directions(Directions(steps:
["leave station through main gate",
"follow street for 300m",
"turn right into Sesame Street",
/*...*/]))

Now we can instantiate TodoItems with due dates and locations.

But currently we cannot change any of TodoItem’s properties. Let’s use and DSL approach here as-well, by adding a Change DSL and a method that we can call with such a change request.

struct TodoItem:Identifiable,Equatable {
enum Change:Codable,Equatable {
case title (to:String)
case details (to:String)
case due (to:TodoDate)
case location(to:Location)
case finish
case unfinish
}
let id : UUID
let title : String
let details : String
let state : State
let due : TodoDate
let location: Location
let created : Date
let changed : Date

init(title:String) {
self.init(UUID(),title,"",.unfinished,.unknown,.unknown, .now, .now)
}

private init(_ i:UUID,_ t:String,_ ds:String,_ c:State,_ d:TodoDate,_ l:Location,_ cr:Date,_ cd:Date) {
id=i; title=t; details=ds ;state=c; due=d; location=l; created=cr; changed=cd
}

func alter(_ c:Change...) -> Self { c.reduce(self) { return $0.alter($1) } }

private func alter(_ c:Change) -> Self {
switch c {
case let .title(to:t): return Self(id, t ,details, state, due, location,created,.now)
case let .details(to:d): return Self(id, title,d, state, due, location,created,.now)
case .finish : return Self(id, title,details, .finished, due, location,created,.now)
case .unfinish : return Self(id, title,details, .unfinished, due, location,created,.now)
case let .location(to:l): return Self(id, title,details, state, due, l ,created,.now)
case let .due(to:d): return Self(id, title,details, state, d , location,created,.now)
}
}
}

The TodoItem.Change enum defines all the changes that are applicable

  • change title to aString
  • change details to aString
  • change due to aTodoDate
  • change location to aLocation
  • finish & unfinish the item

A change value is passed into the alter method, like:

var item = TodoItem(title:"Get coffee")
item = item.alter(.title(to:"Get coffee — ASAP"))

Doesn’t this read like English quite a lot?

“Item, alter title to: «Get coffee — ASAP»”.

The TodoList looks similar:

struct TodoList: Identifiable {

let id : UUID
let items: [TodoItem]

enum Change {
case add (Add ); enum Add { case item(TodoItem) }
case remove(Remove); enum Remove { case item(TodoItem) }
case update(Update); enum Update { case item(TodoItem) }
}

init() { self.init(UUID(),[]) }
private init(_ i:UUID,_ its:[TodoItem]) {
id = i; items = its
}

func alter(_ c:Change...) -> Self { c.reduce(self) { $0.alter($1) } }
private func alter(_ c:Change) -> Self {
switch c {
case let .add (.item(i)): return Self(id,items + [i])
case let .remove(.item(i)): return Self(id,items.filter{$0.id != i.id})
case let .update(.item(i)): return
items.contains(where:{ $0.id == i.id })
? self
.alter(
.remove(.item(i)),
.add (.item(i)))
: self
}
}
}

TodoList.Change defines three commands:

  • .add(.item(item)) appends the item to the items array.
  • .remove(.item(item)) will remove the item from the items array.
  • .update(.item(item)) will update an existing item (identified by its id) in the items array, by calling self.alter(.remove(.item(i)), .add (.item(i))).

Now it is time to look at AppState, an object that will keep all the app’s data.

struct AppState {
enum Change {
case add (TodoItem)
case remove(TodoItem)
case update(TodoItem)
case setting(Setting)
enum Setting {
case todos(TodoList)
}
}
let todos:TodoList
init() { self.init(TodoList()) }
func alter(by changes:[Change]) -> Self { changes.reduce(self) { $0.alter(by:$1) } }
}
private extension AppState {
init(_ t:TodoList) { todos = t }
func alter(by change:Change) -> Self {
switch change {
case let .add (t): return Self(todos.alter(.add (.item(t))))
case let .remove(t): return Self(todos.alter(.remove(.item(t))))
case let .update(t): return Self(todos.alter(.update(.item(t))))
case let .setting(.todos(todos)): return Self(todos)
}
}
}
extension AppState: Codable {}

Again we use the Change DSL approach to add, remove and update a TodoItem by forwarding those messages to the todos member. To replace the todos, we call appState.alter(.setting(.todos(newTodoList))).

In all cases, TodoList, TodoItem and AppState, the alter methods will return a new object, this allows all of them to be immutable. Now, there is lot to be said, why immutability is to be prefered. Here I want to keep it short: What cannot change on purpose cannot change by accident — a whole category of bugs that just cannot happen.

But since our models need to be recreated to reflect changes, we need a place to keep the current version around. A store.
A store could be an class object that keeps the AppState around, which would be totally fine, but I’d like to show you a different approach.

typealias Access<S> = (                    ) -> S  // get current state
typealias Change<C> = ( C... ) -> () // change state by aplying Change C{1.*}
typealias Reset = ( ) -> () // reset to defaults
typealias Updated = ( @escaping () -> () ) -> () // subscriber
typealias Destroy = ( ) -> () // destroy persistent data

typealias Store<S,C> = ( /*S:State, C:Change*/
state: Access<S>,
change: Change<C>,
reset: Reset,
updated: Updated,
destroy: Destroy
)

Store<S,C> is defined as a tuple of functions

  • Access<S> returns the current state, which is typed generically as S.
  • Change<C> takes arguments of type C and forward them to the internal state.
  • Reset resets the state to default values.
  • Updated takes a function as parameter. Any function added here will be executed when changes occur. It is a lightweight subscriber pattern.
  • Destroy destroys the store

The following code implements a Store<AppState, AppState.Change> that keeps its state in an AppState object and saves it to the disk for each change:

func createDiskStore(
pathInDocs p: String = "state.json",
fileManager fm: FileManager = .default
) -> Store<AppState, AppState.Change>
{
var s = loadAppStateFromStore(pathInDocuments:p,fileManager:fm) { didSet { c.forEach { $0() } } } // state
var c: [() -> ()] = [] // callbacks
return (
state:{ s },
change:{ s = s.alter(by:$0);persist(state:s,at:p,with:fm) },
reset:{ s = AppState() ;persist(state:s,at:p,with:fm) },
updated:{ c = c + [$0] /*add callback*/ },
destroy:{ destroyStore(at:p,with:fm) }
)
}

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

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

Calling let store: Store = createDiskStore() will create a new Store.

The App Architecture

After we have seen how we can use declarative domain models, I want to present you an architecture, that uses the same principles. I call this architecture «Khipu».

At its core it is an implementation of Robert C. Martin’s «Clean Architecture», which uses UseCases.

A UseCase taks a request, processes the request using an Interactor, takes the result provided by the Interactor and turns it into a Response model.

protocol UseCase {
associatedtype RequestType
associatedtype ResponseType
func request(to request:RequestType)
// init(..., responder: @escaping (Response) -> ())
}

The UseCase protocol has two associated types for Request and Response, a request method that takes a Request object. During initialization it is expected that a respond function is provided.
You don’t see an Interactor, as this is actually an implementation detail.

Let us have a look at the ItemAdder UseCase:

struct ItemAdder:UseCase {
enum Request { case add (TodoItem) }
enum Response{ case added(TodoItem) }

typealias RequestType = Request
typealias ResponseType = Response

init(store s:Store<AppState,AppState.Change>, responder r: @escaping (Response) -> ()) {
interactor = Interactor(store: s, responder: r)
}

private let interactor:Interactor

func request(to request: Request) {
switch request {
case let .add(t): interactor.add(item: t)
}
}
}
private extension ItemAdder {
private struct Interactor {
init(store: Store<AppState,AppState.Change>, responder: @escaping (Response) -> ()) {
self.store = store
self.respond = responder
}

func add(item i: TodoItem) {
store.change(.add(i))
respond(.added(i))
}

private let store: Store<AppState,AppState.Change>
private let respond: (Response) -> ()
}
}
  • ItemAdder’s Request has one command: add(TodoItem), it’s Response also has one case: added(TodoItem). If we would do something more complicated, maybe network-related here, the Response might have two cases, one for success, one for failure.
  • In the init we take the provided store and respond function and use it to create the Interactor.
  • In the ItemAdder request method we pattern match to extract the todo item from the Request .add(TodoItem) and pass it to the Interactor by calling its add(item:) method.
  • In the add(item:) method of ItemAdder.Interactor, which is only visible to ItemAdder, the provided item gets added to the store and the respond callback function is called.

The ItemRemover looks very similar:

struct ItemRemover:UseCase {
enum Request { case remove (TodoItem) }
enum Response{ case removed(TodoItem) }

typealias RequestType = Request
typealias ResponseType = Response

init(store s:Store<AppState,AppState.Change>, responder r: @escaping (Response) -> ()) {
interactor = Interactor(store:s, responder:r)
}

private let interactor:Interactor
func request(to request:Request) {
switch request {
case let .remove(t):interactor.remove(item:t)
}
}
}

private extension ItemRemover {
private struct Interactor {
init(store: Store<AppState,AppState.Change>, responder: @escaping (Response) -> ()) {
self.store = store
self.respond = responder
}

func remove(item i: TodoItem) {
store.change(.remove(i))
respond(.removed(i))
}

private let store: Store<AppState,AppState.Change>
private let respond: (Response) -> ()
}
}

Now let us take this two UseCases along with another to update a TodoItem, and bundle them to what I call a Feature.
Feature communicate with each other and receive Messages , i.e. from the user interface.

enum Message {
case todos(Todos)
enum Todos {
case cmd(CMD)
case ack(ACK)
enum CMD {
case add (item:TodoItem)
case remove (item:TodoItem)
case update (item:TodoItem)
}
enum ACK {
case added (item:TodoItem)
case removed(item:TodoItem)
case updated(item:TodoItem)
}
}
}

This allows the following messages

.todos(.cmd(.add(item:aItem)))
.todos(.cmd(.remove(item:aItem)))
.todos(.cmd(.update(item:aItem)))

.todos(.ack(.added(item:aItem)))
.todos(.ack(.removed(item:aItem)))
.todos(.ack(.updated(item:aItem)))

A Feature job is to receive a Message, check, if it is interested in this Message, translate into one or more UseCase Request, call the UseCase accordingly and finally translate a UseCase Response back into a Message and call a provided callback function with it.

To achieve all this, we will use a technique that isn’t often used in OO but fits perfectly with our needs: Partial Application.

Partially applied functions are functions that, when executed, return another function which then can be written to a variable and called over and over again at will.

func createAdder(x:Int) -> (Int) -> Int {
var value = x
return {
value = value + $0; return value
}
}

let add = createAdder(x: 1)
add(2) // -> 3
add(2) // -> 5

createAdder keeps the state in the variable value and returns the function that will add to the value and return it.

In Khipu we use partial application to create the features.

createTodoListFeature takes a store and a callback function of type Output and returns a function of type Input. Both Output and Input takes a Message and returns nothing. The returned Input function is the Feature. In the following code block it is marked as entry point. This function pattern matches for messages that start with .todos(.cmd(…)) and calls the execute(cmd:) function if so.
The execute(cmd:) function pattern matches the Message.Todos.CMD values and translates them into Request for the proper UseCase:

  • the Message command .add(item:t) is mapped to adder.request(to:.add(t))
  • .remove(item:t) to remover.request(to:.remove(t))
  • and .update(item:t) to updater.request(to:.update(t))

the UseCases are instantiated with responder functions that will take the UseCase’s Response and translate it into a Message, which is used to call the Output.

typealias  Input = (Message) -> ()
typealias Output = (Message) -> ()

func createTodoListFeature(
store s: Store<AppState,AppState.Change>,
output out: @escaping Output
) -> Input
{
let adder = ItemAdder (store:s,responder:process(on:out))
let remover = ItemRemover(store:s,responder:process(on:out))
let updater = ItemUpdater(store:s,responder:process(on:out))
func execute(cmd:Message.Todos.CMD) {
switch cmd {
case let .add (item:t): adder .request(to:.add (t) )
case let .remove(item:t): remover.request(to:.remove(t) )
case let .update(item:t): updater.request(to:.update(t) )
}
}
return { // entry point
if case let .todos(.cmd(c)) = $0 { execute(cmd:c) }
}
}
// MARK -
private func process(on out:@escaping Output) -> (ItemAdder.Response) -> () {
{
switch $0 {
case let .added(t):out(.todos(.ack(.added(item:t))))
}
}
}
private func process(on out:@escaping Output) -> (ItemRemover.Response) -> () {
{
switch $0 {
case let .removed(t):out(.todos(.ack(.removed(item:t))))
}
}
}
private func process(on out:@escaping Output) -> (ItemUpdater.Response) -> () {
{
switch $0 {
case let .updated(t):out(.todos(.ack(.updated(item:t))))
}
}
}

Now that we have seen the anatomy of a Feature, let us assemble what I call the AppDomain. It’s signature is quite similar to our Feature:

func createAppDomain(
store : Store<AppState,AppState.Change>,
receivers : [Input],
rootHandler: @escaping Output
) -> Input
{
let features: [Input] = [
createTodoListFeature(store:store,output:rootHandler)
]

return { msg in
(receivers + features).forEach {
$0(msg)
}
}
}

createAppDomain takes a store, receivers (like Feature Input functions) and a rootHandler called Output function. In it’s body it has a list with all Features (in our case just one) and returns an Output function that does nothing else than iterate over all receivers and features and execute them by passing a Messsage. The AppDomain contains all the logic of our app. It’s surface area is just one function call. This makes it easily testable (more on this later) and can be integrated with any UI easily.

Let’s connect the AppDomain with a SwiftUI user interface.

First we need a view model for our TodoList model — TodoListViewModel — and a view model for TodoItemTodoItemViewModel.

import SwiftUI

final class TodoItemViewModel: Identifiable, ObservableObject, Equatable {
init(for i:TodoItem, update u:@escaping (TodoItemViewModel) -> ()) {
backedItem = i
update = u
id = i.id
title = i.title
details = i.details
due = i.due
location = i.location
created = i.created
changed = i.changed
completed = i.state == .finished
}
var item: TodoItem { backedItem }
private var backedItem : TodoItem { didSet { update(self) } }
private var update : (TodoItemViewModel) ->()
@Published public var id : UUID
@Published public var created : Date
@Published public var changed : Date
@Published public var title : String { didSet { backedItem = backedItem.alter(.title(to:title) ) } }
@Published public var details : String { didSet { backedItem = backedItem.alter(.details(to:details) ) } }
@Published public var completed: Bool { didSet { backedItem = backedItem.alter(completed ? .finish : .unfinish) } }
@Published public var due : TodoDate { didSet { backedItem = backedItem.alter(.due(to:due) ) } }
@Published public var location : Location { didSet { backedItem = backedItem.alter(.location(to:location) ) } }
}
extension TodoItemViewModel {
public static func == (lhs:TodoItemViewModel, rhs:TodoItemViewModel) -> Bool { rhs.id == lhs.id }
}

TodoItemViewModel is a class that takes and TodoItem and a callback function. It writes all of TodoItems properties to corresponding counterparts on the view model. For those properties that might be changed it updates the backedItem by calling it’s alter method, i.e.: backedItem.alter(.title(to:title). Each time backedItem is changed, the update callback will be executed var backedItem : TodoItem { didSet { update(self) } }

The TodoListViewModel keeps a list of TodoItemViewModels in items. It is published to SwiftUI views.
It is initialized with a store — to which it subscribes itself to listen for updates, to process the current state each time an update is triggered ( store.updated { self.process(store.state()) }) —, a roothandler called callback function that takes a Message as parameter.
It has methods to add, delete and update TodoItems. These Function call the roothandler with corresponding commands.
Finally there is the process method, which takes the current state and iterates over all items and maps them with a TodoItemViewModel.

import SwiftUI

final class TodoListViewModel:ObservableObject {
@Published public var items: [TodoItemViewModel] = []
private let roothandler:(Message) -> ()

init(store:Store<AppState,AppState.Change>, roothandler r: @escaping(Message) -> ()) {
roothandler = r
store.updated { self.process(store.state()) }
process(store.state())
}
func add (_ i:TodoItem ) { roothandler(.todos(.cmd(.add (item:i )))) }
func delete(_ i:TodoItemViewModel) { roothandler(.todos(.cmd(.remove(item:i.item)))) }
func update(_ i:TodoItemViewModel) { roothandler(.todos(.cmd(.update(item:i.item)))) }

func process(_ appState:AppState) {
DispatchQueue.main.async {
self.items = appState.todos.items.map{TodoItemViewModel(for:$0, update:self.update) }
}
}
}

It is time to look at the Views

import SwiftUI

let gradient = LinearGradient(
colors: [.pink, .orange],
startPoint: .top,
endPoint: .bottom
)

let actionColor = Color.red

struct ContentView: View {
@StateObject private var todoList:TodoListViewModel
@State private var presentTitleInput = false
@State private var enteredTitle = ""

init(todoList tl: TodoListViewModel) { _todoList = StateObject(wrappedValue:tl) }
var body: some View {
VStack {
NavigationView {
List {
Section("todos".uppercased()) {
if todoList.items.count > 0 {
if !todos.isEmpty {
ForEach(todos, id:\.id) { i in
NavigationLink(destination:ItemView.Edit(item:i)) { ItemView.Row(item:i) }
}.onDelete {
$0.forEach {
todoList.delete(todos[$0].wrappedValue)
}
}
}
} else {
Text("no items found")
}
}
}.toolbar { Button { askTitleForNewItem() } label: { Image(systemName:"plus") } }

}
.animation(.linear, value: todoList.items)
.navigationTitle("Todos")
.accentColor(actionColor)
}
.environmentObject(todoList)
.foregroundStyle(gradient)
.alert("New Item",
isPresented: $presentTitleInput,
actions: {
TextField("title", text:$enteredTitle)
Button { save() } label: { Text("Add Item") }
Button(role:.cancel) { cancel() } label: { Text("Cancel" ) } },
message: { Text("Please enter title for new Item.") }
)
}
private func bind(_ item: TodoItemViewModel) -> Binding<TodoItemViewModel> {
Binding(
get: { item },
set: { todoList.items[todoList.items.firstIndex(of:item)!] = $0 }
)
}
private var todos: [Binding<TodoItemViewModel> ] { todoList.items.sorted{ i0, i1 in
(i0.completed ? 1 : 0, i1.changed)
< (i1.completed ? 1 : 0, i0.changed)
}.map { bind($0)} }
}

private extension ContentView {
func save() { saveToList() }
func askTitleForNewItem() { presentTitleInput = true }
func cancel() { reset() }
func reset() { enteredTitle = "" }
}

private extension ContentView {
func saveToList() {
!enteredTitle.isEmpty()
? todoList.add(TodoItem(title:enteredTitle.trimmed()))
: ()
reset()
}
}

extension ItemView {
struct Row: View {
@Binding var item : TodoItemViewModel
@EnvironmentObject var todoList : TodoListViewModel
@State private var showDateSelector: Bool = false

var body: some View {
VStack {
HStack {
Image(systemName:item.completed
? "checkmark.square"
: "square"
)
.onTapGesture { item.completed.toggle() }
Text(item.title)
}.contextMenu {
Button { item.completed.toggle() } label: { Text(item.completed ? "unfinish" : "finish") }
Button { showDateSelector = true } label: { Text("set due date" ) }
Button(role:.destructive) { todoList.delete(item) } label: { Text("delete" ) }
}
}.sheet(isPresented:$showDateSelector) {
ItemView.Due.EditView(item:$item)
}
}
}
}

Results in:

Navigating into an item, the edit view is displayed.

import SwiftUI

extension ItemView {
struct Edit:View {
@Binding var item : TodoItemViewModel
@EnvironmentObject var todoList: TodoListViewModel
@State private var confirmDeletion = false
@FocusState private var isFocused:Bool
var body: some View {
VStack {
ScrollView {
ItemView .Header(item:$item)
ItemView.Details(item:$item)
Spacer()
}
}
.onTapGesture {
isFocused = false
}
.navigationTitle(item.title)
.toolbar {
Button(role:.destructive) {
confirmDeletion = true
} label: { Text("delete") }
.accentColor(actionColor)
}
.alert("Delete Item",
isPresented: $confirmDeletion,
actions: {
Button(role:.destructive) { todoList.delete(item) } label: { Text("Delete Item") }
Button(role:.cancel ) { } label: { Text("Cancel" ) } },
message: { Text("Please confirm deletion of \"\(item.title)\".") }
)
}
}
}

extension ItemView {
struct Header: View {
@Binding private var item : TodoItemViewModel
@EnvironmentObject private var todoList: TodoListViewModel
@State private var title : String
@State private var details : String
@FocusState private var isFocused:Bool
init(item i: Binding<TodoItemViewModel>) { _item = i; title = i.wrappedValue.title; details = i.wrappedValue.details }

var body: some View {
VStack {
TextField("Enter Title", text:$title)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(size:18))
TextField("Enter Details",text:$details,axis:.vertical)
.lineLimit(6...10)
.focused($isFocused)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(size:18))
}
.padding()
.onChange(of:title ) { item.title = $0 }
.onChange(of:details) { item.details = $0 }
.onTapGesture {
isFocused = false
}
}
}
}

extension ItemView {
struct Details: View {
@Binding var item: TodoItemViewModel
@State private var showingDatePicker = false

var body: some View {
VStack {
Grid(alignment:.center,horizontalSpacing:20,verticalSpacing:20) {
GridRow(alignment:.top) {
Text("due")
.gridColumnAlignment(.trailing)
VStack {
switch item.due {
case .unknown :Button { showDatePicker() } label: { ItemView.Due.UnknownView (item:$item) }
case .date :Button { showDatePicker() } label: { ItemView.Due.DateView (item:$item) }
case .timeSpan:Button { showDatePicker() } label: { ItemView.Due.TimeSpanDisplayView(item:$item) }
}
}
.foregroundStyle(actionColor)
}
GridRow(alignment:.top) {
Text("completed")
.gridColumnAlignment(.trailing)
Button {
item.completed.toggle()
} label: {
Image(systemName:item.completed
? "checkmark.square"
: "square"
)
}
.foregroundStyle(actionColor)
}.onChange(of:item.completed) {
item.completed = $0
}
}
.font(.system(size:18))
.onChange(of: item.due) { newValue in
showingDatePicker = false
}

ItemView.Loc.Selector(item: $item)

switch item.location {
case .unknown :ItemView.Loc.UnknownView()
case .address :ItemView.Loc.AddressView (for:$item)
case .coordinate:ItemView.Loc.CoordinateView(for:$item)
case .directions:ItemView.Loc.DirectionView (for:$item)
}
}
.sheet(isPresented:$showingDatePicker) { ItemView.Due.EditView(item:$item).presentationDetents([.height(540), .medium]) }
}
}
}

private extension ItemView.Details {
func showDatePicker() { showingDatePicker = true }
}

I will not post all the UI codes here, simply because that would be too much. Also I am not a master of SwftUI — yet — so be careful and dont assume that I do everything in the best way. I also would happily accept pull request for eliminating issues in the UI.

Behaviour and Test Driven Development

Finally let’s discuss Behaviour Driven Development (BDD) and Test. Driven Development (TDD).

I use Quick & Nimble to write the tests — or specifications as they are called in BDD.

BDD and TDD are pretty similar, mostly they differ who is writing the tests/specifications. Unit tests are written by the developer. Specifications are ideally written by the customer — or someone representing the customer, who isn’t part of the development team.
I want to propose an approach that combines best of both worlds.

The customer describes high level, what a model needs to be.

final class TodoItemSpecifications: QuickSpec {
override class func spec() {
describe("TodoItem") {
context("just created") {
it("has provided title" ) { }
it("has empty details" ) { }
it("has unkown due date") { }
it("isnt finished" ) { }
it("has no location" ) { }
}
context("change title") {
it("has different title" ) { }
it("has provided title" ) { }
it("has unchanged details") { }
it("has same due date" ) { }
it("has same state" ) { }
it("has same location" ) { }
}
context("change details") {
it("has changed details") { }
it("has correct details") { }
it("has different title") { }
it("has same due date" ) { }
it("has same state" ) { }
it("has same location" ) { }
}
//....
}
}
}

In this specification skeleton the customer describes the behaviour of a TodoItem. By looking at this, we can infer, that a TodoItem is created

  • with a title
  • has no details text
  • has an unknown due date
  • is unfinished
  • and has no location

Changing the title or details results in otherwise unchanged properties.

Now we, the developer, take over. We create the TodoItem struct to fullfil the specs for a newly created item.

struct TodoItem:Identifiable {

let id : UUID
let title : String
let details : String
let state : State
let due : TodoDate
let location: Location
let created : Date
let changed : Date

init(title:String) {
self.init(UUID(),title,"",.unfinished,.unknown,.unknown, .now, .now)
}
private init(_ i:UUID,_ t:String,_ ds:String,_ c:State,_ d:TodoDate,_ l:Location,_ cr:Date,_ cd:Date) {
id=i; title=t; details=ds ;state=c; due=d; location=l; created=cr; changed=cd
}
}

enum TodoDate {
case unknown
}
extension TodoItem {
enum State:Codable,Equatable {
case unfinished
}
}
enum Location {
case unknown
}

We know add our tests to the specification.

final class TodoItemSpecifications: QuickSpec {
override class func spec() {
describe("TodoItem") {

let t0 = TodoItem(title:"Get Coffee")
context("just created") {
it("has an id" ) { expect(t0.id ).toNot(beNil() ) }
it("has provided title" ) { expect(t0.title ).to (equal("Get Coffee")) }
it("has empty details" ) { expect(t0.details ).to (equal("") ) }
it("has unkown due date") { expect(t0.due ).to (equal(.unknown) ) }
it("isnt finished" ) { expect(t0.state ).to (equal(.unfinished) ) }
it("has no location" ) { expect(t0.location).to (equal(.unknown) ) }
}
//...
}
}
}

Each it/expect pair is a small unit test, when we run the test suite, all six will execute successfully.

We know add the tests for changing the title. First we need to add the DSL command.

struct TodoItem {
enum Change {
case title(to:String)
}

let id : UUID
let title: String
// ...

func alter(_ c:Change ) -> Self {
switch c {
case let .title(to:t): return self
}
}
}

We add a Change enum, that has one case title(to:String).
We add a method called alter, that takes a Change value as parameter and pattern matches to retrieve the title string from .title(to:). For now the method returns an unchanged self.

We return to our specification and change it to reflect the changes.

final class TodoItemSpecifications: QuickSpec {
override class func spec() {
describe("TodoItem") {
// ...
context("change title") {
let t1 = t0.alter(.title(to:"Get Coffee — ASAP"))
it("has same id" ) { expect(t1.id ).to (equal(t0.id) ) }
it("has different title" ) { expect(t1.title ).toNot(equal(t0.title) ) }
it("has provided title" ) { expect(t1.title ).to (equal("Get Coffee — ASAP")) }
it("has unchanged details") { expect(t1.details ).to (equal(t0.details) ) }
it("has same due date" ) { expect(t1.due ).to (equal(t0.due) ) }
it("has same state" ) { expect(t1.state ).to (equal(t0.state) ) }
it("has same location" ) { expect(t1.location).to (equal(t0.location) ) }
}
// ...

We create a new item by calling alter on the existing one: let t1 = t0.alter(.title(to:”Get Coffee — ASAP”)). We check, that only the title has changed and everything else stays the same. it(“has different title” ) and it(“has provided title” ) will fail, as we aren’t changing the title yet. We add that code now:

struct TodoItem:Identifiable {
enum Change:Codable,Equatable {
case title(to:String)
}

let id : UUID
let title: String
// ...
private init(_ i:UUID,_ t:String,_ ds:String,_ c:State,_ d:TodoDate,_ l:Location,_ cr:Date,_ cd:Date) {
id=i; title=t; details=ds ;state=c; due=d; location=l; created=cr; changed=cd
}

private func alter(_ c:Change ) -> Self {
switch c {
case let .title(to:t): return Self( id, t, details, state, due, location,created,.now)
}
}
}

When alter is called with parameter .title(to:"Get Coffee — ASAP"), a new TodoItem is created with the new title overwriting the old one t. Now all tests will succeed.

We can now repeat this for all TodoItem’s properties.

  • add command to the Change DSL
  • add case to the switch statement in alter, return unchanged self
  • formulate tests for the behaviour statements in the specification
  • run the tests, see some of them fail
  • return a new TodoItem object from alter with overwriting the desired property
  • the tests succeed

See all TodoItem tests (220+).

Of course we cannot only test models, but other components as-well.

Here are the specifications for the ItemAdder UseCase:

final class ItemAdderSpecifications:QuickSpec {
override class func spec() {
var itemAdder:ItemAdder!
var store:AppStore!
var fetchedItem:TodoItem!

describe("ItemAdder"){
beforeEach {
store = createDiskStore(pathInDocs: "ItemAdderSpecifications.json")
itemAdder = ItemAdder(store: store, responder: { response in
switch response {
case let .added(t): fetchedItem = t
}
})
}
afterEach {
destroy(&store)
itemAdder = nil
fetchedItem = nil
}
context("store") {
it("has no item") {
expect(store.state().todos.items).to(beEmpty())
}
}
context("adding Item") {
var item:TodoItem!
beforeEach {
item = TodoItem(title: "Get Coffee")
itemAdder.request(to: .add(item))
}
afterEach {
item = nil
}

it("adds item to store") {
expect(store.state().todos.items).to(equal([item]))
}
it("emits response") {
expect(fetchedItem).to(equal(item))
}
}
}
}
}

But we can also test the whole AppDomain:

typealias AppStore = Store<AppState,AppState.Change>

final class AppsSpecifications: QuickSpec {
override class func spec() {
var store :AppStore!
var roothandler:((Message) -> ())!
var app :Input!
beforeEach{
store = createDiskStore(pathInDocs:"appspecs.json")
roothandler = { msg in }
app = createAppDomain(store:store, receivers:[], rootHandler:roothandler)
}
afterEach{
destroy(&store)
roothandler = nil
app = nil
}

describe("ItemsApp") {
context("uninitialized") {
it("has no todos") { expect(store.state().todos.items).to(haveCount(0)) }
}
context("initialized") {
it("has todos object" ) { expect(store.state().todos ).toNot(beNil()) }
it("has empty todos items") { expect(store.state().todos.items).to (beEmpty()) }
context("add") {
let i0 = TodoItem(title:"hey Ho")
beforeEach {
app(.todos(.cmd(.add(item:i0))))
}
it("has a todo item") { expect(store.state().todos.items).to(equal([i0])) }
context("add") {
let i1 = TodoItem(title:"Let's go")
beforeEach {
app(.todos(.cmd(.add(item:i1))))
}
it("has two todo items") { expect(store.state().todos.items).to(equal([i0,i1])) }
context("remove") {
beforeEach {
app(.todos(.cmd(.remove(item:i0))))
}
it("has the added item, id") { expect(store.state().todos.items.first?.id).to(equal(i1.id)) }
it("has a todo item" ) { expect(store.state().todos.items ).to(haveCount(1)) }
context("remove twice fails silently") {
beforeEach {
app(.todos(.cmd(.remove(item:i0))))
}
it("has a todo item") { expect(store.state().todos.items.map(\.id)).to(equal([i1.id])) }
}
}
}
context("remove") {
beforeEach {
app(.todos(.cmd(.remove(item:i0))))
}
it("has todos object" ) { expect(store.state().todos ).toNot(beNil() ) }
it("has empty todos items") { expect(store.state().todos.items).to (beEmpty()) }
context("removing unpresent item fails silently") {
beforeEach {
app(.todos(.cmd(.remove(item:i0))))
}
it("has todos object" ) { expect(store.state().todos ).toNot(beNil() ) }
it("has empty todos items") { expect(store.state().todos.items).to (beEmpty()) }
}
}
context("update") {
beforeEach {
app(.todos(.cmd(.update(item: i0.alter(.title(to:"Hey Ho!"))))))
}
it("has a todo item" ) { expect(store.state().todos.items ).to(haveCount(1) ) }
it("has the added item, title" ) { expect(store.state().todos.items.first?.title).to(equal("Hey Ho!")) }
it("has the added item, id" ) { expect(store.state().todos.items.first?.id ).to(equal(i0.id) ) }
it("has the items completed unchanged") { expect(store.state().todos.items.first?.state).to(equal(i0.state) ) }
it("no due date known" ) { expect(store.state().todos.items.first?.due ).to(equal(i0.due) ) }
context("inexistent item") {
beforeEach {
app(.todos(.cmd(.update(item:TodoItem(title:"new item")))))
}
it("will not update anything") { expect(store.state().todos.items.map{$0.title}).toNot(contain("new item")) }
it("has still one todo item" ) { expect(store.state().todos.items ).to (haveCount(1) ) }
}
}
}
}
}
}
}

As you can see here, we can nest the specification tests, they play nice with Xcode, too.

Conclusion

Using a declarative domain approach in Swift lets us write clean and highly maintainable code fast. The DSL values can be quite close to spoken English and it is a delight to write tests.

Further Readings and Resources

Join the discussion on Daily.dev

--

--

Manuel Meyer
CodeX
Writer for

Freelance Software Developer and Code Strategist, currently working on a book about the Declarative Domain Paradigm. Want to publish me? Get in contact!