A New Coding Paradigm: Declarative Domain Programming
I want to introduce you to a programming paradigm, that I discovered and that I have explored for the past five years: In Declarative Domain Programming (DDP) we use type declarations to model logic, behaviour and relationships. Like in Object Orientated Programming (OOP), messages are used for different modules to communicate with each other. And similar to Functional Programming (FP) immutability is favoured.
The language I am using is Swift, as it offers nested types, enums with associated values and type inference. We combine these to create Domain Specific Langauges (DSL). Our models will have Change
DSLs, which will encode any interaction. Our app will contain use cases, that have DSLs for their Request
and Response
types. Different parts of the app — like different features and the user interface — will communicate with a Message
DSL.
You don’t have to be fluent in Swift to follow the code examples. Just give it a try.
Let’s look at a simple example what DDP is and how it compares to more conventional coding.
We want to create a counter object, that increments and decrements a value. In conventional coding it looks something like this:
struct ConventionalCounter {
let value: UInt
init() {
self.init(value: 0)
}
private init(value: UInt) {
self.value = value
}
func increment() -> Self {
return Self(value:value + 1)
}
func decrement() -> Self {
return Self(value: value - 1)
}
}
It has functions to increment and decrement the internal value.
It would be used like:
var conventionalCounter = ConventionalCounter()
conventionalCounter = conventionalCounter.increment()
print(conventionalCounter.value)
In DDP we would have a DSL that describes incrementing and decrementing as type declarations, here the Change
enum:
struct DeclarativeCounter {
enum Change {
case increment
case decrement
}
let value: UInt
init() {
self.init(value: 0)
}
private init(value: UInt) {
self.value = value
}
func alter(_ c:Change) -> Self {
return switch c {
case .increment: Self(value: value + 1)
case .decrement: Self(value: value - 1)
}
}
}
Instead of one method for incrementing and one for decrementing we have one alter
method that takes a Change
value, either increment
or decrement
, i.e.:
var declarativeCounter = DeclarativeCounter()
declarativeCounter = declarativeCounter.alter(.increment)
print(declarativeCounter.value)
If we want to in- or decrement with values other than one we might code in conventional programming:
struct ConventionalCounter {
let value: UInt
init() {
self.init(value: 0)
}
private init(value: UInt) {
self.value = value
}
func increment() -> Self {
return increment(by:1)
}
func decrement() -> Self {
return decrement(by:1)
}
func increment(by x: UInt) -> Self {
return Self(value: value + x)
}
func decrement(by x: UInt) -> Self {
return Self(value: value - x)
}
}
We added increment(by:)
and decrement(by:)
methods to alter the value by a given step.
We use them like:
var conventionalCounter = ConventionalCounter()
conventionalCounter = conventionalCounter.increment(by:5)
conventionalCounter = conventionalCounter.decrement(by:2)
print(cconventionalCounter.value). // prints 3
In DDP it would be done something like:
struct DeclarativeCounter {
enum Change {
enum Value {
case value(UInt)
}
case increment
case decrement
case incrementBy(Value)
case decrementBy(Value)
}
let value: UInt
init() {
self.init(value: 0)
}
private init(value: UInt) {
self.value = value
}
func alter(_ c:Change) -> Self {
return switch c {
case .increment : alter(.incrementBy(.value(1)))
case .decrement : alter(.decrementBy(.value(1)))
case let .incrementBy(.value(x)): Self(value: value + x)
case let .decrementBy(.value(x)): Self(value: value - x)
}
}
}
We added .incrementBy(.value(…))
and .decrementBy(.value(…))
to the Change
DSL and pattern match them in the alter
method.
var declarativeCounter = DeclarativeCounter()
declarativeCounter = declarativeCounter.alter(.incrementBy(.value(5)))
declarativeCounter = declarativeCounter.alter(.decrementBy(.value(2)))
print(declarativeCounter.value) // prints 3
In DDP behaviour is encoded in types, usually nested enums with associated values.
Behaviour Driven Domain Modelling
We will develop “Projectster”, a project planning tool, using a Behaviour Driven Development (BDD) approach.
First, let’s write down the specifications we know
Project
- has an id
- has a title, which can be changed
- has details, which can be changed
- has tasks, which can be added, updated and removed
- has one or no leader, which can be appointed
- has collaborators, which can be added, updated and removed
- has or has not a date, which can be changedProjectTask
- has an id
- has a title, which can be changed
- has a state (not started, in progress or finished), which can be changed
- has collaborators, which can be added and removedProjectMember
- has an id
- has a name, which can be changed
Our initial version of the models looks like
ProjectMember
struct ProjectMember {
let id : UUID
let name: String
init(name: String) {
self.id = UUID()
self.name = name
}
}
ProjectTask
struct ProjectTask {
enum State {
case notStarted
case inProgress
case finished
}
let id : UUID
let title : String
let state : State
let collaborators: [ProjectMember]
init(title: String) {
self.id = UUID()
self.title = title
self.state = .notStarted
self.collaborators = []
}
}
ProjectDate
enum ProjectDate {
case unknown
case date(Date)
}
Project
struct Project {
enum Leading {
case noone
case leader(ProjectMember)
}
let id : UUID
let title : String
let details : String
let tasks : [ProjectTask]
let leader : Leading
let collaborators: [ProjectMember]
let date : ProjectDate
init(title:String) {
self.id = UUID()
self.title = title
self.details = ""
self.tasks = []
self.leader = .noone
self.collaborators = []
self.date = .unknown
}
}
Note, that all properties of ProjectMember
, ProjectTask
and Project
are declared with let
— which makes them constant and there-for unchangeable. To reflect changed values we must recreate any object.
As we want to use BDD, let’s formulate our first tests, or — in BDD lingo: our first specifications (or specs). I use the fantastic tools Quick and Nimble.
import Quick
import Nimble
import Projectster
final class ProjectMemberSpec: QuickSpec {
override class func spec() {
describe("ProjectMember") {
var projectMember: ProjectMember!
beforeEach {
projectMember = ProjectMember(name:"Joe Doe")
}
afterEach {
projectMember = nil
}
context("newly created") {
it("has an id" ) { expect(projectMember.id ).toNot(beNil() ) }
it("has a given name") { expect(projectMember.name).to (equal("Joe Doe")) }
}
}
}
}
This specification describes ProjectMember
. Newly created it has an id and a given name. It contains two unit test:
it("has an id" ) { expect(projectMember.id ).toNot(beNil() ) }
it("has a given name") { expect(projectMember.name).to (equal("Joe Doe")) }
- ProjectMember, newly created, has an id
- ProjectMember, newly created, has a given name
The initial ProjectTask
specification:
final class ProjectTaskSpec: QuickSpec {
override class func spec() {
describe("ProjectTask") {
var projectTask: ProjectTask!
beforeEach {
projectTask = ProjectTask(title: "Obtain brain slugs")
}
afterEach {
projectTask = nil
}
context("newly created") {
it("has an id" ) { expect(projectTask.id ).toNot(beNil() ) }
it("has a given title" ) { expect(projectTask.title ).to(equal("Obtain brain slugs")) }
it("has state not started") { expect(projectTask.state ).to(equal(.notStarted) ) }
it("has no collaborators" ) { expect(projectTask.collaborators).to(beEmpty() ) }
}
}
}
}
It has four tests:
- ProjectTask, newly created, has an id
- ProjectTask, newly created, has a given title
- ProjectTask, newly created, has state not started
- ProjectTask, newly created, has no collaborators
Project
specification:
final class ProjectSpec: QuickSpec {
override class func spec() {
describe("Project") {
var project: Project!
beforeEach {
project = Project(title:"World Domination!")
}
afterEach {
project = nil
}
context("newly created") {
it("has an id" ) { expect(project.id ).toNot(beNil() ) }
it("has a given title" ) { expect(project.title ).to (equal("World Domination!")) }
it("has empty details" ) { expect(project.details ).to (beEmpty() ) }
it("has empty tasks" ) { expect(project.tasks ).to (beEmpty() ) }
it("has empty collaborators") { expect(project.collaborators).to (beEmpty() ) }
it("has unknown date" ) { expect(project.date ).to (equal(.unknown) ) }
}
}
}
}
It contains six test:
- Project, newly created, has an id
- Project, newly created, has a given title
- Project, newly created, has empty details
- Project, newly created, has empty tasks
- Project, newly created, has empty collaborators
- Project, newly created, has unknown date
We have seen how we can model and test the initial states of our models. Now let us have a look how we can express change in our models in DDP. As stated in the beginning, we will use type declarations to do so. First we will alter the model ProjectMember
. It only has one property that we want to change: the name.
struct ProjectMember {
enum Change {
case name(to:String)
}
let id : UUID
let name: String
init(name:String) {
self.id = UUID()
self.name = name
}
func alter(_ c:Change) -> Self {
return switch c {
default: self
}
}
}
As we can see, we have added a nested enum called Change
— the Change
DSL — and an alter
method, that switches over a Change
value. For now that switch returns the unchanged ProjectMember
, this is the minimal compilable code. Before we adapt the alter
method to actually return an altered ProjectMember
, we extend our specification:
final class ProjectMemberSpec: QuickSpec {
override class func spec() {
describe("ProjectMember") {
var projectMember: ProjectMember!
beforeEach {
projectMember = ProjectMember(name:"Joe Doe")
}
afterEach {
projectMember = nil
}
context("newly created") {
it("has an id" ) { expect(projectMember.id ).toNot(beNil() ) }
it("has a given name") { expect(projectMember.name).to (equal("Joe Doe")) }
}
context("change name") {
var pm0: ProjectMember!
beforeEach {
pm0 = projectMember.alter(.name(to: "Jane Doe"))
}
afterEach {
pm0 = nil
}
it("has new given name" ) { expect(pm0.name).to(equal("Jane Doe") ) }
it("has an unchanged id") { expect(pm0.id ).to(equal(projectMember.id)) }
}
}
}
}
Two new tests where added:
- ProjectMember, change name, has new given name
- ProjectMember, change name, has an unchanged id
Before each of the two tests are run, pm0 = projectMember.alter(.name(to: “Jane Doe”))
is executed.
The first one will fail, as our alter
method actually does not alter yet. We change that now:
struct ProjectMember {
enum Change {
case name(to:String)
}
let id : UUID
let name: String
init(name: String) {
self.init(UUID(), name)
}
private init(_ id:UUID,_ name:String) {
self.id = id
self.name = name
}
func alter(_ c:Change) -> Self {
return switch c {
case let .name(to: n): Self(id, n)
}
}
}
In alter
we pattern match for the new name, write it to n
, and return a new ProjectMember
by invoking a newly added private init
with id
and n
. Now all test succeed again.
ProjectMember’s
Change
DSL only has one command: .name(to:<newName>)
.
ProjectTask’s
Change
DSL is a more interesting.
struct ProjectTask {
enum Change {
case title(to:String)
case state(to:State)
case add(Add); enum Add {
case collaborator(ProjectMember)
}
case remove(Remove); enum Remove {
case collaborator(ProjectMember)
}
}
enum State {
case notStarted
case inProgress
case finished
}
let id : UUID
let title : String
let state : State
let collaborators: [ProjectMember]
public init(title: String) {
self.init(UUID(), title, .notStarted, [])
}
private init(_ id:UUID,_ title:String,_ state:State,_ collaborators:[ProjectMember]) {
self.id = id
self.title = title
self.state = state
self.collaborators = collaborators
}
func alter(_ c:Change) -> Self {
return switch c {
default: self
}
}
}
Via enum-nesting and enum’s associated values it allows to encode
.title(to:”New Title”)
.state(to:.notStarted)
,.state(to:inProgress)
&.state(to:.finished)
.add(.collaborator(newCollaborator))
.remove(.collaborator(collaborator))
We will now write the tests in ProjectMember’s
spec before we adapt the alter
method to actually create new ProjectMembers
with altered values.
final class ProjectTaskSpec: QuickSpec {
override class func spec() {
describe("ProjectTask") {
var projectTask: ProjectTask!
beforeEach {
projectTask = ProjectTask(title:"Obtain brain slugs")
}
afterEach {
projectTask = nil
}
context("newly created") {
it("has an id" ) { expect(projectTask.id ).toNot(beNil() ) }
it("has a given title" ) { expect(projectTask.title ).to(equal("Obtain brain slugs")) }
it("has state not started") { expect(projectTask.state ).to(equal(.notStarted) ) }
it("has no collaborators" ) { expect(projectTask.collaborators).to(beEmpty() ) }
}
context("change") {
var t0: ProjectTask!
context("title") {
beforeEach {
t0 = projectTask.alter(.title(to: "Hypno Toads"))
}
afterEach {
t0 = nil
}
it("has new title" ) { expect(t0.title ).to(equal("Hypno Toads") ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged state") { expect(t0.state ).to(equal(projectTask.state) ) }
it("has unchanged coll.") { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
}
}
}
}
Four new tests were added:
- ProjectTask, change title, has new title
- ProjectTask, change title, has unchanged id
- ProjectTask, change title, has unchanged state
- ProjectTask, change title, has unchanged collaborators
Before each of this test is executed, all prior beforeEach
blocks will run, so before each test t0 = projectTask.alter(.title(to: “Hypno Toads”))
is performed. After each test we clean up. The tests themself — the it/expect
pairs — are quite simple: check that an expected change is seen and check that all other properties stay the same. You could say, that they implement the scientific method: change one variable and observe what changes and what not.
func alter(_ c:Change) -> Self {
return switch c {
case let .title(t): Self(id, t, state, collaborators)
default: self
}
}
Now the altered ProjectTask
will contain the newly given title.
For the state change we can add quite some tests, we check for transitions from one state into another. And again: the tests check that the desired behaviour is seen and that other properties stay unchanged.
context("change") {
context("state") {
context("from not started to in progress") {
beforeEach {
t0 = projectTask.alter(.state(to:.inProgress))
}
afterEach {
t0 = nil
}
it("has state in progress") { expect(t0.state ).to(equal(.inProgress) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title ) ) }
it("has unchanged coll." ) { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
context("from not started to finished") {
beforeEach {
t0 = projectTask.alter(.state(to:.finished))
}
afterEach {
t0 = nil
}
it("has state finished" ) { expect(t0.state ).to(equal(.finished) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title") { expect(t0.title ).to(equal(projectTask.title ) ) }
it("has unchanged coll.") { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
context("from in progress to finished") {
beforeEach {
t0 = projectTask
.alter(.state(to:.inProgress))
.alter(.state(to:.finished))
}
afterEach {
t0 = nil
}
it("has state finished" ) { expect(t0.state ).to(equal(.finished) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title") { expect(t0.title ).to(equal(projectTask.title ) ) }
it("has unchanged coll.") { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
context("from finished to not started") {
beforeEach {
t0 = projectTask
.alter(.state(to:.finished))
.alter(.state(to:.notStarted))
}
afterEach {
t0 = nil
}
it("has state not started") { expect(t0.state ).to(equal(.notStarted) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title ) ) }
it("has unchanged coll." ) { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
context("from in progress to not started") {
beforeEach {
t0 = projectTask
.alter(.state(to:.inProgress))
.alter(.state(to:.notStarted))
}
afterEach {
t0 = nil
}
it("has state not started") { expect(t0.state ).to(equal(.notStarted) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title ) ) }
it("has unchanged coll." ) { expect(t0.collaborators).to(equal(projectTask.collaborators)) }
}
}
}
In each test block the first test will fail, as we haven't written any code to change the state:
func alter(_ c:Change) -> Self {
return switch c {
case let .title (t): Self(id, t , state, collaborators)
case let .state(to:s): Self(id, title, s , collaborators) //<- here
default: self
}
}
Adding the .state(to:…)
case, all tests will run successfully.
We add the specification to add and remove collaborators to and from a task.
context("add collaborator") {
var t0: ProjectTask!
var c0: ProjectMember!
beforeEach {
c0 = ProjectMember(name:"Joe Doe")
t0 = projectTask.alter(.add(.collaborator(c0)))
}
afterEach {
c0 = nil
t0 = nil
}
it("has one collaborator") { expect(t0.collaborators).to(equal([c0]) ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged state" ) { expect(t0.state ).to(equal(projectTask.state)) }
}
context("remove collaborator") {
var t0: ProjectTask!
var c0: ProjectMember!
beforeEach {
c0 = ProjectMember(name:"Joe Doe")
t0 = projectTask
.alter(.add(.collaborator(c0)))
.alter(.remove(.collaborator(c0)))
}
afterEach {
c0 = nil
t0 = nil
}
it("has no collaborator") { expect(t0.collaborators).to(beEmpty() ) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id) ) }
it("has unchanged title") { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged state") { expect(t0.state ).to(equal(projectTask.state)) }
}
To turn all tests green again, we add .add(.collaborator(…))
and .remove(.collaborator(…))
cases to alter’s switch statement.
func alter(_ c:Change) -> Self {
return switch c {
case let .title (t) : Self(id, t , state, collaborators)
case let .state(to:s) : Self(id, title, s , collaborators)
case let .add (.collaborator(c)): Self(id, title, state, collaborators + [c])
case let .remove(.collaborator(c)): Self(id, title, state, collaborators.filter{$0.id != c.id})
}
}
On the left hand side we see the vocabulary that can be formed by the Change
DSL, on the right hand side we see the code that implement those commands . They are so simple that I call them axioms.
I will not include the spec for Project
, as it has 120 test — a bit much for this article.
struct Project {
enum Change {
case title (to:String)
case details(to:String)
case date(ProjectDate)
case add(Add); enum Add {
case task(ProjectTask)
case collaborator(ProjectMember)
}
case update(Update); enum Update {
case task(ProjectTask)
case collaborator(ProjectMember)
}
case remove(Remove); enum Remove {
case task(ProjectTask)
case collaborator(ProjectMember)
}
case appoint(Appoint); enum Appoint {
case leader(ProjectMember)
case noone
}
}
enum Leading {
case noone
case leader(ProjectMember)
}
let id : UUID
let title : String
let details : String
let tasks : [ProjectTask]
let leader : Leading
let collaborators: [ProjectMember]
let date : ProjectDate
init(title:String) {
self.init(UUID(),title, "", [], .noone, [], .unknown)
}
private init (_ id:UUID, _ title:String,_ details:String,_ tasks:[ProjectTask],_ leader:Leading,_ collaborators:[ProjectMember],_ date:ProjectDate) {
self.id = id
self.title = title
self.details = details
self.tasks = tasks
self.leader = leader
self.collaborators = collaborators
self.date = date
}
func alter(_ c:Change) -> Self {
return switch c {
case let .title (to: t): Self(id, t , details, tasks , leader , collaborators , date)
case let .details (to: d): Self(id, title, d , tasks , leader , collaborators , date)
case let .add (.task(t)): Self(id, title, details, tasks + [t] , leader , collaborators , date)
case let .add (.collaborator(c)): Self(id, title, details, tasks , leader , collaborators + [c] , date)
case let .remove (.task(t)): Self(id, title, details, tasks.filter{ $0.id != t.id }, leader , collaborators , date)
case let .remove(.collaborator(c)): Self(id, title, details, tasks , leader , collaborators.filter{ $0.id != c.id }, date)
case let .appoint (.leader(l)): Self(id, title, details, tasks ,.leader(l), collaborators , date)
case .appoint (.noone ): Self(id, title, details, tasks ,.noone , collaborators , date)
case let .date (d): Self(id, title, details, tasks , leader , collaborators , d )
case let .update (.task(t)): update(task: t)
case let .update(.collaborator(c)): update(collaborator: c)
}
}
private func update(task:ProjectTask) -> Self {
if let idx = tasks.firstIndex(where: { $0.id == task.id }) {
var tasks = tasks
tasks[idx] = task
return Self(id, title, details, tasks, leader, collaborators, date)
}
return self
}
private func update(collaborator:ProjectMember) -> Self {
if let idx = collaborators.firstIndex(where: { $0.id == collaborator.id }) {
var collaborators = collaborators
collaborators[idx] = collaborator
return Self(id, title, details, tasks, leader, collaborators, date)
}
return self
}
}
Project has a Change DSL that encodes 12 commands:
.title(to:…)
.details(to:…)
.add(.task(…))
.add(.collaborator(…))
.remove(.task(…))
.remove(.collaborator(…))
.appoint(.leader(…))
.appoint(.noone)
.date(.unknown)
.date(.date(...))
.update(.task(…))
.update(.collaborator(…))
In the alter
method most commands are paired with axioms, as we have seen them earlier. For .update(.task(…))
and .update(.collaborator(…))
I added helper methods, as the required code is a bit more complex, we want to maintain the position of the updated task and collaborator.
Now we add AppState
, wich will hold the App’s current state. It is just another model type with a DSL to add, remove and update projects.
struct AppState {
enum Change {
case add(Add); enum Add {
case project(Project)
}
case remove(Remove); enum Remove {
case project(Project)
}
case update(Update); enum Update {
case project(Project)
}
}
let projects: [Project]
init() {
self.init([])
}
private init(_ projects: [Project]) {
self.projects = projects
}
func alter(_ c:Change) -> Self {
return switch c {
case let .add (.project(p)): Self(projects + [p])
case let .remove(.project(p)): Self(projects.filter{ $0.id != p.id })
case let .update(.project(p)): update(project: p)
}
}
private func update(project:Project) -> Self {
if let idx = projects.firstIndex(where: { $0.id == p.id }) {
var projects = projects
projects[idx] = project
return Self(projects)
}
return self
}
}
As the other model types AppState
is immutable, to reflect a change alter
returns a new object. There-for we need a code that holds the most current state — an AppStore
:
final class AppStore {
private(set) var state : AppState { didSet { persister.persist(appState: state); notify() } }
private var subscribers: [AppStoreSubscriber]
private let persister : AppPersisting
init(persister: AppPersisting) {
self.state = persister.loadAppState()
self.subscribers = []
self.persister = persister
}
func change(_ c:AppState.Change) {
state = state.alter(c)
}
func subscribe(subscriber s:AppStoreSubscriber) {
subscribers.append(s)
}
private func notify() {
subscribers.forEach { subscriber in
subscriber.updated(store: self)
}
}
}
protocol AppStoreSubscriber {
func updated(store:AppStore)
}
AppStore
has the state
, a list of subscribers
and a persister
.
A persister
reads the state from disk and writes it back. Subscribers
can subscribe to the store
and will be notified once state
is overwritten with a new value.
The AppStore’s
change
method accepts AppState.Change
values and forward them to the AppState
alter
method, resulting in a new AppState
.
Now that we have seen how to model behaviour, let’s create the next layer in our App:
The Use Cases
Our App has three use cases:
- add a
Project
- remove a
Project
- update a
Project
A use case is typed as
protocol UseCase {
associatedtype RequestType
associatedtype ResponseType
func request(to request:RequestType)
// init(..., responder: @escaping (ResponseType) -> ())
}
A UseCase
must define a RequestType
, a ResponseType
and a request
method.
I took the idea of using use cases from Robert C. Martin’s Clean Architecture.
struct ProjectAdder:UseCase {
enum Request {
case add(Project)
}
enum Response {
case added(Project)
}
typealias RequestType = Request
typealias ResponseType = Response
private let store : AppStore
private let respond: (Response) -> ()
init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}
func request(to request: Request) {
switch request {
case let .add(p):
store.change(.add(.project(p)))
respond(.added(p))
}
}
}
ProjectAdder
implements the UseCase
protocol. It has two DSLs: Request
with one request .add(Project)
and Response
with one response .added(Project)
. A ProjectAdder
is created with store
object and a callback
, which takes a Respond
value. By calling request(.add(aProject))
on the adder
, the project
will get added to the store
and the callback
will be executed: respond(.added(project)).
The ProjectRemover
looks very similar
struct ProjectRemover: UseCase {
enum Request{
case remove(Project)
}
enum Response {
case removed(Project)
}
typealias RequestType = Request
typealias ResponseType = Response
private let store:AppStore
private let respond: (Response) -> ()
init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}
func request(to request: Request) {
switch request {
case let .remove(p):
store.change(.remove(.project(p)))
respond(.removed(p))
}
}
}
And so does the ProjectUpdater
struct ProjectUpdater: UseCase {
enum Request {
case update(Project)
}
enum Response {
case updated(Project)
}
typealias RequestType = Request
typealias ResponseType = Response
let store : AppStore
let respond: (Response) -> ()
init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}
func request(to request: Request) {
switch request {
case let .update(p):
store.change(.update(.project(p)))
respond(.updated(p))
}
}
}
This three UseCases
just forward messages to the store
object, but in more realistic codes they might be more complex. i.e. the Response
DSL might have cases for success
and failure
, something like
enum Response {
case adding(Project,Outcome); enum Outcome {
case succeeded
case failed(Error)
}
}
this encodes .adding(project, .succeeded)
and .adding(project, .failed(error))
UseCases
are easy to test:
final class ProjectAdderSpec: QuickSpec {
override class func spec() {
describe("ProjectAdder") {
var projectAdder: ProjectAdder!
var store : AppStore!
var response : ProjectAdder.Response!
var project : Project!
var persister : DiskAppPersister!
beforeEach {
persister = DiskAppPersister(pathInDocuments:"ProjectAdderSpec.json")
store = AppStore(persister:persister)
projectAdder = ProjectAdder(store:store, responder:{ response = $0 })
project = Project(title:"World Domination!")
}
afterEach {
persister.destroy()
persister = nil
projectAdder = nil
store = nil
response = nil
project = nil
}
context("newly created") {
it("has no project in store's state") { expect(store.state.projects).to(beEmpty()) }
it("has not received a response yet") { expect(response).to(beNil() ) }
}
context("add a project") {
beforeEach {
projectAdder.request(to:.add(project))
}
it("adds project to store's state") { expect(store.state.projects).to(equal([project]) ) }
it("responds with added project" ) { expect(response ).to(equal(.added(project))) }
}
}
}
}
Let’s move to the next layer in our app’s architecture.
The Feature
A feature consist of several UseCases
. It receives Messages
from other parts of the app — i.e. from the user interface — and translates those messages into request
for the appropriate UseCase
.
enum Message {
case projects(Projects); enum Projects {
case add (Project)
case added (Project)
case remove (Project)
case removed(Project)
case update (Project)
case updated(Project)
}
}
typealias Input = (Message) -> ()
typealias Output = (Message) -> ()
func createProjectsFeature(store:AppStore,output:@escaping Output) -> Input {
let projectAdder = ProjectAdder (store:store, responder:process(on:output))
let projectRemover = ProjectRemover(store:store, responder:process(on:output))
let projectUpdater = ProjectUpdater(store:store, responder:process(on:output))
func execute(cmd: Message.Projects) {
if case let .add(p) = cmd { projectAdder .request(to:.add(p) ) }
if case let .remove(p) = cmd { projectRemover.request(to:.remove(p)) }
if case let .update(p) = cmd { projectUpdater.request(to:.update(p)) }
}
return { msg in // Entry Point
if case let .projects(c) = msg{ execute(cmd:c) }
}
}
private func process(on out:@escaping Output) -> (ProjectAdder.Response) -> () {
{
switch $0 {
case let .added(p): out(.projects(.added(p)))
}
}
}
private func process(on out:@escaping Output) -> (ProjectRemover.Response) -> () {
{
switch $0 {
case let .removed(p): out(.projects(.removed(p)))
}
}
}
private func process(on out:@escaping Output) -> (ProjectUpdater.Response) -> () {
{
switch $0 {
case let .updated(p): out(.projects(.updated(p)))
}
}
}
createProjectsFeature
takes a store
and an Output
callback, sets up the three uses cases and returns an Input
. Both Input
and Output
are functions that take a Message
value. We can save the returned Input
function and call it with a Message
whenever needed. When a Message
is forwarded to this partially applied function, it is pattern matched to check if it was meant for the Projects feature (if case let .projects(c) = msg{ execute(cmd:c) }
) and if this is the case, execute(cmd:)
is called. Here the appropriate UseCase’s
request is called.
When done the UseCase
executes the callback with the appropriate Response
value. This will be translated into a Message
value and forwarded to all features via the out
callback.
Now let’s have a look at the final Layer.
The AppDomain
All app’s features are bundled into the app domain. createAppDomain’s
signature is similar to the one of the feature: It takes an store
and a callback
and returns an Input
function. This function forwards any Message
to every feature (features.forEach { $0(msg) }
)
func createAppDomain(
store : AppStore,
rootHandler: @escaping Output) -> Input
{
let features: [Input] = [
createProjectsFeature(store:store, output:rootHandler)
]
return { msg in
features.forEach { feature in feature(msg) }
}
}
Assemble the App
This app has a SwiftUI user interface.
@main
struct ProjectsterApp: App {
var body: some Scene {
WindowGroup {
ContentView(projectsViewModel:projectsViewModel)
}
}
}
fileprivate let store = AppStore(persister:DiskAppPersister())
fileprivate let rootHandler: ((Message) -> ())! = createAppDomain(
store : store,
rootHandler: { rootHandler($0) }
)
fileprivate let projectsViewModel = ProjectsViewModel(store:store, roothandler:rootHandler)
The ContentView
is given a ViewModel
of type ProjectsViewModel
, which has the app’s store
and the roothandler
. roothandler
is the Input
method returned by createAppDomain
, which also has a method that wraps the roothandler
.
Observable final class ProjectsViewModel {
var projects : [ProjectDetailViewModel]
let roothandler: (Message) -> ()
init(store:AppStore, roothandler:@escaping(Message) -> ()) {
projects = convert(projects:store.state.projects, roothandler:roothandler)
roothandler = roothandler
store.subscribe(subscriber:self)
}
func add (project p:Project ) { roothandler(.projects(.add (p) )) }
func remove(project p:ProjectDetailViewModel) { roothandler(.projects(.remove(p.project))) }
}
extension ProjectsViewModel: AppStoreSubscriber {
func updated(store:AppStore) {
projects = convert(projects:store.state.projects, roothandler:roothandler)
}
}
fileprivate func convert(projects:[Project], roothandler r: @escaping (Message) -> ()) -> [ProjectDetailViewModel] {
projects.map {
ProjectDetailViewModel(project:$0, roothandler:r)
}
}
ProjectsViewModel
is initialized with a store
and a callback
roothandler
. It subscribes itself as a subscriber
to the store
. If the AppStore
changes, update(store:)
will be called and the store’s
state’s
projects
will be converted into ProjectDetailViewModels
.
The user now can create a new Project
, by calling projectsViewModel.add(project:Project(title:aTitle))
, which will result in roothandler(.projects(.add(project)))
. This will forward the Message
to all features. Feature Projects will react to this by calling projectAdder.request(.add(project))
. After adding it to the store
, ProjectAdder
will respond with .added(project)
, this will be translated by the feature to the Message
.projects(.added(project))
— the circle is complete.
We can put the AppDomain
under test:
final class AppDomainSpec: QuickSpec {
override class func spec() {
var appDomain: Input!
var store : AppStore!
var message : Message!
var project : Project!
var persister: DiskAppPersister!
describe("AppDomain") {
beforeEach {
persister = DiskAppPersister(pathInDocuments:"AppDomainSpec.json")
store = AppStore(persister:persister)
appDomain = createAppDomain(store:store, rootHandler:{ message = $0 })
project = Project(title:"World Domination")
}
afterEach {
persister.destroy()
persister = nil
appDomain = nil
message = nil
project = nil
}
context("Newly Created") {
it("has no project in store" ) { expect(store.state.projects).to(beEmpty()) }
it("has not received a message yet") { expect(message ).to(beNil() ) }
}
context("Projects Feature") {
context("Add Project") {
beforeEach {
appDomain(.projects(.add(project)))
}
it("has one project in store" ) { expect(store.state.projects).to(equal([project]) ) }
it("has received added project message") { expect(message ).to(equal(.projects(.added(project)))) }
}
context("Remove Project") {
beforeEach {
appDomain(.projects(.add(project)))
appDomain(.projects(.remove(project)))
}
it("has no project in store" ) { expect(store.state.projects).to(beEmpty() ) }
it("has received removed project message") { expect(message ).to(equal(.projects(.removed(project)))) }
}
context("Update Project") {
var p0: Project!
beforeEach {
appDomain(.projects(.add(project)))
p0 = project.alter(.title(to:"World Domination! ASAP"))
appDomain(.projects(.update(p0)))
}
it("has updated project in store" ) { expect(store.state.projects).to(equal([p0]) ) }
it("has received updated project message") { expect(message ).to(equal(.projects(.updated(p0)))) }
}
}
}
}
}
More Examples
DDP allows to write versatile apps. Here are some examples.
Game of Life
Implementing Conway’s Game of Life proves, that Swift’s subset used in DDP is Turing complete.
Pendulums
This is a simulation of simple and double pendulums.
Just one added complexity — adding a joint — changes the pendulums to show chaotic behaviour.
Snake
Anyone feeling nostalgic for old Nokia phones? Here is the app you miss most — Snake!
Furthermore
So far anything I have shown could also be programmed in other paradigms, like OOP or FP. But in DDP we can do things not possible in those.
Compilable documentation
DSLs used in DDP can be treated as data. It is possible, to write the values encodable in our DSLs down. In the image below we see three columns of documentation, taken from a smart lighting app. In the first one we find the command, i.e. .load(.lights)
, in the second we have the success response, .loading(.lights([l]), .succeeded)
and in the third the failure response, .loading(.lights([]), .failed(error))
. This reads like spoken English. It is a compilable documentation that cannot go out of sync with the rest of an app, as it wouldn’t compile anymore. Also this documentation can be put under tests and act as input values for tests.
Conclusion
DDP allows to create efficient codes very fast. All interactions are declared in DSLs, which can be designed to follow spoken English quite nicely. Even non-coders can understand what’s happening as I have shown in another article. Testing these codes is very simple and uniform across all levels.
Resources
Source Codes
- Projectster Code: https://gitlab.com/vikingosegundo/projectster
- Game of Life: https://gitlab.com/vikingosegundo/declaration-of-live
- Pendulums: https://gitlab.com/vikingosegundo/declarativependulum
- Snake: https://gitlab.com/vikingosegundo/declarative-snake
- Smart Lighting App: https://gitlab.com/vikingosegundo/brighter-hue
- and more…