A New Coding Paradigm: Declarative Domain Programming

Manuel Meyer
CodeX
Published in
21 min readSep 28, 2024

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.

Projectster in action

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 changed
  • ProjectTask
    - 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 removed
  • ProjectMember
    - 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

Glider Gun in 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.

Pendulums

Snake

Anyone feeling nostalgic for old Nokia phones? Here is the app you miss most — Snake!

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.

Lighting Commands

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.

--

--

CodeX
CodeX

Published in CodeX

Everything connected with Tech & Code. Follow to join our 1M+ monthly readers

Manuel Meyer
Manuel Meyer

Written by Manuel Meyer

Freelance Software Developer and Code Strategist.

Responses (5)