The Declarative Domain Paradigm in Swift
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 callingself.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 asS
.Change<C>
takes arguments of typeC
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’sResponse
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 providedstore
andrespond
function and use it to create theInteractor
. - In the
ItemAdder
request
method we pattern match to extract the todo item from theRequest
.add(TodoItem)
and pass it to theInteractor
by calling itsadd(item:)
method. - In the
add(item:)
method ofItemAdder.Interactor
, which is only visible toItemAdder
, the provided item gets added to thestore
and therespond
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 toadder.request(to:.add(t))
.remove(item:t)
toremover.request(to:.remove(t))
- and
.update(item:t)
toupdater.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 TodoItem
— TodoItemViewModel
.
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.