Lambdaspire
Skylark

Lambdaspire - Swift Command/Query Dispatch 📦

A package to help atomise business logic, isolate orchestration, maximise testability, and separate mutation from inquiry.

📦 Lambdaspire - Swift Command/Query Dispatch

You may also know this pattern as Command Query Responsibility Segregation / Separation (CQRS) or more simply Command Query Segregation / Separation (CQS).

Usage

High Level

Standard Usage

The standard approach leverages DependencyResolutionScope and Resolvable.

Register the necessary components with any DependencyRegistry.

In this example we're using ContainerBuilder from the Dependency Resolution package.

let builder: ContainerBuilder = .init()

builder
    .commandQueryDispatch()
    .standard(
        commandHandlers: [
            ExampleCommandHandler.self,
            OtherExampleCommandHandler.self,
            // ...
        ],
        queryHandlers: [
            ExampleQueryHandler.self,
            OtherExampleQueryHandler.self,
            // ...
        ])

// ...

let container = builder.build()

Retrieve a CommandQueryDispatcher to dispatch commands / queries.

Inline:

let dispatcher: CommandQueryDispatcher = container.resolve()

let info = try await dispatcher.dispatch(GetInfoQuery(subject: "Programming"))

In a Resolvable class:

@Resolvable
class ExampleModel {
    
    @Published private(set) var displayedError: Error? = nil
    
    private let dispatcher: CommandQueryDispatcher
    
    func addSomething() {
        Task {
            do {
                try await dispatcher.dispatch(AddSomethingCommand())
            } catch {
                displayedError = error
            }
        }
    }
}

// ...

// The dispatcher will be filled by dependency injection when the container resolves an ExampleModel instance.
let model: ExampleModel = container.resolve()

model.addSomething()

Define your commands, queries, and handlers as necessary.

Queries implement CQDQuery, and query handlers implement HandlesQuery.

struct GetInfoQuery : CQDQuery {
    typealias Value = MyInfoType
    
    var subject: String
}

@Resolvable
class GetInfoQueryHandler : HandlesQuery {

    private let api: Api
    private let analytics: AnalyticsEngine

    func handle(_ query: GetInfoQuery) async throws -> MyInfoType {
        
        analytics.logSearch(subject: query.subject)
        
        let result = try await api.get("/search?subject=\(subject)")
        
        analytics.logSearch(result: result)
        
        return result.info
    }
}

Commands implement CQDCommand, and command handlers implement HandlesCommand.

struct AddSomethingCommand : CQDCommand {
    // Empty is fine, but fill with data if you need.
}

@Resolvable
class AddSomethingCommandHandler : HandlesCommand {

    private let api: Api
    private let authorisation: AuthorisationEngine
    
    func handle(_ command: AddSomethingCommand) async throws {
        
        try await authorisation.ensureAuthorisation(to: .addSomething)
        
        try await api.post("/something")
    }
}

These very contrived examples demonstrate handlers orchestrating complex activity in isolated, reusable, testable components complete with dependency injection.

Non-Standard Usage

You can subvert the standardised approach if you want.

Create your own implementation of the CommandQueryDispatcher protocol and register it however you like. The standard implementation registers as transient but if your dispatcher is (for whatever reason) stateful you could sensibly register as singleton or scoped.

Register handlers however your dispatcher dictates.

class MySuperSpecialUniqueDispatcher : CommandQueryDispatcher {
    
    private let handlers: MyCustomHandlerRegistry
    
    init(handlers: MyCustomHandlerRegistry) {
        self.handlers = handlers
    }
    
    func dispatch<T>(_ command: T) async throws where T : CQDCommand {
        handlers.handle(command)
    }
    
    func dispatch<T>(_ query: T) async throws -> T.Value where T : CQDQuery {
        handlers.handle(query)
    }
}

// ...

let builder: ContainerBuilder = .init()

builder.transient { MySuperSpecialUniqueDispatcher(handlers: .whatever) }

It probably doesn't make a lot of sense to use this package if you're going to do this for production code, but it might be useful for test / preview scenarios.

SwiftData

There's no reason you can't use SwiftData with this. You just need to find a way to inject a suitable ModelContext into your handlers.

Here's an naive end-to-end example using a few other Lambdaspire packages:


import SwiftUI
import SwiftData
import LambdaspireAbstractions
import LambdaspireSwiftUIFoundations
import LambdaspireDependencyResolution
import LambdaspireSwiftCommandQueryDispatch

@main
struct MyApp: App {
    
    private let modelContainer = getModelContainer()
    
    var body: some Scene {
        WindowGroup {
            IoCBootstrapperView {
                NavigationStack {
                    RootView()
                }
            }
        }
        .modelContainer(modelContainer)
    }
}

// ...

func getModelContainer() -> ModelContainer {
    
    let schema = Schema([
        SomeEntity.self
    ])
    
    let modelConfiguration = ModelConfiguration(
        schema: schema,
        isStoredInMemoryOnly: false,
        cloudKitDatabase: .private("iCloud.com.example.whatever"))

    return try! ModelContainer(
        for: schema,
        configurations: [modelConfiguration])
}


// ...

struct IoCBootstrapperView<Content: View> : View {
    
    var content: () -> Content
    
    @State private var rootScope: DependencyResolutionScope?
    
    @Environment(\.modelContext) private var modelContext
    
    var body: some View {
        if let rootScope {
            content()
                .resolving(from: rootScope)
        } else {
            ProgressView()
                .task {
                    rootScope = getAppContainer(modelContext)
                }
        }
    }
}

// ...

func getAppContainer(_ modelContext: ModelContext) -> Container {
    let b: ContainerBuilder = .init()
    
    // SwiftData
    b.transient { modelContext }
    
    // Dispatcher
    b.commandQueryDispatch().standard(
        commandHandlers: [
            AddEntityCommandHandler.self
        ],
        queryHandlers: [
            GetRandomEntityQueryHandler.self
        ])
    
    // Scope
    b.transient { $0 }
    
    return b.build()
}

// ...

@Model
class SomeEntity {
    
    var name: String = ""
    
    init(name: String = "") {
        self.name = name
    }
}

// ...

struct AddEntityCommand : CQDCommand { }

@Resolvable
class AddEntityCommandHandler : HandlesCommand {
    
    private let modelContext: ModelContext
    
    func handle(_ command: AddEntityCommand) async throws {
        modelContext.insert(SomeEntity(name: "Entity \(Int.random(in: 100...999))"))
        try! modelContext.save()
    }
}

// ...

struct GetRandomEntityQuery : CQDQuery {
    typealias Value = SomeEntity
}

@Resolvable
class GetRandomEntityQueryHandler : HandlesQuery {
    
    private let modelContext: ModelContext
    
    func handle(_ query: GetRandomEntityQuery) async throws -> SomeEntity {
        guard let result = try modelContext.fetch(FetchDescriptor<SomeEntity>()).first else {
            throw DomainError.notFound
        }
        return result
    }
}

// ...

@ResolvedScope
struct RootView : View {

    @State private var random: SomeEntity?

    @Resolved private var dispatcher: CommandQueryDispatcher
    
    @Query private var entities: [SomeEntity]

    var body: some View {
        List {
            ForEach(entities, id: \.persistentModelID) { e in
                Text(e.name)
            }
        }
        .sheet(item: $random) { r in
            Text("Random: \(r.name)")
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    Task {
                        try! await dispatcher.dispatch(AddEntityCommand())
                    }
                } label: {
                    Text("Add")
                }
            }
            
            ToolbarItem(placement: .secondaryAction) {
                Button {
                    Task { @MainActor in
                        random = try! await dispatcher.dispatch(GetRandomEntityQuery())
                    }
                } label: {
                    Text("View Random")
                }
            }
        }
    }
}