Sitemap

Colibri and Clean Architecture — Declarative Coding in Swift

12 min readMay 11, 2025

--

A feature containing three use cases

In two previous articles I introduced Colibri, a declarative language hiding in Swift’s type system and showed how to use it with BDD. Now I want to demonstrate how to use the same principles (interfacing data structures with DSLs) to create general purpose architectures, inspired by Robert C. Martin’s Clean Architecture.

The architecture I am about to present — I call it Khipu — is organised in three layers.

  • The lowest layer are formed by UseCases. They contain the application logic and each of them has their own Request and Response DSL types. They operate on the model types.
  • Several UseCases from a Feature. Features share a Message DSL. Their task is to listen for messages and — if needed — translate them in to Requests for one of their UseCases. If a UseCase triggers a Response its Feature will translate it into a Message and hand it over to the next layer, the AppDomain.
  • The AppDomain receives messages from all its Features and the UI und forwards them to each Feature and receiver (including the UI).

Let us have a look at each of this levels by inspecting a simple program, a time keeping project management tool.

Time Tracker

First I will talk a bit about the model types we have developed in the previous article. I will keep it short.

public struct Project: Identifiable, Equatable, Codable {
public let id : UUID
public let title : String
public let tasks : [ProjectTask]
public var duration: TimeInterval { tasks.reduce(0) { $0 + $1.duration } }

public enum Change {
case setting(Setting); public enum Setting {
case title(to: String)
}
case adding(Adding); public enum Adding {
case task(ProjectTask)
}
case updating(Updating); public enum Updating {
case task(ProjectTask)
}
case removing(Removing); public enum Removing {
case task(ProjectTask)
}
}

public init(title: String) {
self.init(UUID(), title, [])
}
private init(_ id: UUID,_ title: String,_ tasks: [ProjectTask]) {
self.id = id
self.title = title
self.tasks = tasks
}

public func alter(by c:Change...) -> Self { c.reduce(self) { $0.alter(by:$1) } }
public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.title(to: t)): Self(id, t , tasks )
case let .adding (.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} + [t])
case let .removing(.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} )
case let .updating(.task(t)) : update(task: t)
}
}

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, tasks)
}
return self
}
}

Project has an id, an title and a list of ProjectTasks. It also has a duration which is computated by adding its tasks durations.
The Change DSL codes commands for setting the title to a new string, for adding, updating and removing ProjectTasks. In the alter method we see how those commands are implemented.

public struct ProjectTask: Identifiable, Equatable, Codable, Hashable {
public let id : UUID
public let title : String
public let dueDate : DueDate
public let state : State
public let timeSlips: [TimeSlip]
public var duration : TimeInterval { timeSlips.reduce(0) { $0 + $1.duration } }

public enum State: Identifiable, Codable, CaseIterable, CustomStringConvertible {
public var id: Self { self }
case unfinished
case inProgress
case finished
public var description: String {
switch self {
case .unfinished: return "unfinished"
case .inProgress: return "in progress"
case .finished : return "finished"
}
}
}

public enum Change {
case setting(Setting); public enum Setting {
case title (to: String)
case dueDate(to: DueDate)
case state (to: State)
}
case recording(Record); public enum Record {
case start
case stop
}
}

public init(title: String) {
self.init(UUID(), title, .unknown, .unfinished, [])
}
private init(_ id: UUID,_ title: String,_ dueDate:DueDate,_ state:State,_ timeSlips:[TimeSlip]) {
self.id = id
self.title = title
self.dueDate = dueDate
self.state = state
self.timeSlips = timeSlips
}

public func alter(by changes:Change...) -> Self { changes.reduce(self) { $0.alter(by: $1) } }
private func alter(by change: Change) -> Self {
return switch change {
case let .setting(.title (to: t)): Self(id, t , dueDate, state, timeSlips )
case let .setting(.dueDate(to: d)): Self(id, title, d , state, timeSlips )
case let .setting(.state (to: s)): Self(id, title, dueDate, s , timeSlips )
case .recording(.start) : Self(id, title, dueDate, state, startRecoding())
case .recording(.stop) : Self(id, title, dueDate, state, stopRecoding() )
}
}
private func startRecoding() -> [TimeSlip] {
if let last = timeSlips.last,
last.stop == nil {
return timeSlips
}
return timeSlips + [TimeSlip(start: .now)]
}
private func stopRecoding() -> [TimeSlip] {
if let last = timeSlips.last,
last.stop == nil {
return timeSlips.filter { $0.id != last.id } + [last.alter(by: .setting(.stop(to: .now)))]
}
return timeSlips
}
}

public enum DueDate: Equatable, Codable, Hashable {
case unknown
case date(Date)
}

ProjectTask has an id, a title, a due date, a state (unfinished, in progress or finished), a list of time slips. It also has a duration computated by adding all its time slips durations.
The Change DSL allows for the setting of the title, the due date and the state. It also has commands for starting and stopping recording of the time past by. Recording time is done by adding time slips to the list.
The alter methods again pairs the Change commands with the minimal implementation. We see that for the commands .recording(.start) and .recording(.stop) helper functions startRecoding and stopRecording are used. They ensure that time recording can only be started or stoped if appropriate.

public struct TimeSlip: Identifiable, Equatable, Codable, Hashable {
public let id : UUID
public let start : Date
public let stop : Date?
public var duration: TimeInterval {
if let stop = stop {
return stop.timeIntervalSince(start)
}
return Date.now.timeIntervalSince(start)
}

public enum Change {
case setting(Setting); public enum Setting {
case stop(to:Date)
}
}

public init(start: Date) {
self.init(UUID(), start, nil)
}
private init (_ id:UUID,_ start:Date,_ end:Date?) {
self.id = id
self.start = start
self.stop = end
}

public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.stop(to: d)): Self(id, start, d)
}
}
}

A TimeSlip has an id, a start date, an optional stop date and a computed duration that determines how much time has passed.
A TimeSlip is always created with a start date, there-for its Change DSL only has a command to set the stop date: .setting(.stop(to:<new Date>)).

public struct AppState: Equatable, Codable {
public let projects: [Project]

public enum Change {
case adding(Add); public enum Add {
case project(Project)
}
case removing(Remove); public enum Remove {
case project(Project)
}
case updating(Update); public enum Update {
case project(Project)
}
}

public init() {
self.init([])
}

private init(_ projects: [Project]) {
self.projects = projects
}

public func alter(by cs:Change...) -> Self { cs.reduce(self) { $0.alter(by:$1) } }
private func alter(by c:Change) -> Self {
return switch c {
case let .adding (.project(p)): Self(projects.filter{ $0.id != p.id } + [p])
case let .removing(.project(p)): Self(projects.filter{ $0.id != p.id } )
case let .updating(.project(p)): update(project: p)
}
}

private func update(project p:Project) -> Self {
if let idx = projects.firstIndex(where: { $0.id == p.id }) {
var projects = projects
projects[idx] = p
return Self(projects)
}
return self
}
}

The AppState holds the Projects in our app. Projects can be added, removed and updated.

Finally an AppStore will hold the AppState and write it to the disk using a persister object. Subscribers will be notified if changes occur.

final public class AppStore {

public private(set) var state : AppState { didSet { persister.persist(appState: state); notify() } }
private var subscribers: [AppStoreSubscriber]
private let persister : AppPersisting

public init(persister: AppPersisting) {
self.state = persister.loadAppState()
self.subscribers = []
self.persister = persister
}

public func change(by c:AppState.Change) {
state = state.alter(by: c)
}

public func subscribe(subscriber s:AppStoreSubscriber) {
subscribers.append(s)
}

private func notify() {
subscribers.forEach {
$0.updated(store: self)
}
}
}

public protocol AppStoreSubscriber {
func updated(store:AppStore)
}

Now that we have seen the model types, let’s dive into the architecture.

The UseCases

A UseCase has a Request and a Response type. Requests are send to it via the request method. Once it has a Response, it will use a callback function that was passed in during creation.

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

All dependencies that are required to fulfill a UseCases job are passed in during creation as-well.

Adding a Project:

public struct ProjectAdder:UseCase {
public enum Request { case add (Project) }
public enum Response { case added(Project) }

typealias RequestType = Request
typealias ResponseType = Response

private let store : AppStore
private let respond: (Response) -> ()

public init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}

public func request(to request: Request) {
switch request {
case let .add(p):
store.change(by: .adding(.project(p)))
respond(.added(p))
}
}
}

ProjectAdder has a Request with one command — .add(<new Project>) — and one Response. In this case response only has one value — .added(<new Project>) — as we don’t expect anything to go wrong. But in many cases, like network stores, we would like to have two values: one for the success case and one for the failure case.
In request we see that if the .add request is decoded, the store will be changed by adding the project: store.change(by: .adding(.project(p)))
After that the respond callback is called with a Response command: respond(.added(p)). This is all very simple in this case, but the request method can contain more sophisticated and complex codes, including asynchronous behaviour.

The ProjectRemover looks very similar:

public struct ProjectRemover: UseCase {
public enum Request { case remove (Project) }
public enum Response { case removed(Project) }

typealias RequestType = Request
typealias ResponseType = Response

private let store : AppStore
private let respond: (Response) -> ()

public init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}

public func request(to request: Request) {
switch request {
case let .remove(p):
store.change(by: .removing(.project(p)))
respond(.removed(p))
}
}
}

And so does the ProjectUpdater:

public struct ProjectUpdater: UseCase {
public enum Request { case update (Project) }
public enum Response { case updated(Project) }

typealias RequestType = Request
typealias ResponseType = Response

let store : AppStore
let respond: (Response) -> ()

public init(store:AppStore, responder:@escaping (Response) -> ()) {
self.store = store
self.respond = responder
}

public func request(to request: Request) {
switch request {
case let .update(p):
store.change(by: .updating(.project(p)))
respond(.updated(p))
}
}
}

The heavy lifting is performed in the AppStore, which is passed in as a dependency. It is what Martin calls a Gateway, in this case to the file system.

The three UseCases are grouped into a Feature, called Projects.

The Features

The Feature is a function, that is an Input. It is called with a Message value.

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

Both Input and Output are just functions that take a Message value as input.
In our app the Message is very simple:

public enum Message {
case projects(Projects); public enum Projects {
case add (Project)
case added (Project)
case remove (Project)
case removed(Project)
case update (Project)
case updated(Project)
}
}

For real world applications a Message might be more complex, i.e. in this smart lighting app.

The Projects Feature:

public 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
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)))
}
}
}

The createProjectsFeature takes all the dependencies that its use cases will need (here it is just the AppStore), it also takes an Output function and will return an Input function. As we will see later, the returned Input function will be saved in a list to be reused later.
The Input function — which is essentially the feature itself — uses pattern matching to check if the message is meant for the Projects feature. If so it will forward the command to the function execute, where pattern matching is again used to determine which use case will be invoked with a Request.
The UseCases are created with Output functions, which translate UseCase Responses to Messages and calls a provided callback function out with it.

The AppDomain

In our app AppDomain is the top level. Its sole task is to forward Messages to Features.
We create an AppDomain very similar to the Features: A createAppDomain function takes all dependencies needed by the features (here: only AppStore). It takes an Output function, called rootHandler, and returns an Input function — the AppDomain itself.
In that returned function we iterate over all features and call any with passing the Message.

public func createAppDomain(
store : AppStore,
rootHandler: @escaping Output) -> Input
{
let features: [Input] = [
createProjectsFeature(store:store, output:rootHandler)
]
return { msg in
features.forEach { $0(msg) }
}
}

That’s it. If we call createAppDomain we obtain a single Input function that is the frontend for our whole app logic — just an UI is missing at this point.

Connection an User Interface

We will create and connect a SwiftUI user interface. To do so we use view models, ProjectsViewModel and ProjectDetailViewModel

@Observable final public class ProjectsViewModel {
public var projects : [ProjectDetailViewModel]
private let roothandler: (Message) -> ()

public init(store:AppStore, roothandler r:@escaping(Message) -> ()) {
projects = convert(projects:store.state.projects, roothandler:r)
roothandler = r
store.subscribe(subscriber:self)
}

public func add (project p:Project ) { roothandler(.projects(.add (p) )) }
public func remove(project p:ProjectDetailViewModel) { roothandler(.projects(.remove(p.project))) }
}

extension ProjectsViewModel: AppStoreSubscriber {
public 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 has a list of ProjectDetailViewModel and a roothandler — which is the function that is our AppDomain.
It is created with an AppStore and the roothandler function.
It converts AppStore’s projects to the view models and subscribes itself as a subscriber to the store.
If a Project is added, roothandler is called with the command .projects(.add(project)). If a Project is removed, .projects(.remove(project)) is passed.

ProjectDetailViewModel:

@Observable public final class ProjectDetailViewModel {
public var id : UUID { project.id }
public var title : String { project.title }
private let roothandler: (Message) -> ()

public var project: Project {
didSet {
roothandler(.projects(.update(project)))
}
}

public init(project: Project, roothandler: @escaping (Message) -> ()) {
self.project = project
self.roothandler = roothandler
}
}

extension ProjectDetailViewModel: Identifiable, Equatable, Hashable {
public static func == (lhs: ProjectDetailViewModel, rhs: ProjectDetailViewModel) -> Bool {
lhs.id == rhs.id
}

public func hash(into hasher: inout Hasher) {
return hasher.combine(id)
}
}

ProjectDetailViewModel has an id and a title which are both fowarded to the project it also has. If a new project is set, .projects(.update(project)) is send to the roothandler.

I will not post here the complete SwiftUI view here — they are pretty straightforward. You can check the code for them.
Only one example showing how tasks are chengred:

public struct ProjectTaskView: View {
@Binding private var projectDetailViewModel: ProjectDetailViewModel
@State private var task : ProjectTask
@State private var taskTitle : String
@State private var selectedTaskState : ProjectTask.State

public init(projectDetailViewModel: Binding<ProjectDetailViewModel>, task: ProjectTask) {
self._projectDetailViewModel = projectDetailViewModel
self.task = task
self.taskTitle = task.title
self.selectedTaskState = task.state
}

public var body: some View {
VStack {
TextField("\(task.title)", text:$taskTitle)
.padding(.all, 4)
.overlay(
RoundedRectangle(cornerRadius:8)
.stroke(.secondary, lineWidth:1)
)
Picker("State of \(task.title)", selection:$selectedTaskState) {
ForEach(ProjectTask.State.allCases) { state in
Text(String(describing:state))
}
}
TimelineView(.periodic(from: .now, by: 1)) { _ in
Text("\(task.duration.duration)")
}
}
.navigationTitle(taskTitle)
.padding()
.onChange(of:selectedTaskState) {
switch $1 {
case .inProgress: task = task.alter(by: .recording(.start))
default : task = task.alter(by: .recording(.stop))
}
task = task.alter(by: .setting(.state(to:$1)))
}.onChange(of:taskTitle) {
task = task.alter(by: .setting(.title(to:$1.trimmed)))
}.onChange(of:task) {
projectDetailViewModel.project = projectDetailViewModel.project.alter(by: .updating(.task($1)))
}
}
}

Assembling the App

We create projectsViewModel with a store and the roothandler. The roothandler is the AppDomain function. It recursively uses roothandler, too.
The ContentView is created with the projectsViewModel

@main
struct ColibriTimeTrackerApp: 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)

Conclusion

Well, I assume you all agree, that this has been a wild ride. Thanks for sticking with me.
It might seem like quite a hassle to build such a limited app, and you are right. But this is a frame for much bigger and complex apps. In theory you can have any number of features with any number of uses cases to scale a production app. To keep it brain sized I recommend to have around seven use cases per feature at maximum and to not exceed seven features per app domain — so roughly 50 use cases per app. This should be more than enough for mosts apps. But if it isn’t there is nothing from stopping you from having several app domains in one app. In this case you would have a Message type per app domain and a app wide message type which all app domains listen to and translate them into their Message type if needed — just as the features are doing. If we stick with our seven rule this would allow for up to 350 use cases in one app. Now that is really more than enough.

The Code

You will find the whole project on GitLab.

--

--

Manuel Meyer
Manuel Meyer

Written by Manuel Meyer

Freelance Software Developer and Code Strategist.

No responses yet