Lambdaspire
Skylark

Lambdaspire - Swift DDD 📦

A package to help you write more Domain Driven Design (DDD) style code with SwiftData.

📦 Lambdaspire - Swift DDD

As with any architecturally unopinionated data access layer, SwiftData code is prone to many of the common pitfalls of undisciplined software development: Repetitive code in some monolithic "business layer", poorly contained side-effects, or (worse) business logic carelessly scattered about, offering no clarity on the domain it represents.

This package aims to provide lightweight but opinionated ways to manage your app's domain logic with concepts inspired by the principles of Domain Driven Design.

What's Inside (so far)

The package is an ongoing work in progress that will likely never be "finished".

For now, the package provides a basic approach to performing a "Unit of Work" and mechanisms for raising and handling domain events within it.

Unit of Work

A Unit of Work encompasses a body of activity executed against a given domain context. Upon completion of the work, domain events raised are handled and any necessary transaction management (committing upon success or rolling back upon failure) is performed.

The typical usage would be a UnitOfWork wrapping a SwiftData ModelContext (i.e. UnitOfWork<ModelContext>).

SwiftData Models that are changed, updated, or deleted during the work can raise domain events which will be handled before and/or after committing the changes to persistence.

Domain Entities

A Domain Entity (in this package) is just an object capable of raising events and exposing those events to some domain context for collection.

The typical usage would be on a SwiftData @Model.

Domain Entities are most concisely created with the @DomainEntity macro.

@Model
@DomainEntity
class Customer {

    let uuid: UUID = UUID()

    var orders: [Order] = []

    // ... init ...

    func placeOrder(_ order: Order) {

        orders.append(order)

        raiseEvent(OrderPlaced(customerId: uuid, orderId: order.id))
    }
}

The @DomainEntity macro adds conformance to HasDomainEvents (which enables raiseEvent) and creates a @Transient array of DomainEvent for accumulation during a Unit of Work and collection at the end of it. The code could be written manually, but macro takes care of it and is more future-proof.

Domain Events and Handlers

Domain events and their handlers allow you to manage the side effects of certain activities that occur within the domain. This may include cleanup tasks when a Model is deleted, scheduling notifications when a Model is created or updated, or (consensual) logging and analytics operations for all data changes.

In this package, there are two types of handlers:

  • Pre-commit handlers, and
  • Post-commit handlers

Domain events are described as simple structs conforming to DomainEvent.

struct OrderPlaced : DomainEvent {
    var customerId: UUID
    var orderId: UUID
}

Domain event handlers can be defined as structs conforming to DomainEventHandler.

struct OrderPlacedHandler : DomainEventHandler {
    static func handle(event: OrderPlaced, resolver: DependencyResolver) async throws {
        resolver
            .resolve(NotificationService.self)
            .notifyCustomer(
                customerId: event.customerId,
                message: "Your Order #\(event.orderId) is being processed.")
    }
}

The handle function accepts the specific type of DomainEvent in question and a DependencyResolver to resolve dependencies in a "service locator" fashion.

Pre-Commit Handlers

These execute before changes are persisted and will prevent the commission of the Unit of Work if they fail.

Use these for side-effects that are intra-domain, with implications on domain integrity.

Any DomainEventHandler is pre-commit by default.

Post-Commit Handlers

These execute after changes are persisted and fail gracefully, independently of each other. A failing post-commit handler will not rollback a Unit of Work.

Use these for side-effects that are extra-domain, perhaps with dependencies on external services / resources that may be unreliable.

A DomainEventHandler is post-commit if it overrides the default implementation of static var isPostCommit: Bool { true } to instead return false.

struct OrderPlacedHandler : DomainEventHandler {

    static let isPostCommit = true

    static func handle(event: OrderPlaced, resolver: DependencyResolver) async throws {
        // unchanged
    }
}

Multiple Handlers Supported

There can be many handlers for each type of event. The order of execution is not guaranteed, aside from pre-commit handlers preceding all post-commit handlers.

How to use it

Here's a minimal, illustrative, non-compiling example of all the pieces, combined.

// Define the domain.
@Model
@DomainEntity
class Customer {

    let uuid: UUID = UUID()
    var orders: [Order] = []

    // ... init ...

    func placeOrder(_ order: Order) {
        orders.append(order)
        raiseEvent(OrderPlaced(customerId: uuid, orderId: order.uuid))
    }
}

@Model
class Order {
    let uuid: UUID = UUID()
    
    // ... init ...
}

struct OrderPlaced : DomainEvent {
    var customerId: UUID
    var orderId: UUID
}

// Define the event handlers.
struct OrderPlacedHandler : DomainEventHandler {

    static let isPostCommit = true

    static func handle(event: OrderPlaced, resolver: DependencyResolver) async throws {
        resolver
            .resolve(NotificationService.self)
            .notifyCustomer(
                customerId: event.customerId,
                message: "Your Order #\(event.orderId) is being processed.")
    }
}

// Define the services.
class NotificationService {
    func notifyCustomer(customerId: UUID, message: String) {
        print("Greetings, valued customer \(customerId).\n\n\(message)\n\nThank you.")
    }
}

func example() async throws {

    // 1. 
    // We'll need a DependencyResolver to connect arbitrary dependencies to the handlers.
    // Use ServiceLocator from LambdaspireDependencyResolution or create your own.
    // Register services / dependencies.
    let serviceLocator: ServiceLocator = .init()
    serviceLocator.register(NotificationService())

    // 2. 
    // The DomainEventHandlerRegistrar is the package's default implementation the 
    // DomainEventDelegator protocol which is responsible for marshalling events to their handlers
    // with dependency resolution capabilities.
    let registrar: DomainEventHandlerRegistrar = .init(resolver: serviceLocator)

    // 3. 
    // Register each handler.
    // In this example, only one.
    registrar.register(OrderPlacedHandler.self)

    // 4.
    // Create a UnitOfWork using the registrar as a delegator and a SwiftData ModelContext.
    // Assume modelContext is already established (perhaps via @Environment).
    let unitOfWork: UnitOfWork<ModelContext> = .init(delegator: registrar, context: modelContext)

    // 5. 
    // Perform domain activity.
    try await unitOfWork.execute { context in
        
        let customer = getCurrentlyAuthenticatedUserCustomerRecord(context)
        
        let pretendNewOrder: Order = .init()

        customer.placeOrder(pretendNewOrder)
    }
}

There is a more comprehensive example and additional documentation in the readme on GitHub.

The End

Found a bug? Got ideas? Put up a Pull Request. 🙏